1. 先从一道“修不好”的Bug说起:竞态的真实模样
做服务端开发这些年,我最怕的不是业务逻辑复杂,而是遇到那种“偶尔出错、一上线就出问题、本地怎么都复现不了”的并发Bug。这类Bug十有八九都出在进程同步与互斥没有做好。说得直白一点,就是多个执行流同时访问同一份数据、同一个资源,大家没有规矩,结果数据被改得乱七八糟,严重的直接进程崩溃。进程同步与互斥这个主题,用一句话概括就是:解决“多个人同时干活,怎么保证别人的改动能被正确看见、不互相踩脚”。
这个主题适合谁看?如果你是正在学操作系统课程的学生、刚接触多线程编程的开发者、或者已经在业务里写过并发代码但总被“灵异Bug”折腾的人,这篇文章都能派上用场。我会从原理讲到实战,再用三个完整的可运行例子带你把互斥锁、信号量、文件锁这些家伙全部用一遍,最后把死锁、优先级反转这些经典大坑摊开给你看。中途穿插的代码、命令、排查思路,都是我实际验证过的,你可以直接照着折腾。
1.1 临界区到底是什么
先讲清楚一个词:临界区。它指的是代码里访问共享资源的那一小段区域。注意是“一小段”,不是整个程序。比如多个线程都要往同一个数组里写数据,那“写数组的这几行代码”就是临界区。你把锁加在整个程序外面,等于把多线程又变回单线程,性能全废了;你把锁漏掉,数据分分钟出问题。所以临界区要能精确识别出来,这是一切同步互斥工作的起点。
我习惯用一个生活类比来理解临界区:一个公司只有一间会议室(共享资源),两个部门要同时用(多个进程),如果谁先进去谁就用,没有规则,后到的部门直接把正在开会的另一个部门赶出去,那会议资料全乱套。临界区就是“进去开会的那段时间”,互斥锁就是门上那把钥匙,谁拿了钥匙谁才能进,出来再还回来。整个过程讲究的是:同一时刻最多只有一个执行流在临界区里。
1.2 竞态条件:为什么两次运行结果不一样
竞态条件(Race Condition)就是多个执行流在不受控的时机下访问共享数据,导致结果依赖执行顺序。我见过最典型的例子是一个多线程计数器:8个线程,每个循环加1万次,理想结果是8万,但实际跑出来经常是7万3、6万8这种数字,每次还不一样。为什么?
因为“i = i + 1”在CPU层面不是一条指令,而至少是“读取旧值→计算新值→写回”三步。线程A读到了101,还没写回102,线程B也读到101,两个线程都写回102,那最后就只有102,白白丢了一次累加。这种丢操作在并发量大的时候会累积起来,结果自然就错了。如果大家访问共享数据的代码段不加以保护,运行结果就和“谁先抢到CPU”有关,而调度时机是不确定的——这就是竞态条件。
早期我排查这种问题,第一反应是怀疑编译器优化,甚至怀疑服务器CPU有问题,后来才发现是自己在临界区上偷了懒。从那以后我形成了一条铁律:所有被多线程/多进程共享的可变数据,访问它的路径必须经过同步机制。
2. 互斥与同步的核心实现原理:锁、信号量、原子操作
理解了问题是一个“时机错乱”的问题,接下来就要看解决问题的几套武器。这一节我会把互斥锁、自旋锁、读写锁、信号量、条件变量、原子操作挨个拆清楚。它们听起来很多,但核心思想只有两条:“互斥”是把临界区的大门锁上,保证同时只进一个人;“同步”是让多个执行流按约定的顺序协作,比如生产者没生产完,消费者就得等着。
2.1 互斥锁:最朴素的“门闩”
互斥锁(Mutex)的思路最直接:共享数据前面放一把锁,线程进来之前必须先 lock(),拿到锁才能进临界区,用完再 unlock()。如果锁已经被别人持有,那当前线程就会阻塞等待,直到锁被释放。
底层原理上,互斥锁依赖操作系统提供的原子操作和线程调度。加锁失败时,线程会从运行态切到睡眠态,等锁可用时再被唤醒。这意味着互斥锁是有成本的:两次上下文切换(睡眠+唤醒)大概有几微秒甚至更多开销。所以互斥锁适合临界区比较长、锁竞争不特别极端的场景。临界区就几行代码、执行时间极短的情况下,用互斥锁反而是浪费。
2.2 自旋锁与读写锁:场景选型的门道
自旋锁(Spinlock)和互斥锁解决的问题一样,但策略完全不同。它拿到不到锁时不会睡觉,而是在原地“高速旋转”,反复检查锁是否被释放。好处是没有上下文切换,响应极快;坏处是白白烧CPU。因此自旋锁只适合临界区极短、锁持有时间纳秒级或微秒级的场景,比如内核里保护某个链表的插入操作。
读写锁(RWLock)解决的是另一个痛点:有些共享数据“读多写少”,比如配置表,大量线程都在读,偶尔一个线程需要更新。如果一律用互斥锁,所有读线程之间互相也要排他,性能白白浪费。读写锁允许多个读者同时进入临界区,但写者必须独占。需要留神的是,如果写者频繁到来,读者可能被持续饿死,一直等不到锁;反过来如果读者一直来,写者也可能等待太久。实际项目里我会用写优先或公平模式的读写锁实现,并监控等待时间。
2.3 信号量与条件变量:从“锁”到“协调”
信号量(Semaphore)是一个计数器,配合两个原子操作:P(produce/wait,计数器减1,若为负则阻塞)和 V(signal/post,计数器加1,唤醒一个等待者)。它不仅能互斥(初始值设为1),还能做资源计数:比如限制同一时刻最多N个线程访问连接池,信号量初始值就设为N。这是很多语言里的Semaphore的底层逻辑。
条件变量(Condition Variable)则解决“同步”问题:线程往往需要等待某个条件成立再继续,比如消费者要等队列里出现数据,如果自己循环占着CPU去检查会很浪费。条件变量提供了 wait() 和 signal() 机制:条件不满足时线程挂起,别人改变条件后发一个通知把它唤醒。注意条件变量必须跟互斥锁配合使用,因为“判断条件→等待”这个流程需要原子性,否则就会出现在等待途中错过通知的情况。这一点新手特别容易踩。
我个人的选型经验可以总结为一张表:
| 机制 | 核心能力 | 适用场景 | 常见成本 |
|---|---|---|---|
| 互斥锁 | 独占临界区 | 大多数共享数据保护 | 上下文切换延时 |
| 自旋锁 | 极短临界区独占 | 内核、实时性要求高的场景 | CPU空转 |
| 读写锁 | 读共享/写独占 | 配置类读多写少场景 | 读写公平性难平衡 |
| 信号量 | 资源计数/限流 | 连接池、生产者消费者 | 计数逻辑更容易出错 |
| 条件变量 | 等待通知机制 | 任务队列、事件驱动 | 必须与mutex搭配 |
2.4 原子操作和内存模型:一切互斥的底层地基
不管是锁还是信号量,它们的实现最终都依赖CPU提供的原子指令,比如 x86 的 LOCK 前缀、compare-and-swap(CAS)。原子操作的意思是:这个操作在执行过程中不会被线程调度打断。所以最简单的互斥完全可以不用锁:比如给计数器累加时用__sync_fetch_and_add或atomicAdd这样的接口。
这里还藏着一个更深的主题:内存模型。现代CPU和编译器为了性能,会重排指令的执行顺序。线程A把数据写好了,然后设置标志位,线程B看到标志位后去读数据,如果这两步之间没有内存屏障(Memory Barrier),B读到的可能是还没写好的旧数据。像 C++ 的 std::atomic、Go 的 atomic、Java 的 volatile 以及各种锁库,背后都在帮你处理这些内存可见性和乱序问题。所以我的建议是:尽量使用语言或库提供的同步原语,而不是手写汇编或搞黑魔法。自己写无锁代码,一定要对内存模型有绝对把握,否则坑会非常深。
3. 实战一:线程计数器从错乱到稳定
光讲原理不过瘾,我们来跑一个真实例子。就用最经典的共享计数器,一步步看它是怎么错的、又怎么用互斥锁修正的。我会用 C 语言和 pthread 库来演示,因为它是操作系统课程和面试里最常见的底子,换成 C++、Java、Go、Python 的思路也完全一致。
3.1 准备环境和基线代码
需要一台 Linux 机器,装好 gcc 和 pthread 库。Debian/Ubuntu 上基本自带,没装的话执行:
sudo apt update && sudo apt install -y gcc libc6-dev编写基线代码,故意不加锁,模拟一个“裸奔”的共享计数器。每个线程循环10万次,共开8个线程,预期结果是80万。
#include <stdio.h> #include <pthread.h> #define THREAD_NUM 8 #define LOOP_NUM 100000 long counter = 0; void* worker(void* arg) { for (int i = 0; i < LOOP_NUM; i++) { counter = counter + 1; } return NULL; } int main() { pthread_t t[THREAD_NUM]; for (int i = 0; i < THREAD_NUM; i++) { pthread_create(&t[i], NULL, worker, NULL); } for (int i = 0; i < THREAD_NUM; i++) { pthread_join(t[i], NULL); } printf("expected: %d, actual: %ld\n", THREAD_NUM * LOOP_NUM, counter); return 0; }编译运行:
gcc -O2 -o counter counter.c -lpthread ./counter我在自己的测试机上跑了一次,输出是:
expected: 800000, actual: 534217而且每次运行数字都不一样。这就是竞态条件的现场。
3.2 裸奔的数据竞争:为什么结果总是偏小
从结果看,实际数值几乎总是小于预期。原因就是前面说的“读-算-写”三步中间被打断:线程A读到 counter=100,线程B也读到 counter=100,A写回101,B也写回101,等于两次累加只生效了一次。多个线程互相覆盖对方的写结果,丢的更新越来越多,结果自然偏小。
如果你用-O0编译,可能错误没那么明显,甚至结果偶尔正确;但加了-O2优化后,编译器可能把 counter 的读取放在寄存器里,错误率反而更高。这也是很多人说“换个编译选项Bug就消失了,再换一个又回来了”的原因——其实Bug一直没消失,只是被掩盖了。
3.3 加互斥锁的正确姿势
现在给临界区加上一把互斥锁。pthread 里对应的类型是pthread_mutex_t,使用流程:初始化 →加锁 →临界区操作→解锁→销毁。
#include <stdio.h> #include <pthread.h> #define THREAD_NUM 8 #define LOOP_NUM 100000 long counter = 0; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void* worker(void* arg) { for (int i = 0; i < LOOP_NUM; i++) { pthread_mutex_lock(&lock); counter = counter + 1; pthread_mutex_unlock(&lock); } return NULL; } int main() { pthread_t t[THREAD_NUM]; for (int i = 0; i < THREAD_NUM; i++) { pthread_create(&t[i], NULL, worker, NULL); } for (int i = 0; i < THREAD_NUM; i++) { pthread_join(t[i], NULL); } pthread_mutex_destroy(&lock); printf("expected: %d, actual: %ld\n", THREAD_NUM * LOOP_NUM, counter); return 0; }编译运行后,输出稳定为:
expected: 800000, actual: 800000多跑几次,结果都一样。这就是互斥锁的作用:保证任意时刻只有一个线程能执行counter = counter + 1这一段,其他线程要么排队,要么等锁释放。
3.4 测一下锁到底带来多大开销
顺手做个简单的性能观测:在不加锁和加锁两种情况下用time命令记录运行时间。我的机器上,不加锁版本大约 0.02 秒,加锁版本约 0.06 秒,慢了不少,但换来的是正确性。
如果觉得锁竞争太激烈,可以考虑三种优化路径:
- 降低临界区长度:只把“counter++”锁起来,不要锁住整个打印、日志等无关代码。
- 分片计数:每个线程用自己的计数变量,最后汇总。汇总时再锁一次,锁竞争从“每次循环”降到“一次”。
- 原子操作:直接用 GCC 内置的
__atomic_add_fetch(&counter, 1, __ATOMIC_SEQ_CST),完全不需要锁,性能比互斥锁还要好一大截。
原子操作在“单一计数器”这种简单场景下是绝佳选择,但别以为所有场景都能照搬。一旦需要“先检查再操作”的组合逻辑,比如“余额是否足够?足够就扣款”,原子操作的单条指令就不够了,依然需要锁或者 CAS 循环来保证整体原子性。
4. 实战二:信号量实现生产者-消费者模型
计数器只是互斥的入门,真实的开发里还要处理“协作同步”。最经典的例子就是生产者-消费者模型:一个或多个线程生产数据放入缓冲区,一个或多个线程从缓冲区取数据消费。关键点在于:缓冲区满时生产者必须等待,缓冲区空时消费者必须等待。只靠互斥锁是搞不定的,所以这一节我们用信号量来实现。
4.1 模型设计与信号量初始化
设计一个最简单的单生产者单消费者模型,缓冲区是一个固定大小的数组,容量设为 N=8。需要三个信号量:
empty:表示缓冲区中空闲槽位的数量,初始值为 N。生产者每次放入数据前先 P(empty),如果没有空位就阻塞。full:表示缓冲区中已有数据的数量,初始值为 0。消费者每次取数据前先 P(full),如果缓冲区为空就阻塞。mutex:保护缓冲区的读写操作本身,避免多个线程同时操作数组下标导致越界或覆盖,初始值为1。
这个设计里,信号量既做了同步(empty/full控制生产消费节奏),又做了互斥(mutex保护缓冲区)。P操作和V操作来自经典的 Dijkstra 信号量原语,对应 pthread 里的sem_wait和sem_post。
4.2 关键代码与实现细节
代码里定义缓冲区、信号量,生产者和消费者各一个线程。生产者写入数字,消费者读出并打印。为了防止无限运行,限定生产10个数据后结束。
#include <stdio.h> #include <pthread.h> #include <semaphore.h> #include <unistd.h> #define BUFFER_SIZE 8 #define PRODUCE_NUM 10 int buffer[BUFFER_SIZE]; int in = 0, out = 0; sem_t empty; sem_t full; sem_t mutex; void* producer(void* arg) { for (int i = 0; i < PRODUCE_NUM; i++) { sem_wait(&empty); sem_wait(&mutex); buffer[in] = i; printf("produce: %d at slot %d\n", i, in); in = (in + 1) % BUFFER_SIZE; sem_post(&mutex); sem_post(&full); usleep(100000); // 模拟生产耗时 } return NULL; } void* consumer(void* arg) { for (int i = 0; i < PRODUCE_NUM; i++) { sem_wait(&full); sem_wait(&mutex); int val = buffer[out]; printf("consume: %d from slot %d\n", val, out); out = (out + 1) % BUFFER_SIZE; sem_post(&mutex); sem_post(&empty); usleep(200000); // 模拟消费耗时 } return NULL; } int main() { sem_init(&empty, 0, BUFFER_SIZE); sem_init(&full, 0, 0); sem_init(&mutex, 0, 1); pthread_t p, c; pthread_create(&p, NULL, producer, NULL); pthread_create(&c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); sem_destroy(&empty); sem_destroy(&full); sem_destroy(&mutex); return 0; }编译时同样需要链接 pthread 和实时库:
gcc -O2 -o prodcons prodcons.c -lpthread -lrt ./prodcons注意sem_init的第二个参数设为 0,表示信号量只在当前进程内的线程间共享。如果你要跨进程使用信号量,就必须设为 1,并配合共享内存或文件映射来使用。后面实战三会细讲。
4.3 跑起来看结果与容量参数影响
运行结果会输出“produce”和“consume”交替出现。由于生产者睡眠100毫秒、消费者睡眠200毫秒,生产速度比消费快,你会看到缓冲区在慢慢变满,但永远不会超过8个slot。如果把生产者睡眠改成200毫秒、消费者改成100毫秒,缓冲区会经常被清空,消费者会等在生产信号量上。这两个方向都能直观感受到信号量在调节“生产与消费的步调”。
这个模型的价值不仅在操作系统课程里。你日常遇到的消息队列、任务队列、线程池任务派发、日志异步写入,底层逻辑基本都是生产者-消费者。区别只是缓冲区从数组变成了真正的队列,信号量可能被语言层封装成BlockingQueue、Condition或channel。但核心节奏控制思想,还是 empty 和 full 两个信号量在互相抬杠:一个负责不让缓冲区爆掉,一个负责不让消费者空转。
5. 实战三:多进程场景的同步与互斥
线程之间同步用 pthread 一族的工具很顺手,但是真实系统里更多时候是多个进程协作。进程有独立地址空间,不能像线程那样直接共享一个全局变量,所以同步的玩法要换一层。常见方案有三种:文件锁、共享内存加信号量,以及系统级的进程间通信(如消息队列、Socket)。这里我演示前两种,因为它们是后台服务和运维脚本里最容易用上的。
5.1 文件锁:多个进程安全写同一个文件
先描述一个实际场景:多个 worker 进程同时往同一个日志文件追加内容。如果只是裸用fprintf(fp, ...),两个进程同时写时,文件偏移量可能互相覆盖,最后日志行会乱掉、串行。解决办法之一是用flock给文件加一把建议锁,写之前锁住文件,写完解锁。
#include <stdio.h> #include <sys/file.h> #include <unistd.h> #include <string.h> void write_log(const char* msg) { FILE* fp = fopen("/tmp/test.log", "a"); if (!fp) return; int fd = fileno(fp); flock(fd, LOCK_EX); // 加独占锁 fprintf(fp, "[%ld] %s\n", (long)getpid(), msg); fflush(fp); flock(fd, LOCK_UN); // 释放锁 fclose(fp); } int main() { for (int i = 0; i < 100; i++) { write_log("hello file lock"); usleep(10000); } return 0; }如果启动多个这样的进程同时执行,最终/tmp/test.log里每行日志都完整、不会交叉。这里有个容易忽略的点:flock 的锁是“建议锁”,别的进程如果不理会 flock,照样能写入,锁就形同虚设。所以所有写这个文件的进程都必须遵守同一套加锁约定。这是文件锁最大的局限性。
5.2 共享内存加信号量:进程间的“数据库”
如果多个进程需要共享一个更复杂的数据结构,比如一个数组、一张表、一段配置缓存,那文件锁的效率就不太行了。更好的方案是用共享内存 + 信号量。共享内存在多个进程的地址空间里映射同一块物理内存,进程 A 写入的数据,进程 B 直接就能看到。
使用 POSIX 共享内存接口shm_open+mmap,配合进程间信号量。步骤是:
shm_open("/myshm", O_CREAT | O_RDWR, 0666)创建或打开一片共享内存对象。ftruncate(fd, 4096)设置大小。mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)映射到进程地址空间。sem_open("/mysem", O_CREAT, 0666, 1)创建命名信号量,用于互斥保护共享区。- 业务代码里:
sem_wait(sem)→ 读写共享段 →sem_post(sem)。
下面是一个写端示例,它往共享内存里写入一串字节:
#include <fcntl.h> #include <sys/mman.h> #include <semaphore.h> #include <stdio.h> #include <string.h> #include <unistd.h> int main() { int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666); ftruncate(fd, 4096); char* addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); sem_t* sem = sem_open("/mysem", O_CREAT, 0666, 1); sem_wait(sem); strcpy(addr, "hello from shared memory"); sem_post(sem); munmap(addr, 4096); close(fd); return 0; }读端就把addr内容打出来,加锁方式一样。这里要注意:sem_open的第二个参数带O_CREAT,多个进程只要传相同的名字,拿到的就是同一个信号量;用完要sem_close,不需要删对象则不必调用sem_unlink。共享内存对象用完后调用shm_unlink("/myshm")清理,否则系统里会残留对象。
进程间同步和线程间同步最大的区别就是“名字空间”:线程间用同一个进程内的地址来引用锁,进程间则通过文件系统路径或对象名来共享同一个内核对象。所以你会发现,进程间同步原语的API总是多出一些名字参数,比如sem_open("/xxx")、flock(path),这不是故意增加复杂度,而是为了在独立的进程地址空间之间建立一个共同知道的“接头暗号”。
5.3 进程内与进程间同步的选择思路
实际项目里,进程间同步选型我会按这个顺序考虑:
- 如果只是保护同一个文件的写入,文件锁最简单,别引入额外复杂度。
- 如果需要大流量共享结构化数据,共享内存 + 信号量是性能王者,但代码要仔细处理生命周期和异常情况。
- 如果场景天然是“一个生产端、一个消费端”,用管道或消息队列反而省事,因为它自带阻塞和缓冲。
- 如果已经是微服务架构,跨机器同步就得上分布式锁(基于数据库或协调服务),但那是另一个层次的话题了。
永远不要一上来就选最炫的机制。同步机制越底层,出错时排查难度越高。共享内存被写坏时,你连“是哪个进程干的”都很难定位,因为在多个进程视角里数据是同一份,没有明确的责权边界。
6. 真正常踩的坑:死锁、优先级反转与排查工具
如果说前面几节是“把代码写对”,这一节则是“出了问题怎么想、怎么查”。多线程编程里最出名的一群坑都和这一节有关,而且它们的共同特点都是:平时一切正常,压力一大就炸。
6.1 死锁:四个必要条件和处理套路
死锁指的是一组线程互相等待对方持有的资源,谁也走不动。经典的例子是线程A持有锁1想要锁2,线程B持有锁2想要锁1,两个人僵持住了。
死锁必须同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。换句话说,资源是排他的、拿到不撒手、别人抢不走、而且等待关系形成了环。处理死锁的思路有两个层次:
- 预防/避免:破坏任意一个条件。最常见的是固定锁的获取顺序,让所有线程都按“先锁1再锁2”的顺序拿锁,循环等待就没了。也可以用 trylock,拿到不到就释放已有锁、重试,破坏“持有并等待”。
- 检测与恢复:在运行时检测等待图是否成环,检测到后强制杀死某个线程释放资源。数据库里用的就是这么一套思路,但通用编程里很少自己实现检测器,所以更实际的做法还是从设计上避免它。
我自己的矛盾排查方法很简单:凡是涉及多个锁嵌套的地方,写注释标明“锁顺序”;能用一把锁的场景,绝不用两把。很多业务代码死锁都是在“重构时顺手加了一处锁”之后埋下的雷。
6.2 优先级反转:一个容易被忽视的严重问题
优先级反转听起来反直觉:高优先级线程居然被低优先级线程卡住了。场景是这样:低优先级线程持有锁在临界区里干活,中等优先级线程不断抢占CPU,导致低优先级线程迟迟无法继续、释放锁,高优先级线程就在锁上一直等。
最出名的案例是火星探路者探测器上的软件故障——高优先级任务长时间没法运行,最终引发系统复位。解决手段有几种:优先级继承(低优先级线程在持有锁期间暂时提升到等待它的最高优先级)和优先级置顶(持有锁时直接把优先级提到系统允许的最高值)。Linux 的实时互斥锁(pthread_mutexattr_setprotocol设置为PTHREAD_PRIO_INHERIT或PTHREAD_PRIO_PROTECT)就支持这些策略。
这个坑容易发生在实时系统、嵌入式设备、以及带有优先级调度逻辑的任务系统中。普通Web服务里优先级反转不那么致命,但在高并发场景下,它的表现就是“某个请求被奇怪地拖死”,十分隐蔽。
6.3 锁的粒度与性能取舍
锁也不是加得越细越好。加锁太频繁,锁本身的开销会超过业务执行的时间;加锁太粗,多线程并行度又不足,等于回到单线程。实践经验是:先保证正确性,再用性能剖析数据决定要不要优化锁粒度。
如果一个共享对象被频繁读取但极少修改,就考虑读写锁或使用不可变发布(写完构建新对象再原子替换引用)。如果是热点计数器,就考虑分片或无锁原子操作。如果多个共享变量之间存在不变量(比如转账时两个账户余额总和必须不变),那么它们必须放在同一个临界区里保护,不要为了减小锁粒度而拆成两把锁——一旦拆开,中间状态就可能被别的线程看到。这里我吃过亏:曾经把一个“余额+流水”的双写场景拆成两把锁,结果压测时出现了流水已写入但余额未扣光的中间状态,下游对账全对不上。后来合并成同一把锁,问题立刻消失。
6.4 排查实录:用工具找到锁问题
没有工具加持,排查并发问题会是一场噩梦。我的工具箱里有这几件宝贝:
- ThreadSanitizer(TSan):编译时加
-fsanitize=thread,运行时会直接报数据竞争,包括“代码在哪一行访问了变量、另一个线程在哪一行访问了变量”。这是排查竞态最快的手段。 - Valgrind Helgrind / DRD:不要求重新编译,但运行速度慢很多,适合小规模测试。
- gdb:死锁现场可以用
thread apply all bt打出所有线程的调用栈,一眼看出大家都在等什么锁。 - strace:观察系统调用层面,比如进程是不是卡在
futex等待上。 - perf / top:查看CPU占用。自旋锁空转时CPU会飙高,这是重要信号。
有一次线上服务出现间歇性卡顿,我用gdbattach 到进程后thread apply all bt,看到8个线程全部阻塞在futex等待上,而一个业务线程又卡在磁盘IO里,锁被它长时间持有。这样一来就清晰了,不是死锁,而是“锁持有者被拖慢导致的扇出阻塞”。对症下药后,把持锁期间不必要的读盘逻辑移出临界区,卡顿立刻消失。排查并发问题的要领就一句话:把“谁的锁、被谁拿、锁住多久、等待多久”这四个问题查清楚,80%的疑难现场都能定位。
最后再分享一个我个人的小习惯:写共享数据加锁时,把锁的职责写进注释里,比如“这把锁只保护 counter 字段,别用它干别的”。因为同步机制最怕的不是不会用,而是后续维护的人在不理解边界的情况下,随便扩大锁的范围或遗漏访问路径。用一个互斥锁保护多个不同的共享对象,代码一旦复杂,死锁和竞态就都来了。少用锁、用小的锁、明确锁的边界,并发代码自然就稳。