HOOOS

进程崩溃后,它持有的跨进程 Robust Mutex 是如何被自动释放的

0 207 Linux极客 Linux 内核多线程编程系统编程
Apple

在多进程共享内存的并发编程中,跨进程锁(Shared Mutex)是一个常见的设计。但它有一个致命的阿喀琉斯之踵:如果持有锁的进程在临界区内突然崩溃(比如收到 SIGSEGV 信号或被 kill -9),这个锁就会永远处于被持有状态,导致其他等待该锁的进程陷入永久死锁。

为了解决这个问题,POSIX 标准引入了 Robust Mutex(健壮互斥锁)。当持有锁的进程意外死亡时,系统能够自动感知、释放该锁,并将锁的状态标记为“非干净”状态,通知下一个获取锁的进程进行数据恢复。

这一机制的背后,是 Linux 内核与 glibc(或其它 C 库)之间一次极其精妙的协同配合。


核心契约:登记在册的 Robust List

要让内核在进程死亡时帮它“收尸”释放锁,内核首先必须知道这个进程到底持有了哪些锁。

如果每次加锁、解锁都要调用一次系统调用去通知内核,性能开销是不可接受的。为了实现“零系统调用加解锁”的性能,Linux 引入了一个用户态与内核态共享的单链表:Robust List

1. 注册链表头

当一个线程首次初始化或准备使用 Robust Mutex 时,C 库会通过系统调用 set_robust_list 向内核登记一个指针,指向该线程用户态空间的一个结构体:struct robust_list_head

// 内核中记录的每个线程的 robust_list 头部结构
struct robust_list_head {
    struct robust_list list;          // 链表指针,指向当前持有的锁
    long futex_offset;               // futex 变量相对于锁结构体的偏移量
    struct robust_list *pending_list; // 正在操作但尚未完全加入链表的锁(防止在加锁瞬间崩溃)
};

每个线程的 task_struct 中都有一个指针指向这个在用户态的 robust_list_head这是一次性注册,后续的加锁、解锁操作完全在用户态完成,不需要任何系统调用。

2. 用户态轻量级链表维护

当线程调用 pthread_mutex_lock 成功获取一个 Robust Mutex 时,C 库会在用户态将这个锁的节点插入到上述注册的单向链表中。

由于插入操作只是几行简单的指针赋值,它极其高效。

  • 加锁时:将锁节点挂入链表。
  • 解锁时:将锁节点从链表移除。

崩溃时刻:内核的“收尸”流程

当进程因为各种原因崩溃,进入内核的 do_exit() 死亡流程时,内核开始履行它的契约。

[进程崩溃触发 exit]
       │
       ▼
  do_exit()
       │
       ▼
exit_robust_list() ───► 遍历该线程绑定的用户态 robust_list
                               │
            ┌──────────────────┴──────────────────┐
            ▼                                     ▼
      [检查 futex 变量]                     [发现未释放的锁]
  判断 TID 是否等于当前死亡线程          将 futex 值设为 FUTEX_OWNER_DIED
            │                                     │
            └──────────────────┬──────────────────┘
                               ▼
                        sys_futex(..., FUTEX_WAKE, ...) 唤醒等待者

kernel/exit.c 中,内核会调用 exit_robust_list(struct task_struct *tsk)

  1. 读取用户态内存:内核根据 tsk->robust_list 找到用户态的链表头。由于进程虽然死了,但它的虚拟内存空间在内核执行 exit 期间尚未被完全销毁,内核仍能安全地读取这部分用户态内存。
  2. 遍历链表:内核沿着链表逐个检查该线程持有的锁(通过 futex_offset 找到具体的 futex 变量)。
  3. 验证所有权:内核会读取 futex 变量的值。如果该值中记录的线程 ID(TID)确实是当前正在死去的线程,说明这个锁确实被该死去的线程拿着,且没有被释放。
  4. 修改状态并唤醒
    • 内核将该 futex 变量的值修改为 FUTEX_OWNER_DIED(一个特定的标志位,0x40000000)。
    • 内核调用类似于 FUTEX_WAKE 的操作,唤醒正在这个 futex 上挂起等待的其他进程/线程。

继任者的苏醒:如何处理前任的“烂摊子”

当另一个进程(我们称之为“继任者”)正在阻塞等待这个锁时,它会被内核唤醒。

1. 收到 EOWNERDEAD

继任者从 pthread_mutex_lock() 系统调用中醒来,但它并不会像往常一样拿到 0(成功),而是会收到一个特殊的错误码:EOWNERDEAD

这个错误码是一个严正的警告:“你拿到了这把锁,但是前一个持有它的进程在临界区内崩溃了。它保护的共享内存数据可能处于不一致或损坏的状态,请谨慎处理!”

2. 继任者的抉择

收到 EOWNERDEAD 后,继任者必须对共享内存进行状态检查和修复。修复完成后,必须显式调用:

pthread_mutex_consistent(&mutex);

这个调用会告诉 C 库和内核:我已经把前任留下的烂摊子收拾干净了,共享内存现在是健康的。 随后,该锁恢复正常状态,可以继续像普通锁一样循环使用。

3. 如果继任者摆烂怎么办?

如果继任者收到 EOWNERDEAD 后,没有调用 pthread_mutex_consistent,而是直接调用了 pthread_mutex_unlock 释放锁。

那么 C 库会将这个锁标记为 ENOTRECOVERABLE(不可恢复)
一旦锁进入这个状态,此后所有试图获取该锁的进程都将永远返回 ENOTRECOVERABLE。这把锁相当于彻底废弃,只能销毁重来。这种“宁为玉碎”的机制防止了损坏的数据被不知情的后续进程继续读写,从而避免了脏数据的扩散。


防御性设计的极致:Pending List 的存在

在上述流程中,存在一个极其微小的 Race Condition(竞争冒险):

  • 线程正在尝试获取锁,它刚刚在用户态将锁挂入了 robust_list,但还没来得及执行原子操作修改 futex 变量,此时进程突然被 kill -9
  • 或者反过来:线程刚刚把 futex 变量改了,但还没来得及将锁挂入 robust_list,此时崩溃。

如果发生这种情况,内核在遍历 robust_list 时就会产生漏判或误判。

为了解决这个极端的边界问题,robust_list_head 中设计了一个 pending_list
在执行加解锁的临界汇编指令前,C 库会先把当前操作的锁地址写入 pending_list(这只需一次内存写入)。当安全完成原子操作后,再将 pending_list 置空。

内核在 exit_robust_list 时,除了遍历主链表,还会专门检查 pending_list 指向的那个孤立节点。内核会通过读取该节点的 futex 状态,动态判定该节点究竟是属于“已持有未挂载”还是“已挂载未激活”,从而做出正确的状态修正。

总结

Linux 的 Robust Mutex 机制是一套兼顾性能与高可用性的系统级工程实现:

  • 常态下零开销:加解锁完全在用户态通过维护链表和原子操作完成,不需要为了防备崩溃而频繁调用系统调用。
  • 异常时硬担保:依托进程销毁时由内核无条件执行的 do_exit 扫尾工作,保证了即使持有者被“物理消灭”,锁也一定会被释放,决不妥协出死锁。
  • 业务级一致性:通过 EOWNERDEADpthread_mutex_consistent 将数据恢复的主动权交还给业务层,在保障系统级活锁的同时,最大程度规避了业务层的数据损坏。

点评评价

captcha
健康