Linux 并发编程:线程、锁与同步
单线程程序按顺序执行,简单但低效——等待 I/O 的时候 CPU 闲着,多核处理器只用了一个核。并发编程让程序同时做多件事,充分利用硬件资源。
代价是复杂度:多个线程共享内存,同时读写同一块数据会出问题,需要同步机制来协调。这篇从线程基础讲起,覆盖 pthread(POSIX 标准)和 C++11 并发接口,并与 FreeRTOS 做对比,帮你建立完整的并发编程认知。
一、线程基础
线程是什么
线程是进程内的执行单元。一个进程可以有多个线程,这些线程共享同一块地址空间——代码段、堆、全局变量都是共享的,但每个线程有自己独立的栈和寄存器状态。
和进程的区别:
| 进程 | 线程 | |
|---|---|---|
| 地址空间 | 独立 | 共享(同进程内) |
| 创建开销 | 大(fork 要复制页表) | 小 |
| 通信方式 | IPC(管道、共享内存) | 直接读写共享变量 |
| 崩溃影响 | 不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
线程共享的资源:堆内存、全局变量、文件描述符、信号处理。
线程独占的资源:栈、寄存器、线程局部存储(TLS)、errno。
graph TD
subgraph 进程
Code[代码段]
Heap[堆]
Global[全局变量]
FD[文件描述符]
subgraph 线程 A
StackA[栈 A]
RegA[寄存器 A]
end
subgraph 线程 B
StackB[栈 B]
RegB[寄存器 B]
end
end
和 FreeRTOS 任务对比:FreeRTOS 的任务本质上就是线程,每个任务有独立的栈,共享全局内存。区别在于 FreeRTOS 运行在裸机上,调度器由 RTOS 内核管理;Linux 线程由内核调度,底层是 clone() 系统调用。
pthread:创建和等待线程
POSIX 线程(pthread)是 Linux 下线程的标准接口,编译需要加 -lpthread。
1 | int pthread_create(pthread_t *thread, const pthread_attr_t *attr, |
thread:输出参数,存储新线程的 IDattr:线程属性,通常传NULL使用默认值start_routine:线程入口函数,签名必须是void* func(void*)arg:传给入口函数的参数,只能传一个指针,多个参数用结构体包装
1 | int pthread_join(pthread_t thread, void **retval); |
- 等待指定线程结束,类似进程的
waitpid retval:获取线程返回值,不关心传NULL- 不调用
pthread_join会造成资源泄漏(线程变成”僵尸线程”)
基本用法:
1 |
|
C++11 std::thread
C++11 提供了更简洁的线程接口,不需要手动转换函数指针:
1 |
|
也可以用 lambda:
1 | std::thread t([](int id) { |
std::thread 对象销毁前必须调用 join() 或 detach(),否则程序会调用 std::terminate() 崩溃。detach() 让线程在后台独立运行,主线程不等它,适合不需要返回值的后台任务。
二、mutex:互斥锁
竞态条件
多个线程同时读写共享变量时,结果取决于线程执行的先后顺序,这叫竞态条件(race condition)。
1 | // 全局计数器,两个线程各加 100000 次 |
counter++ 看起来是一条语句,实际上是三步:读取 → 加一 → 写回。两个线程可能同时读到同一个值,各自加一后写回,导致一次加法丢失。两个线程各加 100000 次,结果可能远小于 200000。
sequenceDiagram
participant T1 as 线程 A
participant M as counter=5
participant T2 as 线程 B
T1->>M: 读取 counter=5
T2->>M: 读取 counter=5
T1->>M: 计算 5+1=6,写回
T2->>M: 计算 5+1=6,写回
Note over M: 期望结果 7,实际结果 6,丢失一次加法
pthread_mutex
mutex(互斥量)保证同一时刻只有一个线程进入临界区:
1 | pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化 |
或者动态初始化:
1 | pthread_mutex_t mutex; |
核心 API:
1 | int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁,已锁则阻塞等待 |
修复上面的竞态条件:
1 | pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; |
加锁后只有一个线程能进入 counter++,另一个线程阻塞等待,保证操作不会交叉。
C++11 std::mutex
C++11 的 mutex 接口更安全,配合 RAII 封装避免忘记解锁:
1 |
|
lock_guard 是最简单的 RAII 锁,构造加锁,析构解锁,不能手动解锁。需要中途解锁用 unique_lock:
1 | std::unique_lock<std::mutex> lock(mtx); |
unique_lock 更灵活,也是条件变量必须配合的锁类型。
与 FreeRTOS mutex 对比
FreeRTOS 的 mutex 和 pthread mutex 概念相同,API 不同:
1 | // FreeRTOS |
关键区别:FreeRTOS mutex 支持优先级继承(防止优先级反转),pthread mutex 默认不支持,需要设置 PTHREAD_PRIO_INHERIT 属性。
三、条件变量
光有锁不够
mutex 解决了”同时只有一个人操作”的问题,但有些场景需要线程等待某个条件成立。比如生产者消费者模型:消费者从队列取数据,队列为空时应该等待,不能一直空转占 CPU。
用 mutex 轮询是一种写法,但很低效:
1 | // 低效的忙等待,浪费 CPU |
条件变量让线程在条件不满足时阻塞睡眠,条件满足时被唤醒,不占 CPU。
pthread_cond
1 | pthread_cond_t cond = PTHREAD_COND_INITIALIZER; |
pthread_cond_wait 做了三件事:解锁 mutex → 阻塞等待 → 被唤醒后重新加锁。这三步是原子的,保证不会错过信号。
生产者消费者完整示例:
1 |
|
注意等待条件用 while 而不是 if,原因是虚假唤醒:线程可能在没有收到信号的情况下被唤醒(POSIX 标准允许这种情况),用 while 唤醒后重新检查条件,确保条件真的满足再继续。
C++11 std::condition_variable
1 |
|
cv.wait(lock, predicate) 内部等价于 while (!predicate()) cv.wait(lock),自动处理了虚假唤醒,比 pthread 写法更安全。
sequenceDiagram
participant P as 生产者
participant Q as 任务队列
participant C as 消费者
C->>Q: queue 为空,cond_wait 阻塞
P->>Q: 生产数据,push
P->>C: cond_signal 唤醒
C->>Q: 取出数据,处理
C->>Q: 处理完,继续 cond_wait
Note over P,C: 队列满时生产者同样阻塞等待消费者取走
与 FreeRTOS 对比
FreeRTOS 里生产者消费者直接用队列 xQueueSend / xQueueReceive,队列满或空时自动阻塞,内部封装了条件变量的逻辑。Linux 下需要自己用条件变量实现这个机制,或者使用 std::queue + 条件变量封装成类似的接口。
四、读写锁
读多写少的场景
mutex 每次只允许一个线程进入,包括只读操作。但实际上多个线程同时读同一块数据是安全的,没有必要互斥。读写锁区分了两种操作:
- 读锁(共享锁):多个线程可以同时持有读锁
- 写锁(独占锁):写锁同一时刻只有一个线程持有,且持有写锁时不允许任何读锁
适合读操作远多于写操作的场景,比如配置表、路由表、缓存。
pthread_rwlock
1 | pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; |
1 | // 多个线程可以同时读 |
C++17 std::shared_mutex
C++11 没有原生读写锁,C++17 引入了 std::shared_mutex:
1 |
|
C++11 环境下可以用 pthread_rwlock 封装,或者用 boost::shared_mutex。
五、原子操作
不用锁的并发
mutex 有开销:加锁解锁涉及系统调用,线程可能阻塞切换。对于简单的计数器、标志位,用原子操作代替锁更高效。
原子操作是硬件保证不可分割的操作,读-改-写三步由 CPU 指令保证原子完成,不会被其他线程打断。
std::atomic
1 |
|
常用操作:
1 | std::atomic<int> val(0); |
内存序
std::atomic 默认使用 memory_order_seq_cst(顺序一致),最安全但性能最低。性能敏感场景可以用更弱的内存序,但需要深入理解 CPU 乱序执行,容易出错。
对于大多数场景,默认内存序足够,不要过早优化:
1 | counter.fetch_add(1); // 默认顺序一致 |
std::atomic<bool> 常用于线程间的标志位通知,比 volatile bool 更安全(volatile 不保证原子性,只防止编译器优化)。
六、常见并发问题
死锁
死锁是两个或多个线程互相等待对方释放锁,永远卡住。最典型的场景:
graph LR
TA[线程 A] -->|持有| MA[mutex_a]
TA -->|等待| MB[mutex_b]
TB[线程 B] -->|持有| MB
TB -->|等待| MA
style TA fill:#c0392b,color:#fff
style TB fill:#c0392b,color:#fff
1 | // 线程 A |
避免死锁的方法:
固定加锁顺序:所有线程按同一顺序加锁,永远先锁 A 再锁 B,不会形成环。
使用 trylock + 回退:
1 | while (1) { |
C++17 std::scoped_lock:同时锁多个 mutex,内部用死锁避免算法:
1 | std::scoped_lock lock(mutex_a, mutex_b); // 自动避免死锁 |
线程安全的设计原则
减少共享状态:共享的数据越少,需要保护的地方越少。能用局部变量的不用全局变量,能用消息传递的不用共享内存。
锁的粒度尽量小:只在真正需要保护的地方加锁,不要把整个函数都包进去。锁住的代码越少,并发度越高。
1 | // 不好:整个循环都加锁 |
优先用高层抽象:能用 std::queue + 条件变量封装的线程安全队列,不要自己手写锁逻辑。能用 std::atomic 的不用 mutex。
线程池
每次来一个任务就创建一个线程,用完销毁,开销很大——线程创建涉及内核调用,栈内存分配,频繁创建销毁在高并发场景下会成为瓶颈。
线程池的思路是:预先创建固定数量的线程,这些线程一直存活,等待任务队列里有任务时取出来执行,执行完继续等待下一个任务。创建开销只在启动时发生一次。
线程池由三部分组成:
- 任务队列:存放待执行的任务,生产者往里放,工作线程从里取
- 工作线程:固定数量,循环从队列取任务执行
- 同步机制:mutex 保护队列,条件变量让线程在队列为空时睡眠
graph LR
subgraph 调用方
C1[submit task1]
C2[submit task2]
C3[submit task3]
end
subgraph 线程池
Q[(任务队列\nmutex + cond)]
W1[工作线程 1]
W2[工作线程 2]
W3[工作线程 3]
W4[工作线程 4]
end
C1 --> Q
C2 --> Q
C3 --> Q
Q --> W1
Q --> W2
Q --> W3
Q --> W4
用 C++11 实现一个简单线程池:
1 |
|
使用:
1 | ThreadPool pool(4); // 4 个工作线程 |
析构函数里设置 stop = true 后唤醒所有线程,线程检查到 stop && tasks.empty() 就退出,join 等待所有线程结束,保证任务全部执行完再销毁。这个模式把线程基础、mutex、条件变量综合在一起,是并发编程里的经典设计。
并发编程的核心矛盾是正确性和性能:加锁保证正确但降低并发度,去掉锁提高性能但容易出问题。实际开发里先保证正确,再在性能瓶颈处针对性优化。FreeRTOS 和 Linux pthread 的概念是相通的——任务/线程、信号量/mutex、队列/条件变量,换了平台换了 API,背后的思路一样。






