系列文章:
- Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序
- Rust 原理丨从汇编角度看原子操作
- Rust 实战丨手写一个 SpinLock
- Rust 实战丨手写一个 oneshot channel
- Rust 实战丨手写一个 Arc
在本系列的前面所有篇章中,我们对非阻塞类的并发操作进行了详细的阐述和实践(除了 SpinLock,不过自旋锁是通过自旋来实现阻塞作用,本质上线程并没有陷入阻塞等待的状态)。
后面我们将继续参考 Rust Atomics and Locks 书中的后续篇章,继续手写几个阻塞类的并发工具,有 Mutext(互斥锁)、RWMutex(读写锁)和 CondVar(条件变量)。它们都有一个共同的特点:线程会陷入阻塞,让出 CPU,在等待某个条件满足要求后,被会重新并重新调度执行。这就需要借助内核的能力了,我们需要内核支持:
- 记住那些陷入阻塞的线程;
- 在满足条件后,能够唤醒对应的正确的线程。
熟悉操作系统原理的读者应该清楚,我们编写的应用程序,一般是处于用户态,而想要跟内核进行交互,需要陷入内核态,而这种切换,很大程度需要依赖于操作系统提供的系统调用能力,即 syscall
。
所以在进入手写 Mutex、RWMutex 和 CondVar 篇章之前,我们需要先来学习一下,不同的操作系统,都为我们在并发操作中提供了什么样的能力和限制。
在 Rust Atomics and Locks 第八章(Operating System Primitives)中,作者介绍并比较了各平台提供的操作系统级并发原语,包括 POSIX 的 pthread
系列、Linux 的 futex
、macOS 的 os_unfair_lock
,以及 Windows 的重量级内核对象
、轻量级对象
和基于地址的等待机制
。
在本篇,笔者将基于自己的理解,尝试对这章进行梳理和总结,以便为后面的手写实践篇章奠定一个良好的理论基础,这里还是建议读者去阅读原文,以便获得更多的细节,加深理解。
读完本篇你能学到什么
POSIX 线程原语 pthread
在 Unix 类操作系统中,比如 Linux,libc
就承担了跟内核进行交互的标准接口。在 libc
的基础之前,诞生了一个标准:Portable Operationg System Interface,即熟知的 POSIX。在 Rust 中,对应了 libc crate。
Windows 系统并不遵循 POSIX 标准,而是一系列的系统库来提供内核交互能力,比如 kernel32.dll。
针对线程操作,POSIX 定义了一系列的数据类型和函数,即所谓的 pthreads。它提供了以下几个比较重要的并发原语,我将其归纳为一个表格,供你参考。
Linux Futex
在 Linux 中,所有 pthread
原语的实现,都是通过 futex 这个系统调用。它是全程是 fast user-space mutex。它的实现核心是:通过操作一个 32 位的原子变量来实现等待和唤醒。等待操作会将一个线程陷入睡眠,而唤醒操作会唤醒那些操作同一个原子变量的睡眠中的线程。
这里我们简单进行一下展开,思考一下这个 futex
这个名字的含义,fast user-space mutex 翻译成中文就是快速用户空间互斥锁。我们知道,系统调用的代价是比较昂贵的,需要频繁地在用户态和内核态之间进行切换,对性能是很不友好的。
在 Linux 系统中,futex 机制并非独立存在,而是与互斥锁、条件变量等同步原语协同工作,形成 “用户态自旋 + 内核态等待” 的分层设计,以兼顾性能与功能。
比如在 Mutex 互斥锁场景下,采用 “两级等待” 策略:
- 用户态自旋阶段:尝试获取锁时先通过原子操作(如
atomic_compare_exchange
)自旋尝试,避免内核调用。 - 内核态等待阶段:若自旋失败,通过 Futex 的
FUTEX_WAIT
陷入内核,将线程挂起,直到其他线程通过FUTEX_WAKE
唤醒。
这样多数短时间持锁场景可在用户态完成,仅在长时间竞争时陷入内核,相比纯内核互斥锁(如 spinlock)大幅降低系统调用开销。
这里有个很重要的点:判断和陷入等待,是原子的。也就是说,线程 A 在确定陷入等待时,如果关联的原子变量已经发生了变化,这个时候,不会陷入等待,而是会直接返回。这也就避免了唤醒信号的丢失。
这里我整理了 futex 的核心操作,供你参考:
maxOS 原语:pthread 与 os_unfair_lock
在 macOS 上,线程/锁的内核 syscalls(__psynch_*
等)不是公开稳定 ABI,官方要求开发者只通过 LibSystem(libc + libpthread + Objective-C/Swift runtime 等)来访问,它们都完全实现了 pthread
。
不过值得注意的是,macOS 在的 pthread lock 一般会稍微慢一些,这是因为在 macOS 10.12 版本之前,它们默认都是公平锁(fair locks),不过在 macOS 10.12 (Sierra, 2016) 起新增了 os_unfair_lock,它是一个不公平、阻塞型、低开销的锁,取代了已弃用的 OSSpinLock
。
需要注意,os_unfair_lock
没有提供对应的条件变量或读写锁功能 。也就是说,如果需要使用条件等待或读写锁语义,仍需使用 pthread_cond_t
或 pthread_rwlock_t
等 POSIX 原语,或者使用更高层的 GCD(Grand Central Dispatch)并发模型。Apple 将os_unfair_lock
定位为替代早期的 OSSpinLock
的低级锁,以解决 OSSpinLock
存在的优先级反转问题,同时提供比 pthread_mutex
更快的性能。os_unfair_lock
内部会在必要时让出 CPU 而非自旋等待,从而避免高优先级线程饥饿,但调度上又不像 pthread_mutex
那样严格 FIFO。
Windows
Windows 提供了一系列独特的并发原语,可分为 重量级内核对象、轻量级对象(如 Critical Section、SRW 锁、Condition Variable 信号量等)和基于地址的等待三大类。它们在 API 设计、用法和实现上各不相同,体现了 Windows 从早期到现代的演进。
重量级内核对象
Windows 的重量级同步原语是由内核完全管理的对象,典型代表包括:Mutex(互斥量)、Event(事件)、Semaphore(信号量)、WaitableTimer(可等待计时器)等 。这些对象通过 Windows API 创建,相当于创建了一个内核对象句柄(HANDLE),类似打开文件会得到文件句柄一样 。每个对象在内核有对应的数据结构,操作系统维护其状态和等待队列。具体可以参考: 重量级内核对象。
我整理了它们的基本使用方式,供你参考:
轻量级对象
"轻量级"同步原语是指不以独立内核对象形式存在、主要在用户态运作、仅在必要时调用内核的机制。
是不是有点 futex 的感觉?🤭