一、互斥:临界资源的排他性访问
1. 核心概念
互斥,即对临界资源的排他性访问,是多线程安全的基础。
- 临界资源:多线程环境下,会被多个线程同时读写的资源,比如全局变量、文件句柄、硬件设备等。这类资源的读写操作不具备原子性,直接并发访问会导致数据一致性问题。
- 排他访问:同一时刻,只能有一个线程对临界资源进行读写操作,其他线程必须等待,直到当前线程释放资源。
2. 为什么需要互斥?
以一个简单的 A++ 操作为例,这个看似简单的语句,在汇编层面至少会被拆解为 3 步:
- 从内存中读取变量
A的值到寄存器; - 将寄存器中的值加 1;
- 将寄存器的值写回内存中的
A。
在多线程并发时,线程调度可能发生在任意步骤之间。比如线程th1执行完前两步后被切换,线程th2接着执行完整的三步,此时th1再切回继续执行第三步,就会覆盖th2的修改,最终导致数据错误。
互斥机制的作用,就是将这段非原子性的代码包裹为原子操作,确保其在一次线程调度中完整执行。
3. 互斥锁的使用步骤与核心 API
在 Linux 多线程编程中,互斥锁的核心数据结构是 pthread_mutex_t,使用流程遵循 定义→初始化→加锁→解锁→销毁 的五步原则,每个步骤都对应明确的函数接口。
(1)定义互斥锁
#include <pthread.h>
// 定义一个互斥锁变量(全局变量保证所有线程可见)
pthread_mutex_t g_mutex;
(2)初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
- 功能:初始化已定义的互斥锁。
- 参数
mutex:指向要初始化的互斥锁变量的指针;attr:互斥锁属性,传入NULL表示使用默认属性。
- 返回值 :成功返回
0,失败返回非零错误码。
(3)加锁操作
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 功能 :对临界区代码加锁,加锁后的代码至解锁前的区域为原子操作,不允许线程调度打断。
- 关键特性 :如果互斥锁已被其他线程持有,当前线程会阻塞等待,直到锁被释放。
- 参数 :
mutex:指向已初始化的互斥锁指针。 - 返回值 :成功返回
0,失败返回非零错误码。
(4)解锁操作
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 功能:释放持有的互斥锁,允许其他等待的线程获取锁并执行临界区代码。
- 核心规则 :加锁和解锁必须由同一个线程执行,不允许跨线程解锁。
- 参数 :
mutex:指向已加锁的互斥锁指针。 - 返回值 :成功返回
0,失败返回非零错误码。
(5)销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 功能:互斥锁使用完毕后,释放其占用的系统资源。
- 参数 :
mutex:指向要销毁的互斥锁指针。 - 返回值 :成功返回
0,失败返回非零错误码。
(6)互斥锁核心使用规范
- 临界区代码要短小精悍:加锁后的代码执行时间越长,线程阻塞等待的时间就越久,会严重降低程序的并发效率。
- 临界区内禁止休眠 / 大耗时操作:在临界区中调用
sleep()、read()/write()(大文件)等耗时操作,会导致锁被长时间持有,其他线程无法执行,完全丧失并发优势。
(7)互斥锁完整代码示例
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 1. 定义互斥锁
pthread_mutex_t g_mutex;
// 临界资源:全局计数器
int g_counter = 0;
// 线程函数:对计数器累加
void *thread_func(void *arg) {
int thread_id = *(int *)arg;
for (int i = 0; i < 5; i++) {
// 3. 加锁:pthread_mutex_lock
int lock_ret = pthread_mutex_lock(&g_mutex);
if (lock_ret != 0) {
printf("线程%d 加锁失败!错误码:%d\n", thread_id, lock_ret);
continue;
}
// 临界区(原子操作,保证同一时刻只有一个线程执行)
// 遵循"短小精悍"原则:仅保留核心的临界资源操作
g_counter++;
printf("线程%d 累加后:g_counter = %d\n", thread_id, g_counter);
// 注意:此处若加sleep(1),会导致另一个线程长时间阻塞,违背互斥锁使用规范
// 4. 解锁:必须由当前加锁线程执行
int unlock_ret = pthread_mutex_unlock(&g_mutex);
if (unlock_ret != 0) {
printf("线程%d 解锁失败!错误码:%d\n", thread_id, unlock_ret);
}
sleep(1); // 非临界区可执行耗时操作
}
return NULL;
}
int main() {
pthread_t th1, th2;
int id1 = 1, id2 = 2;
// 2. 初始化互斥锁(默认属性)
int init_ret = pthread_mutex_init(&g_mutex, NULL);
if (init_ret != 0) {
printf("互斥锁初始化失败!错误码:%d\n", init_ret);
return -1;
}
// 创建两个线程
pthread_create(&th1, NULL, thread_func, &id1);
pthread_create(&th2, NULL, thread_func, &id2);
// 等待线程结束
pthread_join(th1, NULL);
pthread_join(th2, NULL);
// 5. 销毁互斥锁
int destroy_ret = pthread_mutex_destroy(&g_mutex);
if (destroy_ret != 0) {
printf("互斥锁销毁失败!错误码:%d\n", destroy_ret);
return -1;
}
printf("最终计数器值:%d\n", g_counter);
return 0;
}
(8)运行结果说明
两个线程会交替对 g_counter 进行累加,最终结果稳定为 10,不会出现数据不一致问题。如果去掉互斥锁,最终结果会小于 10,且每次运行结果都不相同。
二、同步:线程的有序化执行
1. 核心概念
同步 是有先后顺序的排他性资源访问,它要求线程按照预定的逻辑顺序执行,本质上是互斥的一个特例。
比如生产消费模型中,必须保证生产者线程生产出数据后,消费者线程才能读取数据,这就是典型的同步场景。
2. 同步与互斥的核心区别
| 特性 | 互斥锁 | 信号量(同步) |
|---|---|---|
| 核心目标 | 排他性访问临界资源 | 按顺序访问临界资源 |
| 锁 / 资源释放方 | 加锁线程自己释放 | 由其他线程交叉释放(th1 释放 th2,th2 释放 th1) |
| 临界区限制 | 禁止休眠、大耗时操作 | 允许短时间休眠、小耗时操作 |
| 资源数量 | 仅支持单一资源 | 支持多资源(计数信号量) |
3. 信号量:实现同步的核心工具
在 Linux 中,同步机制的实现通常依赖信号量 ,其核心数据结构是 sem_t,使用流程为 定义→初始化→PV 操作→销毁 。
(1)定义信号量
#include <semaphore.h>
// 定义一个信号量变量
sem_t g_sem;
(2)初始化信号量
int sem_init(sem_t *sem, int pshared, unsigned int value);
- 功能:初始化信号量,设置其共享属性和初始值。
- 参数
sem:指向要初始化的信号量变量的指针;pshared:共享属性,0表示线程间使用,非0表示进程间使用;value:信号量初始值,0表示无资源(线程阻塞),1表示单资源(二值信号量),大于1表示多资源(计数信号量)。
- 返回值 :成功返回
0,失败返回-1。
(3)信号量的 PV 操作
信号量的核心操作是P 操作(申请资源) 和V 操作(释放资源),这两个操作都是原子操作,对应两个核心函数。
P 操作:申请资源(sem_wait)
int sem_wait(sem_t *sem);
- 功能 :尝试申请信号量资源,执行
sem = sem - 1。- 若操作后
sem >= 0,线程继续执行; - 若操作后
sem < 0,线程阻塞等待,直到有其他线程释放资源。
- 若操作后
- 参数 :
sem:指向已初始化的信号量指针。 - 返回值 :成功返回
0,失败返回-1。
V 操作:释放资源(sem_post)
int sem_post(sem_t *sem);
- 功能 :释放信号量资源,执行
sem = sem + 1。 - 核心规则:由目标线程的 "依赖线程" 交叉释放(如消费者释放生产者、生产者释放消费者)。
- 关键特性:线程执行该函数时不会阻塞,释放后会唤醒等待该信号量的线程。
- 参数 :
sem:指向已初始化的信号量指针。 - 返回值 :成功返回
0,失败返回-1。
(4)销毁信号量
int sem_destroy(sem_t *sem);
- 功能:释放信号量占用的系统资源。
- 参数 :
sem:指向要销毁的信号量指针。 - 返回值 :成功返回
0,失败返回-1。
(5)计数信号量的特殊用法
信号量初值可设置为大于 1 的数值(如 3、5),适用于多资源互斥场景(资源数本身不唯一)。例如:
- 初始化信号量
sem_init(&sem, 0, 3),表示同时允许 3 个线程访问临界资源; - 每个线程执行
sem_wait()申请资源,sem_post()释放资源; - 当第 4 个线程执行
sem_wait()时,会阻塞等待前 3 个线程中任意一个释放资源。
(6)信号量核心使用规范
- 允许短时间休眠 / 小耗时操作:信号量的核心目标是保证线程执行顺序,而非极致的并发效率,因此临界区中可执行
sleep(1)等短耗时操作; - 交叉释放规则:同步场景下,信号量的 PV 操作需由不同线程交叉执行(如生产者 V 操作释放消费者,消费者 V 操作释放生产者)。
(7)信号量完整代码示例(生产消费模型)
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
// 定义信号量:控制生产消费顺序
sem_t g_sem_producer; // 生产者信号量
sem_t g_sem_consumer; // 消费者信号量
// 临界资源:产品缓冲区
int g_product = 0;
// 生产者线程函数
void *producer_func(void *arg) {
for (int i = 0; i < 5; i++) {
// P操作:申请生产者资源(初始值为1,可直接执行)
sem_wait(&g_sem_producer);
// 生产产品(允许短耗时操作)
g_product = i + 1;
printf("生产者生产产品:%d\n", g_product);
sleep(1); // 模拟生产耗时(信号量场景下允许)
// V操作:释放消费者资源(交叉释放,让消费者可以消费)
sem_post(&g_sem_consumer);
}
return NULL;
}
// 消费者线程函数
void *consumer_func(void *arg) {
for (int i = 0; i < 5; i++) {
// P操作:申请消费者资源(初始值为0,阻塞等待生产者释放)
sem_wait(&g_sem_consumer);
// 消费产品(允许短耗时操作)
printf("消费者消费产品:%d\n", g_product);
sleep(1); // 模拟消费耗时(信号量场景下允许)
// V操作:释放生产者资源(交叉释放,让生产者可以继续生产)
sem_post(&g_sem_producer);
}
return NULL;
}
int main() {
pthread_t th_producer, th_consumer;
// 初始化信号量
// 生产者信号量初始值1:允许先生产
sem_init(&g_sem_producer, 0, 1);
// 消费者信号量初始值0:必须等生产后才能消费
sem_init(&g_sem_consumer, 0, 0);
// 创建线程
pthread_create(&th_producer, NULL, producer_func, NULL);
pthread_create(&th_consumer, NULL, consumer_func, NULL);
// 等待线程结束
pthread_join(th_producer, NULL);
pthread_join(th_consumer, NULL);
// 销毁信号量
sem_destroy(&g_sem_producer);
sem_destroy(&g_sem_consumer);
return 0;
}
(8)计数信号量示例(多资源访问)
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
// 定义计数信号量:初始值3,允许3个线程同时访问
sem_t g_count_sem;
// 临界资源:资源使用计数
int g_res_used = 0;
void *thread_func(void *arg) {
int thread_id = *(int *)arg;
// P操作:申请资源
sem_wait(&g_count_sem);
g_res_used++;
printf("线程%d 占用资源,当前使用数:%d\n", thread_id, g_res_used);
sleep(2); // 模拟小耗时操作
// 释放资源
g_res_used--;
printf("线程%d 释放资源,当前使用数:%d\n", thread_id, g_res_used);
// V操作:释放资源
sem_post(&g_count_sem);
return NULL;
}
int main() {
pthread_t th[5];
int ids[5] = {1,2,3,4,5};
// 初始化计数信号量:允许3个线程同时访问
sem_init(&g_count_sem, 0, 3);
// 创建5个线程
for (int i = 0; i < 5; i++) {
pthread_create(&th[i], NULL, thread_func, &ids[i]);
}
// 等待所有线程结束
for (int i = 0; i < 5; i++) {
pthread_join(th[i], NULL);
}
sem_destroy(&g_count_sem);
return 0;
}
(9)运行结果说明
- 生产消费模型:严格按照生产→消费→生产→消费的顺序执行;
- 计数信号量模型:同一时刻最多有 3 个线程占用资源,第 4、5 个线程会阻塞,直到前 3 个线程释放资源。
三、死锁:多线程编程的 "隐形陷阱"
1. 死锁的概念
死锁是指由于锁资源的申请和释放逻辑不合理,导致多个线程互相等待对方持有的锁,最终所有线程都无法继续执行的现象。
比如线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1,此时两个线程会永远阻塞,程序陷入停滞。
2. 死锁产生的四个必要条件
死锁的发生必须同时满足以下四个条件,只要破坏其中任意一个,就能避免死锁。
- 互斥条件:一个资源每次只能被一个线程使用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源,在未使用完毕前,不能被强行剥夺。
- 循环等待条件:若干线程之间形成头尾相接的循环等待资源关系。
3. 死锁的规避思路
- 按固定顺序申请锁:所有线程都按照相同的顺序获取多个锁,避免循环等待。比如线程 A 和线程 B 都先获取锁 1,再获取锁 2。
- 锁的申请时限:使用
pthread_mutex_trylock()尝试加锁,设置超时时间,超时后放弃申请并释放已持有的锁。 - 减少锁的嵌套:尽量避免一个临界区内部再申请其他锁,降低锁依赖的复杂度。
- 资源一次性申请:在线程执行初期,一次性申请所有需要的锁,避免中途申请新锁。
四、总结
在 Linux 多线程编程中,互斥锁 解决了临界资源的排他性访问问题,保证了数据一致性,核心规则是 "加解锁同线程、临界区短小精悍";信号量在此基础上实现了线程的有序执行,核心规则是 "交叉释放资源、允许短耗时操作",计数信号量还可适配多资源互斥场景。而死锁作为多线程编程的常见问题,需要我们通过规范锁的使用逻辑来规避。
