Rust in Everything
基于 tokio 1.54.1

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 靠它判断要不要清理。

📝NOTE
冷知识:等待节点原来在堆上,被取消的等待者会一直占着内存

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。 两处代码共同维持它:

  1. 排队的人先把计数清空。 上一节的慢路径,进链表之前已经把计数里剩下的许可全拿走了。
  2. 释放的许可先给排队的人。 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 个空闲。这就是模块文档说的:要很多许可的任务不会被一群要少量许可的任务饿死。

💡TIP
打个比方:面包店里排队买面包

货架上摆着现成的面包(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。

📝NOTE
冷知识:「攒一批再叫」是修一个悬垂指针修出来的

#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 什么都不做。

❗IMPORTANT
动脑筋:1 到 5 各打印几?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());
提示:A 被取消时手里已经有几个许可?它们去了哪?答案在本页末尾。

预算在拿到许可之后扣

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 次之后,也会让出一次线程,规则见协作式调度预算。

📝NOTE
冷知识:1.54.0 之前,预算是先扣的

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_PERMITSacquire() 要 1 个,acquire_many(n: u32) 要 n 个SemaphorePermit drop 时 release(n)
Mutex1lock() 要 1 个MutexGuard drop 时 release(1)
RwLockMAX_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 把 Acquire drop 掉。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 位)加一把同步锁保护的侵入式等待链表,节点嵌在 Acquire future 里。
  • 许可够:一次 CAS,不碰锁。不够:先锁链表,再把计数里剩下的全拿走,挂进链表。
  • 公平性靠不变量「有人排队时计数为 0」:排队前清空计数,释放时先分给链表尾(最早来的),链表空了才加回计数。新来的请求只看计数,自然插不了队。
  • 叫醒最多 32 个一批,放锁后再 wake。Acquire 被 drop 时,已分到的许可交给下一个等待者。
  • Mutex 是 1 个许可,解锁直接交给下一个等待者;RwLock 读 1 个、写全部,写者排队后新读者进不去。
  • 预算在拿到许可之后才扣,预算不足时许可留在节点里,下次 poll 直接完成。

💬 关联讨论

发起讨论
加载中...