一、并发的本质
1. 并发≠并行
先弄清楚两个概念:
并发(Concurrency):多个任务在时间片上交替执行,宏观上"同时",微观上是串行切换
并行(Parallelism):多个任务在多个CPU核心上真正同时执行
在单核ARM芯片上,你写的多线程代码是并发;在多核处理器上,才可能是并行。但无论哪种情况,只要存在共享资源,就必须面对竞态条件(Race Condition)。
2. 竞态的根源
竞态的根源是多个执行流对共享资源的访问顺序不确定。解决它,就是多线程编程的核心。
访问顺序不确定有如下几种情况:
(1)一行C代码≠一条指令
看这段常见的计数器代码:
counter++ 看起来简单,编译后实际是三条指令:
LOAD counter → 寄存器
ADD 寄存器 + 1
STORE 寄存器 → counter
两个线程各执行10万次,理论结果是20万。但实测往往只有13-18万。原因就是两个线程的指令可能"交叉执行"。
(2)编译器和CPU乱序执行
为了优化性能,编译器可能重排指令顺序,CPU也有乱序执行机制。我们写的代码顺序,不一定是实际执行顺序。
(3)多核CPU各有各的缓存
每个核心都有自己的L1/L2 Cache,对同一内存地址的修改,不会立即对其他核心可见。这叫缓存一致性问题。
二、POSIX线程库三大同步原语
POSIX线程库提供了几种同步机制,互斥锁、条件变量、读写锁。
2.1 互斥锁
互斥锁的语义很简单:同一时刻只有一个线程能持有锁。
需要特别注意的是:锁的粒度要小。
如果误把整个业务逻辑都放在锁里面,结果多线程变成了排队执行,性能还不如单线程。
错误示范:锁的粒度太大的例子
正确做法:只锁共享数据的访问,不锁计算逻辑。
2.2 条件变量
生产者线程产生数据,消费者线程处理数据。消费者怎么知道"有数据了"?这时候可以使用条件变量。
条件变量正是解决线程等待-通知场景的最优解——它能让线程在条件不满足时休眠,条件满足时精准唤醒,既保证响应速度,又能最大化降低CPU占用。
错误方案:轮询方式
正确方案:使用条件变量
使用条件变量的典型步骤:
等待方步骤:
- 加互斥锁(pthread_mutex_lock);循环检查条件(while(condition == false));条件不满足时,调用pthread_cond_wait休眠;被唤醒后,重新检查条件,执行业务逻辑;解锁互斥锁(pthread_mutex_unlock)。
通知方步骤:
- 加互斥锁(pthread_mutex_lock);修改条件(如:设置flag为true、添加数据到队列);发送通知(pthread_cond_signal或pthread_cond_broadcast);解锁互斥锁(pthread_mutex_unlock)。
代码如:
为什么必须用while而不是if?
因为存在虚假唤醒"(spurious wakeup)——线程可能在没有收到signal的情况下被唤醒。这是POSIX标准允许的行为,用while可以再次检查条件。
2.3 读写锁
如果你的场景是"90%读、10%写",用互斥锁太浪费——读操作之间本不需要互斥。
下图对比三种同步原语的适用场景:
三、死锁
死锁是多线程编程最经典的问题。它的四个必要条件(Coffman条件):
互斥:资源不能共享
持有并等待:持有一个锁的同时等待另一个锁
不可抢占:锁不能被强制释放
循环等待:A等B,B等A
破坏任意一个条件就能预防死锁。实践中最有效的是破坏"循环等待":规定加锁顺序。
经典AB-BA死锁例子如:
以上程序卡死,CPU占用为0。这是经典的AB-BA死锁模式。两个线程以相反的顺序获取两把锁,在特定时序下互相等待,形成死锁。
执行时序图:
修复以上死锁问题:统一加锁顺序
规避死锁问题的工程实践建议:
-
- 在代码规范中明确锁的层级顺序使用
pthread_mutex_trylock
- 实现超时机制开发阶段启用死锁检测工具(如Helgrind、ThreadSanitizer)
四、总结
三条核心原则
最小化共享:能不共享就不共享,能用消息传递就不用共享内存
最小化临界区:锁的粒度越小越好,只保护数据访问,不保护计算逻辑
统一加锁顺序:从根源上避免死锁
403