线程资源共享与访问控制策略
线程资源共享概述
在现代操作系统中,线程作为进程内的执行单元,共享进程的大部分资源。这一特性极大地提高了程序的执行效率,因为线程间通信无需像进程间那样通过复杂的机制(如管道、套接字等),而是可以直接访问进程内的共享资源。
共享资源类型
- 内存空间:进程的堆内存和全局变量对于该进程内的所有线程都是可访问的。例如,在 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 += amount
和 balance -= 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_lock
和 pthread_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,以便其他线程可以获取。
死锁问题与预防
在使用同步机制时,死锁是一个需要特别关注的问题。死锁是指多个线程互相等待对方释放资源,导致所有线程都无法继续执行的情况。
死锁的产生条件
- 互斥条件:资源只能被一个线程独占使用。例如,互斥锁就是基于互斥条件,保证同一时刻只有一个线程能访问共享资源。
- 占有并等待条件:一个线程在持有一个资源的同时,还等待获取其他资源。比如,线程 A 持有资源 R1,同时等待获取资源 R2。
- 不可剥夺条件:资源只能由持有它的线程主动释放,不能被其他线程强行剥夺。
- 循环等待条件:存在一个线程集合 {T1, T2, …, Tn},T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,…,Tn 等待 T1 持有的资源,形成一个循环等待链。
死锁的预防方法
- 破坏互斥条件:在某些情况下,可以通过允许资源共享来破坏互斥条件。但这种方法对于一些必须独占的资源(如打印机)并不适用。
- 破坏占有并等待条件:可以要求线程在开始执行前一次性获取所有需要的资源,而不是逐步获取。例如,在一个涉及多个文件操作的多线程程序中,线程在开始时就打开所有需要的文件,而不是在操作过程中逐个打开。
- 破坏不可剥夺条件:当一个线程获取了部分资源后,如果无法获取其他资源,就释放已持有的资源。操作系统可以提供一些机制,如优先级调度,来剥夺低优先级线程的资源给高优先级线程。
- 破坏循环等待条件:对资源进行编号,规定线程只能按照编号递增的顺序获取资源。例如,有资源 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 适用于那些虽然变量名相同,但每个线程需要独立数据副本的场景,例如日志记录、线程本地的缓存等。这样可以避免使用同步机制带来的开销,提高程序的执行效率。
总结
线程资源共享是多线程编程的核心特性之一,它带来了高效的执行效率,但也伴随着资源竞争和数据不一致等问题。通过合理使用互斥锁、读写锁、信号量等访问控制策略,以及线程局部存储技术,可以有效地解决这些问题。同时,要特别注意死锁问题的预防,确保多线程程序的稳定性和可靠性。在实际应用中,需要根据具体的场景和需求,选择最合适的访问控制策略,以实现高效、安全的多线程编程。