HOOOS

进程崩溃后,Linux 内核是如何清理 Robust Mutex 的?深度解析其底层清理机制

0 31 Linux内功修炼 Linux 内核操作系统多线程编程
Apple

在多线程或多进程共享内存的并发编程中,死锁是一个经典的幽灵。而在所有死锁场景中,最让人头疼的一种是:一个持有共享锁(Mutex)的进程突然崩溃(如收到 SIGSEGV 信号),导致该锁永远处于被持有状态,其他等待该锁的进程/线程将被永久饿死。

为了解决这个痛点,Linux 引入了 Robust Mutex(健壮互斥锁)。当持有锁的线程在未释放锁的情况下意外退出时,内核能够感知并主动清理该锁,将锁的状态标记为“所有者已死”(EOWNERDEAD),从而允许其他生存下来的线程接管该锁。

这套机制是如何在用户态(User Space)和内核态(Kernel Space)之间无缝协作的?其底层的清理链路是怎样的?本文将从内核源码和协议层面进行深度拆解。


一、 核心痛点:为什么普通 Mutex 做不到?

在 Linux 中,现代的 pthread_mutex 底层是基于 Futex(Fast Userspace Mutex) 实现的。

Futex 的核心思想是:无竞争时在用户态解决,有竞争时才陷入内核。

  • 当一个锁没有被占用时,线程通过原子操作(如 CAS)在用户态直接修改锁变量(Futex Word)并成功获取锁,完全不需要系统调用,性能极高。
  • 只有当发生碰撞(锁已被占用)时,等待者才会通过 sys_futex 系统调用陷入内核挂起。

这种设计带来了一个致命问题:内核在平时根本不知道谁持有了哪把锁。

因为获取锁的过程完全是在用户态通过直接修改内存完成的,内核没有记录。一旦持有锁的进程崩溃,内核只知道这个进程要退出了,但根本不知道它在用户态的共享内存里还霸占着哪几个锁。

要解决这个问题,内核必须和用户态的动态链接库(如 glibc 的 NPTL)达成某种契协议:线程在拿锁时,必须在某个内核看得见的地方“备个案”;线程退出时,内核去这个备案处“查账”,并帮它把没还的锁还掉。

这个“备案处”,就是 Robust List(健壮锁链表)


二、 协议的基础:Robust List 的注册

当一个线程被创建,或者 glibc 初始化 Robust Mutex 支持时,线程会通过系统调用 sys_set_robust_list 向内核注册一个用户态的链表头。

// 系统调用定义
syscall(SYS_set_robust_list, struct robust_list_head *head, size_t len);

这个系统调用的作用非常简单:它把一个位于用户态内存空间的链表头指针 robust_list_head,记录在内核中该线程对应的 task_struct 结构体中。

+------------------------------+
|       Kernel Space           |
|  task_struct (for thread)    |
|   +-----------------------+  |
|   | robust_list           |--+-----\
|   +-----------------------+  |     |
+------------------------------+     |  (指向用户态内存)
                                     |
+------------------------------+     |
|       User Space             |     |
|   struct robust_list_head    |<----+
|   +-----------------------+  |
|   | list (双向链表头)       |  |
|   | list_op_pending       |  |
|   +-----------------------+  |
+------------------------------+

1. robust_list_head 的结构

这个结构体定义在内核头文件 <linux/futex.h> 中,其用户态定义与内核态完全对应:

struct robust_list_head {
    struct robust_list list;          // 已经成功持有的 Robust Mutex 链表
    long list_op_pending;             // 正在操作中(处于临界区)的 Mutex 指针
};
  • list:这是一个单向/双向链表(通常是单向环形链表),所有被当前线程成功持有的 Robust Mutex,都会被链接在这个链表里。
  • list_op_pending:极为关键的指针。它指向正在尝试获取或释放、但还没有完全加入/移出 list 链表的那个 Mutex。它的存在是为了解决极端并发下的竞争问题(后文详述)。

三、 用户态加锁:glibc 的“备案”过程

当我们在用户态调用 pthread_mutex_lock 去拿一把 Robust Mutex 时,glibc 会在修改锁变量前后,对这个链表进行维护。

假设我们有一把锁 mutex,其内存地址对应的 futex 变量为 futex_word

  1. 设置 Pending 状态
    glibc 首先将 list_op_pending 指向当前这个 mutex 的地址。
    这意味着告知内核:“我准备对这把锁下手了。”
  2. 尝试原子加锁(CAS)
    glibc 尝试将 futex_word 的值从 0 修改为自己的线程 ID(TID)。
  3. 正式加入链表
    如果加锁成功,glibc 会将该 mutex 插入到 robust_list_head.list 链表中。
  4. 清除 Pending 状态
    list_op_pending 重新置为 NULL

为什么需要 list_op_pending

假设没有 list_op_pending,只有 list
如果线程在**步骤2(CAS成功拿到锁)步骤3(将锁放入链表)**之间突然被信号击杀(崩溃),此时锁实际上已经被该线程占有(futex_word 写入了其 TID),但因为还没来得及放入 list,内核在它死后无法通过遍历 list 找到这把锁。这把锁就彻底成了死锁。

有了 list_op_pending,内核在清理时,除了遍历 list 之外,还会特意去检查 list_op_pending 指向的那个处于中间态的锁


四、 内核态清理:do_exit() 的大扫除

当进程收到崩溃信号(如 SIGSEGVSIGABRT)或者正常调用 exit() 退出时,内核会为该进程中的每个线程调用 do_exit() 函数进行资源回收。

清理 Robust Mutex 的工作正是在 do_exit() 的生命周期内完成的。

do_exit()
  └── exit_signals()
  └── exit_mm()
  └── exit_robust_list(tsk)  <-- 清理的核心入口

1. 清理入口 exit_robust_list

kernel/futex/core.c(或旧版 kernel/futex.c)中,内核定义了 exit_robust_list 函数。

由于 Robust List 存在于用户态,内核必须在其用户虚拟内存空间(mm_struct)被完全销毁(exit_mm())之前完成清理,否则将无法读取和修改链表中的指针。

void exit_robust_list(struct task_struct *tsk)
{
    struct robust_list_head __user *head = tsk->robust_list;
    ...
    // 1. 首先处理处于 Pending 状态的“夹生”锁
    if (pending)
        handle_futex_death(pending, tsk, pip);

    // 2. 循环遍历用户态的链表,逐个处理持有的锁
    while (entry != &head->list) {
        ...
        // 提取当前的 futex 地址
        uaddr = device_address_of_robust_entry(entry);
        
        // 核心清理逻辑
        if (handle_futex_death(uaddr, tsk, pi))
            break;
        ...
    }
}

为了防止用户态程序恶意构造死循环链表(例如让 entry 指针形成互指),内核在遍历链表时设置了上限(通常为 ROBUST_LIST_LIMIT = 20488)。如果遍历次数超过这个值,内核会强行中止,防止内核线程卡死在 do_exit 中。

2. 核心操作 handle_futex_death

对于链表中的每一个 Futex(也就是 Mutex 对应的共享内存变量),内核会调用 handle_futex_death

这个函数需要完成以下几件至关重要的事情:

第一步:读取并验证 Futex 变量

内核通过安全的用户态内存读取函数(如 get_user)读取 uaddr(Futex 变量在用户态的地址)。
内核需要验证:这个 Futex 变量里记录的 Owner TID,是不是等于当前正在死去的这个线程的 TID?

  • 如果一致,说明这把锁确实是死者生前持有的,需要清理。
  • 如果不一致,说明可能存在用户态的竞争或者损坏,内核不能乱改,直接跳过。

第二步:打上死亡标记(FUTEX_OWNER_DIED

一旦确认所有权,内核需要修改这个用户态的 Futex 变量。
它将 futex_word 写入以下状态:

$$\text{futex_word} = \text{FUTEX_OWNER_DIED} \mid \text{FUTEX_WAITERS} \mid 0$$

  • FUTEX_OWNER_DIED(第30位为1):这是内核给下一个拿锁者的“遗言”,告诉它:“原锁持有者已经崩溃了。”
  • FUTEX_WAITERS(第31位为1):保留等待者标记,确保有其他人在等这把锁。
  • 原 TID 清零:原持有者的 TID 被抹去,因为他已经不复存在。

这个修改是通过内核的原子操作(带有 Page Fault 处理的 cmpxchg)写入用户态内存的。如果此时发生缺页异常,内核会当场尝试修复缺页,确保数据能写进去。

第三步:唤醒等待者(futex_wake

锁的状态改完后,内核需要唤醒正在这个 Futex 上长眠(挂起)的其他线程。
内核调用 futex_wake(uaddr, 1, ...),唤醒一个(或所有,取决于具体配置,通常是唤醒队列首部的一个)等待者。


五、 生存者的救赎:用户态如何感知?

当内核在 handle_futex_death 中唤醒了下一个等待线程时,这个线程正阻塞在 sys_futex(..., FUTEX_WAIT, ...) 系统调用中。

由于内核已经将 FUTEX_OWNER_DIED 写入了 Futex 变量:

  1. 等待线程从内核态返回用户态。
  2. glibc 的 pthread_mutex_lock 代码被唤醒,它去读取 futex_word,发现第 30 位(FUTEX_OWNER_DIED)被置为了 1。
  3. glibc 意识到前任持有者已经“暴毙”,锁现在是非正常释放状态。
  4. pthread_mutex_lock 不会返回普通的 0(成功),而是向调用者返回一个特殊的错误码:EOWNERDEAD

拿到 EOWNERDEAD 后,应用层该怎么办?

作为活下来的线程,当你收到 EOWNERDEAD 时,意味着你已经成功拿到了这把锁,但是内核向你发出了警告:“这把锁保护的共享数据可能处于不一致/损坏的状态,因为前任是在干活干到一半时突然暴毙的!”

此时,活着的线程有责任对共享数据进行一致性检查和修复。修复完成后,必须调用:

pthread_mutex_consistent(&mutex);

这个调用会告诉 glibc 和内核:“我已经修复了共享数据,这把锁现在恢复正常了。”
下一次调用 pthread_mutex_unlock 时,锁就会被平滑地重置为正常状态。

如果持有锁的线程在拿到 EOWNERDEAD没有调用 pthread_mutex_consistent 就直接 unlock 了,那么这把锁将被永久标记为 不可用状态(ENOTRECOVERABLE,后续任何线程尝试 lock 这把锁都将直接报错失败,以防损坏的数据被进一步扩散。


六、 总结

Linux 清理崩溃进程持有的 Robust Mutex 的底层逻辑,可以用以下这张链路图来闭环:

 [用户态进程崩溃] ──> 进入内核 do_exit()
                          │
                          ▼
                  exit_robust_list()
                          │
        ┌─────────────────┴─────────────────┐
        ▼                                   ▼
检查 list_op_pending (悬空锁)       遍历 robust_list (已持有的锁)
        └─────────────────┬─────────────────┘
                          │
                          ▼
                 handle_futex_death()
                          │
        ┌─────────────────┴─────────────────┐
        ▼                                   ▼
修改 Futex Word 内存:                唤醒等待线程 (futex_wake)
设置 FUTEX_OWNER_DIED 标志                  │
                                            ▼
                                   等待线程返回用户态
                                   获得锁,并得到 EOWNERDEAD

通过这一套**“用户态链表备案 + 内核态死亡遍历 + 状态位原子修改 + 唤醒通知”**的精妙设计,Linux 成功在保障高性能的前提下,彻底解决了进程因非正常死亡而导致的共享锁死锁问题。

点评评价

captcha
健康