batch_semaphore
主线位置:任务主线的「poll 返回
Pending→ 被唤醒」。这里叫醒任务的不是驱动,而是另一个任务释放了许可。
flowchart TB
acq["acquire(n)"] --> cas{计数够 n?}
cas -->|够,CAS 扣掉| ready[Ready]
cas -->|不够| lock[先锁等待链表]
lock --> takeall[CAS:剩下的全拿走]
takeall --> enq[节点挂到链表头]
enq --> pend[Pending]
rel["release(n)"] --> lock2[锁等待链表]
lock2 --> assign[从链表尾开始分配]
assign -->|某个等待者凑够| wake[摘下,攒进 WakeList]
assign -->|链表空了还有剩余| back[加回计数]
wake --> unlock[放锁后批量 wake]
tokio::sync::Semaphore 是对外的类型,真正干活的是 crate 内部的 batch_semaphore::Semaphore。「batch」指一次可以申请多个许可。
结构:一个计数,一条链表
pub(crate) struct Semaphore {
waiters: Mutex<Waitlist>,
/// The current number of available permits in the semaphore.
permits: AtomicUsize,
// ...
}
struct Waitlist {
queue: LinkedList<Waiter>,
closed: bool,
}
waiters 外面的 Mutex 是同步锁(loom::sync::Mutex:开了 parking_lot feature 用 parking_lot,否则是 std::sync::Mutex)。它只在要排队或要叫醒人时才拿,临界区里不会 .await。
permits 的布局(第 137 行):
pub(crate) const MAX_PERMITS: usize = usize::MAX >> 3;
const CLOSED: usize = 1;
// The least-significant bit in the number of permits is reserved to use
// as a flag indicating that the semaphore has been closed. Consequently
// PERMIT_SHIFT is used to leave that bit for that purpose.
const PERMIT_SHIFT: usize = 1;
| 位 | 含义 |
|---|---|
| 第 0 位 | CLOSED,信号量已关闭 |
| 第 1 位起 | 可用许可数,存的是 n << 1 |
真正用到的标志只有 1 位,但 MAX_PERMITS 留出了 3 位,64 位平台上是 2^61 − 1。注释说明了原因:旧实现用了 3 位标志,保留它们是为了将来加标志时不算破坏性变更。Semaphore::new 超过这个数直接 panic。
等待节点放在 future 里
Acquire 是 acquire(n) 返回的 future,链表节点 Waiter 直接嵌在它里面:
pub(crate) struct Acquire<'a> {
node: Waiter,
semaphore: &'a Semaphore,
num_permits: usize,
queued: bool,
}
struct Waiter {
state: AtomicUsize,
waker: UnsafeCell<Option<Waker>>,
pointers: linked_list::Pointers<Waiter>,
// ...
_p: PhantomPinned,
}
这和 ScheduledIo 的等待链表是同一种做法:future 被 pin 住以后地址不变,节点可以挂进链表,排队不需要额外分配。Waiter::state 存的是还差几个许可,初始等于 num_permits,分到一个减一个,减到 0 就是凑够了。queued 记录节点是否可能在链表里、或者手里是否有已分到的许可,Drop 靠它判断要不要清理。
0.2.14 之前,Mutex、RwLock、Semaphore 和有界 mpsc 共用的内部信号量叫 semaphore_ll。它的等待队列是一条原子单链表,每个等待者一次堆分配,这块内存归信号量所有,只有在释放许可、从队头摘下等待者时才回收。等许可的任务被取消(比如超时),它的节点照样留在链表里,要等到有人释放许可才被清掉。issue #2237 报告的就是这种情况下的内存泄漏,按 PR 的说法,大量任务在等待中被取消时,信号量的内存占用会非常高。
2020 年 3 月的 #2325(0.2.14)换成了本页这个实现:侵入式链表,节点嵌在 Acquire future 里,靠 pin 保证它不移动;future 被取消时自己把节点摘掉,就是后面的 Drop。许可计数放在锁外用原子变量更新,只有要碰等待链表时才拿锁。当时有界 mpsc 有几个方法要在没有 pin 的上下文里调 poll_acquire,就先留在旧实现上,后来怎么迁过来的见 mpsc。
快路径:一次 CAS
poll_acquire 先在锁外读计数,够就 CAS 扣掉:
let mut curr = self.permits.load(Acquire);
let mut waiters = loop {
// Has the semaphore closed?
if curr & Self::CLOSED > 0 {
return Poll::Ready(Err(AcquireError::closed()));
}
let mut remaining = 0;
let total = curr
.checked_add(acquired)
.expect("number of permits must not overflow");
let (next, acq) = if total >= needed {
let next = curr - (needed - acquired);
(next, needed >> Self::PERMIT_SHIFT)
} else {
remaining = (needed - acquired) - curr;
(0, curr >> Self::PERMIT_SHIFT)
};
if remaining > 0 && lock.is_none() {
// ...
lock = Some(self.waiters.lock());
}
match self.permits.compare_exchange(curr, next, AcqRel, Acquire) {
Ok(_) => {
acquired += acq;
if remaining == 0 {
if !*queued {
// ...
node.state.store(0, Release);
*queued = true;
// ...
return Poll::Ready(Ok(()));
} else if lock.is_none() {
break self.waiters.lock();
}
}
break lock.expect("lock must be acquired before waiting");
}
Err(actual) => curr = actual,
}
};
许可够的时候,整个过程是一次 load 加一次 CAS,不碰锁。try_acquire(第 294 行)就是这段快路径单独拿出来:不够就返回 NoPermits,关了就返回 Closed。
慢路径:拿走剩下的全部,然后排队
许可不够时,上面的循环有两处和直觉不同。
不够也照拿。 else 分支把 next 设成 0,把当前计数里的许可全部拿走(acq = curr >> 1)。拿到的部分记在 acquired 里,出循环后用 assign_permits 写进节点:state 从「需要 n 个」减成「还差 n − 已拿」。然后把节点挂到链表头,返回 Pending:
let (should_remove, _assigned) = node.assign_permits(&mut acquired);
// ...
if should_remove {
self.add_permits_locked(acquired, waiters);
return Poll::Ready(Ok(()));
}
// ...
// If the waiter is not already in the wait queue, enqueue it.
if !was_queued {
// ...
waiters.queue.push_front(node);
}
先锁,再 CAS。 一旦发现不够(remaining > 0),先拿等待链表的锁,再去做把计数清零的 CAS。注释解释了顺序:如果先扣计数再拿锁,在这两步之间别人 release 的许可会因为链表里还没有这个节点而被加回计数,这个任务进了链表之后就没人叫它了。release 也要拿同一把锁,所以「清零计数」和「挂进链表」对 release 来说是一步完成的。
公平:有人排队时,计数恒为 0
源码里没有一句「如果有人在排队,就不许插队」的判断。公平性来自一个不变量:等待链表非空时,permits 计数一定是 0。 两处代码共同维持它:
- 排队的人先把计数清空。 上一节的慢路径,进链表之前已经把计数里剩下的许可全拿走了。
- 释放的许可先给排队的人。
add_permits_locked只有在链表空了以后,才把剩余的许可加回计数:
while rem > 0 {
let mut waiters = lock.take().unwrap_or_else(|| self.waiters.lock());
'inner: while wakers.can_push() {
// Was the waiter assigned enough permits to wake it?
let _assigned = match waiters.queue.last() {
Some(waiter) => {
let (should_remove, assigned) = waiter.assign_permits(&mut rem);
if !should_remove {
// ...
break 'inner;
}
assigned
}
None => {
is_empty = true;
// If we assigned permits to all the waiters in the queue, and there are
// still permits left over, assign them back to the semaphore.
break 'inner;
}
};
let mut waiter = waiters.queue.pop_back().unwrap();
if let Some(waker) =
unsafe { waiter.as_mut().waker.with_mut(|waker| (*waker).take()) }
{
wakers.push(waker);
}
// ...
}
if rem > 0 && is_empty {
// ...
let prev = self.permits.fetch_add(rem << Self::PERMIT_SHIFT, Release);
// ...
rem = 0;
}
drop(waiters); // release the lock
wakers.wake_all();
}
入队用 push_front,分配从 last() / pop_back() 开始,所以链表尾是最早来的那个。许可先分给它;它还没凑够,assign_permits 把手上的许可全给它然后 break,后面的人一个也分不到,计数也不会增加。
有了这个不变量,新来的 acquire 和 try_acquire 只看计数就够了:计数是 0,快路径必然失败,只能去链表尾部排队。一个要 3 个许可的等待者排在前面时,后面要 1 个的也拿不到,哪怕信号量里「本来」有 1 个空闲。这就是模块文档说的:要很多许可的任务不会被一群要少量许可的任务饿死。
货架上摆着现成的面包(permits 计数)。来了一位要 3 个的顾客,货架上只有 1 个:他先把这 1 个放进自己的篮子(acquired,最后写进 Waiter::state),然后去排队(push_front)。之后出炉的面包,店员不往货架上放,而是放进排得最早的那位的篮子(add_permits_locked 从 last() 开始分)。他的篮子装满了,店员才喊他(摘下节点,waker 攒进 WakeList)。队伍空了,面包才回到货架上。
新来的顾客只看货架。货架是空的,他就只能排队,哪怕他只要 1 个,而前面那位的篮子里已经躺着 2 个。有人等不及走了(Acquire 被 drop),他篮子里的面包先分给还在排队的人,没人排队才放回货架。
比方到这里为止:真顾客被喊到就能拿着面包走,这里被叫醒的任务还要等调度器把它排上才能运行。这段时间许可已经是它的,却没有人在用。后面 Mutex 一节说的「直接交接」的代价就在这里。
实验:要 3 个的排在前面
主任务先拿走全部 3 个许可。然后依次让 A 要 3 个,B、C、D 各要 1 个,都排上队。主任务再一个一个地还:
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::yield_now;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let sem = Arc::new(Semaphore::new(3));
// main holds all 3 permits
let held = sem.clone().acquire_many_owned(3).await.unwrap();
let mut handles = Vec::new();
// A asks for 3 first, then B, C, D ask for 1 each
for (name, n) in [("A", 3u32), ("B", 1), ("C", 1), ("D", 1)] {
let sem = sem.clone();
handles.push(tokio::spawn(async move {
let p = sem.acquire_many(n).await.unwrap();
println!("{name} got {n}");
yield_now().await;
drop(p);
}));
yield_now().await; // let it enqueue before the next one
}
let mut held = held;
for i in 1..=3 {
// give back one permit at a time
let one = held.split(1).unwrap();
drop(one);
println!(
"released {i}: available={} try_acquire(1)={:?}",
sem.available_permits(),
sem.try_acquire().map(|_| ()).map_err(|e| e.to_string())
);
yield_now().await;
}
for h in handles {
h.await.unwrap();
}
println!("end: available={}", sem.available_permits());
}
输出:
released 1: available=0 try_acquire(1)=Err("no permits available")
released 2: available=0 try_acquire(1)=Err("no permits available")
released 3: available=0 try_acquire(1)=Err("no permits available")
A got 3
B got 1
C got 1
D got 1
end: available=3
还回去的每个许可都直接记到 A 的节点上,计数始终是 0,try_acquire(1) 三次都失败,B、C、D 也一直没被叫醒。第 3 个还回去时 A 凑够了,被摘下来叫醒。A 用完释放 3 个,add_permits_locked 从链表尾依次分给 B、C、D。
叫醒:攒一批,放锁后再叫
add_permits_locked 把要叫醒的 waker 攒进 WakeList,一批最多 32 个。攒满一批(can_push() 为假)或者分完,就放锁,在锁外 wake_all();还有许可没分完就重新拿锁,继续下一批。
WakeList 是栈上一个 32 格的定长数组,攒 waker 不需要堆分配,代价是满了就得先叫醒一批。叫醒放在锁外,close 里的注释给了原因:waker 被叫醒或被 drop 时可能回头调用这个信号量,而这把锁不可重入,持锁 wake 有死锁的可能。
close 用同样的分批方式叫醒所有等待者。它在持锁时同时设置计数里的 CLOSED 位和 Waitlist::closed,注释说两者必须在锁内一起改:计数够的 acquire 走快路径不看链表,只能靠 CLOSED 位发现关闭;而慢路径拿到锁后看的是 waiters.closed。
#2325 最初的写法不是这样。释放许可时,它在锁内把凑够许可的那一段节点整段切下来,成为一条单独的链表,放锁后再沿着它逐个叫醒。四天后的 #2340(同在 0.2.14 发布)指出这里有悬垂指针:节点被切出去以后就不在锁保护的主链表里了,这时如果对应的 future 被取消,它的 Drop 会立即完成,future 被释放,节点却还在待叫醒的那条链表上。修法是改成在锁内逐个 pop_back,把 waker 暂存在栈上的定长数组里(当时是 8 个),放锁后叫醒,数组满了就再拿一次锁接着来。1.11.0 的 #4071 把这个数组换成了现在的 WakeList,一批 32 个。
同一个 #2340 也把 close 改成了在锁内逐个 pop_back 并直接 wake。这一处一直持锁叫醒,到 2026 年 10 月的 #8529 才改成上面说的分批方式(随 1.51.6 和 1.53.3 发布)。提交说明写的问题是:waker 被叫醒或被 drop 时如果回头调用这个信号量,比如释放一个许可,就会在这把锁上死锁;有界 mpsc::Receiver 被关闭或 drop、OnceCell 初始化时走的也是这段代码。
Acquire 被 drop:还回分到的部分
Acquire 可能在排队途中被丢弃,比如外面套了 timeout 或者在 select! 里输了。这时它可能已经分到了一部分许可。Drop:
impl Drop for Acquire<'_> {
fn drop(&mut self) {
// If the future is completed, there is no node in the wait list, so we
// can skip acquiring the lock.
if !self.queued {
return;
}
// This is where we ensure safety. The future is being dropped,
// which means we must ensure that the waiter entry is no longer stored
// in the linked list.
let mut waiters = self.semaphore.waiters.lock();
// remove the entry from the list
let node = NonNull::from(&mut self.node);
// Safety: we have locked the wait list.
unsafe { waiters.queue.remove(node) };
let acquired_permits = self.num_permits - self.node.state.load(Acquire);
if acquired_permits > 0 {
self.semaphore.add_permits_locked(acquired_permits, waiters);
}
}
}
两件事:把节点从链表里摘掉(节点就在这个 future 的内存里,future 没了,链表里不能留着指向它的指针);把已分到的 num_permits - state 个许可交给 add_permits_locked,它们会先分给下一个排队的人。所以取消一次 acquire 不会漏掉许可,也不会让后面的人多等。
future 正常完成时,poll 在返回 Ready 前把 queued 置回 false,许可的所有权交给调用者(SemaphorePermit 之类),Drop 什么都不做。
B got 1 出现在哪两行之间?current_thread 运行时,暂停的时钟(start_paused = true):
let sem = Arc::new(Semaphore::new(2));
let mut held = sem.clone().acquire_many_owned(2).await.unwrap();
// A wants 3 but gives up after 10ms
let s = sem.clone();
let a = tokio::spawn(async move {
timeout(Duration::from_millis(10), s.acquire_many(3)).await.is_ok()
});
yield_now().await;
// B wants 1, queued behind A
let s = sem.clone();
let b = tokio::spawn(async move {
let _p = s.acquire().await.unwrap();
println!("B got 1");
sleep(Duration::from_secs(1)).await;
});
yield_now().await;
drop(held.split(1).unwrap());
println!("1: available = {}", sem.available_permits());
sleep(Duration::from_millis(20)).await; // A times out here
println!("2: A got its 3 = {}", a.await.unwrap());
println!("3: available = {}", sem.available_permits());
drop(held);
println!("4: available = {}", sem.available_permits());
b.await.unwrap();
println!("5: available = {}", sem.available_permits());
预算在拿到许可之后扣
Acquire::poll 先调 poll_acquire,拿到许可以后才调 poll_proceed:
let result = match semaphore.poll_acquire(cx, needed, node, queued) {
Poll::Pending => Poll::Pending,
Poll::Ready(r) => {
r?;
// ...
let coop = ready!(crate::task::coop::poll_proceed(cx));
coop.made_progress();
Poll::Ready(Ok(()))
}
};
预算已经用完时,这次 poll 返回 Pending,但许可已经在节点里了(state 为 0,queued 为真)。下一次 poll 需要的许可数是 0,直接 Ready;如果在这之间 future 被 drop,Drop 会把这些许可还回去。所以 Mutex::lock()、Semaphore::acquire()、RwLock::read() 在一个循环里连续成功 128 次之后,也会让出一次线程,规则见协作式调度预算。
2026 年 10 月的 #8371(1.54.0)之前,Acquire::poll 先调 poll_proceed,再调 poll_acquire。PR 说明列出了这个顺序的问题(issue #8365,标题是「Mutex does not guarantee FIFO locking order」):任务在 lock().await 之前就把预算用完了,poll_proceed 返回 Pending,future 提前返回,节点根本没进等待链表;这时另一个还有预算的任务来 lock(),它的节点先进了链表,锁释放时它先拿到锁,违反了文档承诺的 FIFO。
修法就是本节看到的顺序:先 poll_acquire,节点在第一次 poll 时就排进队列,再扣预算。快路径拿到的许可也改成写进 node.state,这样预算不足返回 Pending 时,许可留在节点里,下次 poll 需要的是 0 个。
建在上面的三个类型
| 类型 | 许可总数 | 获取 | 释放 |
|---|---|---|---|
Semaphore | 用户指定,最多 MAX_PERMITS | acquire() 要 1 个,acquire_many(n: u32) 要 n 个 | SemaphorePermit drop 时 release(n) |
Mutex | 1 | lock() 要 1 个 | MutexGuard drop 时 release(1) |
RwLock | MAX_READS,或 with_max_readers 指定 | read() 要 1 个,write() 要全部 | 读锁还 1 个,写锁还全部 |
Semaphore 是一层很薄的包装:add_permits 就是 release,forget_permits 用 CAS 从计数里减掉、不经过链表,close 直接转发。
Mutex:一个许可
Mutex 就是 batch_semaphore::Semaphore::new(1) 加一个 UnsafeCell<T>。acquire 里关闭的情况直接 unreachable!():这个信号量是 Mutex 私有的,从来不会被关闭。
上面的不变量对 Mutex 意味着:解锁时,锁直接交给排在最前面的等待者。release(1) 把唯一的许可记在它的节点上,然后叫醒它;在它真正运行之前,锁已经是它的了,别人 try_lock 也拿不到。
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::yield_now;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let m = Arc::new(Mutex::new(Vec::new()));
let guard = m.lock().await;
let mut handles = Vec::new();
for id in [3, 1, 4, 0, 2] {
let m = m.clone();
handles.push(tokio::spawn(async move {
m.lock().await.push(id);
}));
yield_now().await; // id is queued before the next task is spawned
}
drop(guard);
// the permit was handed to the first waiter, not put back
println!("after unlock: try_lock ok = {}", m.try_lock().is_ok());
for h in handles {
h.await.unwrap();
}
println!("lock order: {:?}", *m.lock().await);
}
after unlock: try_lock ok = false
lock order: [3, 1, 4, 0, 2]
drop(guard) 之后主任务马上 try_lock,失败:许可已经交给任务 3。五个任务拿到锁的顺序就是它们排队的顺序。
这种「直接交接」的代价是:被叫醒的任务要等调度器把它排上,这段时间锁是占着的,没人在用。换来的是严格的 FIFO,没有任务会一直抢不到锁。
RwLock:写锁要全部许可
#[cfg(not(loom))]
const MAX_READS: u32 = u32::MAX >> 3;
MAX_READS 是 536,870,911。read 要 1 个许可,write 要 self.mr 个,也就是全部。
写者排队时,按慢路径把当前剩下的许可全拿走,之后每个读者释放的许可也都记到写者的节点上。计数一直是 0,所以写者排队以后新来的读者进不去,只能排在写者后面。文档把这叫做 write-preferring:写者不会被源源不断的读者饿死。
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::task::yield_now;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let l = Arc::new(RwLock::new(0));
let r1 = l.read().await;
let lw = l.clone();
let w = tokio::spawn(async move {
*lw.write().await += 1;
println!("writer done");
});
yield_now().await; // writer is now queued
println!("try_read while writer waits: ok = {}", l.try_read().is_ok());
let lr = l.clone();
let r2 = tokio::spawn(async move {
let v = *lr.read().await;
println!("reader 2 sees {v}");
});
yield_now().await;
drop(r1);
println!("dropped reader 1");
w.await.unwrap();
r2.await.unwrap();
}
try_read while writer waits: ok = false
dropped reader 1
writer done
reader 2 sees 1
读锁 r1 还在,写者在排队,这时 try_read 失败,读者 2 也只能排队,最后读到的是写者写入后的值。
写锁降级(downgrade)就是 release(mr - 1):留 1 个许可当读锁,其余还回去,排队的读者按顺序拿到。
动脑筋答案
完整程序在 #[tokio::main(flavor = "current_thread", start_paused = true)] 里跑(需要 test-util feature)。M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:
1: available = 0
B got 1
2: A got its 3 = false
3: available = 0
4: available = 1
5: available = 2
- 1:还回来的 1 个许可没有进计数,而是记到了排在最前面的 A 的节点上,A 还差 2 个。B 排在 A 后面,一个也分不到。计数一直是 0。
B got 1在 1 和 2 之间:主任务sleep(20ms)期间,A 的 10ms 超时先到,timeout把Acquiredrop 掉。Drop算出 A 已分到3 - 2 = 1个许可,交给add_permits_locked,这时链表尾是 B,B 正好凑够,被叫醒,打印B got 1。- 2:A 超时,
false。 - 3:A 那 1 个许可给了 B,没有回到计数,还是 0。
- 4:
drop(held)还回最后 1 个,链表已经空了,加回计数,1。 - 5:B 睡完 1 秒(暂停的时钟会自动快进)后 drop 许可,计数回到 2。
取消一个排了一半的 acquire,许可不会丢,也不会回到计数让新来的人插队,而是先给下一个等待者。
小结
batch_semaphore是一个AtomicUsize计数(第 0 位是关闭标志,许可数左移 1 位)加一把同步锁保护的侵入式等待链表,节点嵌在Acquirefuture 里。- 许可够:一次 CAS,不碰锁。不够:先锁链表,再把计数里剩下的全拿走,挂进链表。
- 公平性靠不变量「有人排队时计数为 0」:排队前清空计数,释放时先分给链表尾(最早来的),链表空了才加回计数。新来的请求只看计数,自然插不了队。
- 叫醒最多 32 个一批,放锁后再
wake。Acquire被 drop 时,已分到的许可交给下一个等待者。 Mutex是 1 个许可,解锁直接交给下一个等待者;RwLock读 1 个、写全部,写者排队后新读者进不去。- 预算在拿到许可之后才扣,预算不足时许可留在节点里,下次 poll 直接完成。