Rust in Everything
基于 tokio 1.54.1

ScheduledIo 与就绪状态

主线位置:IO 主线的「ScheduledIo 记下 waker」和「mio 报告就绪 → wake」。任务和驱动在这里交接。

flowchart TB
  subgraph drv[驱动线程]
    ev[mio 事件] --> set["set_readiness(Set):就绪位或上,tick 加 1"]
    set --> wk["wake:取出 waker,放锁后调用"]
  end
  subgraph task[任务线程]
    pr["poll_readiness:读原子"] -->|有就绪位| sys[系统调用]
    sys -->|WouldBlock| clr["clear_readiness(Clear tick)"]
    pr -->|没有| st[拿锁,存 waker,再读一次]
  end
  set -.-> word[("readiness:一个 usize")]
  clr -.-> word
  pr -.-> word
  st -.-> lock[("waiters:一把 Mutex")]
  wk -.-> lock

结构

ScheduledIo 只有三个字段:

pub(crate) struct ScheduledIo {
    pub(super) linked_list_pointers: UnsafeCell<linked_list::Pointers<Self>>,

    /// Packs the resource's readiness and I/O driver latest tick.
    readiness: AtomicUsize,

    waiters: Mutex<Waiters>,
}
  • linked_list_pointers:挂在驱动的 RegistrationSet 链表上用的前后指针(见反应器)。
  • readiness:就绪状态。驱动写、任务读,任务也会写(清位)。全程无锁。
  • waiters:等待这个 socket 的 waker。只有任务真要挂起、或驱动要唤醒时才碰这把锁。

结构体上方有一大段 cfg_attr,按 CPU 架构设置对齐:x86_64、aarch64、powerpc64 上是 128 字节,s390x 上是 256,arm、mips 等是 32,其他 64。注释给的理由是避免伪共享:每个 socket 的 readiness 被驱动线程和任务线程来回写,两个 socket 的状态如果落在同一条缓存行(Intel 从 Sandy Bridge 起一次预取一对 64 字节的行,所以按 128 算)上,互不相干的两个连接也会互相拖慢。代价是每个连接多占一些内存。

一个 usize 里的三样东西

// The `ScheduledIo::readiness` (`AtomicUsize`) is packed full of goodness.
//
// | shutdown | driver tick | readiness |
// |----------+-------------+-----------|
// |   1 bit  |   15 bits   |  16 bits  |

const READINESS: bit::Pack = bit::Pack::least_significant(16);

const TICK: bit::Pack = READINESS.then(15);

const SHUTDOWN: bit::Pack = TICK.then(1);

(scheduled_io.rs 第 168 行)

bit::Pack 是一对 (mask, shift)。least_significant(16) 占最低 16 位;then(15) 接着上一个字段的最高位往上放 15 位。展开后:

字段位掩码取值
READINESS0–150x0000_FFFFReady 的位集合,现在只用了低 6 位
TICK16–300x7FFF_00000 到 32767,回绕
SHUTDOWN310x8000_0000驱动已关闭

总共 32 位,所以 32 位平台上也放得下;64 位平台上高 32 位不用。READINESS 里的位来自 io/ready.rs:

位值含义
READABLE0b1可读
WRITABLE0b10可写
READ_CLOSED0b100读端关闭(对端关了写)
WRITE_CLOSED0b1000写端关闭
PRIORITY0b1_0000带外数据,仅 Linux 和 Android
ERROR0b10_0000出错

读方向看的是 READABLE | READ_CLOSED,写方向看的是 WRITABLE | WRITE_CLOSED(Direction::mask)。对端关闭之后,读端会一直「就绪」,read 返回 0,这正是 EOF 该有的样子。

三样东西放在同一个原子里,是因为它们必须一起读、一起改:任务读到「可读」时要同时拿到当时的 tick,后面清位要用;驱动置位时要同时把 tick 加 1;关闭位一旦设上,任何清位都不能把它抹掉。分开放就需要锁。

set_readiness:驱动和任务都走这一个函数

set_readiness:

pub(super) fn set_readiness(&self, tick_op: Tick, f: impl Fn(Ready) -> Ready) {
    let _ = self.readiness.fetch_update(AcqRel, Acquire, |curr| {
        // If the io driver is shut down, then you are only allowed to clear readiness.
        debug_assert!(SHUTDOWN.unpack(curr) == 0 || matches!(tick_op, Tick::Clear(_)));

        const MAX_TICK: usize = TICK.max_value() + 1;
        let tick = TICK.unpack(curr);

        let new_tick = match tick_op {
            // Trying to clear readiness with an old event!
            Tick::Clear(t) if tick != usize::from(t) => return None,
            Tick::Clear(t) => usize::from(t),
            Tick::Set => tick.wrapping_add(1) % MAX_TICK,
        };
        let ready = Ready::from_usize(READINESS.unpack(curr));
        // Keep the shutdown bit, so that a clear after shutdown (a
        // `WouldBlock` observed once the driver is gone) does not turn the
        // next wait into a wait for an event that will never come.
        let next = TICK.pack(new_tick, f(ready).as_usize());
        Some(SHUTDOWN.pack(SHUTDOWN.unpack(curr), next))
    });
}

fetch_update 是一个 CAS 循环:读当前值,算新值,CAS 失败就用最新值重算。闭包返回 None 时放弃,不写。

两种调用:

调用方tick_opf效果
驱动 dispatch_eventTick::Setcurr | ready就绪位或上新事件,tick 加 1(模 32768)
任务 clear_readinessTick::Clear(t)curr - masktick 还是 t 才清位,tick 不变

为什么要有 tick

mio 是边沿触发的(epoll 的 EPOLLET、kqueue 的 EV_CLEAR):socket 从「没数据」变成「有数据」时报告一次,之后只要数据还没读完,就不会再报。所以 tokio 的规则是:就绪位一旦置上,就一直留着,直到任务亲眼看到 WouldBlock 才清。

问题出在「看到 WouldBlock」和「清位」是两步,中间驱动可能又来了一个事件:

时刻任务线程驱动线程readiness
1poll_readiness:可读,记下 tick = 5READABLE,tick 5
2read() 返回 WouldBlock(缓冲区已空)同上
3对端又发来数据,poll 报告可读;set_readiness(Set)READABLE,tick 6
4clear_readiness:Clear(5)见下
  • 有 tick:时刻 4 发现 tick 是 6 不是 5,闭包返回 None,什么也不改。任务回到循环开头,再看一次就绪位,还是可读,再 read(),读到时刻 3 到达的数据。
  • 没有 tick:时刻 4 把 READABLE 清掉。时刻 3 的那次边沿已经被消耗了,内核不会再报。数据躺在 socket 缓冲区里,任务挂起等一个永远不会来的事件。

这是一个 ABA 问题:任务在时刻 1 和时刻 4 看到的就绪位都是 READABLE,但中间已经换了一个事件。tick 让两个「可读」可以区分。

💡TIP
打个比方:旗杆上带计数器的信箱

信箱上有一面小旗(READINESS 里的 READABLE),旗杆上还有一个只会往上翻的计数器(TICK)。邮递员(驱动的 dispatch_event)每次来都把旗子竖起来,计数器翻一格(Tick::Set)。你(任务)看到旗子竖着,记下计数器的读数(ReadyEvent 里的 tick),去开信箱。信箱是空的(WouldBlock),你放下旗子之前先看一眼计数器(Tick::Clear(t)):还是刚才那个数,说明这段时间没人来过,放下旗子;数变了,说明邮递员刚来过,旗子不动,回去再开一次信箱。

比方到这里为止:真信箱里有信,你迟早会看见,旗子放错了只是晚一点发现。socket 不一样,边沿触发下同一批数据内核只报告一次,旗子一旦错放下,就再也没人来把它竖起来。另外计数器只有 15 位,翻满 32768 格会回到原来的数。

tick 只有 15 位。如果读到事件和清位之间恰好来了 32768 的整数倍个事件,tick 会转一圈回到原值,误判仍然可能发生。这在实际中几乎不可能:两步之间只隔一次系统调用。

这几位的历史:

  • 2023 年 11 月以前 tick 是 8 位,旁边还有 7 位给 slab 用的代数。#5833 去掉 slab 后空出了位,#6135 把 tick 扩到 15 位,提交说明直言「目前没有证据说明需要,只是位空着,不用白不用」。
  • 但 ReadyEvent 里的 tick 字段和比较时用的还是 u8,扩出来的 7 位实际上没起作用。直到 2026 年 9 月的 #8331 才改成 u16,同时加了测试 stale_event_does_not_clear_readiness_after_u8_wraparound:读事件后再置位 256 次,用旧事件清位,就绪位必须还在。用 u8 比较时,256 次之后 tick 截断回原值,旧事件就会把新的就绪位清掉。
📝NOTE
冷知识:tick 原来是整个驱动共用的一个数

1.34.0 之前,tick 不是每个 ScheduledIo 自己的计数,而是驱动的全局计数:驱动每转一圈加 1,置位时把当时的值截成 u8 存进 ScheduledIo。issue #6133 描述了它的漏洞:一个 socket 的就绪状态停在第 0 圈,任务闲着没去碰它;这期间驱动转了 256 圈;任务因为别的原因醒来,进了读循环,恰好在「看到 WouldBlock」和「清位」之间,驱动在第 256 圈给它置位。256 截成 u8 是 0,清位以为没有新事件,把新的就绪位清掉,这个 IO 资源就挂住了。issue 还说明,把位数加宽只会让它更难发生,不能修好它。

#6134(1.34.0)把 tick 改成每个资源一份:只有这个 socket 自己被置位时才加 1,就是现在 set_readiness 里的 Tick::Set => tick.wrapping_add(1)。别的 socket 再忙,也推不动它的 tick。之前 Tick::Set 带着一个参数,是驱动传进来的全局 tick。上面说的扩到 15 位是同一版本里另外一个 PR。

clear_readiness:关闭状态不清

clear_readiness:

pub(crate) fn clear_readiness(&self, event: ReadyEvent) {
    // This consumes the current readiness state **except** for closed
    // states. Closed states are excluded because they are final states.
    let mask_no_closed = event.ready - Ready::READ_CLOSED - Ready::WRITE_CLOSED;
    self.set_readiness(Tick::Clear(event.tick), |curr| curr - mask_no_closed);
}

清的是 event.ready,也就是任务当时看到的那几位,不是全部。关闭位不清:连接关了就是关了,之后的每次读写都应该立即返回,而不是去等一个不会再来的事件。

❗IMPORTANT
动脑筋:清了位,还会再等吗?

macOS(kqueue)上,用 AsyncFd 包一个 UnixStream,分两种情况:对端写 1 字节;对端关闭写方向。每种情况跑三轮,每轮 readable().await 拿到 guard 后不读数据,直接 clear_ready():

for round in 0..3 {
  match timeout(Duration::from_millis(200), fd.readable()).await {
    Ok(guard) => {
      let mut guard = guard.unwrap();
      println!("第 {round} 轮:返回");
      guard.clear_ready(); // 不读数据,直接清位
    }
    Err(_) => println!("第 {round} 轮:200ms 内没返回"),
  }
}
两种情况下,第 1 轮分别是「返回」还是「200ms 内没返回」?提示:第一种情况里那 1 字节始终没被读走。答案在本页末尾。

set_readiness 保留 SHUTDOWN 位也是同一个道理。驱动关闭后任务仍可能调 clear_readiness,如果把关闭位清掉,下一次等待就会挂死。

还有一个反方向的入口 assume_ready:不等驱动的事件,直接把就绪位或上,tick 不变。accept 出来的 TcpStream、UnixStream 假定可读可写,UnixStream::pair 和 UnixDatagram::pair 假定可写:新接受的连接一般可写,并且往往已经带着对端发来的第一批数据,满载时第一个可读事件可能排在其他连接的事件后面很久。猜错的代价是一次 WouldBlock,然后照常清位(Registration::assume_ready 的注释)。

📝NOTE
冷知识:assume_ready 是 1.54.0 才加的,同一个 PR 还修了关闭位

1.54.0 之前,accept 出来的流和别的注册一样,从空的就绪位开始,第一次读写要等驱动报告它的第一个事件。#8435 给了一组数据:一台跑满的 HTTP/2 服务器(2 核、4 个 worker,每次 poll 取的事件数按 #8410 设了上限)上,新接受的连接要等 64 到 128ms 才第一次尝试读,而这次读每次都读到了数据(追踪的 5298 个连接全是如此)。原因是新连接的事件排在所有老连接的事件后面。

这个 PR 的第一个提交修的是上面 set_readiness 里「保留关闭位」那几行。之前它只用 tick 和就绪位重新拼出整个字,驱动关闭之后的一次 clear_readiness 会把关闭位抹掉,下一次等待就会一直挂着,而不是返回关闭错误。提交说明写道,关闭前就绪位已经置上的资源本来就能触发它;accept 出来的流一律假定就绪之后,这就成了常见情况,所以先修。

Waiters:两个专用槽,加一条链表

#[derive(Debug, Default)]
struct Waiters {
    /// List of all current waiters.
    list: LinkedList<Waiter>,

    /// Waker used for `AsyncRead`.
    reader: Option<Waker>,

    /// Waker used for `AsyncWrite`.
    writer: Option<Waker>,
}

(Waiters)

等待一个 socket 有两种 API:

API用法waker 存在哪
poll_read_ready / poll_write_ready,以及基于它的 AsyncRead、AsyncWritepoll_xxx(&self, cx),没有 future 可以存状态reader 或 writer 槽,每个方向只有一个
readable()、writable()、ready()、async_io,以及 AsyncFdasync fn,返回一个 futurefuture 里自带的 Waiter 节点,挂进 list

poll_* 风格的函数只有 &self 和 cx,没地方放链表节点,所以每个方向只给一个槽,后来的覆盖先前的。这对 AsyncRead 足够了:同一个流不会有两个任务同时读(poll_read 要 Pin<&mut Self>)。TcpStream::split 出来的读半边和写半边可以在两个任务里用,正好一个用 reader、一个用 writer。Registration 的文档把这条约束写明了:最多两个任务同时用,一个读一个写;违反它不会内存不安全,但会丢通知、任务挂住。

async fn 风格的 API 返回的 future 可以把一个 Waiter 节点放在自己身体里(future 被 pin 住,地址不变),挂进侵入式链表,所以任意多个任务可以等同一个 socket。

实验:两个任务等同一个 socket

两个任务先后挂起,等同一个 socket 可读,然后对端写入 1 字节。一轮用 poll_read_ready,一轮用 readable():

use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
use tokio::time::sleep;

async fn pair() -> (Arc<TcpStream>, std::net::TcpStream) {
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  let peer = std::thread::spawn(move || std::net::TcpStream::connect(addr).unwrap());
  let (s, _) = listener.accept().await.unwrap();
  // accept 出来的流被假定可读;先 try_read 一次,拿到 WouldBlock,把就绪位清掉
  let mut b = [0u8; 8];
  assert_eq!(
    s.try_read(&mut b).unwrap_err().kind(),
    std::io::ErrorKind::WouldBlock
  );
  (Arc::new(s), peer.join().unwrap())
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  for mode in ["poll_read_ready", "readable"] {
    let (s, mut peer) = pair().await;
    let mut tasks = Vec::new();
    for name in ["A", "B"] {
      let s = s.clone();
      tasks.push(tokio::spawn(async move {
        if mode == "poll_read_ready" {
          std::future::poll_fn(|cx| s.poll_read_ready(cx)).await.unwrap();
        } else {
          s.readable().await.unwrap();
        }
        println!("  {name} 被唤醒");
      }));
      sleep(Duration::from_millis(10)).await; // 让 A 先挂起,B 后挂起
    }
    println!("{mode}:A、B 都在等,对端写入");
    peer.write_all(b"x").unwrap();
    sleep(Duration::from_millis(200)).await;
    for (name, t) in ["A", "B"].iter().zip(&tasks) {
      if !t.is_finished() {
        println!("  {name} 200ms 后还在等");
      }
    }
    for t in tasks {
      t.abort();
    }
  }
}

输出:

poll_read_ready:A、B 都在等,对端写入
  B 被唤醒
  A 200ms 后还在等
readable:A、B 都在等,对端写入
  B 被唤醒
  A 被唤醒

poll_read_ready 那一轮,B 的 waker 覆盖了 A 留在 reader 槽里的 waker,A 再也没人叫醒。数据明明到了,A 却一直挂着,这就是 Registration 文档说的「丢通知」。readable() 那一轮,两个 Waiter 节点都在链表里,都被唤醒。B 先于 A,是因为新节点 push_front 插在表头,唤醒时从表头开始遍历。

wake:放锁之后再叫醒

ScheduledIo::wake 由驱动在 set_readiness 之后调用:

pub(super) fn wake(&self, ready: Ready) {
    let mut wakers = WakeList::new();

    let mut waiters = self.waiters.lock();

    // check for AsyncRead slot
    if ready.is_readable() {
        if let Some(waker) = waiters.reader.take() {
            wakers.push(waker);
        }
    }

    // check for AsyncWrite slot
    if ready.is_writable() {
        if let Some(waker) = waiters.writer.take() {
            wakers.push(waker);
        }
    }

    'outer: loop {
        let mut iter = waiters.list.drain_filter(|w| ready.satisfies(w.interest));

        while wakers.can_push() {
            match iter.next() {
                Some(waiter) => {
                    let waiter = unsafe { &mut *waiter.as_ptr() };

                    if let Some(waker) = waiter.waker.take() {
                        waiter.is_ready = true;
                        wakers.push(waker);
                    }
                }
                None => {
                    break 'outer;
                }
            }
        }

        drop(waiters);

        wakers.wake_all();

        // Acquire the lock again.
        waiters = self.waiters.lock();
    }

    // Release the lock before notifying
    drop(waiters);

    wakers.wake_all();
}
  • 两个专用槽按方向取:可读事件只取 reader,可写事件只取 writer。
  • 链表里只摘下兴趣匹配的节点(ready.satisfies(w.interest)),摘下时把节点的 is_ready 置为 true,future 下次被 poll 时据此知道自己已经被通知过。
  • waker 先收集进栈上的 WakeList,放锁之后再统一调用。注释说在锁里调 waker 可能死锁:waker 里可以是任意代码,只要它回头碰这个 socket 的 waiters,就会拿同一把锁。WakeList 一次最多装 32 个,等待者更多时就分批:装满、放锁、唤醒、重新拿锁、继续。

Readiness future:链表节点放在 future 里

readable() 一路调到 ScheduledIo::readiness,返回的是 Readiness:

struct Readiness<'a> {
    scheduled_io: &'a ScheduledIo,

    state: State,

    /// Entry in the waiter `LinkedList`.
    waiter: UnsafeCell<Waiter>,
}

enum State {
    Init,
    Waiting,
    Done,
}

它的 poll 是一个三态的状态机:

stateDiagram-v2
  [*] --> Init
  Init --> Done: 原子里已有就绪位
  Init --> Done: 拿锁后再看,已有就绪位
  Init --> Waiting: 存 waker,节点挂进链表
  Waiting --> Waiting: is_ready 为假,更新 waker
  Waiting --> Done: is_ready 为真
  Done --> [*]: 读原子,返回 ReadyEvent
  • Init:先不拿锁读一次原子,就绪就直接返回。不就绪再拿锁、再读一次。第二次读是必要的:第一次读和拿锁之间,驱动可能已经置位并调过 wake(),那次 wake() 时链表里还没有这个节点,如果不再看一眼,就会错过这次事件。拿着锁读到「不就绪」之后挂进链表,驱动下一次 wake() 必须先拿到这把锁,一定能看到这个节点。
  • Waiting:被 poll 时拿锁看 is_ready。wake() 把节点从链表摘下时置的就是它。没被通知就用 clone_from 更新 waker(调用方这次传来的 waker 可能和上次不同),返回 Pending。
  • Done:重新读一次原子,返回当前的就绪位和 tick。注释说返回的 tick 可能比唤醒它的那个事件更新,就绪位也可能已经被别人清空,这都可以接受:调用方会去试系统调用,WouldBlock 了就清位,再等一轮。

Drop 拿锁把节点从链表里摘掉。future 被取消(比如在 select! 里输了)时,链表里不会留下一个指向已释放内存的节点。节点已经被 wake() 摘掉的话,再摘一次是空操作。

poll_readiness 那条路(专用槽)也有同样的「拿锁后再读一次」,下一章会读到。

动脑筋答案

完整程序(UnixStream 用的是 std 的):

use std::io::Write;
use std::os::unix::net::UnixStream;
use std::time::Duration;
use tokio::io::unix::AsyncFd;
use tokio::time::timeout;

#[tokio::main(flavor = "current_thread")]
async fn main() {
  for case in ["对端写 1 字节", "对端关闭写方向"] {
    let (a, mut b) = UnixStream::pair().unwrap();
    a.set_nonblocking(true).unwrap();
    // SAFETY: `a` 归 AsyncFd 所有,fd 在 AsyncFd drop 之前一直打开
    let fd = unsafe { AsyncFd::register(a) }.unwrap();
    if case == "对端写 1 字节" {
      b.write_all(b"x").unwrap();
    } else {
      b.shutdown(std::net::Shutdown::Write).unwrap();
    }
    println!("{case}:");
    for round in 0..3 {
      match timeout(Duration::from_millis(200), fd.readable()).await {
        Ok(guard) => {
          let mut guard = guard.unwrap();
          let r = guard.ready();
          println!(
            "  第 {round} 轮:返回,is_readable={} is_read_closed={}",
            r.is_readable(),
            r.is_read_closed()
          );
          guard.clear_ready(); // 不读数据,直接清位
        }
        Err(_) => println!("  第 {round} 轮:200ms 内没返回"),
      }
    }
  }
}

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:

对端写 1 字节:
  第 0 轮:返回,is_readable=true is_read_closed=false
  第 1 轮:200ms 内没返回
  第 2 轮:200ms 内没返回
对端关闭写方向:
  第 0 轮:返回,is_readable=true is_read_closed=true
  第 1 轮:返回,is_readable=true is_read_closed=true
  第 2 轮:返回,is_readable=true is_read_closed=true
  • 写 1 字节,第 0 轮:kqueue 报告可读,驱动置上 READABLE,readable() 返回。
  • 写 1 字节,第 1、2 轮:clear_ready() 走的就是 clear_readiness,tick 没变,READABLE 被清掉。那 1 字节还在 socket 里,但边沿触发下内核已经报告过这次「变成可读」,不会再报,readable() 只能一直等。这就是 tokio 自己只在亲眼看到 WouldBlock 之后才清位的原因。AsyncFd 把清位交给了调用方,没读到 WouldBlock 就清,就会这样挂住。
  • 关闭写方向,三轮都返回:EOF 事件同时带来 READABLE 和 READ_CLOSED。clear_readiness 减掉的是 event.ready - READ_CLOSED - WRITE_CLOSED,只清掉 READABLE,READ_CLOSED 留着。readable() 等的是 Interest::READABLE 的掩码,也就是 READABLE | READ_CLOSED,有其中一位就返回,所以每轮都立即返回。
  • is_readable 一直是 true:这不说明 READABLE 位还在。Ready::is_readable 的定义是「有 READABLE 或者有 READ_CLOSED」,读端关闭也算可读,因为这时 read 会立即返回 0。从公开 API 看不到 READABLE 这一位本身。

小结

  • readiness 一个 usize:低 16 位就绪位,中间 15 位 tick,第 31 位关闭标志。三样东西必须一起读、一起改,所以放在一个原子里。
  • 驱动置位时 tick 加 1;任务清位时带着当初读到的 tick,对不上就放弃。这样「看到 WouldBlock」和「清位」之间新到的边沿事件不会被吞掉。
  • 关闭位和读写关闭位都不会被清:这些是终态,之后的读写应立即返回。
  • poll_* 风格的 API 每个方向只有一个 waker 槽,后来的覆盖先前的;readable() 这类 async fn 把链表节点放在 future 里,任意多个任务可以等同一个 socket。
  • wake() 在锁里收集 waker,放锁后再调用,每批最多 32 个。

💬 关联讨论

发起讨论
加载中...