Linux内核分析之进程间通信-05

31.1 futex 原理 —— 快速用户空间互斥

futex 的全部设计围绕一个问题:内核如何为"用户内存里的一个字"提供睡眠/唤醒服务,却不知道任何锁语义? 答案是三层抽象:用户内存字是锁状态、futex 键是队列身份、哈希桶是队列容器。本节拆解这三层与私有/共享键的语义。


31.1.1 分界线:值在用户、队列在内核

futex 操作的字面语义 (内核不理解"锁"):

 futex_wait(&uaddr, expected):
   "若 *uaddr == expected 则睡到有人 futex_wake(uaddr)"
 futex_wake(&uaddr, n):
   "唤醒正在等 *uaddr 的至多 n 个线程"

 所有锁语义 (谁持锁/递归/错误检查) 全在用户态 glibc:
   pthread_mutex_lock:
     state CAS 0→1            [无竞争: 完成]
     失败 → state=2(有等待者) → futex_wait(state, 2)
   pthread_mutex_unlock:
     state 1→0 或 2→0
     若原值是 2 → futex_wake(&state, 1)   [定点唤醒一个]

 为什么值校验必须是协议的一部分:
   唤醒与状态变更的顺序无法原子 — "先改值后 wake" 与
   "先查值后 sleep" 之间存在窗口, wait 的 expected 参数
   就是关闭这个窗口的凭据 (31.2.2 节竞态纪律)

"零竞争零系统调用"的收益量级:CAS 约 20ns,一次系统调用往返 1-2μs——无竞争路径快 100 倍。这决定了 glibc 的所有优化方向:先用户态自旋、延迟置"有等待者"位(避免不必要的 wake syscall)、PTHREAD_MUTEX_ADAPTIVE_NP 的自旋档位。

31.1.2 futex 键:把地址变成队列身份

不同进程/不同映射的"同一个锁"必须落到同一条队列,而"不同锁"绝不能串——union futex_key 是这场翻译的结果:

// include/linux/futex.h:32-53
union futex_key {
    struct {            /* 共享映射 (FLAGS_SHARED) */
        u64 i_seq;      /* 后备 inode 的序号 */
        unsigned long pgoff;    /* 文件内页偏移 */
        unsigned int offset;    /* 页内偏移 */
    } shared;
    struct {            /* 私有映射 (默认) */
        union {
            struct mm_struct *mm;   /* 进程地址空间 */
            u64 __tmp;
        };
        unsigned long address;  /* 进程内地址 */
        unsigned int offset;
    } private;
    struct {            /* 内核内部用 */
        u64 ptr;
        unsigned long word;
        unsigned int offset;
        unsigned int node;  /* NOT hashed! */
    } both;
};
同一物理锁在不同进程眼中的地址不同 (各自 mmap),
"身份"必须锚定到物理层:

 私有锁 (FLAGS_PRIVATE, 默认):
   key = (mm, address, offset)
   → 只在进程内匹配, 哈希也只需进程内唯一
   → 7.0 的 futex_private_hash: 进程可申请
     本地哈希表 (futex_hash_prctl), 多线程
     大户不再挤全局桶 — 锁竞争随核数线性化的
     长年顽疾的解法

 共享锁 (FLAGS_SHARED, shm/mmap 共享内存上的锁):
   key = (inode->i_seq, pgoff, offset)
   → 锚定后备文件页 — 不同进程地址不同,
     但页缓存页唯一 (30.1 节"共享=同文件"的回声)

 get_futex_key() (core.c:548) 的职责:
   逐页表/页缓存解析地址 → 物理身份; 共享模式
   需持页引用防遍历中途映射被拆

31.1.3 哈希桶:队列的容器

// kernel/futex/futex.h:134-139
struct futex_hash_bucket {
    atomic_t waiters;       /* 快查: 桶上有没有人 */
    spinlock_t lock;
    struct plist_head chain;    /* 等待者链 (plist: 优先级排序) */
    struct futex_private_hash *priv;
} ____cacheline_aligned_in_smp;
futex_wait(uaddr) 的容器视角:

 get_futex_key → key ──hash──> 全局哈希表 (futex_hash)
                              大小随内存规模自适配
        ┌──────────────────────────────────────┐
        │ bucket[0] [chain: (key1,q1)(key2,q2)]│
        │ bucket[1] [chain: (key3,q3)]         │  多把锁的等待者
        │ ...                                  │  共享一个桶,
        │ bucket[k] [chain: (keyN,qN)]         │  哈希碰撞即共桶
        └──────────────────────────────────────┘
 futex_wake 遍历桶内链表, 以 futex_match(key) 定点
 (同桶不同锁的等待者被扫描但不被唤醒 — 31.2.1 节)

 waiters 原子计数的意义: futex_wake 先查
 futex_hb_waiters_pending(hb) (waitwake.c:173),
 为零则连桶锁都不拿 — "无等待者时的 wake syscall
 只剩一次查表" 的快速否决

plist(优先级链表)的选择是为 PI 铺路:普通等待者都在同一优先级,PI 变体要求"按有效优先级排队、唤醒最高者"——plist 让插入/摘头保持优先级序(17 章 RT 互斥的同族结构)。


小结

futex 的分界设计:锁值在用户内存(无竞争零内核参与)、队列身份由 futex_key 承载(私有按 mm+address、共享按 inode 序号+页偏移)、等待者挂在哈希桶的 plist 链上(waiters 原子计数提供快速否决,7.0 的进程本地哈希解决多线程挤桶)。内核不认识"锁"——它只提供"这个字没变就睡、变了别睡、醒来定点叫人"的原语。下一节进入这些原语的竞态纪律与 requeue 协议。

31.2 futex 系统调用与内核等待队列

wait/wake 的正确性悬在一条竞态纪律上:先锁桶、再读值、值不对就不睡。本节以 futex_wait_setup() 的注释原文为纲展开这条纪律,随后分析唤醒路径、bitset 过滤与 requeue 协议。


31.2.1 futex_wake:定点唤醒

// kernel/futex/waitwake.c:155-215(节选)
int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset)
{
    struct futex_q *this, *next;
    union futex_key key = FUTEX_KEY_INIT;
    DEFINE_WAKE_Q(wake_q);
    int ret;

    if (!bitset)
        return -EINVAL;

    ret = get_futex_key(uaddr, flags, &key, FUTEX_READ);    /* 地址→键 */
    if (unlikely(ret != 0))
        return ret;
    ...
    CLASS(hb, hb)(&key);            /* 定位哈希桶 */

    /* Make sure we really have tasks to wakeup */
    if (!futex_hb_waiters_pending(hb))  /* :173 快速否决 */
        return ret;

    spin_lock(&hb->lock);

    plist_for_each_entry_safe(this, next, &hb->chain, list) {
        if (futex_match(&this->key, &key)) {        /* 键精确匹配 */
            if (this->pi_state || this->rt_waiter) {
                ret = -EINVAL;      /* PI 等待者不许裸 wake */
                break;
            }

            /* Check if one of the bits is set in both bitsets */
            if (!(this->bitset & bitset))
                continue;       /* bitset 过滤 */

            this->wake(&wake_q, this);  /* 摘队+挂唤醒批 */
            if (++ret >= nr_wake)
                break;
        }
    }
    ...
}

唤醒协议的三道筛选:键匹配(同桶不同锁的碰撞者不误伤)、bitset 掩码(FUTEX_WAIT_BITSET 允许等待者按事件位分类,唤醒方选择性叫醒——glibc 条件变量广播的实现基础)、数量上限(nr_wake)。真正的唤醒在放锁后由 wake_q 批量执行(29.1.2 节同款结构)——持桶锁直接 wake 会让被唤醒者立即自旋在桶锁上。

31.2.2 futex_wait_setup:竞态纪律

// kernel/futex/waitwake.c:591-612(注释原文即协议)
int futex_wait_setup(u32 __user *uaddr, u32 val, unsigned int flags,
             struct futex_q *q, union futex_key *key2,
             struct task_struct *task)
{
    ...
    /*
     * Access the page AFTER the hash-bucket is locked.
     * Order is important:
     *
     *   Userspace waiter: val = var; if (cond(val)) futex_wait(&var, val);
     *   Userspace waker:  if (cond(var)) { var = new; futex_wake(&var); }
     *
     * The basic logical guarantee of a futex is that it blocks ONLY
     * if cond(var) is known to be true at the time of blocking, for
     * any cond.  If we locked the hash-bucket after testing *uaddr,
     * that would open a race condition where we could block
     * indefinitely with cond(var) false, which would violate the
     * guarantee.
     *
     * On the other hand, we insert q and release the hash-bucket only
     * after testing *uaddr.  This guarantees that futex_wait() will
     * NOT absorb a wakeup if *uaddr does not match the desired values
     * while the syscall executes.
     */
retry:
    ret = get_futex_key(uaddr, flags, &q->key, FUTEX_READ);
    ...
    if (1) {
        CLASS(hb, hb)(&q->key);

        futex_q_lock(q, hb);            /* [1] 先锁桶 */

        ret = futex_get_value_locked(&uval, uaddr); /* [2] 后读值 */
        if (ret) {
            futex_q_unlock(hb);
            ret = get_user(uval, uaddr);    /* 缺页: 供货后重试 */
            ...
        }

        if (uval != val) {          /* [3] 值已变: 不睡! */
            futex_q_unlock(hb);
            return -EWOULDBLOCK;        /* 即用户态的 EAGAIN */
        }
        queue_me(q, hb);            /* [4] 挂队+放锁 */
    }
    return ret;
}

四步时序是 futex 正确性的全部:

两个线程的交错矩阵 (W=唤醒方, S=睡眠方):

 S: 锁桶 → 读值=val → 挂队 → 放锁 → 睡
 W: 改值 → futex_wake → 锁桶 → 摘队 → 唤醒

 无论 W 的"改值+wake"落在 S 时序的哪个点:
 - W 先完成全部: S 读值时已 val'≠val → -EWOULDBLOCK,
   不睡, 回用户态重验条件 (不丢唤醒)
 - S 挂队后 W 才 wake: W 在桶上找到 q, 定点唤醒
   (不丢唤醒)
 - W 在 S 读值与挂队之间改值: 因 S 已持桶锁, W 的
   锁桶被推迟到 S 放锁后 → 顺序化, 无窗口

 "唤醒不丢"的唯一前提: 等待与唤醒共享同一把桶锁
 作为交汇点 — 这就是 futex 的全部同步魔法

-EWOULDBLOCK 是正常返回而非错误:glibc 收到后循环回用户态重查条件——用户看到的 futex_wait 语义是"可能睡也可能不睡,但条件不满足时绝不白睡"。

31.2.3 futex_wait 的睡眠与超时

// kernel/futex/waitwake.c:706(入口)
int futex_wait(u32 __user *uaddr, unsigned int flags, u32 val, ktime_t *abs_time, u32 bitset)

setup 成功后挂 hrtimer(超时变体)睡眠;醒来后的复查链:被唤醒(q->wake 已摘队)→ 返回 0;超时 → -ETIMEDOUT;信号打断(14 章/27.1 节的 ERESTARTSYS 协商)→ 摘队返回 -ERESTARTSYS——futex 睡眠完全参与 27.1.3 节的系统调用重启协议。虚假唤醒(futex_wake 的 bitset 撞上其他等待者)由用户态循环吸收——内核保证"该醒的醒",用户态保证"醒来重验条件"。

31.2.4 requeue:批量改嫁

// kernel/futex/requeue.c(futex_requeue 主体的协议面)

FUTEX_REQUEUE/FUTEX_CMP_REQUEUE 把 uaddr1 上至多 val 个等待者不唤醒地转移到 uaddr2 的队列:

requeue 的使用场景 (glibc 条件变量):

 pthread_cond_broadcast:
   等待 cond 的 N 个线程若全部唤醒 → N 个线程同时抢
   mutex → 惊群 + 锁风暴
   FUTEX_REQUEUE(cond, mutex, 1, INT_MAX):
     唤醒 1 个去抢锁, 其余改挂到 mutex 的等待队列
     → 锁逐个移交, 零风暴
 CMP 变体: 先校验 *uaddr1 == val3 防陈旧引用 (29 章同款)

 FUTEX_REQUEUE_PI (pi.c): PI 版转移, 目标锁的所有权
 同步移交 — 31.3.3 节

requeue 的正确性难点是"转移中的等待者可同时被对 uaddr1 的裸 wake 击中"——实现用 q->requeue_state 原子状态机(futex.h:205)协调两桶锁的交接;PI 版更要求"转移的是 rt_mutex_waiter 注册"(futex.h:202 的 requeue_pi_key),这是内核 futex 代码中最微妙的并发片段。


小结

wait/wake 的正确性系于 futex_wait_setup 的四步时序——先锁桶、再读值、值不对以 -EWOULDBLOCK 不睡、挂队放锁后才睡——桶锁是唤醒方与睡眠方的唯一交汇点,从此"唤醒不丢"成为可证明的协议而非运气。futex_wake 以键匹配+bitset+数量上限三道筛选定点唤醒,wake_q 批量放锁执行;requeue 把条件变量的广播从惊群改为锁的逐个移交。下一节看这些原语如何被 glibc 组装成 pthread 互斥锁,以及 PI/robust 两个高级变体。

31.3 futex 与 pthread 互斥锁

pthread 互斥锁是 futex 原语的最大消费者:glibc 在一个 4 字节的 state 字上编码锁状态与等待者计数,把 31.2 节的协议组装成三层锁模型;PI 变体(PTHREAD_PRIO_INHERIT)把等待队列换成 rt_mutex 的优先级继承链;robust 链表让持锁者死亡时锁能被内核标记为废弃。本节从内核视角拆解这三种组合。


31.3.1 pthread_mutex 的三层锁模型

glibc 的 state 字编码 (futex 字 = mutex->data.lock):

 值 0: 无锁            — lock 即 CAS 0→1, 完成
 值 1: 有主, 无等待者   — unlock 直接 1→0, 不需要 syscall!
 值 2: 有主, 有等待者   — unlock 必须 futex_wake
                            wait 以 futex_wait(&state, 2) 睡

 pthread_mutex_lock 的完整决策树:

   CAS(0→1) 成功? ──是──> 返回                       [层1 快路径]
        │否
   自旋若干轮重试 (adaptive 模式)? ──成功──> 返回     [层2 自旋]
        │否
   state CAS → 2 (登记"有等待者")
   futex_wait(&state, 2) ──EWOULDBLOCK──> 回层1      [层3 内核]
        │睡眠... 被唤醒
   回到层1 重试 CAS (醒来后重新抢锁)

 pthread_mutex_unlock:
   原 1 → CAS 1→0, 无等待者: 完事 (零系统调用!)
   原 2 → 置 0 + futex_wake(&state, 1)

层 2 的存在理由是临界区极短场景:等待者自旋一个临界区的时间比两次系统调用便宜;层 3 的唤醒风暴治理靠 31.2.4 节 requeue——pthread_cond_broadcast 不唤醒全部,而是"唤 1 个 + requeue 其余到 mutex 队列",锁逐个移交。

各 mutex 类型的内核参与度:

类型 内核参与 futex 用法
PTHREAD_MUTEX_NORMAL 竞争时 wait/wake 上述三层
PTHREAD_MUTEX_ERRORCHECK 同左 + 用户态状态机校验重锁 同左
PTHREAD_MUTEX_RECURSIVE 同左 + 计数器字段 state 字带递归计数位
PTHREAD_MUTEX_TIMED_NP 同左 + 超时变体 futex_wait 带绝对超时
PTHREAD_PRIO_INHERIT 每次竞争都进内核 PI futex(31.3.3)
PTHREAD_MUTEX_ROBUST 死亡时进内核 robust 链(31.3.2)

31.3.2 robust futex:持锁者死亡的善后

普通互斥锁的致命场景:线程 A 持锁后被 SIGKILL——锁的 state 永远停在"有主",所有等待者永久睡眠。robust 机制让内核在进程退出时代为清算:

// include/linux/futex.h:80-100(futex_init_task, 每线程初始化)
static inline void futex_init_task(struct task_struct *tsk)
{
    tsk->robust_list = NULL;        /* 用户态注册的链表头 */
#ifdef CONFIG_COMPAT
    tsk->compat_robust_list = NULL;
#endif
    INIT_LIST_HEAD(&tsk->pi_state_list);    /* PI 状态链 (31.3.3) */
    tsk->pi_state_cache = NULL;
    tsk->futex_state = FUTEX_STATE_OK;
    mutex_init(&tsk->futex_exit_mutex);
}
robust 协议 (用户态与内核的分工):

 用户态 (set_robust_list 注册):
   每把 robust 锁是链表节点; 加锁时先把节点挂链
   ("预设死亡善后"), 再 CAS 拿锁; 解锁后摘链
   → "链上但已加锁" = 死亡时正在持有的锁

 内核 (exit_robust_list, 进程退出钩子):
   遍历死者的 robust 链:
     对每把"持有中"的锁: 置 FUTEX_OWNER_DIED 位
     + futex_wake_op 定点唤醒等待者
   等待者醒来看到 OWNER_DIED → 得知前任暴毙
   → glibc 返回 EOWNERDEAD, 应用决定恢复或标记
   ENOTRECOVERABLE

精妙处在于"无内核持久状态":锁的清单存在用户内存(链表),内核只在死亡瞬间被钩子叫来扫一遍—— compared to SysV sem 的 SEM_UNDO(28.2.2 节,内核记账每个 UNDO),robust 把记账推回用户、内核只做验尸。PI 状态(pi_state_list,futex.h:85)是唯一需要内核记账的例外——下一节的优先级继承无法脱离内核视角。

31.3.3 PI futex:优先级继承

优先级反转问题 (13.4 节 RT 抢占的延续):

 低优先级线程 L 持锁 → 高优先级 H 睡在锁上 →
 中优先级 M 抢占 L 无限跑 → H 的优先级被 M 践踏

 PI 的解法: H 睡在锁上期间, L "继承" H 的优先级,
 M 无法抢占 L → L 尽快出临界区

 普通 futex 无解: 内核不知道"睡在哪个锁上" (只有键),
 更不知道持锁者是谁 (值在用户态)!

 FUTEX_LOCK_PI 的做法: 把"持锁者 PID"写进 futex 字
 (值 = tid, 锁值即持锁者身份!), 内核接管:
   futex_lock_pi (pi.c:918)
     futex_lock_pi_atomic (:515) 原子地:
       校验/登记 owner = 持锁 tid
     建 futex_pi_state (futex.h:144-160):
       pi_mutex = rt_mutex (17 章 RT 互斥的内核形态!)
       owner   = 持锁 task
     H 以 rt_mutex_waiter 挂入 → 优先级沿
       owner 链传播 (boost), L 跑在 H 的优先级上
   futex_unlock_pi (pi.c:1133):
     解除 boost, 把锁原子移交最高优先等待者

"锁值 = 持锁者 tid" 是 PI futex 的核心设计:它把普通 futex 不存在的"持锁者身份"信息编码进同一个用户字,内核才有资格做继承。代价是 LOCK_PI 路径每次都要进内核(优先级提升是内核义务)——PI 互斥锁放弃了"零竞争零系统调用",换取 RT 系统的确定性。futex_pi_state 的生命周期由 pi_state_list 挂在持锁者上,持锁者死亡时 do_exit 经 futex_exit_release 清算(robust 的验尸协议在此扩展为"把锁转给下一个等待者")。


小结

pthread 互斥锁以一个 state 字驱动三层模型:CAS 快路径、自旋中路径、futex_wait/wake 慢路径——"值 1→0 不需要系统调用"是无竞争场景优化的极致。robust 链表把"持锁清单"放回用户内存、内核只在死亡时验尸并置 OWNER_DIED,与 SEM_UNDO 的内核记账形成记账哲学的两极;PI futex 以"锁值即持锁者 tid"补上内核缺失的身份信息,用 rt_mutex 的优先级继承链消灭优先级反转,代价是每次竞争都过内核。futex 至此完整——用户态同步的最后一块内核拼图就位;下一章进入 eventfd 家族——把事件、定时器与信号统统 fd 化。