MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

线程资源共享与访问控制策略

2022-04-164.9k 阅读

线程资源共享概述

在现代操作系统中,线程作为进程内的执行单元,共享进程的大部分资源。这一特性极大地提高了程序的执行效率,因为线程间通信无需像进程间那样通过复杂的机制(如管道、套接字等),而是可以直接访问进程内的共享资源。

共享资源类型

  1. 内存空间:进程的堆内存和全局变量对于该进程内的所有线程都是可访问的。例如,在 C 语言编写的多线程程序中:
#include <stdio.h>
#include <pthread.h>

int shared_variable = 0;

void* thread_function(void* arg) {
    shared_variable++;
    printf("Thread incremented shared variable: %d\n", shared_variable);
    return NULL;
}

int main() {
    pthread_t thread;
    pthread_create(&thread, NULL, thread_function, NULL);
    pthread_join(thread, NULL);
    printf("Main thread sees shared variable: %d\n", shared_variable);
    return 0;
}

在上述代码中,shared_variable 是一个全局变量,主线程和新创建的线程都可以对其进行访问和修改。 2. 文件描述符:当一个进程打开一个文件时,所获得的文件描述符在进程内的所有线程间共享。假设在一个多线程的日志记录程序中:

#include <stdio.h>
#include <pthread.h>
#include <fcntl.h>
#include <unistd.h>

int log_file;

void* log_message(void* arg) {
    const char* message = (const char*)arg;
    write(log_file, message, strlen(message));
    return NULL;
}

int main() {
    log_file = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (log_file == -1) {
        perror("open");
        return 1;
    }

    pthread_t thread1, thread2;
    const char* msg1 = "Message from thread 1\n";
    const char* msg2 = "Message from thread 2\n";

    pthread_create(&thread1, NULL, log_message, (void*)msg1);
    pthread_create(&thread2, NULL, log_message, (void*)msg2);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    close(log_file);
    return 0;
}

这里 log_file 是一个共享的文件描述符,不同线程都可以使用它向文件中写入日志信息。

共享资源带来的问题

虽然线程共享资源提高了效率,但也引入了一系列问题,其中最主要的是资源竞争和数据不一致。

资源竞争

当多个线程同时访问和修改共享资源时,就可能出现资源竞争问题。例如,在银行转账的场景中,假设有两个线程分别进行存款和取款操作:

#include <stdio.h>
#include <pthread.h>

double balance = 1000.0;

void* deposit(void* arg) {
    double amount = *((double*)arg);
    balance += amount;
    return NULL;
}

void* withdraw(void* arg) {
    double amount = *((double*)arg);
    balance -= amount;
    return NULL;
}

int main() {
    pthread_t deposit_thread, withdraw_thread;
    double deposit_amount = 500.0;
    double withdraw_amount = 300.0;

    pthread_create(&deposit_thread, NULL, deposit, (void*)&deposit_amount);
    pthread_create(&withdraw_thread, NULL, withdraw, (void*)&withdraw_amount);

    pthread_join(deposit_thread, NULL);
    pthread_join(withdraw_thread, NULL);

    printf("Final balance: %.2f\n", balance);
    return 0;
}

在这个简单的代码中,如果两个线程几乎同时执行 balance += amountbalance -= amount 操作,由于 CPU 时间片的切换,可能会导致最终的 balance 值不正确。比如,存款线程读取 balance 值为 1000,在执行加法操作前,时间片切换到取款线程,取款线程读取 balance 也是 1000,然后执行减法操作,再切换回存款线程执行加法操作,最终 balance 的值就不是预期的 1200(1000 + 500 - 300),而是 1500(1000 + 500 + 1000 - 300)。

数据不一致

数据不一致通常是由于资源竞争引起的。当多个线程对共享数据进行读写操作时,如果没有适当的同步机制,就可能导致数据处于不一致的状态。以一个简单的计数器为例,假设有一个线程负责增加计数器的值,另一个线程负责读取计数器的值并打印:

#include <stdio.h>
#include <pthread.h>

int counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        counter++;
    }
    return NULL;
}

void* print_counter(void* arg) {
    printf("Counter value: %d\n", counter);
    return NULL;
}

int main() {
    pthread_t increment_thread, print_thread;

    pthread_create(&increment_thread, NULL, increment, NULL);
    pthread_create(&print_thread, NULL, print_counter, NULL);

    pthread_join(increment_thread, NULL);
    pthread_join(print_thread, NULL);

    return 0;
}

在这个程序中,如果 print_counter 线程在 increment 线程还未完全完成计数操作时就读取 counter 的值,那么打印出的 counter 值就不是最终的正确值,从而导致数据不一致。

访问控制策略 - 互斥锁

为了解决线程资源共享带来的问题,需要采用访问控制策略。互斥锁(Mutex)是一种最基本的同步机制。

互斥锁原理

互斥锁本质上是一个二元信号量,它的值只能是 0 或 1。当一个线程获取互斥锁时(将其值设为 0),其他线程就不能再获取,直到该线程释放互斥锁(将其值设为 1)。这样就保证了在同一时刻只有一个线程能够访问共享资源,从而避免资源竞争。

使用互斥锁的代码示例

以之前银行转账的代码为例,加入互斥锁:

#include <stdio.h>
#include <pthread.h>

double balance = 1000.0;
pthread_mutex_t mutex;

void* deposit(void* arg) {
    double amount = *((double*)arg);
    pthread_mutex_lock(&mutex);
    balance += amount;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void* withdraw(void* arg) {
    double amount = *((double*)arg);
    pthread_mutex_lock(&mutex);
    balance -= amount;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    pthread_t deposit_thread, withdraw_thread;
    double deposit_amount = 500.0;
    double withdraw_amount = 300.0;

    pthread_mutex_init(&mutex, NULL);

    pthread_create(&deposit_thread, NULL, deposit, (void*)&deposit_amount);
    pthread_create(&withdraw_thread, NULL, withdraw, (void*)&withdraw_amount);

    pthread_join(deposit_thread, NULL);
    pthread_join(withdraw_thread, NULL);

    pthread_mutex_destroy(&mutex);

    printf("Final balance: %.2f\n", balance);
    return 0;
}

在上述代码中,通过 pthread_mutex_lockpthread_mutex_unlock 函数来控制对 balance 共享变量的访问。当一个线程执行 pthread_mutex_lock 时,如果互斥锁已被其他线程锁定,该线程就会阻塞,直到互斥锁被释放。这样就确保了 balance 的修改操作是原子性的,避免了资源竞争。

访问控制策略 - 读写锁

在很多实际应用场景中,对共享资源的访问存在读多写少的情况。例如,一个数据库查询系统,大量线程可能同时读取数据,但只有少数线程会进行数据更新操作。对于这种场景,使用互斥锁虽然能保证数据一致性,但会降低系统性能,因为读操作之间并不需要互斥。读写锁(Read - Write Lock)就是为了解决这种问题而设计的。

读写锁原理

读写锁允许多个线程同时进行读操作,因为读操作不会修改共享资源,所以不会导致数据不一致。但当有一个线程进行写操作时,其他读线程和写线程都必须等待,直到写操作完成。读写锁有两种状态:读锁定状态和写锁定状态。当处于读锁定状态时,多个读线程可以同时获取锁;当处于写锁定状态时,只有获取写锁的线程可以访问共享资源。

使用读写锁的代码示例

以下是一个简单的使用读写锁的示例,模拟一个新闻发布系统,多个线程读取新闻内容,偶尔有一个线程更新新闻内容:

#include <stdio.h>
#include <pthread.h>
#include <string.h>

char news[100] = "Initial news";
pthread_rwlock_t rwlock;

void* read_news(void* arg) {
    pthread_rwlock_rdlock(&rwlock);
    printf("Thread reading news: %s\n", news);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

void* update_news(void* arg) {
    const char* new_content = (const char*)arg;
    pthread_rwlock_wrlock(&rwlock);
    strcpy(news, new_content);
    printf("Thread updated news: %s\n", news);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

int main() {
    pthread_t read_thread1, read_thread2, update_thread;
    const char* new_news = "New news content";

    pthread_rwlock_init(&rwlock, NULL);

    pthread_create(&read_thread1, NULL, read_news, NULL);
    pthread_create(&read_thread2, NULL, read_news, NULL);
    pthread_create(&update_thread, NULL, update_news, (void*)new_news);

    pthread_join(read_thread1, NULL);
    pthread_join(read_thread2, NULL);
    pthread_join(update_thread, NULL);

    pthread_rwlock_destroy(&rwlock);

    return 0;
}

在这个示例中,read_news 线程使用 pthread_rwlock_rdlock 获取读锁,多个读线程可以同时读取 news 内容。而 update_news 线程使用 pthread_rwlock_wrlock 获取写锁,在写操作期间,其他读线程和写线程都无法访问 news,从而保证了数据一致性。

访问控制策略 - 信号量

信号量(Semaphore)是另一种重要的同步机制,它可以看作是一个计数器。信号量的值表示可用资源的数量,线程通过获取和释放信号量来申请和归还资源。

信号量原理

信号量有一个整数值,当一个线程获取信号量时,如果信号量的值大于 0,则将其值减 1,线程可以继续执行;如果信号量的值为 0,则线程会阻塞,直到其他线程释放信号量(将其值加 1)。信号量可以用于控制对多个共享资源的访问,也可以用于线程间的同步。

使用信号量的代码示例

假设有一个场景,有多个线程需要访问有限数量的打印机资源。我们可以使用信号量来模拟打印机资源的管理:

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

sem_t printer_sem;
const int num_printers = 2;

void* print_job(void* arg) {
    int job_id = *((int*)arg);
    sem_wait(&printer_sem);
    printf("Thread %d is using a printer\n", job_id);
    // 模拟打印操作
    sleep(1);
    printf("Thread %d finished printing\n", job_id);
    sem_post(&printer_sem);
    return NULL;
}

int main() {
    pthread_t threads[5];
    int job_ids[5] = {1, 2, 3, 4, 5};

    sem_init(&printer_sem, 0, num_printers);

    for (int i = 0; i < 5; i++) {
        pthread_create(&threads[i], NULL, print_job, (void*)&job_ids[i]);
    }

    for (int i = 0; i < 5; i++) {
        pthread_join(threads[i], NULL);
    }

    sem_destroy(&printer_sem);

    return 0;
}

在上述代码中,printer_sem 信号量的初始值为 num_printers,表示有 2 台打印机可用。每个线程在执行打印任务前,先通过 sem_wait 获取信号量,如果有可用打印机(信号量值大于 0),则获取成功并将信号量值减 1,开始打印任务;否则线程阻塞等待。打印完成后,通过 sem_post 释放信号量,将其值加 1,以便其他线程可以获取。

死锁问题与预防

在使用同步机制时,死锁是一个需要特别关注的问题。死锁是指多个线程互相等待对方释放资源,导致所有线程都无法继续执行的情况。

死锁的产生条件

  1. 互斥条件:资源只能被一个线程独占使用。例如,互斥锁就是基于互斥条件,保证同一时刻只有一个线程能访问共享资源。
  2. 占有并等待条件:一个线程在持有一个资源的同时,还等待获取其他资源。比如,线程 A 持有资源 R1,同时等待获取资源 R2。
  3. 不可剥夺条件:资源只能由持有它的线程主动释放,不能被其他线程强行剥夺。
  4. 循环等待条件:存在一个线程集合 {T1, T2, …, Tn},T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,…,Tn 等待 T1 持有的资源,形成一个循环等待链。

死锁的预防方法

  1. 破坏互斥条件:在某些情况下,可以通过允许资源共享来破坏互斥条件。但这种方法对于一些必须独占的资源(如打印机)并不适用。
  2. 破坏占有并等待条件:可以要求线程在开始执行前一次性获取所有需要的资源,而不是逐步获取。例如,在一个涉及多个文件操作的多线程程序中,线程在开始时就打开所有需要的文件,而不是在操作过程中逐个打开。
  3. 破坏不可剥夺条件:当一个线程获取了部分资源后,如果无法获取其他资源,就释放已持有的资源。操作系统可以提供一些机制,如优先级调度,来剥夺低优先级线程的资源给高优先级线程。
  4. 破坏循环等待条件:对资源进行编号,规定线程只能按照编号递增的顺序获取资源。例如,有资源 R1、R2、R3,线程必须先获取 R1,再获取 R2,最后获取 R3,这样就不会形成循环等待。

线程局部存储(TLS)

除了使用同步机制来控制对共享资源的访问外,还可以通过线程局部存储(Thread - Local Storage,TLS)来避免资源竞争。

TLS 原理

TLS 为每个线程提供了独立的变量存储空间。也就是说,虽然所有线程都可以访问同一个变量名,但每个线程实际操作的是自己独有的一份数据副本。这样就避免了多个线程对同一共享资源的竞争。

使用 TLS 的代码示例

在 C 语言中,可以使用 pthread_key_t 来实现 TLS:

#include <stdio.h>
#include <pthread.h>

pthread_key_t key;

void* thread_function(void* arg) {
    int* local_value = (int*)pthread_getspecific(key);
    if (local_value == NULL) {
        local_value = (int*)malloc(sizeof(int));
        *local_value = 0;
        pthread_setspecific(key, local_value);
    }
    (*local_value)++;
    printf("Thread local value: %d\n", *local_value);
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_key_create(&key, NULL);

    pthread_create(&thread1, NULL, thread_function, NULL);
    pthread_create(&thread2, NULL, thread_function, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    pthread_key_delete(key);
    return 0;
}

在上述代码中,通过 pthread_key_create 创建了一个线程局部存储的键 key。每个线程通过 pthread_getspecific 获取与该键关联的值,如果值为空,则分配内存并初始化,然后通过 pthread_setspecific 将值与键关联。每个线程对 local_value 的操作都是独立的,不会相互干扰,从而避免了资源竞争。

不同访问控制策略的选择与应用场景

在实际应用中,需要根据具体的场景选择合适的访问控制策略。

互斥锁的适用场景

互斥锁适用于对共享资源的读写操作都需要严格互斥的场景,例如对全局变量的修改、对临界区代码的保护等。当共享资源的读写操作频率相近,或者写操作相对较多时,互斥锁是一个比较合适的选择。

读写锁的适用场景

读写锁适用于读多写少的场景,如数据库查询系统、文件读取操作等。在这些场景中,读操作可以并发执行,提高了系统的整体性能,同时写操作时能保证数据一致性。

信号量的适用场景

信号量适用于控制对多个相同类型资源的访问,或者用于线程间的复杂同步。比如管理打印机资源、控制并发连接数等场景。

TLS 的适用场景

TLS 适用于那些虽然变量名相同,但每个线程需要独立数据副本的场景,例如日志记录、线程本地的缓存等。这样可以避免使用同步机制带来的开销,提高程序的执行效率。

总结

线程资源共享是多线程编程的核心特性之一,它带来了高效的执行效率,但也伴随着资源竞争和数据不一致等问题。通过合理使用互斥锁、读写锁、信号量等访问控制策略,以及线程局部存储技术,可以有效地解决这些问题。同时,要特别注意死锁问题的预防,确保多线程程序的稳定性和可靠性。在实际应用中,需要根据具体的场景和需求,选择最合适的访问控制策略,以实现高效、安全的多线程编程。