不少写并发程序的人,最后都会在一个坑里摔一跤:单线程跑得好好的,换到多线程,数据就开始出乱子。明明加了锁,结果不对,或者进程直接卡死。线程同步这个话题,看起来翻来覆去就是互斥锁、条件变量、信号量那几样,真正到了排查现场,问题往往不在API怎么调,而在"这行代码到底保护了什么数据""这个锁有没有歪着加"。这篇文章我就从一次典型的排查经历讲起,把Linux下线程同步的几类手段掰开揉碎聊一遍,适合正在写多线程程序、或者被并发bug折磨到头疼的开发者参考。
1. 竞态条件:线程同步要解决的根源问题
1.1 一个再常见不过的翻车现场
之前接了个活,帮一位朋友排查一个并行统计工具。工具本身很简单:一个大目录下有几千个日志文件,程序起8个线程,每个线程负责处理一部分文件,统计关键词出现次数,最后合并。单线程跑的时候结果完全正确,换成8线程,结果偶尔会少几万次。
这位朋友的第一反应是"是不是该加锁的地方漏了",然后给全局计数器加了把锁。跑了几轮,问题依旧。后来我们把并行度调到2,用同样的输入反复跑,终于稳定复现。这个时候才意识到,不是锁加少了,而是有一处代码绕过了锁直接操作了共享数据。
这个场景特别典型。线程同步解决的不是"代码写得对不对"的问题,而是"多个线程访问同一份数据时,如何保证访问顺序和结果可控"。从根上讲,它要避免的是竞态条件(race condition),也就是多个线程对共享数据的读写顺序不确定,导致最终结果取决于线程调度的时间巧合。你每次运行得到不同的值,不是运气问题,是代码里存在未被控制的共享访问。
1.2 计数器自增为什么不是原子的
很多初学者第一次接触线程同步,都会从"两个线程同时给一个数字加一"开始。我们先看这个最经典的例子。假设全局有个counter,每个线程执行100000次counter++,两个线程各跑一遍,最后counter应该是200000,对吧?
但实际跑下来,这个值几乎不可能等于200000,而且每次跑的结果都不一样。原因要从CPU的角度看。counter++这行代码,在机器指令层面至少拆成三步:
LOAD reg, [counter] ; 把 counter 的值读到寄存器 ADD reg, 1 ; 寄存器加一 STORE [counter], reg ; 把结果写回内存这是一个经典的"读-改-写"三步操作。如果线程A执行完LOAD之后、STORE之前,线程B也执行了LOAD,那么两个线程拿到的counter是同一个旧值。它们各自加一,再各自写回,结果只相当于加了一次。这个过程可以用一句话概括:两个线程交替执行同一段代码,中间没有互斥,操作互相覆盖。
1.3 数据竞争是真正意义上的"未定义行为"
这里要泼一盆冷水:在C/C++这类语言里,数据竞争不只是"结果不对",而是未定义行为。什么叫未定义行为?就是编译器在优化时,假设程序不存在数据竞争,可以自由地把counter放进寄存器、重排指令、甚至把整个循环合并成一次内存操作。一旦你的代码里存在数据竞争,编译器优化的后果是不可预测的,你甚至可能看到"加了锁还是不对"这种更诡异的现场。
拿上面那个循环举例,在高优化级别下,编译器可能直接把两个线程各自的counter++全部优化成对寄存器的累加,只在循环结束时写一次内存。两个线程各自累加自己的寄存器,最后结果完全错乱。这时候你再怎么调整锁的调用时机都没用,因为代码被优化后的执行路径,已经不是你脑子里想的那条了。
所以线程同步的真正技术目标是建立happens-before关系。两个线程如果对同一个共享变量有读写依赖,就必须通过某种同步手段(锁、条件变量、原子操作等)让它们之间存在明确的时间先后顺序。理解了这一点,再回头看各种同步原语,就清楚它们各自在干什么了。
2. 互斥锁:最基础也最容易被用错的同步原语
2.1 pthread_mutex的四个标准动作
互斥锁(mutex)是Linux下最常用的同步原语,职责很简单:保证同一时间只有一个线程进入临界区。POSIX线程库提供了一组固定的操作模式,我用最朴素的方式列出来:
#include <pthread.h> pthread_mutex_t mtx; // 初始化:静态初始化 pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 或者动态初始化 pthread_mutex_init(&mtx, NULL); // 加锁 pthread_mutex_lock(&mtx); // 临界区代码 pthread_mutex_unlock(&mtx); // 销毁 pthread_mutex_destroy(&mtx);这四步看起来简单,实际写起来有非常多细节。首先,pthread_mutex_lock和pthread_mutex_unlock的返回值必须检查。我知道很多人会直接忽略返回值,但Linux下的线程同步函数在某些情况下是会失败的,尤其是pthread_mutex_lock返回EINVAL(锁不合法)、EDEADLK(死锁检测)这类错误时,你忽略它,程序后续行为就完全不可控。
2.2 锁粒度:范围越大越安全,性能越差
用锁的关键是选粒度。我曾经见过一个线程池实现,为了省事,给整个任务队列加了一把大锁,从取任务、解析任务、执行任务到写回结果,全程持有锁。功能上没毛病,可一旦任务执行时间稍长,其他线程全部阻塞在锁上,多线程直接退化成单线程。
锁粒度问题可以这么理解:
- 粗锁:临界区大,并发度低,写起来不容易错,但性能上限低。
- 细锁:临界区小,并发度高,但代码复杂,容易引入死锁和逻辑漏洞。
实际开发中我的策略是:先用一把粗锁跑通正确性,再用性能分析工具确认瓶颈,最后针对热点路径细粒度化。不要一上来就设计一堆细锁,那通常是把简单问题复杂化。
2.3 死锁的三个典型姿势
死锁是互斥锁最常见的坑,我见过的基本就三种类型。
第一种是重复加锁。同一个线程对一个还未解锁的普通互斥锁再次pthread_mutex_lock,会直接把自己锁死。
void func() { pthread_mutex_lock(&mtx); pthread_mutex_lock(&mtx); // 死锁,自己等待自己 pthread_mutex_unlock(&mtx); pthread_mutex_unlock(&mtx); }第二种是交叉加锁。线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两个线程互相等待,谁也不让。
// 线程A pthread_mutex_lock(&lock1); pthread_mutex_lock(&lock2); // 等待lock2 // 线程B pthread_mutex_lock(&lock2); pthread_mutex_lock(&lock1); // 等待lock1第三种是忘记解锁。函数中间提前return了,锁没释放。这种问题在代码review时很容易漏掉,因为正常路径看起来是有效的。
死锁的四个必要条件(互斥、持有并等待、不可剥夺、循环等待)任何一个被破坏,死锁就不可能发生。工程上最简单的手段就是给所有锁规定一个全局一致的加锁顺序,比如所有线程都先拿编号小的锁,再拿编号大的锁,交叉死锁直接从设计上杜绝。
2.4 给锁加个"自动释放"的壳
C++里有std::lock_guard可以用,但纯C环境下一不小心就会在某个return分支漏解锁。Linux下gcc提供了一套cleanup属性,可以模拟出类似的效果:
static inline void cleanup_mutex(void *ptr) { pthread_mutex_unlock(*(pthread_mutex_t **)ptr); } #define LOCK_GUARD(mutex) \ pthread_mutex_t *__mg __attribute__((cleanup(cleanup_mutex))) = &(mutex); \ pthread_mutex_lock(__mg)这样写:
void func() { LOCK_GUARD(mtx); // 无论从哪个分支return,_mg都会自动调pthread_mutex_unlock if (cond) return; ... }这个宏看起来有点魔幻,但在真实项目中非常实用。把"解锁"这件事交给编译器通过作用域结束自动处理,你只需要保证每个函数入口处用LOCK_GUARD包裹临界区。我从这以后几乎再也没出现过"漏解锁"的问题。
3. 条件变量:等消息的正确姿势
3.1 互斥锁解决不了"等待"
互斥锁本质上解决的是"互斥",也就是同一时刻只能有一个线程访问临界区。但很多场景下,线程需要的是"等待"。一个非常典型的例子:工作线程从任务队列里取任务执行,队列为空的时候,它应该停下来等,而不是疯狂重试。
如果只用互斥锁,大部分人第一版会写成这样:
while (1) { pthread_mutex_lock(&mtx); if (queue.count > 0) { task = pop_task(); pthread_mutex_unlock(&mtx); execute(task); } else { pthread_mutex_unlock(&mtx); sched_yield(); // 或者usleep } }这个写法的问题显而易见:当队列长期为空时,线程在循环里反复加锁解锁、检查条件、主动让出CPU。每个空转周期都在做无用功,白白消耗CPU,还会增加其他线程的调度延迟。线程实时性要求高的时候,这种忙轮询还会导致任务处理延迟抖动。
条件变量(condition variable)就是为了解决"等消息"而生的。它的思想是:让线程在条件不满足时进入睡眠状态,直到另一个线程改变了条件并通知它,才被唤醒继续执行。这避免了轮询,把"主动问"变成"被动等通知"。
3.2 pthread_cond_wait为什么必须配mutex
条件变量最令人困惑的一点是:pthread_cond_wait的签名要求传入一个mutex,而且调用线程必须已经持有这个mutex。为什么?
先看一个朴素的想法。你想"检查队列是否为空,如果为空就等待",假如不配合互斥锁:
// 错误示范 while (queue.count == 0) pthread_cond_wait(&cond); task = pop_task();这里有个时间窗口:你检查到队列为空,准备调用pthread_cond_wait之前,另一个线程刚好往队列里放了一个任务,而且执行了pthread_cond_signal。由于你还没开始等待,这个signal就丢失了。然后你进入pthread_cond_wait,永远睡着。这就是著名的丢失唤醒问题。
pthread_cond_wait的设计正是为了避免这个窗口。它的语义是原子的:调用时,调用者持有mutex;pthread_cond_wait内部会释放这个mutex,然后把线程投入睡眠。等它被唤醒时,又会重新获取mutex,才从函数返回。释放锁和进入睡眠之间没有间隙,signal就不可能在这个窗口里丢失。
用生活化的类比说:就好像你在柜台前排队(持有锁),发现业务没办好(条件不满足),你把号码往窗口一递,人就到旁边沙发上睡觉去了(释放锁并睡眠)。窗口办好了会喊你(signal),你听到喊声再去队列排队,直到排到你才继续办业务(重新获取锁后才返回)。整个过程中,其他人不会插到你睡前的那个窗口前面。
3.3 标准的生产者消费者实现
把上面的知识串起来,一个标准的生产者消费者模型长这样:
#include <pthread.h> #define QUEUE_SIZE 8 struct task_queue { int tasks[QUEUE_SIZE]; int head; int tail; int count; }; pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; struct task_queue q = {0}; // 生产者:往队列里放任务 void producer(int task) { pthread_mutex_lock(&mtx); // 如果队列已满,生产者也应该等待,这里简化处理 q.tasks[q.tail] = task; q.tail = (q.tail + 1) % QUEUE_SIZE; q.count++; pthread_cond_signal(&cond); // 通知可能等在队列为空上的消费者 pthread_mutex_unlock(&mtx); } // 消费者:从队列取任务 void *consumer(void *arg) { for (;;) { pthread_mutex_lock(&mtx); while (q.count == 0) { pthread_cond_wait(&cond, &mtx); } int task = q.tasks[q.head]; q.head = (q.head + 1) % QUEUE_SIZE; q.count--; pthread_mutex_unlock(&mtx); // 锁外执行任务,尽量缩短临界区 execute_task(task); } return NULL; }注意几个细节:
- 生产者在修改共享数据、改变条件之后,才调用
pthread_cond_signal。顺序反过来可能导致消费者被唤醒后发现条件依然不满足,空跑一次。 - 消费者的
while (q.count == 0)用的是while而不是if。原因有两个:一是条件变量存在虚假唤醒(spurious wakeup),即使没有信号,等待线程也可能被唤醒;二是即便有人signal,唤醒的瞬间另一个线程可能已经把任务抢走了,条件不再成立。while循环确保每次被唤醒后重新检查条件。这是条件变量最基础也最关键的写法。 - 任务执行放在解锁之后,避免长时间持有锁阻塞所有生产者。
3.4 signal和broadcast:叫醒一个人还是叫醒所有人
当多个消费者在同一个条件变量上等待时,pthread_cond_signal只会唤醒其中一个,而pthread_cond_broadcast会唤醒全部。
signal适合的情况是:"条件变化了,任意一个等待线程去处理就行"。broadcast适合的情况是:"条件变化了,所有等待线程都应该重新评估自己的状态"。
实际工程里有一个坑:两个消费者在while循环里等待,你signal了一个,但被唤醒的这个消费者抢到锁后发现任务又被别人抢了,于是继续wait。如果此时没有其他生产者再发signal,剩下的任务可能永远不会被处理。最稳妥的做法,在无法严格确认"只叫一个就行"的时候,用pthread_cond_broadcast。代价是多个线程会被同时唤醒,然后竞争同一把锁,但至少不会有"唤醒丢失"问题。
4. 读写锁和信号量:不同并发场景的工具选型
4.1 读写锁:读多写少的专属优化
互斥锁的思路是"谁都别跟我抢",但很多时候数据访问的读操作远多于写操作。比如一个配置表,启动时加载一次,运行期被几十个线程频繁查询、极少更新。如果只用互斥锁,所有读取线程都要串行化,非常浪费。
读写锁(rwlock)的思路是:读锁之间可以共享,写锁独占,写和读互斥。也就是说,多个线程可以同时持读锁进入临界区,但一旦有线程想持写锁,必须等所有读锁释放,而且写锁持有期间,其他读锁也必须等待。
#include <pthread.h> pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; void *reader(void *arg) { for (;;) { pthread_rwlock_rdlock(&rwlock); // 读取共享配置 pthread_rwlock_unlock(&rwlock); } } void *writer(void *arg) { for (;;) { pthread_rwlock_wrlock(&rwlock); // 修改共享配置 pthread_rwlock_unlock(&rwlock); } }读写锁适合读多写少、临界区较短的场景。如果写操作也很频繁,读写锁的维护成本(读计数、等待队列)可能比普通互斥锁更高,反而更慢。有一个常见误区是把读写锁当成"万能并发优化器",结果读多写多场景被它坑得很惨。选型前最好用数据说话:统计一下真实访问比例,读次数超过写次数好几个数量级再考虑读写锁。
4.2 信号量:计数型同步原语
信号量(semaphore)和互斥锁最大的区别在于:互斥锁是二值的(锁住/解锁),信号量是计数的。你可以把它看成一个计数器,sem_wait让计数器减一,如果计数器已经是0,调用线程就阻塞等待;sem_post让计数器加一,并唤醒一个等待者。
这种计数语义非常适合"同一类资源有多份"的场景。最典型的例子是连接池:
#include <semaphore.h> sem_t available; // 初始化:连接池有5个可用连接 sem_init(&available, 0, 5); // 获取连接 sem_wait(&available); Connection *conn = pool_acquire(); // 释放连接 pool_release(conn); sem_post(&available);用信号量实现生产者消费者,还有一套非常优雅的对称写法:两个信号量分别计数"空闲槽位"和"待处理任务",生产者等待空闲槽、生产后发布任务,消费者等待任务、消费后释放槽位。这种写法把同步逻辑直接写进资源计数里,代码往往比条件变量版本更直观。
4.3 信号量的两个现实陷阱
信号量用起来爽,坑也不少。
第一个是sem_wait可能被信号中断。当进程收到signal(比如SIGINT),阻塞在sem_wait上的线程会返回-1,errno变成EINTR,这不算真正的"获取到信号量"。所以正确的写法必须检查这个错误并重试:
while (sem_wait(&available) != 0) { if (errno == EINTR) continue; perror("sem_wait"); exit(1); }不少线上崩溃或者资源泄漏,都是因为没处理EINTR,把一次中断当成获取成功,然后从空连接池里拿了一个野指针。
第二个坑是初始值设置。信号量的初始值代表资源的初始数量。如果你把互斥锁场景的1直接套到信号量上,想着"反正1和0切换就行",很容易弄反sem_wait和sem_post的方向,导致程序第一次调用就阻塞。
5. 自旋锁与原子操作:短临界区里的暴力美学
5.1 忙等不是罪:自旋锁的适用场景
前面讲条件变量的时候,我把轮询批评了一通。但有一种"忙等"是被广泛使用的,那就是自旋锁(spinlock)。自旋锁的思想非常简单:在用户态用一个循环反复尝试获取锁,拿不到就一直转,不进入内核睡眠。
#include <pthread.h> pthread_spinlock_t spin; pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE); pthread_spin_lock(&spin); // 极短的临界区 pthread_spin_unlock(&spin); pthread_spin_destroy(&spin);问题来了:同样是忙等,为什么条件变量的轮询称为糟粕,自旋锁却是一种有效手段?关键区别在于等待的时间尺度。互斥锁加锁失败时,线程会进入内核睡眠,等锁可用时再被唤醒,这个过程涉及两次用户态/内核态切换,大约耗时几个微秒。如果临界区只有几十纳秒,那么睡眠唤醒的开销可能比等锁本身还大。
自旋锁在临界区极短、竞争不激烈、且运行在多核处理器上时,是比互斥锁高效得多的方案。短到什么程度?通常是一条内存赋值、一个计数器自增、一次指针交换这种几个指令能结束的操作。如果临界区超过几十条指令,或线程数远大于CPU核数,自旋锁就会变成灾难:所有核都在空转,死等一个运行不上的线程。
5.2 原子操作:单变量的终极武器
比自旋锁更底层的,是原子操作。现代CPU普遍支持对单个内存单元的原子读改写指令,比如x86的LOCK前缀指令。C11标准把这一套封装进了stdatomic.h,gcc也提供了对应的内建函数:
#include <stdatomic.h> atomic_long counter = 0; void *worker(void *arg) { for (int i = 0; i < 100000; i++) { atomic_fetch_add(&counter, 1); // 原子加一 } return NULL; }这段代码里的atomic_fetch_add在硬件层面保证"读-改-写"三步整体不可分割。多线程并发执行也不会丢数据,根本不需要加锁。对于计数器、标志位、引用计数这种单变量的简单操作,原子操作是性能最好的方案,比任何锁都快。
但注意,原子操作有严格的边界。它只能保证"单个变量的单次操作"是原子的,不能保证"检查一个变量符合条件之后,再修改另一个变量"这种复合操作是原子的。比如你要实现"如果队列没满,把数据入队",就需要检查队列大小和写入队列两个步骤,这两个步骤之间,队列状态可能已经被别的线程改变。这种情况原子操作就无能为力,必须回到互斥锁或自旋锁。
5.3 同步工具的选型决策表
把前面提到的所有同步原语放在一起对比,选型逻辑会清晰很多:
| 同步原语 | 适用场景 | 主要代价 | 注意事项 |
|---|---|---|---|
| 互斥锁 | 保护一段任意长度的临界区 | 频繁竞争时有内核睡眠唤醒开销 | 警惕死锁;锁粒度不要过大 |
| 条件变量 | 等待某个条件成立后再继续 | 需要配合互斥锁,理解wait的原子语义 | 判断条件必须用while;signal可能丢失 |
| 读写锁 | 读多写少、临界区短 | 写锁等待可能饥饿较久 | 读多写少场景才划算 |
| 信号量 | 多份同类资源的计数管理 | 计数语义容易用错初始值 | 处理EINTR;区分"资源"和"锁" |
| 自旋锁 | 临界区极短、低竞争、多核 | 临界区稍长就疯狂烧CPU | 单核机器上基本不能用 |
| 原子操作 | 单变量简单运算(计数器、标志位) | 复合操作不保证原子性 | 内存序理解不到位会出现诡异问题 |
实际项目里,我见过很多"什么场景都用互斥锁"的代码,功能没错,性能平庸。这套决策表谈不上多高级,但在技术评审时非常管用,能快速判断某个锁的选型靠不靠谱。
6. 排查线程问题的现场记录:从死锁到数据竞争
6.1 现场一:加了锁,结果还是错
回到文章开头那个并行统计工具。我们反复确认了流程,发现计数器加了锁,但最终结果依然间歇性丢失。后来一行行对代码,终于发现一个细节:主线程在join完所有工作线程后,合并结果时直接把工作线程的局部统计暴力的写进了全局结构体,这段合并代码没拿锁。
这就是非常典型的**"锁与数据不匹配"**问题:一部分代码路径走锁保护,另一部分路径直通共享数据。多线程程序里,只要有一条路径绕过了锁,锁的保护作用就整体失效,数据竞争依然存在。
排查这种问题,单靠肉眼看代码很容易漏。我推荐直接用动态分析工具。Linux下最经典的是valgrind的helgrind:
gcc -g -o counter counter.c -lpthread valgrind --tool=helgrind ./counterhelgrind会在运行时检测数据竞争,输出类似"Possible data race during write of size 8 at 0x..."这样的报告,直接指向冲突的代码行。比人肉翻代码快太多。
更新的方案是用ThreadSanitizer。编译时加参数就行,运行速度比valgrind快不少:
gcc -g -fsanitize=thread -o counter counter.c -lpthread ./counterTSan会在程序启动时自动插桩,遇到数据竞争直接在终端打印调用栈。我有一次在一个大型模块里排查间歇性数据错乱,就是靠TSan的告警在几分钟内定位到一个完全没有锁保护的全局变量。
6.2 现场二:程序卡住不动,明显是死锁
死锁的现场是另一种画风:程序不崩溃,也不退出,CPU占用率可能很低,进程像被按了暂停键。有一次我维护一个中间件模块,新上了一个"多线程批量任务分发"功能,线上偶发整个模块无响应。一开始以为是负载问题,后来用gdb attach挂上去,执行:
gdb -p <pid> (gdb) thread apply all bt然后看到两个线程的调用栈分别停在:
Thread 1: #0 pthread_mutex_lock (lock=lock1) #1 task_worker_a ... #2 ... Thread 2: #0 pthread_mutex_lock (lock=lock2) #1 task_worker_b ... #2 ...一个线程拿着lock1等lock2,另一个拿着lock2等lock1,交叉等待,经典的循环等待死锁。gdb的线程栈直接给出了答案。
如果在没有gdb的环境,pstack <pid>也能打印出每个线程的调用栈。死锁问题定位本身不难,难的是复现。偶发死锁很多时候依赖线程调度的时机,需要长时间压测、调大线程数、增加任务复杂度才能稳定复现。
6.3 锁顺序倒置:一个完整的复盘过程
上面那个死锁的根因,我复盘后定义为锁顺序倒置。模块里原本约定"先拿任务锁,后拿状态锁",新来的代码里有一处反过来了:先拿状态锁,后拿任务锁。两个方向的调用路径同时在跑,碰到一起就死锁。
修复方案有三条路,我按推荐程度排个序:
- 全局统一锁顺序:所有线程都严格按"任务锁 -> 状态锁"的顺序加锁,结构上消除循环等待。这是成本最低的修复。
- 使用
pthread_mutex_trylock:拿不到锁就快速返回,释放已经持有的锁,让线程重试。适合无法保证全局顺序的复杂场景。 - 合并锁:如果这两把锁保护的临界区其实总是一起出现,干脆用一把锁,省得维护顺序关系。
实际情况里我选了第一条,把新代码的加锁顺序调整成和旧代码一致。问题修复后一个月内没有再复现。
6.4 编译期防御和三板斧
线程同步问题靠人肉盯代码,效率太低了。我现在写多线程代码,有三个习惯,基本成了固定流程:
- 编译时开足警告:
-Wall -Wextra -O2 -g,优化级别开着,保证调试信息可回溯。 - 测试时开TSan:开发自测阶段,用
-fsanitize=thread编译跑一遍回归用例,数据竞争在测试阶段就能暴露,而不是线上爆雷。 - 代码review时查三件事:锁保护的数据是否在每个访问路径上都一致?有没有嵌套锁,顺序是否全局统一?条件变量等待用的是
while还是if?
最后一个问题多说一句,我review过的代码里,条件变量配if的情况很常见。很多人觉得"既然只在一种条件下wait,唤醒之后条件肯定变了",完全忽略了虚假唤醒和多个消费者的竞争。统一改成while循环之后,很多偶发问题当场消失。
我在实际工作中还有一个很小的个人习惯:给锁起名字的时候,把锁和它保护的数据绑定在一起,而不是起lock1、lock2这种无意义的名字。比如tasks_mutex只保护tasks队列的增删查改,state_mutex只保护运行状态标志。这样每次加锁之前看一眼锁的名字,就能发现是不是加错了对象。这个习惯帮我躲过了好几次"锁加对了但数据不是这份数据"的隐蔽bug。
线程同步的资料网上遍地都是,但真正到线上排查的时候,你会发现几乎所有问题都出在"锁没有覆盖到所有访问路径""锁顺序不一致""条件变量判断条件不够严谨"这几类。把这几个基本功打牢,比背十个同步API要管用得多。