醋醋百科网

Good Luck To You!

C语言进阶教程:线程同步:互斥锁、条件变量与信号量

在多线程编程中,线程同步是确保数据一致性和程序正确性的关键。当多个线程需要访问共享资源时,如果缺乏适当的同步机制,就可能导致竞态条件(Race Condition)、死锁(Deadlock)等问题。本节将介绍三种常用的线程同步机制:互斥锁(Mutex)、条件变量(Condition Variable)和信号量(Semaphore)。

一、互斥锁 (Mutex)

互斥锁是最基本的同步原语,用于保护共享资源,确保在任何时刻只有一个线程可以访问该资源。

1. 工作原理

  • 锁定 (Lock/Acquire):当一个线程想要访问共享资源时,它首先尝试获取与该资源关联的互斥锁。如果锁未被其他线程持有,则该线程成功获取锁,并可以访问资源。访问完毕后,线程必须释放锁。
  • 阻塞 (Block):如果线程尝试获取一个已经被其他线程持有的锁,该线程将被阻塞,直到持有锁的线程释放它。
  • 释放 (Unlock/Release):线程完成对共享资源的访问后,必须释放互斥锁,以便其他等待的线程可以获取它。

2. POSIX Threads (Pthreads) 中的互斥锁

在 POSIX 兼容的系统中(如 Linux, macOS),可以使用 pthread_mutex_t 类型的互斥锁。

主要函数:

  • pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr): 初始化互斥锁。
    • mutex: 指向要初始化的互斥锁的指针。
    • attr: 指向互斥锁属性对象的指针,通常设为 NULL 使用默认属性。
  • pthread_mutex_destroy(pthread_mutex_t *mutex): 销毁互斥锁。在互斥锁不再需要时调用,确保没有线程正在等待或持有该锁。
  • pthread_mutex_lock(pthread_mutex_t *mutex): 获取(锁定)互斥锁。如果锁已被其他线程持有,调用线程将阻塞。
  • pthread_mutex_trylock(pthread_mutex_t *mutex): 尝试获取互斥锁。如果锁已被持有,函数立即返回错误(通常是 EBUSY),调用线程不会阻塞。
  • pthread_mutex_unlock(pthread_mutex_t *mutex): 释放(解锁)互斥锁。

示例代码 (Pthreads):

 #include <stdio.h>
 #include <pthread.h>
 
 int shared_data = 0;
 pthread_mutex_t mutex; // 互斥锁
 
 void *increment_thread(void *arg) {
     for (int i = 0; i < 100000; ++i) {
         pthread_mutex_lock(&mutex); // 获取锁
         shared_data++;
         pthread_mutex_unlock(&mutex); // 释放锁
     }
     return NULL;
 }
 
 int main() {
     pthread_t t1, t2;
 
     // 初始化互斥锁
     if (pthread_mutex_init(&mutex, NULL) != 0) {
         perror("Mutex initialization failed");
         return 1;
     }
 
     // 创建线程
     pthread_create(&t1, NULL, increment_thread, NULL);
     pthread_create(&t2, NULL, increment_thread, NULL);
 
     // 等待线程结束
     pthread_join(t1, NULL);
     pthread_join(t2, NULL);
 
     // 销毁互斥锁
     pthread_mutex_destroy(&mutex);
 
     printf("Shared data: %d\n", shared_data); // 预期结果: 200000
     return 0;
 }

3. Windows API 中的互斥锁

在 Windows 系统中,可以使用 HANDLE 类型的互斥对象。

主要函数:

  • CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName): 创建或打开一个命名的或未命名的互斥对象。
    • lpMutexAttributes: 安全属性,通常为 NULL
    • bInitialOwner: 如果为 TRUE,则创建互斥体的线程立即拥有它。
    • lpName: 互斥体的名称(字符串),如果为 NULL,则创建未命名互斥体。
  • WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds): 等待指定的对象(如互斥体)变为 signaled 状态。
    • hHandle: 要等待的对象的句柄。
    • dwMilliseconds: 超时时间(毫秒),INFINITE 表示无限等待。
  • ReleaseMutex(HANDLE hMutex): 释放指定的互斥对象的所有权。
  • CloseHandle(HANDLE hObject): 关闭一个打开的对象句柄(包括互斥体)。

示例代码 (Windows API):

 #include <windows.h>
 #include <stdio.h>
 
 int shared_data = 0;
 HANDLE hMutex; // 互斥锁句柄
 
 DWORD WINAPI IncrementThread(LPVOID lpParam) {
     for (int i = 0; i < 100000; ++i) {
         // 等待互斥锁
         WaitForSingleObject(hMutex, INFINITE);
         shared_data++;
         // 释放互斥锁
         ReleaseMutex(hMutex);
     }
     return 0;
 }
 
 int main() {
     HANDLE hThread1, hThread2;
 
     // 创建互斥锁
     hMutex = CreateMutex(NULL, FALSE, NULL);
     if (hMutex == NULL) {
         printf("CreateMutex error: %d\n", GetLastError());
         return 1;
     }
 
     // 创建线程
     hThread1 = CreateThread(NULL, 0, IncrementThread, NULL, 0, NULL);
     hThread2 = CreateThread(NULL, 0, IncrementThread, NULL, 0, NULL);
 
     // 等待线程结束
     WaitForSingleObject(hThread1, INFINITE);
     WaitForSingleObject(hThread2, INFINITE);
 
     // 关闭互斥锁句柄
     CloseHandle(hMutex);
     // 关闭线程句柄
     CloseHandle(hThread1);
     CloseHandle(hThread2);
 
     printf("Shared data: %d\n", shared_data); // 预期结果: 200000
     return 0;
 }

4. 互斥锁的属性

互斥锁可以有不同的属性,例如:

  • 类型 (Type)
    • 普通锁 (Normal/Default):如果一个线程尝试重复锁定它已经持有的锁,行为是未定义的(可能导致死锁)。
    • 递归锁 (Recursive):允许同一个线程多次获取同一个锁。线程必须解锁相同次数才能真正释放该锁。这在递归函数中访问共享资源时可能有用。
    • 错误检查锁 (Error-checking):如果线程尝试重复锁定、解锁未持有的锁或解锁其他线程持有的锁,会返回错误。
  • 协议 (Protocol):用于处理优先级反转问题,如优先级继承(Priority Inheritance)和优先级天花板(Priority Ceiling)。

在 Pthreads 中,可以通过 pthread_mutexattr_t 对象设置这些属性。

5. 使用互斥锁的注意事项

  • 避免死锁
    • 按序加锁:所有线程都按照相同的顺序获取多个锁。
    • 尝试加锁:使用 pthread_mutex_trylock 或带有超时的等待,避免无限阻塞。
    • 避免长时间持有锁:尽量缩短临界区的长度。
  • 粒度:锁的粒度要合适。锁的范围太大会降低并发性,太小会增加锁操作的开销和复杂性。
  • 初始化与销毁:确保互斥锁在使用前正确初始化,在不再需要时正确销毁。
  • 错误处理:检查锁操作的返回值,妥善处理错误。

二、条件变量 (Condition Variable)

条件变量允许线程在某个条件变为真之前一直等待(阻塞)。它通常与互斥锁配合使用,以避免在等待条件时进行忙等待(busy-waiting)。

1. 工作原理

  • 等待 (Wait):一个线程在检查某个条件不满足时,可以调用条件变量的等待操作。这个操作会自动释放关联的互斥锁,并将线程置于等待状态。
  • 通知 (Signal/Notify):当另一个线程改变了共享数据,使得某个条件可能变为真时,它可以通知(唤醒)一个或多个等待在该条件变量上的线程。
  • 重新获取锁并检查条件:被唤醒的线程会尝试重新获取之前释放的互斥锁。获取成功后,它必须重新检查条件是否真的满足,因为可能存在虚假唤醒(spurious wakeup)或者其他线程先获取了锁并改变了条件。

2. POSIX Threads (Pthreads) 中的条件变量

主要函数:

  • pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr): 初始化条件变量。
  • pthread_cond_destroy(pthread_cond_t *cond): 销毁条件变量。
  • pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex): 等待条件变量。调用此函数前,线程必须已持有 mutex。函数执行时会原子地释放 mutex 并阻塞线程,直到被唤醒。唤醒后,函数会原子地重新获取 mutex
  • pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime): 带超时的等待。
  • pthread_cond_signal(pthread_cond_t *cond): 唤醒至少一个等待在该条件变量上的线程。
  • pthread_cond_broadcast(pthread_cond_t *cond): 唤醒所有等待在该条件变量上的线程。

示例代码 (生产者-消费者模型):

 #include <stdio.h>
 #include <pthread.h>
 #include <unistd.h> // for sleep
 
 #define BUFFER_SIZE 5
 int buffer[BUFFER_SIZE];
 int count = 0; // 缓冲区中的项目数量
 int in = 0;    // 生产者放入数据的位置
 int out = 0;   // 消费者取出数据的位置
 
 pthread_mutex_t mutex;     // 互斥锁保护缓冲区
 pthread_cond_t cond_prod;  // 条件变量:生产者等待缓冲区不满
 pthread_cond_t cond_cons;  // 条件变量:消费者等待缓冲区不空
 
 void *producer(void *arg) {
     int item;
     for (int i = 0; i < 10; ++i) {
         item = i + 100; // 生产一个项目
         pthread_mutex_lock(&mutex);
         while (count == BUFFER_SIZE) { // 缓冲区满,等待
             printf("Producer waiting: buffer full\n");
             pthread_cond_wait(&cond_prod, &mutex);
         }
         buffer[in] = item;
         in = (in + 1) % BUFFER_SIZE;
         count++;
         printf("Produced: %d, count: %d\n", item, count);
         pthread_cond_signal(&cond_cons); // 通知消费者缓冲区不空
         pthread_mutex_unlock(&mutex);
         sleep(1); // 模拟生产耗时
     }
     return NULL;
 }
 
 void *consumer(void *arg) {
     int item;
     for (int i = 0; i < 10; ++i) {
         pthread_mutex_lock(&mutex);
         while (count == 0) { // 缓冲区空,等待
             printf("Consumer waiting: buffer empty\n");
             pthread_cond_wait(&cond_cons, &mutex);
         }
         item = buffer[out];
         out = (out + 1) % BUFFER_SIZE;
         count--;
         printf("Consumed: %d, count: %d\n", item, count);
         pthread_cond_signal(&cond_prod); // 通知生产者缓冲区不满
         pthread_mutex_unlock(&mutex);
         sleep(2); // 模拟消费耗时
     }
     return NULL;
 }
 
 int main() {
     pthread_t prod_tid, cons_tid;
 
     pthread_mutex_init(&mutex, NULL);
     pthread_cond_init(&cond_prod, NULL);
     pthread_cond_init(&cond_cons, NULL);
 
     pthread_create(&prod_tid, NULL, producer, NULL);
     pthread_create(&cons_tid, NULL, consumer, NULL);
 
     pthread_join(prod_tid, NULL);
     pthread_join(cons_tid, NULL);
 
     pthread_mutex_destroy(&mutex);
     pthread_cond_destroy(&cond_prod);
     pthread_cond_destroy(&cond_cons);
 
     return 0;
 }

3. Windows API 中的条件变量

Windows Vista / Server 2008 及更高版本提供了原生的条件变量支持。

主要函数:

  • InitializeConditionVariable(PCONDITION_VARIABLE ConditionVariable): 初始化条件变量。
  • SleepConditionVariableCS(PCONDITION_VARIABLE ConditionVariable, PCRITICAL_SECTION CriticalSection, DWORD dwMilliseconds): 在临界区(Critical Section,功能类似互斥锁)上等待条件变量。
  • SleepConditionVariableSRW(PCONDITION_VARIABLE ConditionVariable, PSRWLOCK SRWLock, DWORD dwMilliseconds, ULONG Flags): 在读写锁(SRWLock)上等待条件变量。
  • WakeConditionVariable(PCONDITION_VARIABLE ConditionVariable): 唤醒一个等待在该条件变量上的线程。
  • WakeAllConditionVariable(PCONDITION_VARIABLE ConditionVariable): 唤醒所有等待在该条件变量上的线程。

对于旧版 Windows,通常需要使用其他同步原语(如事件 Event)来模拟条件变量的行为。

4. 使用条件变量的注意事项

  • 必须与互斥锁配合使用pthread_cond_waitpthread_cond_timedwait 的调用必须在互斥锁的保护下。
  • 循环等待 (Spurious Wakeups)pthread_cond_wait 返回后,必须重新检查条件,因为线程可能被虚假唤醒。通常使用 while (!condition) 循环。
  • signal vs broadcast
    • pthread_cond_signal 只唤醒一个等待线程。如果多个线程等待同一个条件,并且只有一个能继续,使用 signal
    • pthread_cond_broadcast 唤醒所有等待线程。如果条件变化可能使多个线程都能继续,或者不确定哪个线程应该被唤醒,使用 broadcast。被唤醒的线程会竞争互斥锁。
  • 死锁:确保在调用 wait 之前已锁定互斥锁,并且在 signalbroadcast 之后,如果需要,其他线程能够获取锁。

三、信号量 (Semaphore)

信号量是一个非负整数计数器,用于控制对一组共享资源的访问。它可以看作是互斥锁的推广。

1. 工作原理

  • P 操作 (Wait/Decrement/Down):线程尝试减少信号量的值。
    • 如果信号量的值大于 0,则将其减 1,线程继续执行。
    • 如果信号量的值等于 0,则线程阻塞,直到信号量的值大于 0。
  • V 操作 (Signal/Increment/Up):线程增加信号量的值。如果有其他线程因等待该信号量而被阻塞,则其中一个线程将被唤醒。

信号量分为两种:

  • 二进制信号量 (Binary Semaphore):值只能是 0 或 1,功能上类似于互斥锁(但不完全相同,互斥锁有所有权概念,信号量没有)。
  • 计数信号量 (Counting Semaphore):值可以大于 1,用于控制对 N 个相同类型资源的访问。

2. POSIX Threads (Pthreads) 中的信号量

POSIX 标准定义了两种信号量:命名的(System V IPC 或 POSIX named semaphores)和未命名的(内存信号量,通常与 Pthreads 一起使用)。这里主要介绍未命名信号量。

主要函数 (需要链接 -lpthread-lrt):

  • sem_init(sem_t *sem, int pshared, unsigned int value): 初始化未命名信号量。
    • sem: 指向要初始化的信号量对象的指针。
    • pshared: 如果为 0,信号量在当前进程的线程间共享;如果非 0,信号量在进程间共享(需要共享内存支持)。
    • value: 信号量的初始值。
  • sem_destroy(sem_t *sem): 销毁信号量。
  • sem_wait(sem_t *sem): 执行 P 操作(等待)。如果信号量值为 0,则阻塞。
  • sem_trywait(sem_t *sem): 尝试执行 P 操作。如果信号量值为 0,立即返回错误(通常是 EAGAIN)而不阻塞。
  • sem_timedwait(sem_t *sem, const struct timespec *abs_timeout): 带超时的等待。
  • sem_post(sem_t *sem): 执行 V 操作(通知/增加)。
  • sem_getvalue(sem_t *sem, int *sval): 获取信号量的当前值(注意,获取后值可能立即改变)。

示例代码 (限制并发访问数量):

 #include <stdio.h>
 #include <pthread.h>
 #include <semaphore.h>
 #include <unistd.h> // for sleep
 
 #define MAX_CONCURRENT_TASKS 2
 sem_t semaphore; // 计数信号量
 
 void *task_worker(void *arg) {
     int id = *(int*)arg;
     printf("Task %d: Waiting to start...\n", id);
 
     sem_wait(&semaphore); // P 操作,请求资源
 
     printf("Task %d: Started, performing work...\n", id);
     sleep(2); // 模拟工作
     printf("Task %d: Finished.\n", id);
 
     sem_post(&semaphore); // V 操作,释放资源
 
     return NULL;
 }
 
 int main() {
     pthread_t threads[5];
     int task_ids[5];
 
     // 初始化信号量,初始值为 MAX_CONCURRENT_TASKS
     if (sem_init(&semaphore, 0, MAX_CONCURRENT_TASKS) != 0) {
         perror("Semaphore initialization failed");
         return 1;
     }
 
     for (int i = 0; i < 5; ++i) {
         task_ids[i] = i + 1;
         pthread_create(&threads[i], NULL, task_worker, &task_ids[i]);
     }
 
     for (int i = 0; i < 5; ++i) {
         pthread_join(threads[i], NULL);
     }
 
     sem_destroy(&semaphore);
     printf("All tasks completed.\n");
     return 0;
 }

3. Windows API 中的信号量

Windows API 提供了信号量对象。

主要函数:

  • CreateSemaphore(LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG lInitialCount, LONG lMaximumCount, LPCTSTR lpName): 创建或打开一个信号量对象。
    • lInitialCount: 信号量的初始计数。
    • lMaximumCount: 信号量的最大计数。
  • WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds): 等待信号量(P 操作)。
  • ReleaseSemaphore(HANDLE hSemaphore, LONG lReleaseCount, LPLONG lpPreviousCount): 增加信号量的计数(V 操作)。
    • lReleaseCount: 增加的计数值,通常为 1。
    • lpPreviousCount: 可选,用于接收操作前的计数值。
  • CloseHandle(HANDLE hObject): 关闭信号量句柄。

4. 使用信号量的注意事项

  • 初始化与销毁:确保正确初始化和销毁。
  • P/V 操作成对:通常 P 操作和 V 操作需要成对出现,以避免资源泄漏或死锁。
  • 与互斥锁的区别
    • 所有权:互斥锁有所有权概念,即哪个线程锁定了它,就必须由哪个线程解锁。信号量没有所有权,任何线程都可以执行 V 操作。
    • 用途:互斥锁主要用于保护单个资源的互斥访问。信号量可以用于更广泛的同步场景,如控制对 N 个资源的访问、线程间的事件通知等。
  • 死锁:不当使用信号量也可能导致死锁,例如一个线程等待一个永远不会被增加的信号量。

四、总结与选择

特性

互斥锁 (Mutex)

条件变量 (Condition Variable)

信号量 (Semaphore)

基本功能

保护共享资源,实现互斥访问

线程等待某个条件成立

控制对有限数量资源的访问,或进行同步

状态

锁定/未锁定

(与互斥锁配合) 等待/被唤醒

计数器 (非负整数)

所有权

有 (通常由锁定线程解锁)

无 (依赖外部互斥锁)

典型用途

临界区保护

生产者-消费者,等待特定事件

资源池管理,控制并发数,任务间同步

Pthreads

pthread_mutex_t

pthread_cond_t

sem_t

Windows

Mutex (HANDLE), CRITICAL_SECTION

CONDITION_VARIABLE (Vista+), Event

Semaphore (HANDLE)

选择指南:

  • 如果你需要确保一次只有一个线程能访问某段代码或数据(临界区),使用互斥锁
  • 如果一个线程需要等待某个特定条件变为真(而这个条件是由其他线程改变的),并且希望在等待时不消耗 CPU(避免忙等待),使用条件变量(通常与互斥锁一起)。
  • 如果你需要控制同时访问某个资源的线程数量(例如,一个资源池有 N 个可用资源),或者需要一个比互斥锁更通用的同步机制(例如,一个线程完成某事后通知另一个线程开始),使用信号量

理解这些同步原语的特性和适用场景,是编写健壮、高效的多线程程序的关键。

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言