C高级:关于互斥锁(Mutex)与信号量(Semaphore)的学习记录
多线程编程中,多个线程同时访问共享资源时会产生数据竞争(Data Race),导致程序行为不可预测。互斥锁和信号量是 Linux 下最常用的两种同步机制,用于保护共享资源、协调线程执行顺序。本文将从概念原理到实际代码,系统讲解互斥锁和信号量的含义、作用,以及如何创建和使用它们。以下是我在职坐标的学习记录:
一、互斥锁(Mutex)
1.1 什么是互斥锁
互斥锁(Mutual Exclusion Lock,简称 Mutex)是一种用于保护共享资源的同步机制。它的核心思想是:同一时刻只允许一个线程持有锁,其他试图获取同一把锁的线程会被阻塞(挂起等待),直到锁被释放。
可以这样理解:互斥锁就像公共厕所的门锁——一个人进去后锁上门,其他人只能在外面排队等待;当前使用者出来并解锁后,下一个人才能进去。
1.2 互斥锁的作用
- 防止数据竞争:当多个线程同时读写同一个全局变量或共享资源时,使用互斥锁可以保证同一时刻只有一个线程在操作,避免数据不一致。
- 保护临界区(Critical Section):临界区是指访问共享资源的那段代码。互斥锁确保临界区内的代码被原子地执行,不会被其他线程打断。
- 保证操作的原子性:某些操作(如
count++)在 CPU 层面其实不是原子的(包含读-改-写三步),互斥锁可以让这类操作变得"逻辑上原子"。
1.3 互斥锁的工作原理
线程 A 线程 B
│ │
├─ pthread_mutex_lock() ──→ 获取锁成功
│ (进入临界区) │
│ ├─ pthread_mutex_lock() ──→ 阻塞等待...
│ │
├─ pthread_mutex_unlock() ──→ 释放锁
│ │
│ └─ 被唤醒,获取锁成功
│ (进入临界区)
1.4 互斥锁的类型
POSIX 标准定义了四种互斥锁类型:
| 类型 | 说明 |
|---|---|
PTHREAD_MUTEX_NORMAL |
普通锁(默认)。一个线程重复加锁会导致死锁。 |
PTHREAD_MUTEX_ERRORCHECK |
检错锁。重复加锁会返回 EDEADLK 错误,不会死锁。 |
PTHREAD_MUTEX_RECURSIVE |
递归锁。允许同一线程多次加锁,需要相同次数的解锁。 |
PTHREAD_MUTEX_DEFAULT |
默认类型,行为取决于系统实现(通常是 NORMAL)。 |
二、互斥锁的创建与使用
2.1 相关头文件
#include <pthread.h>
2.2 初始化互斥锁
互斥锁可以通过两种方式初始化:静态初始化和动态初始化。
方式一:静态初始化(最简单)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
适用于全局变量或静态变量,使用默认属性(PTHREAD_MUTEX_DEFAULT 类型)。
方式二:动态初始化(可自定义属性)
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
| 参数 | 类型 | 说明 |
|---|---|---|
mutex |
pthread_mutex_t * |
指向互斥锁变量的指针。 |
attr |
const pthread_mutexattr_t * |
互斥锁属性对象。传 NULL 使用默认属性。如果需要设置递归锁等特殊类型,需要先初始化属性对象。 |
动态初始化示例:
pthread_mutex_t mutex;
// 使用默认属性
pthread_mutex_init(&mutex, NULL);
// 或者设置递归锁
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&mutex, &attr);
pthread_mutexattr_destroy(&attr);
2.3 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
| 参数 | 类型 | 说明 |
|---|---|---|
mutex |
pthread_mutex_t * |
要加锁的互斥锁指针。 |
- 成功:返回
0,当前线程持有锁,可以进入临界区。 - 失败:返回错误码。如果锁已被其他线程持有,调用线程会阻塞,直到锁被释放。
非阻塞加锁版本:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
如果锁已被占用,不会阻塞,而是立即返回 EBUSY。适用于不想等待的场景。
2.4 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
| 参数 | 类型 | 说明 |
|---|---|---|
mutex |
pthread_mutex_t * |
要解锁的互斥锁指针。必须是当前线程持有的锁。 |
- 成功:返回
0,释放锁,唤醒一个等待该锁的线程。 - 失败:返回错误码。常见错误包括对未持有的锁调用 unlock(
EPERM)。
2.5 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
| 参数 | 类型 | 说明 |
|---|---|---|
mutex |
pthread_mutex_t * |
要销毁的互斥锁指针。销毁前必须确保没有线程持有或等待该锁。 |
静态初始化的互斥锁不需要显式销毁(程序结束时自动释放)。动态初始化的互斥锁使用完毕后应该调用 destroy 释放资源。
2.6 完整示例:多线程计数器
以下示例展示不使用互斥锁和使用互斥锁的区别:
不加锁(有数据竞争):
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 4
#define INCREMENT_PER_THREAD 1000000
int counter = 0; // 共享全局变量
void *increment(void *arg) {
for (int i = 0; i < INCREMENT_PER_THREAD; i++) {
counter++; // 非原子操作!多个线程同时执行会丢失更新
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
// 期望值:4,000,000 实际值:通常小于期望值(数据竞争)
printf("counter = %d (期望: %d)\n", counter, NUM_THREADS * INCREMENT_PER_THREAD);
return 0;
}
加锁(正确):
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 4
#define INCREMENT_PER_THREAD 1000000
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化
void *increment(void *arg) {
for (int i = 0; i < INCREMENT_PER_THREAD; i++) {
pthread_mutex_lock(&mutex); // 加锁
counter++; // 临界区
pthread_mutex_unlock(&mutex); // 解锁
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("counter = %d (期望: %d)\n", counter, NUM_THREADS * INCREMENT_PER_THREAD);
pthread_mutex_destroy(&mutex); // 销毁互斥锁
return 0;
}
三、信号量(Semaphore)
3.1 什么是信号量
信号量(Semaphore)是一种更通用的同步机制,它维护一个整型计数器,用于控制同时访问某个资源的线程数量。与互斥锁不同,信号量允许多个线程同时持有(只要计数器大于 0),因此它不仅可以用于互斥,还可以用于协调线程的执行顺序(如生产者-消费者模型)。
可以这样理解:信号量就像一个停车场的剩余车位计数器——每进来一辆车,计数器减 1(P 操作);每出去一辆车,计数器加 1(V 操作)。当计数器为 0 时,新的车只能在外面等待。
3.2 信号量的作用
- 互斥访问:当信号量的初始值为 1 时,它退化为一个互斥锁(二值信号量),用于保护共享资源。
- 资源计数:当信号量的初始值为 N 时,它表示有 N 个可用资源。线程获取资源时计数器减 1,释放资源时计数器加 1。
- 同步协调:信号量可以用于协调线程之间的执行顺序。例如,生产者生产一个物品后对信号量加 1,消费者在信号量大于 0 时才能消费。
3.3 信号量的两种操作
信号量只有两种原子操作:
| 操作 | 名称 | 效果 | 说明 |
|---|---|---|---|
| P 操作(Proberen,荷兰语"尝试") | sem_wait() |
信号量值减 1 | 如果减 1 后值小于 0,则线程阻塞等待 |
| V 操作(Verhogen,荷兰语"增加") | sem_post() |
信号量值加 1 | 如果有线程在等待,唤醒其中一个 |
初始值 = 3(表示有 3 个资源)
线程 A: sem_wait() → 值变为 2 → 成功获取资源
线程 B: sem_wait() → 值变为 1 → 成功获取资源
线程 C: sem_wait() → 值变为 0 → 成功获取资源
线程 D: sem_wait() → 值变为 -1 → 阻塞等待...
线程 A: sem_post() → 值变为 0 → 唤醒线程 D
线程 D: 被唤醒,继续执行
3.4 信号量 vs 互斥锁
| 对比维度 | 互斥锁(Mutex) | 信号量(Semaphore) |
|---|---|---|
| 本质 | 二值状态(锁定/解锁) | 整型计数器 |
| 同时访问 | 只允许 1 个线程 | 允许 N 个线程(N = 信号量初始值) |
| 所有权 | 有——只有加锁的线程才能解锁 | 无——任何线程都可以 post |
| 用途 | 保护共享资源(互斥) | 互斥 + 同步协调 |
| 性能 | 通常更快(更简单) | 稍慢(涉及计数器操作) |
| 头文件 | <pthread.h> |
<semaphore.h> |
四、信号量的创建与使用
4.1 相关头文件
#include <semaphore.h>
// 编译时链接 -lpthread(sem_open 可能需要 -lrt)
4.2 初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
| 参数 | 类型 | 说明 |
|---|---|---|
sem |
sem_t * |
指向信号量变量的指针。 |
pshared |
int |
控制信号量的共享范围。0 表示信号量在同一进程的线程间共享(最常用);非 0 表示在多个进程间共享(需要配合共享内存使用)。 |
value |
unsigned int |
信号量的初始值。这个值决定了同时可以有多少个线程通过 sem_wait() 而不被阻塞。 |
常见初始值:
sem_t sem;
// 初始值为 1:等价于互斥锁(二值信号量)
sem_init(&sem, 0, 1);
// 初始值为 5:表示有 5 个可用资源
sem_init(&sem, 0, 5);
// 初始值为 0:用于线程同步,初始时阻塞
sem_init(&sem, 0, 0);
4.3 P 操作(等待/获取)
int sem_wait(sem_t *sem);
| 参数 | 类型 | 说明 |
|---|---|---|
sem |
sem_t * |
要操作的信号量指针。 |
- 成功:返回
0,信号量值减 1。如果减 1 后值仍 ≥ 0,立即返回;如果 < 0,线程阻塞。 - 失败:返回
-1,并设置errno(如EINTR被信号中断)。
非阻塞版本:
int sem_trywait(sem_t *sem);
如果信号量值为 0,不会阻塞,而是立即返回 -1 并设置 errno = EAGAIN。
带超时的版本(Linux):
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
最多等待到 abs_timeout 指定的绝对时间。超时后返回 -1 并设置 errno = ETIMEDOUT。
4.4 V 操作(释放/增加)
int sem_post(sem_t *sem);
| 参数 | 类型 | 说明 |
|---|---|---|
sem |
sem_t * |
要操作的信号量指针。 |
- 成功:返回
0,信号量值加 1。如果有线程在等待该信号量,唤醒其中一个。 - 失败:返回
-1,并设置errno。
4.5 获取信号量当前值
int sem_getvalue(sem_t *sem, int *sval);
| 参数 | 类型 | 说明 |
|---|---|---|
sem |
sem_t * |
信号量指针。 |
sval |
int * |
输出参数,接收信号量的当前值。如果值为负数,其绝对值表示当前等待的线程数。 |
4.6 销毁信号量
int sem_destroy(sem_t *sem);
| 参数 | 类型 | 说明 |
|---|---|---|
sem |
sem_t * |
要销毁的信号量指针。销毁前必须确保没有线程在等待该信号量。 |
五、信号量的完整示例
5.1 示例一:用信号量实现互斥(二值信号量)
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#define NUM_THREADS 4
#define INCREMENT_PER_THREAD 1000000
int counter = 0;
sem_t sem; // 二值信号量,初始值为 1
void *increment(void *arg) {
for (int i = 0; i < INCREMENT_PER_THREAD; i++) {
sem_wait(&sem); // P 操作(加锁)
counter++;
sem_post(&sem); // V 操作(解锁)
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
sem_init(&sem, 0, 1); // 初始值为 1
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("counter = %d (期望: %d)\n", counter, NUM_THREADS * INCREMENT_PER_THREAD);
sem_destroy(&sem);
return 0;
}
5.2 示例二:生产者-消费者模型
这是一个经典的使用场景:生产者生产物品放入缓冲区,消费者从缓冲区取出物品消费。使用两个信号量:
empty:表示缓冲区中空闲位置的数量(初始值为缓冲区大小)。full:表示缓冲区中已有物品的数量(初始值为 0)。
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUFFER_SIZE 5
#define NUM_ITEMS 10
int buffer[BUFFER_SIZE];
int in = 0; // 生产者的写入位置
int out = 0; // 消费者的读取位置
sem_t empty; // 空闲槽位数
sem_t full; // 已填充的槽位数
pthread_mutex_t mutex; // 保护 buffer 的互斥锁
void *producer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < NUM_ITEMS; i++) {
int item = id * 100 + i;
sem_wait(&empty); // 等待空闲槽位
pthread_mutex_lock(&mutex);
buffer[in] = item;
printf("[生产者 %d] 生产物品: %d, 放入位置 %d\n", id, item, in);
in = (in + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(&full); // 通知有新物品
usleep(100000); // 模拟生产时间
}
return NULL;
}
void *consumer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < NUM_ITEMS; i++) {
sem_wait(&full); // 等待有物品可消费
pthread_mutex_lock(&mutex);
int item = buffer[out];
printf("[消费者 %d] 消费物品: %d, 从位置 %d\n", id, item, out);
out = (out + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(&empty); // 释放一个槽位
usleep(200000); // 模拟消费时间(比生产慢)
}
return NULL;
}
int main() {
pthread_t prod[2], cons[2];
int prod_ids[2] = {1, 2};
int cons_ids[2] = {1, 2};
sem_init(&empty, 0, BUFFER_SIZE); // 初始有 5 个空位
sem_init(&full, 0, 0); // 初始没有物品
pthread_mutex_init(&mutex, NULL);
// 创建 2 个生产者线程
for (int i = 0; i < 2; i++) {
pthread_create(&prod[i], NULL, producer, &prod_ids[i]);
}
// 创建 2 个消费者线程
for (int i = 0; i < 2; i++) {
pthread_create(&cons[i], NULL, consumer, &cons_ids[i]);
}
// 等待所有线程结束
for (int i = 0; i < 2; i++) {
pthread_join(prod[i], NULL);
pthread_join(cons[i], NULL);
}
sem_destroy(&empty);
sem_destroy(&full);
pthread_mutex_destroy(&mutex);
printf("所有生产消费完成!\n");
return 0;
}
5.3 示例三:用信号量实现线程同步(控制执行顺序)
假设线程 B 必须等待线程 A 完成某项初始化工作后才能开始执行:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
sem_t sem;
void *thread_a(void *arg) {
printf("[线程 A] 正在执行初始化工作...\n");
sleep(2); // 模拟耗时操作
printf("[线程 A] 初始化完成,通知线程 B 可以开始\n");
sem_post(&sem); // V 操作:通知线程 B
return NULL;
}
void *thread_b(void *arg) {
printf("[线程 B] 等待线程 A 完成初始化...\n");
sem_wait(&sem); // P 操作:阻塞等待
printf("[线程 B] 收到通知,开始执行任务\n");
return NULL;
}
int main() {
pthread_t tid_a, tid_b;
sem_init(&sem, 0, 0); // 初始值为 0,线程 B 会阻塞
// 先创建线程 B(它会阻塞等待)
pthread_create(&tid_b, NULL, thread_b, NULL);
// 再创建线程 A
pthread_create(&tid_a, NULL, thread_a, NULL);
pthread_join(tid_a, NULL);
pthread_join(tid_b, NULL);
sem_destroy(&sem);
return 0;
}
六、互斥锁与信号量的选型建议
| 场景 | 推荐方案 |
|---|---|
| 保护共享变量(如计数器、链表) | 互斥锁 |
| 保护复杂数据结构(如数据库连接池) | 互斥锁 |
| 控制同时访问某资源的线程数量(如连接池限流) | 信号量(初始值 = 最大并发数) |
| 生产者-消费者模型 | 信号量 + 互斥锁 |
| 线程间的执行顺序同步 | 信号量(初始值为 0) |
| 简单互斥,不需要协调顺序 | 互斥锁(性能更好) |
经验法则:
- 互斥锁用于"互斥"——保证同一时刻只有一个线程访问共享资源。
- 信号量用于"同步"——协调多个线程的执行顺序或控制并发数量。
- 实际项目中,两者经常配合使用:信号量控制并发数量,互斥锁保护共享数据结构。
七、常见陷阱与注意事项
7.1 互斥锁的常见错误
- 忘记解锁:在临界区内
return或break时忘记 unlock,导致死锁。 - 重复加锁:对普通互斥锁(
NORMAL类型)重复加锁会导致死锁。如果需要递归加锁,使用PTHREAD_MUTEX_RECURSIVE类型。 - 解锁未持有的锁:对不是当前线程持有的锁调用 unlock 会导致未定义行为。
7.2 信号量的常见错误
- 初始值设置错误:初始值决定了并发访问的数量,设置不当会导致资源过度竞争或线程永久阻塞。
- 忘记 destroy:使用完毕后应调用
sem_destroy(),否则可能导致资源泄漏。 - 在信号量等待时被信号中断:
sem_wait()可能被信号中断(返回EINTR),需要处理这种情况(通常用循环重试)。
7.3 死锁的预防
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行。常见的预防策略包括:
- 锁排序:如果多个线程需要获取多把锁,始终按照相同的顺序获取(如先锁 A 再锁 B)。
- 超时机制:使用
pthread_mutex_trylock()或sem_timedwait(),获取失败时放弃并重试。 - 最小化临界区:尽量减少临界区内的代码量,降低锁竞争的概率。
八、总结
| 机制 | 核心作用 | 初始值含义 | 关键函数 |
|---|---|---|---|
| 互斥锁 | 保证同一时刻只有一个线程访问共享资源 | 无(二值状态) | pthread_mutex_init/lock/unlock/destroy |
| 信号量 | 控制并发数量 + 线程同步 | N = 可用资源数 | sem_init/wait/post/destroy |
互斥锁和信号量是多线程编程的基石。互斥锁简单高效,专注于"互斥";信号量更灵活,既能互斥又能同步。理解它们的原理和使用场景,是写出正确、高效并发程序的前提。
浙公网安备 33010602011771号