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 化。