木匠钉子
Skip to content
博客
返回

一个 Backend 想睡一觉,为什么这么麻烦?

一个 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 多醒一次,最多白忙活一会儿。

真睡过去没人再叫,那就不是午休睡过头这么简单了。