多线程编程中的常见陷阱
说实话,多线程编程就像走钢丝。表面看着挺美——能充分利用多核CPU,能提升系统吞吐量。但等你真正上手,就会发现到处都是坑。我做了十几年系统编程,踩过的坑比吃过的盐还多。今天咱们就来聊聊那些最常见的陷阱。
嗯,先看一张总览图,把今天要讲的五个陷阱串起来。
1. 竞态条件(Race Condition)
竞态条件,说白了就是「谁先谁后」的问题。多个线程同时访问共享数据,执行结果取决于线程调度的顺序。你想想看,这多可怕——同样的代码,这次跑对了,下次跑就错了。
我遇到过最典型的例子:两个线程同时往一个全局链表里插入节点。线程A刚把新节点的next指针指向了头节点,还没来得及更新头指针,线程B就插进来了。结果呢?链表断了,数据丢了。
核心问题:操作不是原子的。一个看似简单的 i++,实际上包含了读取、修改、写入三步。
// 竞态条件示例
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 100000; i++) {
counter++; // 这不是原子操作!
}
return NULL;
}
// 两个线程同时跑,counter 最终结果远小于 200000
我的建议:用互斥锁(mutex)保护共享数据,或者用原子操作(C11 的 atomic 类型)。别指望运气,运气在并发编程里从来靠不住。
2. 数据竞争(Data Race)
数据竞争和竞态条件经常被混为一谈。其实它们不一样。数据竞争是指:两个或多个线程同时访问同一内存位置,至少有一个是写操作,而且没有任何同步机制保护。
嗯,这里有个关键点——数据竞争在C语言里是未定义行为。什么叫未定义行为?就是编译器可以干任何事。它可能给你正确结果,也可能直接崩溃,甚至可能把你的代码优化成完全不同的逻辑。
// 数据竞争示例
int shared_var = 0;
// 线程1
void thread1() {
shared_var = 42; // 写操作
}
// 线程2
void thread2() {
int val = shared_var; // 读操作,没有同步!
printf("%d\n", val);
}
警告:我曾经在一个嵌入式项目中遇到过数据竞争导致的诡异bug。程序在调试模式下跑得好好的,一开优化就崩溃。查了三天才发现是数据竞争。编译器把变量优化到了寄存器里,两个线程各自为政,根本不知道对方的存在。
解决数据竞争的方法很简单:用锁、用原子操作、或者用线程局部存储(thread-local storage)。别偷懒,该加的同步一个都不能少。
3. 锁的顺序问题
当你的程序需要同时持有多个锁时,顺序就变得至关重要。如果线程A先锁了锁1再锁锁2,线程B先锁了锁2再锁锁1,那就死锁了。
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。打破任何一个就能避免死锁。
我的经验:最简单的做法就是给所有锁排个序。比如按内存地址从小到大加锁。所有线程都遵守这个规则,循环等待就不存在了。
// 锁顺序导致死锁
pthread_mutex_t lock1, lock2;
// 线程A
void threadA() {
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2); // 等待lock2
// 干活...
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
}
// 线程B
void threadB() {
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1); // 等待lock1,死锁!
// 干活...
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
}
避坑指南:我曾经在一个支付系统中遇到过锁顺序问题。两个线程分别处理充值扣款和退款,锁的顺序反了,结果线上出现了死锁。从那以后,我强制团队使用锁顺序检查工具,并在代码注释里标明每个锁的层级。
4. 优先级反转(Priority Inversion)
优先级反转是个很有意思的问题。高优先级线程被低优先级线程阻塞,因为低优先级线程持有高优先级线程需要的锁。更糟的是,如果还有中等优先级线程抢占了低优先级线程的CPU时间,高优先级线程就被无限期推迟了。
你想想看,这完全违背了优先级调度的初衷。高优先级线程反而跑得最慢,是不是很讽刺?
| 线程 | 优先级 | 状态 |
|---|---|---|
| 线程H(高优先级) | 高 | 等待锁,被阻塞 |
| 线程M(中优先级) | 中 | 正在运行,抢占CPU |
| 线程L(低优先级) | 低 | 持有锁,但被M抢占 |
解决方案:优先级继承协议。当低优先级线程持有高优先级线程需要的锁时,临时提升低优先级线程的优先级,让它尽快释放锁。
我记得在某个实时操作系统项目中,就因为这个优先级反转,导致一个关键任务超时。后来我们实现了优先级继承,问题才彻底解决。
5. ABA问题
ABA问题是无锁编程(lock-free programming)里的经典陷阱。用CAS(Compare-And-Swap)操作时,你比较一个值,发现它没变,就认为没人动过。但实际情况是:值从A变成了B,又变回了A。你看到的「没变」其实是假象。
// ABA问题示意
// 初始:栈顶是节点A
// 线程1:准备弹出A
// 读取栈顶 = A,A的next = B
// 线程1被中断
// 线程2:弹出A,弹出B,再压入A
// 此时栈顶是A,但A的next变了
// 线程1恢复:CAS比较栈顶还是A,成功
// 但A的next已经不是B了,栈结构被破坏!
我曾经踩过的坑:在一个内存池管理器中用了无锁栈,结果出现了ABA问题。内存被重复释放,导致野指针。查bug查到凌晨三点,最后用带版本号的指针(tagged pointer)解决了。
解决ABA问题的常见方法:
- 使用带版本号的原子指针(如ABA',每次修改都递增版本号)
- 使用垃圾回收机制(如RCU,Read-Copy-Update)
- 使用双宽CAS(Double-Word CAS),同时比较指针和版本号
我的建议:除非你对无锁编程非常熟悉,否则优先使用锁。无锁编程的调试难度是指数级上升的。我见过太多团队为了追求「高性能」引入无锁结构,结果bug修了几个月。
好了,这五个陷阱就聊到这里。每个陷阱背后都有深刻的计算机系统原理。理解它们,不是为了背概念,而是为了在写代码时能下意识地避开。多线程编程没有银弹,唯一的办法就是扎实的基本功加上大量的实践。
公众号:蓝海资料掘金营,微信deep3321