Linux多线程与同步详解:pthread、互斥锁、条件变量与线程池

Linux多线程与同步详解:pthread、互斥锁、条件变量与线程池
Lichermione线程是进程内部的执行流。多个线程共享同一进程的大部分资源,因此创建和切换成本通常低于进程,但共享也会带来数据竞争、死锁、丢失唤醒和生命周期管理等问题。
本文以 POSIX Threads 为主线,讲解线程创建、退出、等待、分离,以及互斥锁、条件变量、信号量、读写锁、生产者消费者模型和线程池设计。
一、进程与线程
1.1 线程是什么
线程可以理解为进程内部的一条执行路线。一个进程至少包含一个线程,即执行 main 的主线程。
Linux 内核使用任务结构调度执行流,用户通常通过 pthread 接口创建 POSIX 线程。不要简单把线程理解成“只有一小段代码的进程”,更重要的是它与同进程线程之间的资源共享关系。
1.2 线程共享什么
同一进程中的线程通常共享:
- 虚拟地址空间中的代码段、全局区和堆;
- 打开的文件描述符;
- 当前工作目录和根目录;
- 用户与组身份;
- 信号处置方式;
- 大部分进程级资源。
线程各自拥有:
- 栈;
- 寄存器上下文和程序计数器;
- 调度状态;
- 线程 ID;
- 信号掩码;
errno;- 线程局部存储。
1.3 线程的优点
- 同一地址空间内交换数据方便;
- 创建和切换通常比进程轻量;
- 能在多核 CPU 上并行执行;
- 一个线程等待 I/O 时,其他线程仍可工作;
- 适合线程池、流水线和后台任务。
1.4 线程的代价
- 一个线程非法访问内存可能终止整个进程;
- 共享数据需要同步;
- 锁竞争会降低性能;
- 时序错误难以复现;
- 线程过多会增加栈内存、调度和缓存开销;
- 同一进程内部缺乏进程级故障隔离。
二、pthread 基础
头文件与编译选项:
1 |
1 | gcc -std=c11 -Wall -Wextra -Wpedantic thread.c -pthread -o thread |
推荐使用 -pthread,它不只是链接库,还会启用与线程相关的编译配置。
pthread 函数大多直接返回错误码,不一定设置 errno:
1 | int rc = pthread_create(...); |
三、创建线程
1 | int pthread_create(pthread_t *thread, |
参数:
thread:接收线程标识;attr:线程属性,传NULL使用默认属性;start_routine:线程入口函数;arg:传给入口函数的指针。
3.1 基本示例
1 |
|
3.2 参数生命周期
传给线程的 arg 只是一个指针。必须确保线程使用它时对象仍然存在。
常见错误:
1 | for (int i = 0; i < 4; ++i) { |
所有线程得到的是同一个 i 的地址,并且读取时值可能已经变化。可以为每个线程准备独立数组元素或动态对象。
四、线程标识
1 | pthread_t pthread_self(void); |
pthread_t 是实现定义的“不透明类型”,不要假设它一定是整数、地址或 Linux 内核 TID。
比较两个 pthread 标识应使用 pthread_equal,不要依赖 == 的可移植性。
Linux 内核线程 ID 可通过 gettid() 获得,但它与 pthread_t 不是同一个概念。
五、线程退出
工作线程常见退出方式:
- 入口函数
return; - 调用
pthread_exit; - 被其他线程请求取消;
- 整个进程通过
exit、_exit、return main或致命信号结束。
1 | void pthread_exit(void *retval); |
返回值不能指向已经失效的线程栈局部变量:
1 | /* 错误:函数返回后 local 已失效 */ |
可以返回:
- 动态分配对象,由
join方释放; - 生命周期足够长的共享对象;
- 经
intptr_t谨慎编码的小整数,但可读性通常不如结构体。
主线程调用 pthread_exit(NULL) 可结束主线程而让其他线程继续,但多数应用更推荐明确 join 所有工作线程。
六、等待与分离
6.1 pthread_join
1 | int pthread_join(pthread_t thread, void **retval); |
join 的作用:
- 等待目标线程终止;
- 获取线程返回值;
- 回收 joinable 线程资源。
一个 joinable 线程应由且仅由一个线程执行 join。
6.2 pthread_detach
1 | int pthread_detach(pthread_t thread); |
分离线程退出后由系统自动回收,不能再 join。适合真正不需要返回值、且程序能可靠管理整体退出的后台任务。
“每个连接创建一个 detached 线程”虽然简单,但高并发下会产生大量线程。实际服务器通常使用线程池或事件驱动模型。
七、线程取消
1 | int pthread_cancel(pthread_t thread); |
取消不是无条件强杀。默认是延迟取消:目标线程执行到取消点时才响应。
如果线程持锁或管理资源,需要用清理处理器或 RAII 确保取消时释放资源。随意启用异步取消会让程序在任意指令位置停止,极难保证一致性。
工程中更推荐协作式停止:
- 设置停止标志;
- 唤醒条件变量或事件;
- 让线程自行离开循环;
join等待退出。
八、数据竞争与临界区
8.1 基本概念
- 临界资源:多个执行流共同访问的资源;
- 临界区:访问临界资源的代码区域;
- 互斥:同一时刻只允许一个执行流进入关键区域;
- 原子操作:从并发观察角度不可分割的操作;
- 数据竞争:多个线程并发访问同一内存位置,至少一个是写,并且缺少适当同步。
在 C/C++ 内存模型中,数据竞争会导致未定义行为,不只是“结果偶尔少加几次”。
8.2 i++ 为什么不是并发原子操作
普通 i++ 通常包含读取、计算和写回。两个线程可能读取同一个旧值,然后分别写回,造成更新丢失。
解决方式取决于业务:
- 使用互斥锁保护复合不变量;
- 使用 C11/C++ 原子类型处理简单计数;
- 把数据限制在线程内部;
- 通过消息队列转移所有权。
九、互斥锁
9.1 初始化和销毁
静态初始化:
1 | pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; |
动态初始化:
1 | pthread_mutex_t mutex; |
动态初始化的锁在不再使用且没有线程持有或等待时销毁。
9.2 加锁与解锁
1 | pthread_mutex_lock(&mutex); |
其他接口:
1 | pthread_mutex_trylock(&mutex); |
trylock 失败不应进入忙等死循环,除非已经证明这比阻塞或事件通知更合适。
9.3 缩小临界区
锁内只放必须一起保护的共享状态,不要轻易在持锁时执行:
- 长时间 I/O;
- 网络请求;
sleep;- 不受控制的回调;
- 复杂内存分配;
- 再次获取未知锁。
临界区过大会降低并发度,过小又可能破坏业务不变量。应围绕“不变量”确定锁边界,而不是机械地只锁一行。
十、死锁
典型死锁:
1 | 线程 A 持有锁 1,等待锁 2 |
预防策略:
- 所有代码遵守统一的锁顺序;
- 避免持锁调用外部回调;
- 减少嵌套锁;
- 必要时使用
trylock或带超时锁; - 用 RAII 保证异常和提前返回时解锁;
- 在设计文档中记录锁保护的数据与顺序。
C++ 可使用:
1 | std::scoped_lock lock(mutex_a, mutex_b); |
它能以避免死锁的方式同时获取多个互斥量。
十一、条件变量
互斥锁保护共享状态,条件变量让线程在“条件不满足”时休眠,并在状态变化后被唤醒。
1 | int pthread_cond_wait(pthread_cond_t *cond, |
11.1 为什么 wait 必须配合互斥锁
pthread_cond_wait 会原子完成:
- 释放互斥锁;
- 进入等待;
- 被唤醒后重新获取互斥锁;
- 返回调用者。
这样避免“释放锁后、真正睡眠前”错过通知。
11.2 为什么必须使用 while
正确模式:
1 | pthread_mutex_lock(&mutex); |
不能只用 if,原因包括:
- 允许虚假唤醒;
- 多个等待者被唤醒后,条件可能先被其他线程消费;
- 通知只是“状态可能变化”,共享状态才是真正条件。
十二、完整的有界阻塞队列示例
下面用互斥锁和两个条件变量实现单生产者、单消费者模型。设计同样支持扩展到多个生产者和消费者。
1 |
|
编译运行:
1 | gcc -std=c11 -Wall -Wextra -Wpedantic bounded_queue.c -pthread -o bounded_queue |
十三、POSIX 信号量
1 |
|
未命名信号量中:
pshared == 0:在同一进程线程之间共享;pshared != 0:供进程间共享,但信号量对象必须放在真正的共享内存中,且平台支持该能力。
环形队列常使用两个计数信号量:
spaces:剩余空槽数量,初值为容量;items:已有数据数量,初值为 0。
多生产者或多消费者还需保护读写下标,不能只依靠两个计数信号量就认为所有共享状态都安全。
十四、读写锁
读写锁允许:
- 多个读者同时持有读锁;
- 写者独占;
- 有写者时不允许其他读者或写者进入。
1 | pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER; |
读写锁不一定比普通互斥锁快。只有读操作比例高、临界区足够长、锁竞争明显时才可能获益。具体的读者/写者公平策略具有实现差异,不能把“读者永远优先”当作可移植语义。
十五、线程安全与可重入
15.1 线程安全
多个线程并发调用时,函数能保持正确结果和共享状态一致性,可称为线程安全。
常见实现方式:
- 不共享可变状态;
- 使用互斥锁;
- 使用原子操作;
- 使用线程局部存储;
- 由调用者提供输出缓冲区。
15.2 可重入
函数在尚未执行完时又被再次调用,仍能正确工作,称为可重入。信号处理器可能造成异步重入,因此可重入要求通常比普通线程安全更严格。
一个使用互斥锁保护全局状态的函数可能线程安全,却不一定异步信号可重入:信号可能在同一线程持锁时打断它,处理器再次调用该函数会自锁死锁。
因此:
- 可重入通常意味着更强的状态隔离;
- 线程安全不自动等于信号处理器安全;
- “加锁后就是可重入”是错误说法。
十六、线程安全单例
旧式“双重检查 + volatile 指针”不能单独保证 C++ 内存模型下的安全发布。volatile 不建立线程间 happens-before 关系。
C++11 起,函数内静态对象的初始化由语言保证线程安全:
1 | class Config { |
需要显式一次性初始化时可使用 std::call_once 和 std::once_flag。
单例解决的是“只有一个实例”,不自动保证该实例内部成员函数线程安全。
十七、线程池设计
线程池通常包含:
- 固定或可调数量的工作线程;
- 任务队列;
- 保护队列的互斥锁;
- “有任务可取”的条件变量;
- 容量限制或背压策略;
- 停止标志;
- 优雅关闭和强制关闭语义。
基本流程:
1 | 提交者加锁 -> 任务入队 -> signal -> 解锁 |
17.1 为什么任务要在锁外执行
如果工作线程持锁执行任务,其他线程无法取任务或提交任务,线程池会退化成串行执行。
17.2 停止策略
常见两种:
- drain:不再接收新任务,但处理完队列中已有任务;
- cancel pending:丢弃尚未开始的任务,运行中的任务协作停止。
析构函数必须明确采取哪种策略,不能让工作线程继续访问已经销毁的队列和互斥量。
17.3 队列必须有上限
无界队列在流量持续超过处理能力时会不断占用内存。生产服务器应设计:
- 最大任务数;
- 提交超时;
- 拒绝策略;
- 调用方降级或限流;
- 队列长度与等待时间监控。
十八、性能与调试
18.1 线程数不是越多越好
CPU 密集型任务的工作线程数通常接近可用 CPU 核数;I/O 密集型任务可以更多,但最终应通过负载测试确定。
过多线程会造成:
- 上下文切换增加;
- 栈内存增加;
- 缓存抖动;
- 锁竞争;
- 调度尾延迟上升。
18.2 常用工具
1 | top -H -p PID |
GDB 中:
1 | info threads |
检测数据竞争可以使用 ThreadSanitizer:
1 | gcc -g -O1 -fsanitize=thread program.c -pthread -o program_tsan |
ThreadSanitizer 与 AddressSanitizer 通常不在同一次构建中组合使用。
十九、常见错误
- 将同一个循环变量地址传给所有线程;
- 返回线程栈局部变量地址;
- 忘记
join或detach; - pthread 接口失败后只打印
perror,忽略其直接返回的错误码; - 用
if代替while检查条件变量谓词; - 在持锁期间执行长时间 I/O 或外部回调;
- 锁顺序不一致导致死锁;
- 用
volatile代替原子变量或互斥锁; - 线程池使用无界任务队列;
- 销毁仍被线程使用的互斥锁、条件变量或任务对象。
二十、总结
- 线程共享地址空间,通信方便,但故障隔离和并发安全更难。
- pthread 创建成功后,应明确线程由谁
join,或何时detach。 - 互斥锁保护共享不变量,条件变量负责在状态不满足时高效等待。
- 条件变量必须和互斥锁配合,并使用
while重新检查谓词。 - 信号量适合计数资源,读写锁适合经过验证的多读少写场景。
- C++11 局部静态对象比“
volatile双重检查”更适合实现线程安全单例。 - 线程池需要背压、停止语义、资源回收和可观测性,而不仅是“队列 + 若干线程”。