用户态锁与内核:谁在管理「等待」与 futex

从底层实现看,用户态(userspace)的锁机制,其核心的阻塞与唤醒功能,最终依赖于内核提供的同步原语。可以用一个比喻理解:用户态的锁像大楼里每个房间的门锁(轻便、快速),内核的同步则像大楼的主门与安防(全局、负责调度)。多数时候大家只用房间门锁(用户态原子操作或自旋),但当线程需要「离开大楼」或「被叫醒」时,必须经过主门——即通过系统调用进入内核。本文说明这一依赖关系、futex(Fast Userspace Mutex) 如何作为桥梁,并辅以 Linux 内核源码与参考文献;关于锁的误用如何导致性能问题,见本博客《为什么「语言速度」是伪命题》中的「锁的误用与性能」一节1

1. 谁在管理「等待」?

用户态程序无法直接控制 CPU 的调度,只有内核才有权暂停一个线程(让出 CPU)并在未来某刻恢复它。内核能获得这一能力,依赖两类入口:系统调用(线程主动进入内核,例如调用 futex 后在内核里执行 schedule() 让出 CPU)与定时中断(周期性的时钟中断让内核有机会更新运行时间、设置「需要调度」标志,从而在返回用户态前或下次进入内核时执行 schedule(),实现抢占或时间片轮转)。定时中断路径在 Linux 上的实现大致为:时钟事件驱动 tick_periodic()(传统周期 tick)或 tick_nohz_handler()(高分辨率/动态 tick)→ update_process_times()kernel/time/timer.c)→ sched_tick()kernel/sched/core.c);sched_tick() 的注释写明 “This function gets called by the timer code, with HZ frequency”,在其中更新 runqueue 时钟、调用当前任务所属调度类的 task_tick,并可能调用 resched_curr() 标记需要重新调度,从而在适当时机触发 __schedule() 切换任务2

因此:能「睡下去」和「被唤醒」的锁,一定依赖内核。

2. 关键桥梁:futex (Fast Userspace Mutex)

在现代 Linux 上,几乎所有高性能用户态锁(如 NPTL 的 pthread_mutexpthread_cond)底层都依赖 futex。其设计哲学正是「大部分时间在用户态解决,必要时才进内核」34

2.1 无竞争时(Fast Path)

线程尝试加锁时,若锁空闲,只需在用户态用一条原子指令(如 CAS)把锁变量从 0 改为 1。全程无系统调用,极快。

2.2 有竞争时(Slow Path)

  1. 用户态:尝试加锁的线程发现锁已被占用,将自身标记为「等待」,然后调用 futex 系统调用进入内核。
  2. 内核态:内核把该线程放入与该 futex 对应的等待队列,并调度其他线程运行,当前线程阻塞。
  3. 释放与唤醒:持锁线程释放时,在用户态用原子指令把锁变量改回 0,并检查是否有等待者;若有,再调用 futex 通知内核唤醒。
  4. 内核响应:内核从等待队列中唤醒被阻塞的线程,该线程得以继续运行并再次尝试获取锁。

因此,futex 本质上是内核提供的「等待队列管理器」,锁的值(0/1)由用户态维护,阻塞与唤醒由内核完成。内核实现见 kernel/futex/:系统调用入口为 SYSCALL_DEFINE6(futex, ...),根据 op 分发到 futex_wait / futex_wake45

3. CPU 层面的锁机制:原子指令与内存序

用户态「无竞争时一条原子指令加锁」依赖 CPU 提供的原子读-改-写(RMW)与内存序保证;否则多核下既无法保证互斥,也无法保证临界区内的写对其他核可见。以下为两种常见架构的要点与权威出处。

3.1 x86:LOCK 前缀与原子性

在 x86 上,LOCK 前缀(opcode F0)可使特定指令在多核下原子执行:目标为内存操作数时,会断言 LOCK# 信号(或等价机制),使该次读-改-写不可被其他 CPU 打断。可加 LOCK 的指令包括 CMPXCHG(比较并交换)、XCHG(与内存交换)、ADD/SUB/INC/DEC 等;XCHG 在目标为内存时即使不加前缀也会具有锁语义。现代 x86(P6 及以后)对已缓存的地址通常采用 cache locking(依赖 MESI 等缓存一致性协议),而非锁总线,从而减少延迟6

LOCK 前缀还带来内存序效果:带 LOCK 的指令与其它 LOCK 指令之间存在全序;普通 load/store 不能与 LOCK 指令重排。因此「加锁」可用带 acquire 语义的原子操作(如 CMPXCHG 成功后相当于 acquire),「解锁」用带 release 语义的写(如原子 store 0),能保证临界区内的修改在解锁后对其它核可见、且其它核的修改在加锁后对本核可见。详见 Intel SDM Vol 3A 第 8 章(Multiple-Processor Management)及 Vol 2A 对 LOCK 的说明6

3.2 ARM:独占加载/存储(LDXR/STXR)与 Exclusive Monitor

ARM 没有像 x86 那样的「单条指令原子 RMW」,而是用 Load-Exclusive / Store-Exclusive 实现:LDXR(Load Exclusive Register)从某地址加载并让该地址被本核的 exclusive monitor 标记;STXR(Store Exclusive Register)仅在该地址仍被本核独占时写入并返回 0,否则写入失败、返回非 0,由软件重试。这样一对 LDXR + STXR 可实现「读-改-写」的原子性,是用户态自旋锁、CAS 等的基础。ARMv8 还提供 LDAXR/STLXR 等带 acquire/release 语义的变种,在实现 mutex 时保证临界区前后的可见性7

Exclusive monitor 是硬件状态:若其它 CPU 在该地址上产生了 store 或其它使独占失效的访问,当前核的 STXR 会失败,从而避免多核同时写。软件需保证在 LDXR 与 STXR 之间不插入会破坏独占性的操作(如显式访问该地址、某些系统寄存器或 cache 维护指令)。详见 ARM 架构参考手册中「Load-Exclusive and Store-Exclusive」与「Synchronization and semaphores」7

3.3 与 futex 的关系

内核自身在实现 futex 的哈希桶、等待队列时,同样依赖各架构的原子与内存屏障;Linux 内核文档 atomic_t.txtmemory-barriers.txt 对原子 RMW、acquire/release 变种及与锁的配合有统一说明7

4. 为什么不能完全在用户态实现「阻塞」锁?

若完全在用户态实现,当线程拿不到锁时只有两种选择:

  1. 自旋(忙等):一直循环检查。持锁时间一长就会白占 CPU,浪费严重。
  2. sleep + 轮询:调用 sleep() 睡一会儿再起来看。延迟不可控(可能刚睡下锁就释放了),且无法做到「锁一释放就立刻被唤醒」。

要实现「锁释放时立刻唤醒」的语义,必须有一个全局的调度者管理线程状态,这个角色只能是操作系统内核。

5. 完全在用户态的锁

有,但适用场景受限:

关于何时用自旋、何时用可睡眠的锁,以及粗粒度锁、持锁做 I/O 对性能的影响,见本博客《为什么「语言速度」是伪命题》#锁的误用与性能1

6. 总结

用户态锁的「快」,是因为无竞争时绕过了内核;它之所以能成为通用的、可阻塞的锁,是因为有竞争时有内核的兜底。


扩展阅读(内核与接口)


内核代码片段(与正文对应)

1. futex 系统调用入口与分发kernel/futex/syscalls.c

用户态调用 futex(uaddr, op, ...) 时,内核根据 op & FUTEX_CMD_MASK 分发到 futex_waitfutex_wake 等;FUTEX_WAIT / FUTEX_WAKEdo_futex()45

// 简化自 kernel/futex/syscalls.c(约 84–106 行、160 行)
long do_futex(u32 __user *uaddr, int op, u32 val, ktime_t *timeout,
              u32 __user *uaddr2, u32 val2, u32 val3)
{
    unsigned int flags = futex_to_flags(op);
    int cmd = op & FUTEX_CMD_MASK;
    // ...
    switch (cmd) {
    case FUTEX_WAIT:
    case FUTEX_WAIT_BITSET:
        return futex_wait(uaddr, flags, val, timeout, val3);
    case FUTEX_WAKE:
    case FUTEX_WAKE_BITSET:
        return futex_wake(uaddr, flags, val, val3);
    // ...
    }
}

SYSCALL_DEFINE6(futex, u32 __user *, uaddr, int, op, u32, val,
                const struct __kernel_timespec __user *, utime,
                u32 __user *, uaddr2, u32, val3)
{
    // 超时处理等 ...
    return do_futex(uaddr, op, val, tp, uaddr2, (unsigned long)utime, val3);
}

2. 等待与唤醒:入队与 schedulekernel/futex/waitwake.c

__futex_wait() 通过 futex_wait_setup() 准备并入队,再调用 futex_do_wait() 进入睡眠;futex_wake() 根据 uaddr 算哈希桶,在桶内链表中找到匹配的等待者并唤醒58

// 简化自 kernel/futex/waitwake.c
// __futex_wait()(约 666–687 行):准备等待、入队、进入 schedule
int __futex_wait(u32 __user *uaddr, unsigned int flags, u32 val,
                 struct hrtimer_sleeper *to, u32 bitset)
{
    struct futex_q q = futex_q_init;
    // ...
    ret = futex_wait_setup(uaddr, val, flags, &q, NULL, current);  /* 入队等 */
    if (ret)
        return ret;
    futex_do_wait(&q, to);   /* 在此 schedule,让出 CPU */
    // ...
}

// futex_wake()(约 155–199 行):查哈希桶、唤醒 nr_wake 个等待者
int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset)
{
    // get_futex_key, futex_hash 得到 hb (hash bucket)
    spin_lock(&hb->lock);
    plist_for_each_entry_safe(this, next, &hb->chain, list) {
        if (futex_match(&this->key, &key)) {
            this->wake(&wake_q, this);
            if (++ret >= nr_wake)
                break;
        }
    }
    spin_unlock(&hb->lock);
    wake_up_q(&wake_q);   /* 真正唤醒等待线程 */
    return ret;
}

3. core.c 中的设计说明kernel/futex/core.c

文件头注释说明 futex 的由来(Rusty Russell 等)、「hashed waitqueues」等设计,与正文「内核管理等待队列」对应4

// kernel/futex/core.c 文件头(约 1–32 行)
/*
 *  Fast Userspace Mutexes (which I call "Futexes!").
 *  (C) Rusty Russell, IBM 2002
 *  ...
 *  Thanks to Ben LaHaise for yelling "hashed waitqueues" loudly enough at me...
 */

References

  1. 本博客 为什么「语言速度」是伪命题:I/O、并发、内存与内核 - §1.5 锁的误用与性能:细粒度锁、持锁时间、自旋与睡眠取舍  2

  2. 定时中断与调度:时钟中断路径调用 update_process_times()kernel/time/timer.c),其内调用 sched_tick()sched_tick()kernel/sched/core.c 中实现,注释写明 “gets called by the timer code, with HZ frequency”,内部执行 update_rq_clock(rq)donor->sched_class->task_tick(rq, donor, 0) 及条件性的 resched_curr(rq),从而在定时中断上下文中为抢占/时间片提供入口。Tick 入口见 kernel/time/tick-common.ctick_periodic)与 kernel/time/tick-sched.ctick_nohz_handlertick_sched_handleupdate_process_times)。Bootlin - timer.cBootlin - core.c(搜索 sched_tick)  2

  3. A futex overview and update - LWN,futex 概述与无竞争 fast path、有竞争时进内核  2 3

  4. Linux 内核 kernel/futex/core.c(futex 设计与 hashed waitqueues)、kernel/futex/syscalls.cSYSCALL_DEFINE6(futex,...)do_futex)。Bootlin - core.cBootlin - syscalls.c  2 3 4 5 6

  5. Linux 内核 kernel/futex/syscalls.cdo_futexFUTEX_WAITfutex_waitFUTEX_WAKEfutex_wake)、kernel/futex/waitwake.cfutex_wait__futex_waitfutex_wake、入队与 wake_up_q)。Bootlin - waitwake.c  2 3 4 5

  6. Intel® 64 and IA-32 Architectures Software Developer’s Manual:Vol 2A 中 LOCK(Instruction set reference)说明 LOCK 前缀可施加的指令及多核原子性;Vol 3A 第 8 章 Multiple-Processor Management 涉及 LOCK#、总线与缓存锁定及内存序。可查 Intel SDM 索引felixcloutier x86 LOCK。  2 3

  7. ARM:架构参考手册中 Load-Exclusive and Store-Exclusive(如 LDXR/STXR、LDAXR/STLXR)与 Synchronization and semaphores 说明独占监视器与原子 RMW。ARM Architecture Reference ManualLinux 内核Documentation/atomic_t.txt 描述 atomic RMW API 与 acquire/release 变种;Documentation/memory-barriers.txt 描述内存屏障与锁的配对。atomic_t.txtmemory-barriers.txt  2 3 4

  8. kernel/futex/waitwake.c 文件头注释:waiter 读用户态 futex 值、调用 futex_wait() 后入队并 schedule();waker 改用户态值后调用 futex_wake() 在哈希桶中查找并唤醒。说明了用户态「锁变量」与内核「等待队列」的协作。  2

  9. In pursuit of faster futexes - LWN,futex 性能与竞争路径优化;Robust futexes - The Linux Kernel documentation - 健壮 futex 与进程退出时的清理 

My Github Page: https://github.com/liweinan

Powered by Jekyll and Theme by solid

If you have any question want to ask or find bugs regarding with my blog posts, please report it here:
https://github.com/liweinan/liweinan.github.io/issues