HOOOS

如果 Robust Mutex 的恢复线程在 consistent 之前再次崩溃,这把锁会经历什么?

0 218 高并发架构师 Linux多线程编程系统编程
Apple

在 Linux 多线程或多进程共享内存的并发编程中,Robust Mutex(鲁棒互斥锁) 是解决“持有锁的线程意外死亡导致死锁”的终极武器。

通常的流程是:线程 A 持锁崩溃 $\rightarrow$ 线程 B 接管并收到 EOWNERDEAD $\rightarrow$ 线程 B 修复数据后调用 pthread_mutex_consistent() $\rightarrow$ 正常解锁。

但如果发生了更极端的二重崩溃线程 B 在成功接管锁(返回 EOWNERDEAD)之后,还没来得及调用 pthread_mutex_consistent(),自己也崩溃了。

此时,系统和后续线程会如何处理这把处于“中间态”的锁?


一句话结论:熔断保护,锁彻底报废

一旦负责收尸的线程(线程 B)在调用 pthread_mutex_consistent() 之前死亡,这把锁就会被系统判定为无可挽回的损坏状态(Inconsistent and Not Recoverable)

后续任何线程尝试调用 pthread_mutex_lock() 都会立即返回错误码 ENOTRECOVERABLE。此时,除了销毁这把锁(pthread_mutex_destroy),你对它做任何操作(加锁、解锁)都是徒劳的。


状态演进:内核与 NPTL(glibc)的精密配合

为了理解这个过程,我们需要拆解 Linux 内核的 futex 机制与用户态 glibc(NPTL 线程库)是如何在这个极端场景下进行接力配合的。

我们将参与者设定为:

  • Thread A:初代持锁者,未解锁直接崩溃。
  • Thread B:二代接管者(收尸人),在 consistent 之前崩溃。
  • Thread C:三代围观者。

第一阶段:Thread A 崩溃,Thread B 接管

  1. Thread A 持有 Robust Mutex 期间被 kill -9 或段错误崩溃。
  2. 内核在回收 Thread A 的进程/线程资源时,会遍历该线程的 robust_list(鲁棒锁链表)。
  3. 内核发现该锁处于被持有状态,于是将锁对应的内核 futex 变量打上 FUTEX_OWNER_DIED 标志,并唤醒正在等待的 Thread B
  4. Thread Bpthread_mutex_lock() 中醒来,收到返回值 EOWNERDEAD
    • 注意:此时在 glibc 的底层实现中,这把锁的内部状态已经被标记为“等待一致性修复”状态。

第二阶段:Thread B 在 consistent 之前崩溃(核心现场)

此时,Thread B 已经成为了这把锁的新 Owner,但是它还没有调用 pthread_mutex_consistent()
就在这个时候,Thread B 也发生了段错误,崩溃退出。

  1. 内核的二次清理
    Thread B 死亡,内核的 do_exit() 再次触发,遍历 Thread B 的 robust_list
    内核发现 Thread B 也是这把锁的 Owner,于是再次将该 futex 变量的 Owner 状态清除,并唤醒下一个等待者 Thread C
    (在内核眼里,它只负责在线程死的时候帮忙“开门”并通知下一位,它并不理解什么是“一致性”)。

  2. 用户态(glibc NPTL)的致命判定
    Thread C 被唤醒,进入 glibc 的 pthread_mutex_lock 底层代码。
    glibc 读取了 Mutex 内部的属性标志位(通常是 __data.__nusers 或特殊的 robust 状态位)。

    glibc 发现:上一个持有者(Thread B)在死之前,没有将这把锁的“一致性标志(Consistent Flag)”置位。

    这说明了什么?说明 Thread B 在试图修复共享数据的中途夭折了。此时的共享数据可能已经被 Thread B 改了一半,处于**双重污染(Double Corruption)**的状态,根本无法判断哪些数据是有效的。

  3. 进入终结态
    为了防止灾难性的脏数据继续扩散,glibc 决定采取熔断机制

    • 它将 Mutex 的状态直接修改为 PTHREAD_MUTEX_TERMINATED(终止状态)。
    • pthread_mutex_lock() 放弃加锁,直接向 Thread C 返回 ENOTRECOVERABLE

为什么这样设计?(内核的“防污染”哲学)

你可能会问:为什么不能让 Thread C 继续尝试修复呢?万一 Thread B 还没开始改数据就崩溃了呢?

这就是安全防御性设计(Defensive Design)

在多线程/多进程共享内存中,数据的一致性全靠锁来保护。如果第一个线程崩溃了,我们假设数据可能损坏,但“或许还能救”(返回 EOWNERDEAD 允许尝试修复)。
但如果负责去救的第二个线程也崩溃了,系统必须假设灾难已经发生。如果允许第三个线程继续去读写这块内存,极大概率会导致更严重的业务逻辑错误,甚至把脏数据持久化到数据库中。

宁可让程序崩溃或锁死,也绝不容忍脏数据扩散。 这就是 ENOTRECOVERABLE 的设计初衷。


现场还原:底层状态对比

我们可以通过一个简单的状态机对比,来看出 pthread_mutex_consistent 的关键作用:

阶段 锁的持有者 共享数据状态 锁内部一致性标志 下一个尝试 Lock 的线程收到的返回值
初始状态 Thread A 正常 已一致 (Consistent) 阻塞等待
A 崩溃后 无 (内核暂管) 可能损坏 不一致 (Inconsistent) EOWNERDEAD (Thread B 收到)
B 成功调用 consistent Thread B 已修复 恢复一致 (Consistent) 正常返回 0 (后续线程)
B 没调用 consistent 就崩溃 无 (内核暂管) 严重损坏 (双重污染) 不一致 (Inconsistent) ENOTRECOVERABLE (Thread C 收到)

工程实践:收到 ENOTRECOVERABLE 后该怎么办?

当你的代码在调用 pthread_mutex_lock 收到 ENOTRECOVERABLE 错误时,说明共享内存里的数据已经彻底脏了。此时,你绝对不能假装无事发生继续运行。

通常只有两种硬核的处理路径:

路径 A:彻底重建(推荐)

如果你有完整的冷备数据,或者共享内存只是个缓存:

  1. 调用 pthread_mutex_destroy() 销毁这把坏锁。
  2. 重新初始化这把锁 pthread_mutex_init()
  3. 丢弃整块共享内存(Shared Memory),重新从数据库或其他可信源拉取数据进行初始化。

路径 B:安全自杀(Fail-Fast)

如果这是核心的进程间通信通道,且无法重建:
直接记录 FATAL 级别日志,然后调用 exit()abort() 让整个进程组退出,由守护进程(如 systemd 或 Kubernetes)拉起干净的初始状态。

点评评价

captcha
健康