一个 Backend 干着干着,卡住了:想继续的条件还不满足。比如锁在别人手里,或者等的 I/O 还没做完。
怎么办?
等呗。
写成代码,大概就是:
if (!condition)
sleep();
等另一个 Backend 把事情做完,再把我叫醒。
不过 PostgreSQL 源码里的等待代码,比预想的要麻烦很多:等一把 LWLock,要排队、改状态、再检查一次,最后才睡;等一个条件,还得先 PrepareToSleep(),醒了以后又要重新检查;后台进程好不容易睡下去,还有 Latch 在旁边负责叫门。
不就是睡个觉吗?
后来发现,麻烦的根本不是睡这一下。操作系统可擅长让进程睡觉了。
真正麻烦的是:
从”我决定要睡”到”我真的睡下去”之间,别人可能刚好把我要等的事情做完了。
比如:
Backend A Backend B
检查 condition
发现还是 false
把 condition 改成 true
看了一眼:没人等
开始睡
↓
↓
↓
A 检查的时候,条件确实不满足。
但就在它准备睡觉的空档,B 把事情做完了。
B 想通知等待者,结果这时候 A 还没进入等待状态,于是什么也没通知。
然后 A 安心睡下。
问题是,之后已经没人有理由再叫它了。
这就是经典的漏唤醒(missed wakeup)。
所以一个并发系统真正需要解决的,不是:
怎么睡?
而是:
我怎么保证:
未来一旦发生能让我继续运行的变化,
一定有人知道我正在等?
事情从这里开始变复杂。
这篇顺着 PostgreSQL 的等待机制走一圈:从一把 LWLock 的等待队列,到只管叫醒的 Latch,再到带条件的 ConditionVariable,顺路看一眼负责保护共享状态的 SpinLock,最后是一群 Backend 等同一个阶段的 Barrier。
LWLock:睡之前,先把名字写上
在 PG 里,拿 LWLock 来看最直观。
假设我尝试拿锁,失败了。
最简单的做法当然是:
try lock
↓
失败
↓
sleep
可刚才那个竞态马上就会出现:
等待者 持锁者
尝试拿锁:失败
释放锁
发现等待队列为空
开始睡
所以 PostgreSQL 不允许等待者看到”锁被占着”以后直接睡。
它大概要经历这么一轮:
先尝试拿锁
↓
失败
↓
把自己挂进等待队列
↓
再尝试拿一次
↓
还是失败
↓
这次才睡
“再试一次”?多少有点死心眼。
刚刚不是才失败吗?
但问题就在”刚刚”。
第一次失败,只能证明那个时刻锁被占着。
从第一次检查到把自己挂进等待队列,中间完全可能发生:
我拿锁失败
↓
持锁者把锁释放了
↓
我才加入等待队列
现在锁已经空了。
如果这时候直接睡,还是可能没人再叫我。
所以入队之后必须重新看一次。
最终,要么发现锁已经能拿了,那就把自己从队列里撤下来继续干活;要么还是拿不到,但这次至少能确定一件事:
我已经登记过了。
以后谁释放这把锁,都能在等待队列里找到我。
这几步看着绕,其实是在建立一个很重要的保证:
我要么已经拿到了锁;
要么在真正睡下之前,
已经进入了别人能够找到的等待协议。
中间不能留下”锁没拿到,人也找不到,但已经睡了”的第三种状态。
这么看,等待队列就不只是一个排队优化。
它首先是在补那条最危险的时间缝。
终于被叫醒了,锁归我了吧?
现在总算睡安稳了。
持锁者释放锁,发现队列里有人,于是把等待者叫起来。
那醒来以后是不是就能直接继续?
还是不行。
LWLock 的唤醒并不是”把锁交给你”。
更像是:
起来吧,现在值得再抢一次了。
被唤醒的 Backend 还要重新执行 LWLockAttemptLock()。
比如 A、B、C 都在等:
锁释放
A、B、C 被叫醒
A:抢到了
B:没抢到
C:没抢到
B 和 C 该等还得接着等。
(真到实现里,PG 其实会想办法别叫醒这么多人,这个马上聊。)
这个区别很重要。
如果把唤醒理解成锁的交接,那被叫醒的人就可以默认”资源已经属于我”。
但 PG 没给这个承诺。
它只承诺:
之前让你等待的状态可能已经变化了。
到底变成什么样,还得重新看。
LW_FLAG_WAKE_IN_PROGRESS 也是类似。
它不是:
这把锁已经预留给某个等待者
而只是:
已经叫醒一批人了,
先让他们跑一下,
别下一次释放又立刻叫醒更多人。
不然上一批等待者甚至还没被调度,下一批又被叫起来,最后一群 Backend 全在忙着醒、抢、失败、再睡。
锁没快多少,CPU 倒先热闹起来了。
这里有个挺反直觉的地方:
被叫醒,并不意味着你等待的事情已经成功。
它只是一次”重新检查”的机会。
而这件事,在别的等待机制里会反复出现。
Latch:只管叫醒,不管传话
LWLock 至少还知道自己在等什么:
我在等这把锁。
后台进程就没这么专一了。
一个进程睡着的时候,可能同时关心:
有没有新工作?
是不是该关闭了?
配置要不要重新加载?
超时到了吗?
socket 有没有数据?
postmaster 还活着吗?
总不能为每种事情都准备一张床。
这时候就轮到 Latch。
把 Latch 理解成”事件通知机制”,还是有点重了。
它更像一个门铃。
SetLatch() 并不告诉你:
来了两个任务
也不告诉你:
有人修改了配置
甚至不负责告诉你到底是谁按的。
它只负责:
别睡了,起来看看。
真正的信息放在别处。
比如:
ShutdownRequestPending
ConfigReloadPending
共享内存中的工作状态
socket 就绪情况
这些才是 Backend 真正该相信的东西。
Latch 自己只保留一个很弱的状态:有没有被 set 过。
所以有人连按十次门铃,并不意味着屋里的人醒来以后一定要处理十件事。
它不是消息队列,也不记次数。
只要 is_set 变成 true,目的就已经达到了:
你现在别继续睡。
这也解释了后台循环里一个很常见的顺序:
ResetLatch()
检查真正关心的状态
没事
↓
WaitLatch()
通知方则反过来:
先修改真正的状态
↓
SetLatch()
顺序不能随便换。
如果先把人叫醒,再去写状态,就可能出现:
Backend 被叫醒
↓
重新检查
↓
什么都没有
↓
又睡了
通知方:
哦对了,我现在才把状态写好
熟悉的味道又回来了。
所以正确的思路是:
先让事情真的发生,再叫别人来看。
Latch 从来不负责携带”事实”,只是负责把一个已经睡着的进程拉回来。
不过这又留下一个问题。
如果很多 Backend 等的不是”有事叫我”,而是同一件具体的事呢?
ConditionVariable:等的不是通知,是条件
比如:
这个 Buffer 的 I/O 做完了吗?
checkpoint 推进到我等的那一轮了吗?
这个 replication slot 还在被别人用吗?
这些等待背后都有一个明确的条件,也就是常说的谓词(predicate)。
这时候单靠 Latch 就有点不够用了。
Latch 知道的是:
把 Backend A 叫醒。
但它不知道:
A、B、C 其实都在等同一个 Buffer 的 I/O。
更不知道状态变化以后,是应该叫醒一个人,还是把大家都叫起来。
ConditionVariable 就补在这里。
不过它有个很容易误解的地方:
ConditionVariable 并不保存条件。
名字挺像,实际它根本不知道 BM_IO_IN_PROGRESS 是什么,也不知道 checkpoint 进行到哪了。
真正的条件仍然放在业务自己的共享状态里。
CV 主要管的是:
哪些 Backend 正在等这个条件?
然后状态有机会发生变化时,再从等待队列里找到对应的 Backend,通过它们的 Latch 把人叫醒。
所以代码会写成:
ConditionVariablePrepareToSleep(cv);
while (!predicate())
ConditionVariableSleep(cv, wait_event_info);
ConditionVariableCancelSleep();
这里最有意思的其实不是 PrepareToSleep()。
是那个 while。
都已经被通知过了,为什么醒来以后还得再判断?
因为:
被通知
≠
条件已经成立
这和 LWLock 刚才的:
被唤醒
≠
已经拿到锁
其实是一回事。
而且 ConditionVariableSleep() 内部也藏着同样的套路:先重置 Latch,再看一眼自己有没有在睡下去之前就已经被通知过——和 LWLock 入队后再试一次,是一个原理。
假设很多人都在等同一个东西。
条件变化以后大家被叫起来,但在你真正拿到 CPU 之前,别的 Backend 完全可能已经先跑了一步,又改变了状态。
或者这次 Latch 被 set,压根不是因为你在等的这件事。
再或者广播本来就会把一拨人都叫起来,但并不意味着每个人醒来以后都能立刻拿到自己想要的结果。
所以等待者醒来以后,最靠谱的行为不是问:
谁把我叫醒了?
而是:
我等的条件现在到底成立了吗?
不成立?
那就接着睡。
听上去有点不信任人,但并发系统里,这种不信任通常挺健康。
多醒一次没事,漏醒一次要命
把这几个东西放一起,会看到一个挺统一的偏好。
LWLock:
醒了
≠
已经拿到锁
Latch:
醒了
≠
知道发生了什么
ConditionVariable:
醒了
≠
条件已经成立
也就是说,PG 对”多醒一次”其实没那么紧张。
醒了发现没事,大不了检查一下继续睡。
真正要命的是另一种:
事情已经发生了,但你永远没醒。
所以它愿意接受多余唤醒,却要非常认真地堵住漏唤醒。
这是个明显不对称的取舍:
多叫醒一次
→ 多一次检查,浪费点 CPU
少叫醒一次
→ 一个 Backend 可能永远等在那里
两边代价根本不是一个量级。
于是很多看起来重复的流程——先登记、再检查,醒来、再检查——其实都在做同一件事:
宁可多看一眼,也不要把正确性押在某一次通知一定精准到达上。
我觉得这比记住某个函数先调用谁更重要。
SpinLock:那”真相”自己会不会被改坏?
前面一直在说:
醒来以后,重新检查真正的共享状态。
但这里偷偷假设了一件事:
这份共享状态自己是可信的。
总不能几个人同时改一个字段,最后谁也不知道改成啥了,然后等待者再认真检查十遍。
所以状态本身还需要另一套机制保护。
这时候 SpinLock、原子操作、LWLock 才真正找到自己的位置。
SpinLock 适合非常短的共享状态更新。
不是拿着它做一段业务逻辑,而是趁别人还没反应过来,把几个字段从一个合法状态迅速推到另一个合法状态。
大概是:
合法状态 A
↓
锁住几条指令
↓
修改字段
↓
合法状态 B
为什么要求这么短?
因为等 SpinLock 的 Backend 可不会优雅地去睡半天。
持锁的人要是去做 I/O、跑复杂逻辑,甚至中间抛个 ERROR,后面那帮人就要开始怀疑人生了。
如果要保护的不再是几个字段的瞬时变化,而是一段需要 shared/exclusive 互斥的共享结构访问,就轮到 LWLock。
它允许等待者真正睡下,也有更完整的等待和异常清理协议,但这不代表可以拿着 LWLock 慢慢干活——共享锁的临界区一样应该尽量短。
两者的机制差别其实很直白:
SpinLock:抢不到主要靠短暂自旋和退避,赌持锁者马上结束
LWLock:先用原子操作抢一下,
抢不到,就排队、睡到 semaphore 上
等 SpinLock 的人是在赌:持锁的那几条指令马上结束,与其睡下去再被调度回来,不如把这点时间空转掉。等 LWLock 的人是真的睡过去,把 CPU 交给操作系统——反正这锁一时半会儿也空不了。
所以 SpinLock 只护得住”几条指令”级别的状态翻转,复杂一点的共享结构、共享/排他的读写互斥,都得靠 LWLock。它也比 SpinLock 扛折腾:持锁期间抛个 ERROR,abort 流程会统一把锁收回去。
LWLock 自己的快速路径尽量靠原子操作解决,只有真的拿不到锁,才进入前面那一大套等待队列和睡眠流程。
这么看,前面那些原语不是”PG 有五种锁,所以每种都学一下”。
它们管的根本不是同一件事。
原子操作 / SpinLock / LWLock
→ 把状态改对
等待队列 / ConditionVariable
→ 记住谁在等状态变化
Latch / semaphore
→ 真正把人叫醒
短时间保护状态,长时间等待未来。
这两件事最好别混在一起。
毕竟你可以等 checkpoint 等几秒,总不能顺手拿着 SpinLock 等它。
Barrier:一个人等已经这么麻烦了,一群人呢?
再往上一层,还有一种更有意思的等待。
并行算法里,有时候不是:
我等某个条件成立。
而是:
等大家都干完这一阶段,
我们再一起往下走。
比如 Parallel Hash Join 里的某些阶段。
几个 Worker 各干各的,但到了阶段边界,必须对一下账:
现在有多少参与者?
已经到了几个?
第一阶段是不是都做完了?
谁负责把系统推进到第二阶段?
这时候就有 Barrier。
它在等待机制之外,又增加了几份共享状态:
participants
arrived
phase
最后一个到达的人发现:
arrived == participants
于是把阶段加一,再通过条件变量把其他等待者叫醒。
不过即使到了 Barrier,前面的规律还是没变。
真正的事实依然不是:
我收到了一次广播
而是:
共享的阶段已经推进了
广播只是提醒大家:
起来看看,我们是不是可以进入下一阶段了。
从一把锁,到一个条件,再到一群 Backend 共享的阶段,等待的东西越来越复杂,但”通知只是提醒、状态才算数”这件事一直没怎么变。
最后
回头看最开始那段:
if (!condition)
sleep();
它的问题不是代码写得太少。
而是中间省掉了太多并发世界里不能假设的东西。
一个 Backend 真想安心睡下,大概得先保证:
先看看自己在等的状态
确实不满足
↓
先把"我正在等"这件事登记好
↓
再看一遍状态
↓
确认还需要等
↓
真正睡下
另一边则是:
先把真正的状态改好
↓
再叫醒等待者
等待者回来以后:
醒了
↓
别急
↓
再看一眼真正的状态
还不行?
继续睡。
所以 PostgreSQL 里才会出现这么多看起来有点啰嗦的东西:等待队列、semaphore、Latch、ConditionVariable、各种原子状态,再往上还有 Barrier。
它们不是为了把”睡觉”设计得高级一点。
真正难的是:
从”我现在不能继续”到”以后某个时刻我可以继续”之间,隔着另一个并发运行的进程,以及一段完全不可预测的时间。
这段时间里,不能漏通知,不能把通知误当成结果,也不能为了等未来一直霸着保护当前状态的锁。
最后所有这些机制都在反复维护一个很朴素的原则:
状态才是真相,唤醒只是提醒。
醒了以后,再看一眼。
毕竟 Backend 多醒一次,最多白忙活一会儿。
真睡过去没人再叫,那就不是午休睡过头这么简单了。