刚接手一个用C++写的后台服务,上线两周后突然在某天晚上“假死”:进程还在,CPU占用不高,所有请求都不返回,日志也停在了某一刻。第一反应是死循环或者网络阻塞,可top里CPU才3%。用gdb attach上去一看,所有工作线程全部卡在pthread_mutex_lock上,等一把锁等到天荒地老。那一刻我意识到,这不是什么玄学故障,就是典型的线程死锁。这篇文章就把我在Linux环境下排查、复现、修复线程安全与死锁问题的完整经历整理出来,包括线程安全的核心原理、各种锁的选型逻辑、一个可复现的死锁Demo、从gdb到TSan的完整排查链路,以及工程上避免死锁的硬性纪律。无论你是刚接触linux系统编程的新手,还是已经被线上死锁折磨过几次的开发者,这篇都能给你一套可以直接抄作业的思路。
1. 线程安全为什么是Linux并发编程的第一道坎?
1.1 数据竞争:多个线程同时读写同一块内存
先聊一个基础但极其关键的问题:什么是线程安全? 很多人会背概念——“多个线程同时访问共享数据时不会产生不确定结果”。但真的出事的时候,大部分人对“不确定结果”的理解只是停留在“值不对”。实际上,线程不安全带来的问题远比“值不对”可怕,它可能让程序崩溃、死循环、卡死,甚至产生安全隐患。
为什么Linux下多线程编程这么容易踩坑?因为线程之间天然共享进程的内存空间。相比进程间的IPC需要显式传递数据,线程间的共享几乎是“无门槛”的:全局变量、静态变量、堆上的对象,所有线程都能直接访问。这种共享给了开发者极大的便利,但也埋下了竞争的种子。
我见过一个新手写的计数器程序,两个线程各自对同一个全局变量counter做100万次counter++,最后打印结果,既不是100万,也不是200万,而是180多万。为什么?因为counter++在CPU层面不是一条指令,而是“读取-修改-写回”三步:
- 从内存把
counter的值load到寄存器 - 在寄存器里加1
- 把寄存器的值store回内存
两个线程并发执行时,完全可能同时读到相同的旧值,各自加1,再写回去。结果就是两次自增只生效一次,数据丢失。
这就是最基本的数据竞争(Data Race)。
1.2 原子性、可见性、有序性:线程安全的三个维度
要真正理解线程安全,你必须把问题拆成三个维度来看。
原子性:一个操作中途不能被其他线程打断。counter++是非原子的,所以会丢数据。原子性的破坏来自CPU的时间片切换——线程A执行到一半被切走,线程B进来操作同一块数据,A回来时的旧数据可能已经过期。
可见性:一个线程修改了共享变量,其他线程什么时候能看到?现代CPU都有多级缓存,线程可能把变量读进了自己的L1 Cache,另一个线程改了主内存的值,本来应该失效的缓存行却没有被同步。在Linux下,线程可能被调度到不同CPU核心上执行,每个核心各自有缓存。没有内存屏障或锁的保护,跨线程的变量更新可能延迟甚至“丢失”。
有序性:编译器和CPU为了优化,会重排指令。单线程下重排不影响结果,但多线程下,A线程先写的ready=1可能被编译器优化到data=42的赋值之前执行,B线程看到ready=1时,data可能还是旧值。
这三个维度里,互斥锁能一次性解决三者:锁保证了原子性(临界区串行执行),锁的内存屏障语义保证了可见性(解锁前的修改对后续加锁的线程可见),锁的代码执行顺序保证了有序性。这就是为什么锁是最基础的线程同步工具。
1.3 一个最简单的线程不安全示例
写个C程序复现上面的丢数据问题,用pthread来实现:
c复制#include <stdio.h>
#include <pthread.h>
#define THREAD_NUM 2
#define LOOP_COUNT 10000000
static int counter = 0;
void *worker(void *arg) {
for (int i = 0; i < LOOP_COUNT; i++) {
counter++; // 非原子操作
}
return NULL;
}
int main(void) {
pthread_t threads[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
pthread_create(&threads[i], NULL, worker, NULL);
}
for (int i = 0; i < THREAD_NUM; i++) {
pthread_join(threads[i], NULL);
}
printf("counter = %d, expect = %d\n", counter, THREAD_NUM * LOOP_COUNT);
return 0;
}
编译运行一下:
bash复制gcc -g -O0 race.c -o race -lpthread
./race
实测结果:
text复制counter = 10641487, expect = 20000000
每次结果都不一样,而且几乎永远小于2000万。这就是数据竞争的典型表现。加上一把pthread_mutex_t互斥锁包住counter++,结果立刻变成20000000,毫无悬念。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁的家族:互斥锁、读写锁、自旋锁与条件变量的选型逻辑
2.1 互斥锁:最常用的临界区保护工具
pthread_mutex_t是Linux下最基础的互斥锁。使用流程就四步:初始化、加锁、解锁、销毁。但我在实际项目里见过大量用错的地方,集中在这几类:
临界区太小。只锁住counter++这一行,没问题。但如果临界区太小,保护不了“复合操作”,比如先检查后更新的check-then-act场景,检查是原子的,更新是原子的,但检查+更新作为一个整体不是原子的,锁如果不包住整个复合操作,数据照样竞争。
临界区太大。有人图省事,把整个函数体都锁住,包括耗时的IO操作、网络请求、sleep。这把“串行化”的范围放大了几十倍,并发性能直接变成垃圾。锁不是不用,而是要用得精准。
忘了解锁。函数里有多个return分支,每个分支前都要解锁,一旦某个分支漏了,这个锁就永远锁死。后面哪个线程再lock,直接卡死。这就是“一次性锁”。
我自己处理这个问题的方法很简单:写清理路径时,尽量减少提前return;必须要return时,先把锁释放掉再返回。实在不行,用RAII(在C++里)或者goto out的统一退出标签。
2.2 读写锁:读多写少场景的优化
pthread_rwlock_t是读写锁,允许多个线程同时加读锁(共享),但写锁是排他的。适合“读多写少”的场景,比如配置表、路由表、缓存这类数据。
注意一个坑:写锁可能被读锁饿死。如果读线程源源不断到来,写线程可能一直等不到写锁。Linux的pthread_rwlock_wrlock默认不保证写优先,高并发下写饿死是真实存在的事故。如果你不能容忍写线程饥饿,宁愿用互斥锁,或者自己实现读写锁的写优先逻辑。
另外,读写锁的性能并不总是优于互斥锁。读锁的加锁/解锁开销比互斥锁大,如果临界区很短,读锁甚至可能比互斥锁更慢。选型要用基准测试说话,别靠感觉。
2.3 自旋锁:临界区极短时的另类选择
pthread_spinlock_t(自旋锁)不会让线程睡眠,而是忙等待——在循环里反复尝试获取锁,直到成功。它的优势是没有线程切换开销,适合临界区非常短(几条指令)且竞争不激烈的场景。
但它有个致命短板:忙等浪费CPU。如果临界区比较长,或者锁竞争激烈,自旋锁会把CPU烧满,性能反而更差。而且单核CPU上,自旋锁可能造成死锁:线程A持锁被时间片切走,线程B在一个核上自旋等A释放锁,但A永远没有机会被调度执行。
我一般在Linux内核模块或者用户态超高并发低粒度的计数器场景里才会考虑自旋锁,普通业务代码里几乎不用。
2.4 条件变量:锁解决不了“等待”的问题
有一类并发需求是“等待某个条件成立”,比如生产者消费者队列:消费者要等队列非空才能取数据。如果只用互斥锁,消费者只能轮询——加锁、检查、解锁、sleep一下、再加锁检查,既浪费CPU又有延迟。
pthread_cond_t条件变量就是为这个场景设计的。典型的配对使用:
c复制pthread_mutex_t mutex;
pthread_cond_t cond;
// 生产者
pthread_mutex_lock(&mutex);
// 往队列里放入数据
pthread_cond_signal(&cond); // 唤醒一个等待线程
pthread_mutex_unlock(&mutex);
// 消费者
pthread_mutex_lock(&mutex);
while (queue_empty()) {
pthread_cond_wait(&cond, &mutex); // 原子地释放mutex并挂起等待
}
// 从队列取出数据
pthread_mutex_unlock(&mutex);
pthread_cond_wait的语义很特别:它原子地把当前线程挂起,同时释放传入的互斥锁。被唤醒后,会自动重新获取互斥锁再返回。所以调用前必须持有锁。
条件变量的经典坑是丢失唤醒:如果signal发生在wait之前,线程就错过了唤醒信号,永远睡下去。所以判空条件必须在锁的保护下检查,并且用while循环而不是if来避免伪唤醒(spurious wakeup)——即使没有signal,wait也可能因为系统信号等原因提前返回。
3. 死锁的四种土壤和一个真实还原的卡死案例
3.1 死锁的标准定义:两个线程都在等对方释放锁
死锁(Deadlock)的教科书定义是:两个或多个线程互相持有对方需要的资源,同时又在等待对方释放资源,导致所有线程都无法继续执行。
它需要同时满足四个必要条件:
| 必要条件 | 含义 | 直观比喻 |
|---|---|---|
| 互斥 | 资源只能被一个线程持有 | 厕所只有一个坑位 |
| 持有并等待 | 线程持有一把锁的同时,又在等待另一把锁 | 占着坑,还让人递纸 |
| 不可剥夺 | 已经持有的锁不能被强制抢走 | 坑位只能自己主动让出来 |
| 循环等待 | 多个线程形成等待环路 | A等B,B等A |
任何一个条件被破坏,死锁就不会发生。工程上所有的防死锁策略,本质上都是在破坏这四个条件中的某一个。
3.2 一个可复现的死锁Demo
我最常用的一段演示代码,是“两个线程按相反顺序加锁”的经典案例。它真实复现了我线上遇到的那种假死场景:
c复制#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
void *thread_a(void *arg) {
printf("thread_a: trying lock A\n");
pthread_mutex_lock(&lock_a);
printf("thread_a: got lock A, sleeping\n");
usleep(100 * 1000); // 让另一个线程有时间锁上B
printf("thread_a: trying lock B\n");
pthread_mutex_lock(&lock_b); // 等B,但B被thread_b持有
printf("thread_a: got lock B\n");
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
return NULL;
}
void *thread_b(void *arg) {
printf("thread_b: trying lock B\n");
pthread_mutex_lock(&lock_b);
printf("thread_b: got lock B, sleeping\n");
usleep(100 * 1000);
printf("thread_b: trying lock A\n");
pthread_mutex_lock(&lock_a); // 等A,但A被thread_a持有
printf("thread_b: got lock A\n");
pthread_mutex_unlock(&lock_a);
pthread_mutex_unlock(&lock_b);
return NULL;
}
int main(void) {
pthread_t ta, tb;
pthread_create(&ta, NULL, thread_a, NULL);
pthread_create(&tb, NULL, thread_b, NULL);
pthread_join(ta, NULL); // 主线程会永久阻塞在这
pthread_join(tb, NULL);
printf("all done\n");
return 0;
}
编译运行:
bash复制gcc -g -O0 deadlock.c -o deadlock -lpthread
./deadlock
你会看到输出停在“trying lock B”和“trying lock A”,程序永远不结束。pthread_join那两行永远不会返回,因为两个工作线程谁也走不到最后。
3.3 为什么“看似正常”的代码会死锁:锁顺序不一致
这段Demo代码的核心错误,是两个线程加锁的顺序不一致。线程A先锁A再锁B,线程B先锁B再锁A。忙的时候可能没事——只要两个线程没有同时交叉持有锁,就不会有问题。但一旦时序对上了,A持有A等待B,B持有B等待A,死锁瞬间发生。
这种“时序敏感”的问题最烦人:本地测试几百次都跑过,一上生产就被触发。因为触发的概率取决于调度时机、负载水平、cache命中这些玄学因素。
我在实际工作中遇到的最典型的锁顺序问题,是账户转账:A账户转账给B账户需要同时锁住两个账户,另一个线程做反向转账时,锁的顺序刚好相反,于是两个转账事务互相等待。排查这类问题的通用解法,是给所有需要加锁的资源定义全局统一的顺序,比如按账户ID排序后加锁,这样就不会出现先锁A再锁B和先锁B再锁A同时存在的局面。
4. 死锁排查实战:从gdb到core dump的完整链路
4.1 服务假死后第一步:别慌,先收集现场
线上服务假死,最忌讳的是上来就kill进程重启,因为你把现场毁掉了。正确姿势是先冻结现场,再收集证据。
第一步,确认进程状态:
bash复制top -Hp <PID>
-H显示线程级别的CPU使用。如果进程卡在锁等待,你会发现每个线程CPU都很低,整个进程CPU也不高。
第二步,用ps看线程状态:
bash复制ps -eLf | grep <PID>
STAT列如果大量是S(sleeping)而非R(running),说明线程都在睡眠等待。再结合日志停下来的位置,基本可以判断是锁同步出了问题。
第三步,pstack一把抓线程栈:
bash复制pstack <PID>
pstack会打印所有线程的调用栈,非常直接。Linux没有内置的pstack,通常由gdb提供,没有的话可以用gstack或者直接上gdb。
4.2 用gdb抓出“谁持有、谁等待”的锁关系
gdb是Linux下排查死锁的最强工具。附加上去后,用thread apply all bt打印所有线程的完整堆栈:
bash复制gdb -p <PID>
(gdb) thread apply all bt
观察执行pthread_mutex_lock的线程栈,最典型的等待位置在__lll_lock_wait或者__GI___pthread_mutex_lock。接着用frame切换进对应线程的栈帧,用p打印锁变量的owner字段,看看这把锁当前被谁持有了。
比如上面那个死锁Demo,gdb输出的关键线索大概是:
text复制Thread 2 (thread_a):
#0 __lll_lock_wait (futex=..., private=0) at lowlevellock.c:51
#1 __GI___pthread_mutex_lock (mutex=0x601040 <lock_b>) at pthread_mutex_lock.c:79
#2 thread_a (arg=0x0) at deadlock.c:17
Thread 1 (thread_b):
#0 __lll_lock_wait (futex=..., private=0) at lowlevellock.c:51
#1 __GI___pthread_mutex_lock (mutex=0x601020 <lock_a>) at pthread_mutex_lock.c:79
#2 thread_b (arg=0x0) at deadlock.c:11
看到没有:线程A在等lock_b,线程B在等lock_a。再用p查lock_a和lock_b:
text复制(gdb) p lock_a
$1 = {__data = {__lock = 1, __count = 0, __owner = <线程A的tid>, ...}}
(gdb) p lock_b
$2 = {__data = {__lock = 1, __count = 0, __owner = <线程B的tid>, ...}}
__owner字段的值就是当前持有锁的线程的tid。这时候“线程A持有lock_a等lock_b,线程B持有lock_b等lock_a”的环已经铁证如山,死锁实锤。
4.3 编译选项与内核参数的“退路”准备
排查死锁有个很现实的问题:生产环境的二进制往往没有调试符号,或者core dump被关掉了。等真的出事了,gdb上去全是内存地址,根本没法看。所以工程上必须在上线前就把退路铺好。
- 编译时加
-g -O0或在Release里至少保留-g生成的符号文件,不要用strip把符号表全部剥掉。如果担心二进制体积,可以保留符号文件另存,崩溃时再用gdb 二进制 core对回去。 - 打开
core dump开关:
bash复制ulimit -c unlimited
这个ulimit -c默认经常是0,意味着进程崩溃时内核不会生成core文件。改成unlimited后又有个问题——core文件可能巨大,建议在/etc/security/limits.conf里配上core文件大小上限,比如core 209715200,限制在200MB以内。
- 如果服务还能响应信号,可以预埋一个自杀式备份:比如用
gdb的batch模式直接导出所有线程栈再退出:
bash复制gdb -p <PID> -batch -ex "thread apply all bt" > /tmp/stack_$(date +%s).txt
这个命令能在一两秒内跑完,抓取完现场,后面你想怎么重启都行。
4.4 更专业的检测工具:helgrind和ThreadSanitizer
静态排查不够,动态检测工具更能提前发现锁序问题。Linux下有两大杀器。
Valgrind的helgrind:能检测锁顺序违反(lock order violation)、数据竞争、重复释放等。缺点是非常慢,跑起来比正常慢20到100倍。我通常只在小规模回归测试里跑:
bash复制valgrind --tool=helgrind ./deadlock
如果存在潜在的锁顺序问题,helgrind能给出类似“Lock order violated”的警告,并指明两把锁分别在哪个位置加锁。这个工具对死锁的预防非常有效,尤其是逻辑复杂、多分支的场景。
GCC/Clang的ThreadSanitizer(TSan):编译时加-fsanitize=thread,运行时实时检测数据竞争,开销比Valgrind小很多。实测下来,TSan在CI里跑冒烟测试还是可以接受的:
bash复制gcc -g -O1 -fsanitize=thread deadlock.c -o deadlock_tsan -lpthread
./deadlock_tsan
TSan会输出线程竞争报告,指出哪两行代码在竞争哪块内存。它和helgrind的侧重点不太一样:helgrind更偏锁序问题,TSan更偏数据竞争。两个都值得加到你的工具库里。
5. 工程上避免死锁的硬性纪律与设计模式
5.1 固定加锁顺序,从全局解决循环等待
避免死锁最有效、成本最低、也最容易被忽视的方法是固定加锁顺序。只要所有线程需要同时锁多把锁时,都按照同一个全局顺序加锁,循环等待就被破坏了。
以转账为例:
c复制// 不安全的写法
void transfer_bad(int from, int to, int amount) {
pthread_mutex_lock(&accounts[from].lock);
pthread_mutex_lock(&accounts[to].lock); // 两个方向锁顺序不一致
// ...
}
// 安全的写法
void transfer_good(int from, int to, int amount) {
int first = from < to ? from : to;
int second = from < to ? to : from;
pthread_mutex_lock(&accounts[first].lock);
pthread_mutex_lock(&accounts[second].lock); // 总是先锁ID小的一侧
// ...
}
不要小看这个“按ID排序”的写法,它能在不改变业务逻辑的前提下,彻底消灭循环等待这一类死锁。唯一的代价是代码里多了一层排序判断,但这点成本换来的稳定性非常值得。
实现时要注意:全局锁顺序的定义必须统一。如果A模块按ID排序,B模块按地址排序,两个模块之间联动时还是会死锁。最好在项目文档里明确写死规则,并让所有涉及锁的开发人员都遵守。
5.2 缩小锁粒度、控制临界区长度
避免死锁的第二条纪律是不要让临界区太长。锁持得越久,其他线程等得越久,出现交叉等待的概率越大。更重要的是,持锁越久,越容易在持锁状态里去调用其他可能加锁的代码,产生隐蔽的锁顺序问题。
我给自己定的铁律是:
- 临界区只保留对共享数据的修改,绝对不做IO、不 sleep、不调外部库。
- 持锁期间不要调用任何可能申请锁的函数,包括自己的其他加锁接口、第三方库、回调函数。
- 锁粒度优先选“细锁”:多个对象各自一把锁,而不是一个全局大锁。细锁虽然增加了编程复杂度,但减少了锁竞争,也减少了死锁面。
一个很容易忽略的点是:避免在持锁时调用printf。日志函数内部可能加锁(很多日志库的缓冲写入是线程安全的,有内部锁),如果两个线程各自持有业务锁去打印日志,而日志锁只有一把,就可能形成一个“业务锁 → 日志锁”的等待链,再加上另一个方向,死锁就诞生了。生产环境里我见过不止一次这种因为日志打印引发的死锁。
5.3 trylock + 超时兜底:给锁等待加个保险丝
有些场景确实无法保证锁顺序一致,比如两个线程要操作两个动态遍历到的节点,节点的顺序在运行期才确定。这时候可以用trylock加回退机制:
c复制pthread_mutex_trylock(&lock_a);
if (pthread_mutex_trylock(&lock_b) != 0) {
pthread_mutex_unlock(&lock_a); // 拿不到B,先释放A,退回去重来
// 等待一小段时间后重试
usleep(1000);
continue;
}
trylock不会阻塞,拿到锁就返回0,拿不到立即返回非0。这种“拿不到就整体放弃、回头重来”的策略,直接破坏了“持有并等待”这个必要条件。缺点是可能出现活锁——两个线程反复互相谦让,谁也没法往下走。所以重试时要加随机退避时间,降低同时重试的概率。
另一个变体是pthread_mutex_timedlock,指定最长等待时间,超时后返回ETIMEDOUT,调用方可以走错误处理路径。这在一些不能立即放弃任务的场景里很有用。
5.4 隐藏的坑:条件变量、信号处理、fork与锁的纠缠
除了标准死锁,Linux下还有几个容易和死锁混淆的坑。
信号处理函数里加锁:信号处理函数可能在任意指令处打断主流程,如果在信号处理函数里试图加一把主流程正在持有的锁,就会立即死锁。正确做法是信号处理函数里只做write到管道或设置标志位,把真正的逻辑留给主循环。
fork与锁:fork子进程时,如果父进程有线程持锁,子进程继承的锁状态可能是锁定的,但持锁线程并不存在于子进程,子进程里任何线程再对这个锁lock,直接死锁。多线程程序里fork要非常小心,最好别让子进程直接继承锁,或者fork后在子进程里执行exec系列函数替换进程镜像。
条件变量没有配锁:pthread_cond_wait之前必须持有对应的互斥锁,等待时锁被自动释放。如果有人在没持锁的情况下调用wait,行为未定义,可能崩溃也可能直接卡死。
锁的销毁时机:当有线程还在等待一把锁时,另一个线程把锁destroy了,这是未定义行为。这类问题的排查远比死锁麻烦,因为现象千奇百怪。工程上强烈建议:锁的生命周期尽量用静态初始化(PTHREAD_MUTEX_INITIALIZER)或者严格管理动态锁的创建和销毁顺序,确保销毁时无人在用。
6. 延伸:数据库死锁和线程死锁是同一个模型吗?
6.1 数据库的锁:另一种资源竞争模型
很多人在热搜里搜“数据库死锁”,因为MySQL这种关系型数据库也有死锁,而且和线程死锁长得非常像。核心模型完全一致:都是多个并发任务持有一些资源、等待另一些资源,形成循环等待。
以MySQL InnoDB为例,事务A执行UPDATE user SET balance = balance - 100 WHERE uid = 1,会给uid=1的行加行锁;事务B执行UPDATE user SET balance = balance + 100 WHERE uid = 2,给uid=2的行加行锁。如果事务A接下来要更新uid=2,事务B要更新uid=1,两个事务就互相等对方的行锁,构成了数据库死锁。
除了行锁,还有表锁、间隙锁(Gap Lock)、插入意向锁等,锁的种类更多,死锁的触发条件也更复杂。但判断死锁的四条铁律——互斥、持有并等待、不可剥夺、循环等待——在数据库里同样适用。
6.2 数据库死锁的处理思路:检测、回滚、日志分析
数据库系统通常自带死锁检测机制。InnoDB会周期性地检测锁等待环,一旦发现死锁,会选出一个回滚代价较小的事务,回滚它,释放它持有的锁,让其他事务继续执行。这就是为什么你在业务日志里偶尔能看到类似Deadlock found when trying to get lock; try restarting transaction的报错——它不是说你操作错了,而是数据库自动做了牺牲。
排查数据库死锁的常用命令:
sql复制SHOW ENGINE INNODB STATUS\G
看LATEST DETECTED DEADLOCK段,里面会列出两个事务的SQL语句、持有什么锁、等什么锁。这是定位数据库死锁的第一手资料。
处理数据库死锁一般从几个方向入手:
- 优化SQL和索引:通过索引让扫描范围变小,减少锁的覆盖范围,降低锁冲突概率。慢查询往往伴随大范围锁,这是数据库死锁高发的根源之一。
- 统一更新顺序:和线程死锁一样,如果有多个事务按不同顺序更新相同集合的行,把顺序统一起来,就能消灭循环等待。
- 减少事务执行时间:事务里不要做长查询、远程调用、等待用户输入,锁持有时间越短,死锁窗口越小。
- 重试机制:因为在InnoDB下死锁会自动回滚一个事务,业务侧捕获死锁异常后简单重试几次即可,把死锁当成一种正常竞争来处理。
其实“慢查询、语句阻塞”和死锁经常是一根藤上的问题:慢查询拉长了锁持有时间,锁持有时间长了,事务之间互相等锁的概率急剧上升,最终演变成死锁。所以处理数据库死锁,先看慢查询日志,把拖慢事务的SQL优化掉,死锁问题往往会自动消失一大半。
对比一下,线程死锁和数据库死锁最根本的区别在于:线程死锁发生后,进程假死,没有自动拯救机制(除非你自己用trylock避免);数据库死锁则有检测器自动回滚一个牺牲事务,系统还能继续运行。所以线程死锁的预防要更严格,纪律性要求更高。
从我这些年踩着各种锁和死锁的坑走过来的经验看,Linux下的线程安全问题,本质是“共享状态”的管理问题。锁不是为了让你串行化,而是为了在你串行化的同时对共享状态的访问保持正确。死锁也从来不是靠某个神奇工具一键解决的,它靠的是固定的加锁顺序、克制的临界区长度、合理的锁粒度,以及一套随时能抓现场、能回放问题的排查工具链。你可以在项目一开始就把这些纪律写进规范,也可以在踩完坑之后再补上——但前者的成本,只有后者的十分之一。
