Rust Future 运行原理:从 IO Model 到 epoll 的完整链路

本文梳理 Rust 异步系统的核心设计与运行机制:从操作系统 I/O 模型演进推导拉式状态机的必然性,解析 Pin 与 Waker 的底层约束,剖析最小运行时实现与 Tokio 的 epoll 驱动机制,并分析取消安全与生命周期边界。


一句话总纲

Future 是一个可以被“非阻塞拉取”的状态机;Executor 负责按需拉取;Waker 是底层内核或硬件通知 Executor“可以再来拉我了”的反向挂号凭证;Pin 保证这个状态机在内存里不被挪动。

理解这四点,需要先回到操作系统的 I/O 与调度机制。


第零层:第一性原理——为什么必须是“拉式状态机”?

1. 物理现实:巨大的时钟周期差距

计算机系统中的计算与网络 I/O 存在巨大的速度差异:

  • CPU 执行一条指令:约 0.3 纳秒;
  • 发起一次跨国网络请求(RTT):约 150 毫秒 = 150,000,000 纳秒。

等待一个网络数据包的时间,CPU 可以执行数亿次时钟周期。如果让操作系统线程同步阻塞等待 I/O,会造成显著的 CPU 算力与线程资源浪费。

2. 操作系统 I/O 模型的演进

操作系统的 I/O 设计经历了四种典型演进:

① 阻塞 I/O (BIO)
   线程调用 read() ──> 内核挂起线程 ──> 硬件数据到达 ──> 唤醒线程
   代价:1 个连接 = 1 个 OS 线程(几 MB 栈内存 + 上下文切换开销),无法支撑百万并发。

② 非阻塞轮询 (NIO)
   线程循环 read() ──> 没数据立刻返回 EWOULDBLOCK ──> 疯狂占用 100% CPU 空转。

③ 多路复用 (I/O Multiplexing: epoll/kqueue) 【就绪通知模型(Readiness)】
   线程向内核注册 10 万个 fd ──> 线程调用 epoll_wait 睡眠 
   ──> 哪个 fd 就绪内核就唤醒线程 ──> 线程主动调用 read(fd) 提取数据。
   ★ 特点:由应用程序自己主动“拉取(Pull)”数据。

④ 异步完成 I/O (AIO: io_uring / Windows IOCP) 【完成通知模型(Completion)】
   应用程序提前准备一块内存 buffer 借给内核 ──> 内核在后台把数据填满 
   ──> 完成后通知应用程序“已经读好了”。
   ★ 特点:内核完成后主动“推送(Push)”数据。

核心认知:以 Linux epoll 为基石的主流操作系统网络栈,本质是 “就绪通知(Readiness)”——内核只告诉你“socket 可读了,你快拿缓冲区来读”,而不是“内核帮你读好送到手里”。

3. 语言层面的十字路口:为什么 Rust 不能学 Go 或 JS?

面对 epoll 的多路复用,各大语言走向了完全不同的分叉路:

分叉 A:Go 语言路线(有栈协程 / Goroutine)

  • 做法:Go 运行时接管底层一切。底层用 epoll,向上给程序员暴露纯阻塞的同步语法 conn.Read()。底层通过动态连续栈(2KB~8KB 起步)、M:N 抢占式调度器和垃圾回收器(GC)运转。
  • Rust 为什么不采用?
    1. 违背“零成本抽象”:每个协程哪怕处于空闲状态,也需要占用独立的协程栈内存。
    2. 违背“无运行时(No Runtime)”约束:Rust 的核心设计目标之一是支持操作系统内核、嵌入式裸机固件以及导出 C-ABI 库,不能强绑定包含 GC 与复杂抢占调度器的重型运行时。

分叉 B:JavaScript / C# 路线(推式 Push / Promise / 回调链)

  • 做法:基于发布-订阅机制。每个异步调用传入回调函数或返回一个 Promise,状态变化时把结果推(Push)给下游执行。
  • Rust 为什么不采用?
    1. 堆内存分配开销:链式组合子与闭包通常需要在堆上分配空间以保存捕获的上下文。
    2. 缺乏天然背压(Backpressure):上游持续推送数据,如果下游消费过慢,中间队列容易积压导致内存激增。
    3. 取消机制(Cancellation)复杂:通常需要沿途传递取消令牌(如 AbortController / CancellationToken),并在执行链路中显式轮询检查。

推导:Rust 的选择——拉式(Pull-based)无栈状态机

Rust 需要一种并发抽象,同时满足:

  1. 单线程支撑高并发(基于 epoll / kqueue 等多路复用机制);
  2. 零独立栈开销(无栈协程,编译期确定结构体大小);
  3. 按需分配(任务状态机可内嵌在调用栈或单次分配在堆上,无需细碎闭包堆分配);
  4. 天然支持背压与低成本取消。

推导出的设计即拉式(Pull-based / 需求驱动)状态机:如果不主动调用 poll,任务不会推进;一旦不再调用 poll 并执行 drop,任务即被取消。


第一层:Future 是什么

在标准库中,Future 只是一个极度精简的 trait:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),
    Pending,
}

关键语义:

  • poll 是非阻塞的。没准备好就立刻返回 Poll::Pending,绝不 sleep、绝不阻塞 OS 线程。
  • poll 由上层驱动反复拉取。每次被拉取时,状态机从上次停下的地方继续推进。
  • 返回 Pending 之前,必须确保存储了 cx.waker()。否则后续没有谁来重新调度,任务将永久挂起。
  • 返回 Ready 之后不应再被 poll。这是调用契约,违规调用会 panic 或引发逻辑错误(状态机进入 Fuse 保护)。

两种模型的本质对比

传统阻塞 I/O:   线程调用 read()  ──> 内核挂起线程 ──> 一个线程同时只能死等一个 I/O
Future 拉式模型: 任务调用 poll() ──> 没数据返 Pending ──> 线程立刻去跑别的任务
                                                         └── 数据到了通过 Waker 把任务重新放回就绪队列

异步的核心机制:将等待 I/O 就绪的阻塞操作,从操作系统内核线程下沉到了用户态的任务状态机。


第二层:async/await 被编译成了什么

你手写的代码:

async fn foo() -> i32 {
    let a = bar().await;       // 断点 1
    let b = baz(a).await;      // 断点 2
    a + b
}

编译器不会为它分配任何多余的堆内存,而是将它编译成一个自引用的 enum 状态机:

enum FooFuture {
    Start,                                          // 初始状态
    State1 { fut: BarFuture },                      // 停在 await bar()
    State2 { a: i32, fut: BazFuture },              // 停在 await baz(a),注意 a 被内嵌了
    Done,
}

impl Future for FooFuture {
    type Output = i32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<i32> {
        let this = unsafe { self.get_unchecked_mut() };
        loop {
            match this {
                FooFuture::Start => {
                    *this = FooFuture::State1 { fut: bar() };
                    // 不 return,继续 loop,立即推进下一阶段
                }
                FooFuture::State1 { fut } => {
                    match Pin::new_unchecked(fut).poll(cx) {
                        Poll::Pending => return Poll::Pending,
                        Poll::Ready(a) => {
                            *this = FooFuture::State2 { a, fut: baz(a) };
                            // 不 return,继续 loop,直接进入 State2
                        }
                    }
                }
                FooFuture::State2 { a, fut } => {
                    match Pin::new_unchecked(fut).poll(cx) {
                        Poll::Pending => return Poll::Pending,
                        Poll::Ready(b) => {
                            let res = *a + b;
                            *this = FooFuture::Done;
                            return Poll::Ready(res);
                        }
                    }
                }
                FooFuture::Done => panic!("poll after completion"),
            }
        }
    }
}

三个核心特征

  1. async fn 被调用时什么都没发生。 foo() 只是构建了上面这个 enum 的初始值(处于 Start)。函数体内部的代码必须等第一次 poll 被调用时才会真正开始执行。
  2. 每个 .await 就是状态机的一个切分点。 返回 Pending 时,所有跨断点存活的变量(如上述代码里的 a)和子 Future 都会被打包并内嵌存入 enum 变体字段。
  3. poll 里的 loop 避免不必要的调度开销。 如果子 Future(如内存缓存命中或非阻塞数据已在缓冲区)立刻返回 Ready,loop 会直接进入下一个分支继续执行,无需退回调度器重新排队;一旦真正遇到 Pending,立即 return 让出线程。

第三层:Pin——保证自引用结构体的内存位置不变

1. 为什么拉式状态机必然出现自引用?

在同步函数中,局部引用非常自然:

let mut x = 1;
let r = &x;
println!("{r}");

但在 async 状态机中,如果一个借用跨越了 .await:

async fn f() {
    let mut x = 1;
    let r = &x;            // r 引用了局部变量 x
    something().await;     // 挂起点
    println!("{r}");       // 恢复后继续使用 r
}

因为要跨断点保存,编译器必须把 x 和 r 同时装进同一个状态机结构体:

struct FFuture {
    x: i32,
    r: *const i32,   // 物理上指向本结构体内部的 x 字段!
}

2. 内存移动带来的未定义行为(UB)

如果这个结构体在内存中被移动了(例如在栈帧间复制、或者作为返回值返回、或者在 Vec 扩容时被 memcpy 到新地址):

移动前:
[ 地址 0x1000: x = 1 ] <─── 指向 0x1000 ─── [ 地址 0x1008: r ]

移动后(搬到了新地址 0x2000):
[ 地址 0x2000: x = 1 ]                       [ 地址 0x2008: r = 0x1000 (悬挂野指针!) ]

x 移动到了 0x2000,但 r 保存的地址依然是 0x1000(悬挂指针)。下次调用 poll 解引用 r 时,就会访问非法内存导致未定义行为(UB)。

在没有运行时垃圾回收来辅助重定位指针的前提下,Rust 的解法是:通过类型系统限制结构体的移动(Move)操作。

3. Pin 是怎么做到的

Pin 是一个零开销的包装结构体:

pub struct Pin<P> {
    pointer: P,   // 内部就包着一个指针(如 &mut T 或 Box<T>)
}

Pin 的本质约束:

  • 对于安全的无自引用类型(实现了 Unpin,如 i32、String),Pin 允许通过 DerefMut 拿到 &mut T:
    impl<P: DerefMut<Target: Unpin>> DerefMut for Pin<P> { ... }
    
  • 对于可能包含自引用的 !Unpin 类型(如 async 编译生成的状态机),Pin 不实现 DerefMut。 无法直接获取 &mut T,就无法使用 std::mem::swap、std::mem::replace 等方式将值移走,从而保证其内存地址在生命周期内固定不变。

第四层:Executor、Task、Waker 三者关系

角色本质职责
TaskFuture + 调度上下文(通常以 Arc<Task> 存在)调度系统分配给 Worker 线程的原子执行单元
Waker一个可克隆的“反向唤醒凭证”,内部存有 Task 的回拨指针Future 在数据就绪时通知 Executor 重新排队的信号线
Executor工作线程池 + 就绪任务队列负责轮流取出 Task 并调用其顶层的 poll()

核心架构图

┌─────────────────────────────────────────────────────────┐
│  Executor (调度环)                                      │
│                                                         │
│   loop {                                                │
│     task = ready_queue.pop_front()  ◄─────────────┐     │
│     waker = Waker::from(task)                     │     │
│     match task.future.poll(waker) {               │     │
│       Ready   => 任务结束,释放资源                 │     │
│       Pending => 丢弃(绝不自动重新入队,等 wake 通知)│     │
│     }                                             │     │
│   }                                               │     │
└───────────────────────────────────────────────────┼─────┘
                                                    │
                                          wake() 时将 Task push_back
                                                    │
┌───────────────────────────────────────────────────┴─────┐
│  Waker 内部指针 (Arc<Task>)                             │
│                                                         │
│  impl Wake for Task {                                   │
│    fn wake(self: Arc<Self>) {                           │
│        self.queue.push_back(self.clone());              │
│    }                                                    │
│  }                                                      │
└─────────────────────────────────────────────────────────┘

核心调度契约

  1. Future 不直接感知 Executor 的具体实现,仅通过 Waker.wake() 发送就绪通知。
  2. Poll::Pending 不能自动重新入队。如果返回 Pending 仍持续入队轮询,执行器就会退化为高 CPU 占用的忙等待。
  3. Waker 需在每次返回 Pending 时刷新(避免漏唤醒)。 Task 在两次 poll 之间可能会被工作窃取(Work-stealing)调度迁移到其他工作线程。每次 poll 返回 Pending 时,需保证存储的是当前最新的 cx.waker()。在多线程底层实现中,通常使用 AtomicWaker 进行并发安全的更新。

第五层:手写一个能跑的最小异步运行时

下面是一个支持防御虚假唤醒、可独立运行的极简执行器与 Future 实现:

use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
use std::thread;
use std::time::{Duration, Instant};

// ========== 1. 防御虚假唤醒的安全 Delay Future ==========
struct SafeDelay {
    deadline: Instant,
    started: bool,
}

impl SafeDelay {
    fn new(dur: Duration) -> Self {
        Self {
            deadline: Instant::now() + dur,
            started: false,
        }
    }
}

impl Future for SafeDelay {
    type Output = &'static str;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // ★ 核心检查:时间真的到了吗?严格防范协作任务触发的虚假唤醒
        if Instant::now() >= self.deadline {
            return Poll::Ready("pong");
        }

        // 仅在首次 poll 时起后台线程模拟硬件中断
        if !self.started {
            self.started = true;
            let waker = cx.waker().clone();
            let sleep_time = self.deadline.saturating_duration_since(Instant::now());
            thread::spawn(move || {
                thread::sleep(sleep_time);
                println!("  [硬件中断/内核通知] 定时器时间到,调用 waker.wake()");
                waker.wake();
            });
        }

        println!("  [poll] 硬件未就绪,返回 Pending");
        Poll::Pending
    }
}

// ========== 2. Task:将 Future 绑定调度与唤醒能力 ==========
struct Task {
    future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>,
    queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}

impl Wake for Task {
    fn wake(self: Arc<Self>) {
        println!("  [wake] Task 被唤醒,放回 Executor 就绪队列");
        self.queue.lock().unwrap().push_back(self.clone());
    }
}

// ========== 3. Executor:纯粹的就绪队列拉取环 ==========
fn run_executor(queue: Arc<Mutex<VecDeque<Arc<Task>>>>) {
    loop {
        let task = queue.lock().unwrap().pop_front();
        let Some(task) = task else { break };

        let waker = Waker::from(task.clone());
        let mut cx = Context::from_waker(&waker);

        let mut fut = task.future.lock().unwrap();
        match fut.as_mut().poll(&mut cx) {
            Poll::Ready(()) => println!("  [executor] Task 彻底完成"),
            Poll::Pending => println!("  [executor] Task 挂起,出让线程跑其他任务"),
        }
    }
}

// ========== 4. 驱动主流程 ==========
fn main() {
    let queue = Arc::new(Mutex::new(VecDeque::new()));

    let future = Box::pin(async {
        println!("  [future] 开始执行");
        let res = SafeDelay::new(Duration::from_millis(100)).await;
        println!("  [future] 收到结果: {res}");
    });

    let task = Arc::new(Task {
        future: Mutex::new(future),
        queue: queue.clone(),
    });
    queue.lock().unwrap().push_back(task);

    println!("执行器启动");
    run_executor(queue);
    println!("执行器退出");
}

运行输出与时序

执行器启动
  [future] 开始执行
  [poll] 硬件未就绪,返回 Pending
  [executor] Task 挂起,出让线程跑其他任务
  [硬件中断/内核通知] 定时器时间到,调用 waker.wake()
  [wake] Task 被唤醒,放回 Executor 就绪队列
  [future] 收到结果: pong
  [executor] Task 彻底完成
执行器退出

工业级异步运行时(如 Tokio)的核心逻辑与上述模型一致,主要区别在于将后台线程替换为内核级 I/O 多路复用(如 epoll_wait),并将互斥锁队列替换为支持工作窃取(Work-stealing)的高性能调度队列。


第六层:真实世界——Tokio 如何把 epoll 接入 poll

在实际网络通信中,Tokio 采用 Executor 与 Reactor 分工协作的双环架构:

【环 1:Executor 调度环(Worker 线程池)】       【环 2:Reactor 驱动环(Driver 线程)】
     从就绪队列取 Task ──> 调用 poll()                 循环调用 epoll_wait(timeout) 
            ▲                                                  │
            │                                                  ▼
            └──────── Waker::wake() ◄── epoll 就绪唤醒触发对应的 ScheduledIo 槽位

1. Optimistic I/O(乐观读写 / Fast Path)

Tokio 的 I/O 读写优先尝试无锁非阻塞调用,而非每次都注册 epoll:

  1. Fast Path(快路径): 调用 stream.read(&mut buf) 时,首先直接执行非阻塞系统调用 recv(fd)。 若内核接收缓冲区已有数据,系统调用直接返回读取字节数并返回 Poll::Ready(Ok(n)),无需经过 epoll 注册与任务调度上下文切换。
  2. Slow Path(慢路径): 仅当 recv 返回 EWOULDBLOCK(接收缓冲区为空)时,才进入慢路径:将当前 Waker 注册到对应的 ScheduledIo 槽位,并借助 epoll_ctl 订阅可读事件。

2. 边缘触发(Edge Triggered)下的驱动循环

Tokio 的 mio 默认使用 epoll 的边缘触发(EPOLLET)。 边缘触发的特性是:只有当 fd 从“不可读”变为“可读”的瞬间,内核才会通知一次。

// Tokio 内部的读轮询骨架
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
    loop {
        // 1. 检查就绪标记(如果未就绪,存入当前 cx.waker() 并返回 Pending)
        ready!(self.io.poll_read_ready(cx))?;

        // 2. 尝试读取
        match self.io.get_ref().try_read(buf) {
            Ok(n) => return Poll::Ready(Ok(())),
            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
                // 3. 抹除旧就绪标记!重新回循环头,下一次迭代将安全挂起
                self.io.clear_readiness(Ready::READABLE);
            }
            Err(e) => return Poll::Ready(Err(e)),
        }
    }
}

为什么必须有 clear_readiness? 在并发场景下,内核通知了就绪,但在你真正调用 recv 之前,缓冲区中的几个字节可能已经被另一个操作读走;如果直接返回 Pending 而不清空缓存的就绪标记,下次进入 poll 依然会误判为“可读”,陷入瞬间死循环。清空就绪标记后再次尝试,才能让任务安全地重新向内核挂号。


第七层:取消机制与取消安全(Cancellation Safety)

1. 取消机制:取消即 Drop(Cancellation is Drop)

在许多语言中,取消异步任务通常依赖显式传递的上下文(如 Go 的 context.WithCancel),需要任务内部主动监听 <-ctx.Done(),否则协程可能泄漏。

在 Rust 的拉式模型中,取消直接由生命周期与所有权系统驱动:

  • 执行器不再调用 poll,Future 就不会继续推进;
  • 当 Future 被 drop 时,其内部保存的局部变量与子 Future 会递归析构;
  • 底层持有的资源(如 socket fd)随之释放关闭,从 epoll 中自然注销。

2. Future 被 Drop 时 Reactor 的监听处理

如果 Future 已经 drop,但 Reactor 仍在 epoll 中监听该 fd,后续事件到达时的处理取决于 drop 的粒度:

情况 A:单次操作 Future 被 Drop(连接保持存活)

例如在 tokio::select! 中读取超时,stream.read() Future 被丢弃,但底层 TcpStream 仍在后续逻辑中使用:

  • Reactor 不注销 epoll:调用 epoll_ctl(EPOLL_CTL_DEL) 需要陷入内核,开销较高,因此 Tokio 会保留监听。
  • 迟到事件的处理:当内核数据到达、epoll_wait 触发时,Reactor 将对应槽位标记为就绪,并调用先前保存的 Waker。如果关联 Task 正在执行其他分支,这次唤醒只是一次无害的虚假唤醒(Spurious Wakeup);若 Task 已结束,wake() 直接返回。
  • 就绪标记留存(Fast Path):槽位中记录的就绪状态不会丢失。后续代码再次对该连接调用 read() 时,可直接检测到就绪标记并发起非阻塞读取,无需重新挂号排队。

情况 B:I/O 资源本身被 Drop(连接关闭)

例如请求处理完毕或发生错误,TcpStream 离开作用域被析构:

  • 内核自动解绑:TcpStream 析构时触发 close(fd)。在 Linux 中,当文件的最后一个引用计数关闭时,内核会自动将其从所有 epoll 实例中移除。
  • Reactor 槽位清理:Tokio 会显式解绑并清理槽位中保存的 Waker,防止内存泄漏。
  • 代际版本号(Generational Token)防并发竞争: 如果 Reactor 捕获到就绪事件的同时工作线程恰好 drop 了 TcpStream,为避免误唤醒被回收或已复用给新连接的槽位,Tokio 在注册给 epoll 的 Token 中附带了代际编号(Generation)。槽位回收时代际号递增;Reactor 处理事件时若发现 Token 的代际号与槽位当前代际号不一致,直接丢弃该过期事件。

3. 取消安全(Cancellation Safety)

由于取消在 Rust 中表现为直接析构(Drop),在组合并发操作时需要特别注意取消安全性。

当使用 tokio::select! 或 tokio::time::timeout 并联多个分支时,只要其中一个分支就绪,其余未完成的分支 Future 就会被直接 drop:

tokio::select! {
    res = socket.read_exact(&mut buf) => { ... } // ❌ 非取消安全!
    _ = timeout => { ... }
}
  • 安全场景(如 AsyncReadExt::read): 若数据尚未到达即被 drop,socket 连接未受损,未破坏未完成的数据流状态。
  • 不安全场景(如 AsyncReadExt::read_exact): 若要求读取 10 个字节,当前已读入 4 个字节时被取消(drop),已读出的 4 个字节会随着中间状态机的销毁而丢失。后续即使基于同一连接重试读取,数据流也已经断裂损坏。

注意:在 tokio::select! 的分支中使用 Future 时,需查阅文档确认方法是否为 Cancellation Safe。若非取消安全,应在 select! 外部持有中间缓冲与状态,或将该 I/O 放入独立的 Task 处理。

4. 跨 .await 的借用与 Send 边界

// ❌ 编译报错:Future 不能跨线程发送
tokio::spawn(async {
    let rc = Rc::new(100);
    sleep(Duration::from_millis(10)).await;
    println!("{rc}");
});
  • 本质机理: tokio::spawn 依赖多线程 Worker 窃取。Task 被挂起后,下次可能在另一个不同的 Worker 线程中恢复执行。
  • Rc 的跨断点判定: 编译器是按字段细粒度检测的。只要 Rc 没有跨越 .await 断点,它就不会被收录进跨断点的状态机字段:
    // ✅ 编译完全通过
    tokio::spawn(async {
        {
            let rc = Rc::new(100);
            println!("{rc}");
        } // rc 在这里 drop,状态机字段里不含 Rc
        sleep(Duration::from_millis(10)).await;
    });
    
  • 同理,std::sync::MutexGuard 跨 .await 会让 Future 失去 Send 且引发死锁,必须使用作用域隔离或者换用 tokio::sync::Mutex。

结语:全链路端到端全景时序

把底层系统调用、内核驱动与异步抽象串成完整时序:

① 用户代码            stream.read(&mut buf).await
   │
② 乐观尝试            直接非阻塞 recv(fd) ──> 若命中缓冲区,直接返回 Ready(n)(零开销退出)
   │ (WouldBlock)
③ 挂号登记            将当前 Task 的 Waker 存入 ScheduledIo,通过 epoll_ctl 注册监听
   │
④ 出让线程            返回 Poll::Pending,Worker 线程去执行队列中的其他就绪任务
   │
⑤ 硬件就绪            网卡接收数据,DMA 写入内存并触发硬件中断
   │
⑥ 内核置位            Linux TCP 协议栈将数据放入 socket 接收缓冲区,标记 fd 可读
   │
⑦ Reactor 唤醒        Driver 线程从 epoll_wait 醒来,解析 Token 找到对应 ScheduledIo
   │
⑧ 唤醒通知            Reactor 执行 waker.wake(),将挂起的 Task 重新推入 Executor 就绪队列
   │
⑨ 调度重入            空闲 Worker 线程取出 Task,再次调用 poll(Pin<&mut State>, cx)
   │
⑩ 真正读取            恢复到断点,再次调用 recv(fd) 成功获取数据,返回 Poll::Ready(n)

Rust 的异步设计通过拉式状态机与编译期零成本抽象,将操作系统多路复用能力与无 GC 内存安全保证结合在一起,在实现低运行时开销的同时保证了系统调度的灵活性。