【Linux】线程同步与互斥(1)
1. 线程互斥
在多线程环境中,线程共享进程的地址空间和资源(如全局变量、堆内存、文件描述符)。当多个线程同时访问临界资源时,可能导致数据不一致或逻辑错误,因此需要通过 “互斥” 机制保证临界区的 “原子性” 访问。本节从核心概念入手,解析线程互斥的必要性与基础原理。
1-1 线程互斥的核心背景概念
要理解线程互斥,需先明确以下 5 个关键概念,它们共同构成了互斥机制的理论基础:
概念 | 定义与作用 |
---|---|
共享资源 | 多线程可共同访问的资源(如全局变量、堆内存、文件、硬件设备等)。 |
临界资源 | 共享资源中需被保护的部分 —— 若多个线程同时操作,会导致数据错乱或逻辑错误。 |
临界区 | 线程内部访问临界资源的代码段(如修改全局变量的 3 行代码)。 |
互斥 | 一种同步机制:任何时刻仅允许一个线程进入临界区,确保临界资源的独占访问。 |
原子性 | 操作的 “不可分割性”—— 要么完整执行,要么完全不执行,不会被调度机制打断。 |
1. 共享资源 vs 临界资源
- 共享资源是 “可被多线程访问的资源”,范围更广;
- 临界资源是 “共享资源中需保护的子集”,是互斥机制的核心保护对象。示例:
- 进程的全局变量
int count = 0
是共享资源,若多个线程执行count++
,则count
属于临界资源(并发修改会导致数据错误); - 进程的代码段是共享资源,但仅读取不修改时,无需保护,不属于临界资源。
- 进程的全局变量
2. 临界区:互斥保护的 “代码范围”
临界区是线程中 “直接操作临界资源的代码”,需满足两个条件:
- 仅包含 “必须独占执行的代码”(范围越小越好,减少线程阻塞时间);
- 多线程的临界区需针对同一临界资源(否则无需互斥)。
示例:若线程 A 和线程 B 都执行count++
,则count++
对应的汇编代码(加载、加 1、存储)是临界区,需用互斥机制保护。
3. 互斥:解决 “并发冲突” 的核心机制
多线程并发访问临界资源时,会出现 “竞态条件(Race Condition)”—— 最终结果依赖线程的执行顺序,导致数据不一致。
竞态条件示例(count++
的问题):count++
看似是 1 行代码,实则对应 3 条汇编指令:
load
:将内存中的count
值加载到 CPU 寄存器;add
:寄存器中的值加 1;store
:将寄存器中的值写回内存。
若线程 A 和线程 B 同时执行count++
,可能出现以下执行顺序:
- 线程 A 执行
load
(寄存器 A=0); - 线程 B 执行
load
(寄存器 B=0); - 线程 A 执行
add
(寄存器 A=1)→store
(内存count=1
); - 线程 B 执行
add
(寄存器 B=1)→store
(内存count=1
)。
最终count
仅从 0 变为 1,而非预期的 2—— 这就是 “竞态条件”,需通过互斥解决:确保线程 A 执行完 3 条指令(完整进入并退出临界区)后,线程 B 才能进入临界区。
4. 原子性:互斥的 “底层保障”
原子性是互斥机制的核心特性 —— 只有当临界区的操作具备原子性时,才能避免竞态条件。
- 原子操作的本质:不会被调度机制打断(CPU 不会在原子操作执行过程中切换线程);
- 硬件支持:CPU 提供原子指令(如
xchg
、cmpxchg
),操作系统基于这些指令实现互斥锁(如pthread_mutex_t
),最终让临界区的访问具备原子性。
示例:通过互斥锁保护count++
后,线程 A 进入临界区执行count++
时,线程 B 会被阻塞,直到线程 A 释放锁 —— 此时count++
的 3 条指令对线程 B 而言,相当于 “不可分割的原子操作”。
总结
- 线程互斥的核心目标:解决多线程对临界资源的并发冲突,保证数据一致性。
- 关键逻辑链:多线程共享临界资源 → 需保护临界区 → 通过互斥机制确保临界区访问的原子性 → 避免竞态条件。
- 后续重点:基于 POSIX 线程库的
pthread_mutex_t
(互斥锁),实现临界区的原子性访问,这是线程互斥的工程实现核心。
2. 线程同步:条件变量、信号量与生产者消费者模型
线程同步是在保证数据安全(互斥) 的基础上,进一步控制线程的执行顺序,避免线程 “无效等待” 或 “饥饿”,实现线程间的协同工作。核心工具包括条件变量和信号量,而生产者消费者模型是同步机制的经典应用场景。
2-1 同步核心概念与竞态条件
1. 同步的定义
同步(Synchronization):在多线程环境中,通过特定机制(如条件变量、信号量),让线程按照预期顺序访问临界资源,既保证数据安全,又避免线程因 “条件不满足” 而空等(如消费者等待队列非空、生产者等待队列非满)。
2. 竞态条件(Race Condition)
- 本质:线程执行结果依赖于 “线程调度顺序”,导致数据不一致或逻辑错误。
- 产生场景:多线程并发访问临界资源,且操作不具备原子性。
- 解决思路:
- 用互斥锁(
pthread_mutex_t
)保证临界区原子性(解决 “数据安全”); - 用同步机制(条件变量 / 信号量)控制线程执行顺序(解决 “顺序协同”)。
- 用互斥锁(
2-2 条件变量(pthread_cond_t
)
条件变量是线程间 “基于条件的同步工具”:当线程的执行依赖于某个 “条件”(如队列非空、队列非满)时,可通过条件变量让线程在条件不满足时阻塞,条件满足时被唤醒。
2.2.1 条件变量的核心函数
函数原型 | 功能 | 关键说明 |
---|---|---|
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr) | 初始化条件变量 | attr 为NULL 表示默认属性,通常动态初始化或用宏PTHREAD_COND_INITIALIZER 静态初始化 |
int pthread_cond_destroy(pthread_cond_t *cond) | 销毁条件变量 | 仅初始化过的条件变量可销毁,销毁后不可再使用 |
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) | 等待条件满足 | 1. 原子操作:阻塞线程 + 释放互斥锁;2. 被唤醒后:自动重新竞争互斥锁,成功后返回;3. 必须与互斥锁配合使用 |
int pthread_cond_signal(pthread_cond_t *cond) | 唤醒一个等待线程 | 从等待队列中随机唤醒一个线程,避免 “惊群效应” |
int pthread_cond_broadcast(pthread_cond_t *cond) | 唤醒所有等待线程 | 适用于 “条件满足时所有线程都需响应” 的场景(如广播 “队列已清空”) |
2.2.2 为什么pthread_cond_wait
需要互斥锁?
条件变量的核心是 “基于共享条件的同步”,而 “条件” 本质是共享数据(如队列的空 / 满状态),必须用互斥锁保护,否则会出现以下问题:
- 条件判断与阻塞的原子性问题:若先解锁再阻塞(错误示例如下),在 “解锁” 到 “阻塞” 的间隙,其他线程可能修改条件并发送信号,导致当前线程错过信号,永久阻塞。
// 错误示例:解锁与阻塞非原子 pthread_mutex_unlock(&mutex); // 间隙:其他线程修改条件并发送信号,当前线程未收到 pthread_cond_wait(&cond, &mutex);
pthread_cond_wait
的原子性保障:函数内部会先检查条件,若不满足则原子执行 “释放锁 + 阻塞线程”,避免上述间隙;被唤醒后,会自动重新竞争锁,确保后续访问条件时的线程安全。
2.2.3 条件变量使用规范(避坑关键)
1. 等待条件:用while
而非if
条件变量存在 “伪唤醒”(Spurious Wakeup)—— 线程被唤醒但条件仍不满足(如系统调度误唤醒)。因此必须用while
循环重新检查条件:
pthread_mutex_lock(&mutex);
// 用while循环:伪唤醒后重新检查条件
while (条件不满足) { pthread_cond_wait(&cond, &mutex);
}
// 条件满足:执行临界区操作
pthread_mutex_unlock(&mutex);
2. 发送信号:先修改条件,再唤醒线程
唤醒前必须确保 “条件已满足”,且修改条件的操作需在互斥锁保护下:
pthread_mutex_lock(&mutex);
// 1. 先修改条件(如队列添加元素,使“队列非空”条件成立)
修改共享条件;
// 2. 再唤醒等待线程
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
2.2.4 条件变量封装
Cond.hpp
// 条件变量的封装#pragma once#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"using namespace MutexModule;namespace CondModule
{class Cond{public:Cond(){pthread_cond_init(&_cond, nullptr);}void Wait(Mutex &mutex){int n = pthread_cond_wait(&_cond, mutex.Get());if (n != 0){std::cout << "pthread_cond_wait error: " << n << std::endl;}}void Signal(){int n = pthread_cond_signal(&_cond);if (n != 0){std::cout << "pthread_cond_signal error: " << n << std::endl;}}void Broadcast(){int n = pthread_cond_broadcast(&_cond);if (n != 0){std::cout << "pthread_cond_broadcast error: " << n << std::endl;}}~Cond(){pthread_cond_destroy(&_cond);}private:pthread_cond_t _cond;};
}
运行结果:主线程每 1 秒唤醒一次,两个线程交替或同时输出 “活动” 信息。
2-3 POSIX 信号量(sem_t
)
信号量是一种 “计数器同步工具”,本质是通过原子操作控制共享资源的访问次数,可用于线程间或进程间同步。核心逻辑是 “P 操作(申请资源,计数器 - 1)” 和 “V 操作(释放资源,计数器 + 1)”。
2.3.1 信号量核心函数
函数原型 | 功能 | 关键说明 |
---|---|---|
int sem_init(sem_t *sem, int pshared, unsigned int value) | 初始化信号量 | 1. pshared :0 = 线程间共享,非 0 = 进程间共享;2. value :信号量初始值(资源总数) |
int sem_destroy(sem_t *sem) | 销毁信号量 | 仅初始化过的信号量可销毁 |
int sem_wait(sem_t *sem) | P 操作(申请资源) | 1. 若信号量值 > 0:值 - 1,立即返回;2. 若值 = 0:阻塞线程,直到值 > 0 |
int sem_post(sem_t *sem) | V 操作(释放资源) | 信号量值 + 1,若有线程阻塞则唤醒一个 |
2.3.2 信号量的封装
Sem.hpp
#pragma once#include <iostream>
#include <pthread.h>
#include <semaphore.h>namespace SemMoudle
{const int defaultvalue = 1;class Sem{public:Sem(unsigned int value = defaultvalue){sem_init(&_sem, 0, value);}void P() // 等待{int n = sem_wait(&_sem);if (n != 0){std::cout << "sem_wait error" << std::endl; // 出错处理}}void V() // 通知{int n = sem_post(&_sem);if (n != 0){std::cout << "sem_post error" << std::endl; // 出错处理}}~Sem(){sem_destroy(&_sem);}private:sem_t _sem;};
}
2.3.3 信号量与互斥锁的区别
特性 | 互斥锁(pthread_mutex_t ) | 信号量(sem_t ) |
---|---|---|
用途 | 保证临界区原子性(互斥) | 控制资源访问次数(同步 + 互斥) |
所有权 | 排他性:同一时间仅一个线程持有 | 无所有权:线程可释放其他线程申请的资源 |
初始值 | 1(二值信号量) | 可自定义(如 N=5 表示 5 个资源) |
适用场景 | 单资源互斥访问(如全局变量) | 多资源同步(如环形队列的空 / 满控制) |
2-4 生产者消费者模型(同步机制的经典应用)
生产者消费者模型是 “解耦生产者与消费者、平衡处理能力” 的设计模式,核心是通过阻塞队列(缓冲区)实现两者的异步通信。
2.4.1 模型核心:321 原则
- 3 种关系:
- 生产者与生产者:互斥(避免同时修改队列导致数据错乱);
- 消费者与消费者:互斥(避免同时读取队列导致数据重复);
- 生产者与消费者:同步(生产者等待队列非满,消费者等待队列非空)+ 互斥(访问队列时独占)。
- 2 种角色:生产者(生成数据并放入队列)、消费者(从队列取出数据并处理)。
- 1 个缓冲区:阻塞队列(核心组件,实现解耦)。
2.4.2 模型优点
- 解耦:生产者与消费者无需知道对方存在,仅通过队列交互;
- 支持并发:生产者和消费者可独立并发执行,提高系统吞吐量;
- 支持忙闲不均:队列可缓冲 “生产者快、消费者慢” 或反之的情况,避免一方等待另一方。
2.4.3 基于条件变量的阻塞队列(单 / 多生产消费)
通过条件变量控制队列的 “空 / 满” 条件,互斥锁保护队列访问,实现阻塞队列(BlockQueue
):
// 阻塞队列的实现
#ifndef MUTEX_HPP
#define MUTEX_HPP#pragma once
#include <iostream>
#include <pthread.h>
#include <string>
#include <queue>
#include "Mutex.hpp"
#include "Cond.hpp"// 生产者放数据进队列,消费者从队列取数据
// 当队列满了就要生产者进行等待,当队列空了就要消费者进行等待
// 一个队列,生产消费者数据交互的媒介
// 一把锁,为了让生产者放数据和消费者取数据的动作是原子的
// 两个条件变量, 为了使消费者和生产者在不同的情况下进行等待
using namespace MutexModule;
using namespace CondModule;
const int defaultcap = 5;template <typename T>
class BlockQueue
{
private:bool IsFull(){return _q.size() >= _cap;}bool IsEmpty(){return _q.empty();}public:BlockQueue(const int &cap = defaultcap): _cap(cap),_csleep_num(0),_psleep_num(0){}void Equeue(const T &in){LockGuard lockguard(_mutex);while (IsFull()){// 重点1. 挂起线程之前,要先释放锁// 重点2. 当线程被唤醒的时候,默认就在临界区内唤醒!// 要从pthread_con_wait成功返回,需要线程重新申请_mutex锁// 重点3. 如果被唤醒,但是申请锁失败,就会在锁上阻塞等待!_psleep_num++;_full_cond.Wait(_mutex);_psleep_num--;}_q.push(in);// 有数据了唤醒消费者// 唤醒操作是放在解锁前还是解锁后???都可以if(_csleep_num > 0){_empty_cond.Signal();std::cout << "wake up consumer..." << std::endl;}}T Pop(){LockGuard lockguard(_mutex);while (IsEmpty()){_csleep_num++;_empty_cond.Wait(_mutex);_csleep_num--;}T data = _q.front();_q.pop();// 有空间了唤醒生产者if(_psleep_num > 0){_full_cond.Signal();std::cout << "wake up producer..." << std::endl;}return data;}~BlockQueue(){}private:std::queue<T> _q;int _cap;Mutex _mutex;Cond _full_cond;Cond _empty_cond;int _csleep_num; // 消费者休眠个数int _psleep_num; // 生产者休眠个数
};#endif // MUTEX_HPP
2.4.4 基于信号量的环形队列(多生产消费)
环形队列用数组模拟,通过信号量控制 “空闲空间数”(_room_sem
)和 “数据数”(_data_sem
),互斥锁保护生产者 / 消费者内部的并发:
// POSIX信号量实现环形队列
// 规则怪谈:
// 1. 队列为空,生产者先行
// 2. 队列为满,消费者先行
// 3. 生产者不能把消费者套一个圈以上
// 4. 消费者不能超过生产者#pragma once
#include "Sem.hpp"
#include <vector>
#include "Mutex.hpp"using namespace SemMoudle;
using namespace MutexModule;template <typename T>
class RingQueue
{
public:RingQueue(): _cap(5),_blank_sem(_cap),_p_step(0),_c_step(0),_data_sem(_cap){_q.resize(_cap);};void Equeue(const T &in){// 先把信号量瓜分再申请锁// 申请空位置信号量_blank_sem.P();LockGuard mutexguard(_pmutex);_q[_p_step] = in;_p_step = (_p_step + 1) % _cap;// 通知数据信号量_data_sem.V();}void Pop(T *out){// 申请数据信号量_data_sem.P();LockGuard mutexguard(_cmutex);*out = _q[_c_step];_c_step = (_c_step + 1) % _cap;// 通知空位置信号量_blank_sem.V();}~RingQueue() {};private:std::vector<T> _q; // 数组模拟环形队列int _cap;Sem _blank_sem; // 空位置信号量,供生产者竞争int _p_step; // 生产者下标:标记下一个要写入的位置Sem _data_sem; // 数据信号量,供消费者竞争int _c_step; // 消费者下标:标记下一个要读取的位置Mutex _pmutex;Mutex _cmutex;
};
总结
- 条件变量:基于 “条件判断” 的同步工具,需与互斥锁配合,解决 “线程等待特定条件” 的问题,核心是
pthread_cond_wait
的原子性。 - 信号量:基于 “计数器” 的同步工具,可同时实现互斥与同步,适用于 “多资源访问控制”。
- 生产者消费者模型:同步机制的经典应用,通过阻塞队列解耦生产者与消费者,平衡处理能力,核心是解决 “3 种关系” 的同步与互斥。
- 如果资源可以被瓜分就考虑用信号量,如果是整体的就考虑用互斥锁