# 异步运行时

# 异步运行时

一个异步运行时主要做以下几件事:

  • 管理和调度异步任务 (Futures/Tasks):

    当使用 tokio::spawn 启动一个新的 Future 时,运行时会把它包装成一个 Task,并放入自己的任务队列中等待执行。它会不断地检查哪些任务可以继续执行(即 "ready"),然后调用它们的 poll 方法来推进它们。

  • 处理非阻塞 I/O (Non-Blocking I/O):

    运行时通过底层操作系统提供的机制(如 Linux 的 epoll, macOS 的 kqueue, Windows 的 IOCP)将这些 I/O 操作注册为“感兴趣的事件”。 当一个 I/O 操作(比如网络数据包到达)准备好时,操作系统会通知运行时,然后运行时就知道哪个等待这个事件的任务可以被唤醒并继续执行了。

  • 管理计时器 (Timers):

    运行时内部会维护一个有序的计时器队列。当一个 sleep 任务被安排时,运行时会记录下它需要唤醒的时间。当时间到达时,运行时会唤醒相应的任务。

  • 提供线程池 (Thread Pool):

    虽然异步代码的目的是避免阻塞,但有时仍然需要执行一些 CPU 密集型任务或必须使用阻塞式 API 的操作(例如,进行复杂的加密计算,或者调用一些老旧的同步 C 库)。 Tokio 提供 tokio::task::spawn_blocking 这样的机制,可以将这些阻塞性任务提交到一个单独的线程池中执行,而不会阻塞主异步运行时所在的线程。

  • 资源管理与错误处理:

    协助管理异步任务的生命周期,例如当一个任务被取消时,清理相关资源。提供结构化的错误处理机制,例如通过 Result 类型传播异步操作的错误。

# 异步运行时底层支持(Future,Waker,EventLoop,I/O Multiplexing)

# Future

这是 Rust 异步编程的基本构建块。一个 async fn 编译后会生成一个实现了 Future trait 的状态机。

Future trait 定义了一个核心方法:poll。

Rust 中的 poll 方法的签名是 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;

Poll::Ready(value): 表示 Future 已经完成,并返回了结果 value。

Poll::Pending: 表示 Future 尚未完成,需要等待某个条件(例如 I/O 完成或计时器到期)才能继续。当返回 Pending 时,Future 必须确保在条件满足时,通过 Waker 机制通知运行时来再次 poll 自己。

# Waker

这是异步运行时能够“唤醒”一个 Pending 的 Future 的关键机制。

当一个 Future 在 poll 方法中返回 Pending 时,它会通过 Context 获取到一个 Waker 的副本(以及注册感兴趣的事件)。这个 Waker 可以被存储起来(例如,存储在一个 I/O 事件的注册表中)。

当外部事件(如操作系统通知网络数据已到达,或者计时器到期)发生时,对应的 Waker 会被调用其 wake() 方法。wake() 方法会通知运行时:那个关联的 Future 现在可能已经准备好再次被 poll 了,把它放回任务队列中。

# Context

Context 对象在 poll 方法中传递,它包含了当前 Future 的 Waker。它允许 Future 在返回 Pending 之前,将自己的 Waker 注册到某个地方,以便在条件满足时被唤醒。

# 底层运行机制(以 Tokio 为例):

# 任务封装 (Task):

当调用 tokio::spawn(my_async_op()) 时,my_async_op() 返回的 Future 会被 Tokio 运行时包装成一个内部的 Task 结构。这个 Task 包含 Future 本身以及一个唯一的 ID 或指针。

这个 Task 被放入一个就绪队列 (Ready Queue)。

# 事件循环 (Event Loop):

Tokio 运行时的一个或多个工作线程 (worker threads) 会运行一个无限循环,这就是事件循环。 在每个循环迭代中,它会做几件事:

  1. 从就绪队列中取出任务: 运行时会从就绪队列中弹出一个 Task。

  2. 驱动任务: 运行时会调用这个 Task 内部 Future 的 poll 方法。

    如果 poll 返回 Poll::Ready(value),任务完成,其结果被处理,Task 资源被清理。 如果 poll 返回 Poll::Pending,说明任务暂时无法继续。此时,Future 内部已经将自身的 Waker 注册到了它所等待的资源(如网络套接字、计时器)上。该任务会被暂时移除出就绪队列。

  3. 等待事件: 如果就绪队列为空,或者所有当前的 Future 都返回了 Pending,运行时会进入一个阻塞状态,等待操作系统通知有新的 I/O 事件发生,或者等待下一个计时器事件到期。这是通过 I/O 多路复用 机制实现的(如 epoll_wait, kqueue, GetQueuedCompletionStatus)。

  4. 处理事件并唤醒任务: 当操作系统通知有 I/O 事件发生时,运行时会通过事先存储的 Waker 找到对应的 Task,调用其 wake() 方法。这个 wake() 调用会把 Task 重新放回就绪队列中,等待在下一个循环迭代中被 poll。

# I/O 多路复用 (I/O Multiplexing):

这是高效处理大量并发 I/O 的关键。运行时不是为每个连接或文件打开一个线程,而是使用一个单独的机制(如 epoll)来同时监控成千上万个 I/O 句柄。

例如,当一个 TCP socket 上有数据可读时,epoll 会通知运行时,然后运行时就知道哪个 Future 正在等待这个 socket 的数据,并唤醒它。

# 计时器驱动 (Timer Driver):

Tokio 内部有一个高效的计时器轮 (timer wheel) 或类似的结构来管理大量的 tokio::time::sleep() 调用。

当 sleep 到期时,计时器驱动会唤醒相应的 Task。

# 工作线程池 (Worker Thread Pool):

Tokio 默认会启动一个由多个操作系统线程组成的工作线程池。每个线程都运行自己的事件循环。

这使得 Tokio 可以在多核 CPU 上实现真正的并行度。任务可以被调度到任何一个空闲的工作线程上执行。

tokio::task::spawn_blocking 则是将阻塞任务提交到一个单独的阻塞线程池中,确保这些阻塞操作不会影响主事件循环的响应性。

# 对 Tokio 的大概理解

简单看来,tokio 就像是使用了 rust 的语言特性,包装了系统底层的 io 多路复用机制,然后向上层提供异步服务而已?

这个理解从一定程度上来说是正确的,但 tokio 不只是一个简单的“包装”,而是一个提供了许多关键组件来实现上述功能的完整、强大且易于使用的异步运行时

顶层:用户友好的异步 API (User-Friendly Async APIs)

这是直接使用的部分,例如 tokio::spawn, tokio::net::TcpStream, tokio::sync::Mutex 等。它们遵循 Rust 的 async/await 语法,能够编写看似同步的异步代码。

中层:Tokio 运行时 (The Tokio Runtime)

  • 调度器 (Scheduler): 这是 Tokio 最复杂的部分之一。它负责管理成千上万的异步任务(Task),决定下一个应该执行哪个任务。它还实现了工作窃取 (work-stealing) 算法,使得在多线程模式下,空闲的工作线程可以从繁忙的线程那里“窃取”任务来执行,从而最大化 CPU 利用率。

  • 计时器系统 (Timer System): 提供 tokio::time::sleep 等功能,下面会详细讲。

  • 异步原语 (Async Primitives): 提供了专门为异步环境设计的同步工具,如 Mutex, Semaphore, Channels (mpsc, oneshot 等)。

  • 阻塞任务处理器 (Blocking Task Handler): 通过 spawn_blocking 将会阻塞线程的代码移到专用的线程池,防止其“毒害”主事件循环。

底层:对操作系统 API 的抽象 (OS API Abstraction)

Tokio 封装了不同操作系统提供的 I/O 多路复用机制:

Linux: epoll
macOS / BSD: kqueue
Windows: IOCP (I/O Completion Ports)

mio 提供了一个统一的、跨平台的、底层的 API。Tokio 在此基础上构建其 I/O 驱动,当 mio 报告某个 I/O 句柄(如一个网络套接字)准备好读或写时,Tokio 就会唤醒等待这个事件的那个异步任务。

# 一些细节

# 一些在标准库的实现,在 tokio 中有另一个版本,为什么?(比如 std::mutex 和 tokio::mutex)

最大的区别在于当锁已经被持有时,另一个任务尝试获取锁的行为。

  • std::sync::Mutex 会阻塞当前线程 (Thread)。
  • tokio::sync::Mutex 会让出当前任务 (Task) 的执行权,但不会阻塞线程。

# 行为对比

特性 std::sync::Mutex (标准库锁) tokio::sync::Mutex (Tokio 异步锁)
主要用途 在多线程 (Thread) 环境下保护共享数据。 在多任务 (Task) 环境下保护共享数据,专为 async 代码设计。
获取锁的方式 let guard = my_mutex.lock().unwrap(); let guard = my_mutex.lock().await;
当锁被占用时 阻塞当前线程。操作系统会挂起整个线程,直到锁被释放。线程在此期间无法做任何其他事情。 异步地等待。当前任务返回 Poll::Pending,并被“挂起”。运行时(Tokio)会切换去执行其他就绪的任务。当锁被释放时,运行时会唤醒这个任务,让它重新尝试获取锁。
对异步运行时的影响 灾难性的。如果在一个异步 worker 线程上使用并发生争用,整个线程都会被冻结。这个线程上成百上千个其他的异步任务都会被卡住,无法取得任何进展。 和谐的。它遵循了异步的“协作式”调度原则。任务只是暂停,线程本身仍然是活跃的,可以继续去处理其他成千上万个任务,最大化了线程的利用率。
是否可以跨 .await 绝对不能。在持有 std::sync::MutexGuard 的作用域内进行 .await 是非常危险的,极易导致死锁 完全可以。这正是它设计的目的。你可以在获取锁后安全地执行 .await 操作,因为锁的 GuardSend 的。

# 为什么 std::sync::Mutex 在异步代码中是危险的?

假设在一个 Tokio worker 线程上:

  1. 任务1 获取了 std::sync::Mutex 的锁。
  2. 任务1 执行了一个 .await 操作(比如 tokio::time::sleep(..).await),这导致它自己被挂起,并将线程的控制权交还给 Tokio 运行时。
  3. Tokio 运行时发现 任务2 已经就绪,于是在同一个线程上开始执行 任务2
  4. 任务2 也尝试去获取同一个 std::sync::Mutex 的锁。
  5. 因为这个锁已经被 任务1 持有,所以 任务2阻塞整个线程
  6. 现在,线程被完全阻塞了。这意味着 Tokio 运行时无法再轮询任何任务,包括那个持有锁并且需要被唤醒才能释放锁的 任务1
  7. 任务1 永远等不到被唤醒,任务2 永远在阻塞等待,死锁发生。

# 何时可以使用 std::sync::Mutex

尽管在 async 函数内部直接使用 std::sync::Mutex 并跨 await 是危险的,但它在某些情况下仍然有用:

  1. 在不包含 .await 的临界区: 如果你只是想保护一小段纯 CPU 计算的代码,并且确信在这段代码中绝对不会有 .await,那么使用 std::sync::Mutex 是可以的,而且它的性能开销通常比 tokio::sync::Mutex 更低。但为了代码风格的一致性和安全,通常还是推荐使用 tokio 的版本。

  2. tokio::task::spawn_blocking 中: 当你需要与阻塞的、非异步的代码(比如一个传统的数据库驱动)交互时,你会使用 spawn_blocking。在这个闭包内部,你完全处于一个同步的上下文中,因此应该使用 std::sync::Mutex 来和其他同步代码进行交互。

# 注意,获取异步锁之后陷入睡眠是不会释放这个锁的

任务获取 Tokio 的异步锁 (tokio::sync::Mutex) 后,即使 await 陷入睡眠(或者等待 I/O),这个锁仍然是被持有的,不会被释放。

这正是 tokio::sync::Mutex 的核心设计目标之一:允许在持有锁的同时执行异步操作。

工作原理:

  1. 任务 A 尝试获取锁: let _guard = my_mutex.lock().await;
  2. 获取成功: _guard 被创建,锁被任务 A 持有。
  3. 任务 A 执行异步操作: some_async_op().await;
    • await 点,任务 A 会返回 Poll::Pending 给 Tokio 运行时。
    • 关键点: 任务 A 自己被挂起,但它仍然是锁的持有者。这个锁并没有被释放。
    • Tokio 运行时发现任务 A 挂起了,就会转去执行就绪队列中的其他任务(任务 B, C, D 等)。
    • 这期间,如果其他任务(比如任务 B)也尝试获取同一个 my_mutex 的锁,它也会遇到锁被持有的情况,然后 lock().await 也会返回 Poll::Pending,任务 B 也会被挂起。
  4. 异步操作完成: some_async_op() 完成后,Tokio 运行时会唤醒任务 A。
  5. 任务 A 继续执行: 任务 A 会从它上次暂停的地方继续执行,_guard 仍然有效。
  6. 任务 A 完成锁保护的临界区:_guard 超出作用域时(或者手动 drop),锁才会被释放。

但要注意: 尽管锁在 await 期间不会释放,并且线程不会阻塞,仍然需要警惕持有锁时间过长可能带来的争用问题。如果一个任务长时间持有异步锁,其他等待这个锁的任务仍然会长时间处于挂起状态,这会影响程序的并发性能。所以,最佳实践仍然是尽可能地缩短临界区(持有锁的代码块)。

# 为什么 guard 需要实现Send trait

当一个任务在一个线程上面执行的时候,他获取了分布锁,并且由于其他的阻塞操作陷入睡眠,但是并没有释放这个锁,后续这个任务可能在其他工作线程上面唤醒继续执行,这就需要这个 guard 可以支持在线程剑传递,因此需要Send

# tokio 的计时器机制

Tokio 的计时器机制是一个非常高效的系统,旨在以最小的开销管理可能存在的成千上万个计时器。它不会为每个计时器创建一个线程,也不会在一个循环里遍历一个巨大的计时器列表来检查谁到期了。

它使用的核心数据结构是分层时间轮 (Hierarchical Timing Wheel)

  • 第 1 层(秒轮): 一个有 64 个槽(Slot)的轮子。每个槽代表一个时间单位(比如 1 毫秒)。这个轮子能表示接下来的 64 毫秒。
  • 第 2 层(分轮): 同样有 64 个槽的轮子。当第一层的轮子转完一整圈(64 毫秒)后,第二层的轮子前进一个槽。所以第二层的每个槽代表 64 毫秒。它能表示 64 * 64 = 4096 毫秒。
  • 第 3 层(时轮): 以此类推,当第二层转完一圈,第三层前进一格。

工作流程如下:

  1. 添加计时器 (tokio::time::sleep)

    • 当调用 tokio::time::sleep(Duration::from_millis(100)) 时,Tokio 运行时会计算出这个计时器应该在未来的哪个时间点被唤醒。
    • 它会根据这个时间点,将这个计时器(实际上是关联到这个 sleep 任务的 Waker)放到时间轮上合适的槽里。
    • 例如,如果当前时间在第 1 轮的第 5 槽,那么 100 毫秒后应该是在 (5 + 100) % 64 = 41,但因为超过了 64,所以它会被放到第 2 轮的 (100 / 64) = 1 号槽,并记录余数。
  2. 计时器驱动 (Timer Driver) 前进

    • Tokio 的运行时有一个内部的“心跳”或“滴答”(tick)。它会定期检查当前时间。
    • 它只需要检查当前时间指针指向的那个槽。例如,时间前进了 1 毫秒,驱动就将指针从第 1 轮的第 5 槽移动到第 6 槽。
  3. 计时器到期

    • 当驱动移动到某个槽时,它会检查这个槽里是否存放了计时器。
    • 如果槽里有计时器,说明它们已经到期了。
    • 驱动会取出这些计时器对应的 Waker,并调用它们的 wake() 方法。
    • wake() 方法会通知 Tokio 的调度器:这些任务现在已经就绪,可以放回就绪队列等待执行了。
  4. 处理长延时 (Cascading / 级联)

    • 如果设置一个很长的 sleep(比如 5 秒),它会被直接放到更高层级的轮子中(比如第 3 层或第 4 层)。
    • 当低层级的轮子转完一整圈时,它会检查高一级轮子的下一个槽。如果那个槽里有计时器,它会把这些计时器“重新计算”并“降级”安放到下一层的轮子中。这个过程称为级联

这种机制的优势:

  • 极高的效率 (O(1) 复杂度):
    • 添加计时器的操作是 O(1) 的,因为它只需要做一次计算就知道该放在哪个槽。
    • 检查到期计时器的操作也是 O(1) 的,因为它只需要看当前时间指针指向的那个槽,完全不需要遍历所有计时器。
  • 可扩展性强: 能够以非常低的 CPU 开销同时管理数百万个计时器。

# tokio 的调度器

Tokio 的调度器是其核心的“大脑”,负责决定在哪个线程上、在什么时候、运行哪个异步任务。它的设计目标是高性能、低延迟和公平性

Tokio 主要提供两种调度器类型,你可以通过 #[tokio::main]宏的 flavor 参数来选择:

  1. 多线程调度器 (Multi-Threaded Scheduler): flavor = "multi_thread"

    • 这是 默认 的调度器,也是 Tokio 功能最强大的调度器。当你写 #[tokio::main] 而不加任何参数时,用的就是它。
    • 设计目标: 主要用于需要高并发的网络服务器等场景,旨在最大化利用多核 CPU 的并行处理能力。

    它的核心工作机制是 “工作窃取” (Work-Stealing):

    • 线程池 (Worker Threads): 运行时会创建一个线程池,通常线程数量等于机器的 CPU 核心数。每个线程都是一个“工作者 (Worker)”。
    • 本地就绪队列 (Per-Worker Local Ready Queue): 每个工作者线程都有自己专属的一个任务队列。当一个任务 A (正在这个工作者上运行) 通过 tokio::spawn 派生出一个新的任务 B 时,任务 B 会被优先放入这个工作者自己的本地队列里。
    • 工作流程:
      1. 每个工作者线程会优先从自己的本地队列的前端取出任务并执行。这非常快,因为没有跨线程的锁竞争。
      2. “窃取”发生: 当一个工作者(比如 W1)完成了自己本地队列的所有任务后,它不会闲下来。它随机地选择另一个工作者(比如 W2)从 W2 的本地队列的尾部“偷”走一半的任务,放到自己的队列里来执行。
    • 为什么“偷”尾部的?
      • 工作者自己总是从队列头部取任务。
      • 小偷从队列尾部偷任务。
      • 这种“头取尾偷”的模式大大减少了同一个队列两端发生锁竞争的可能性,提高了效率。
    • 全局注入队列 (Global Injection Queue): 还有一个全局的任务队列,用于接收从运行时外部(例如,从一个非 Tokio 管理的线程)派生出来的任务。工作者在本地队列为空时,也会尝试从这个全局队列里获取任务。
  2. 当前线程调度器 (Current-Thread Scheduler): flavor = "current_thread"

    • 设计目标: 用于不需要多线程并行的场景,例如构建客户端应用、嵌入式环境或测试。它的开销更小,因为没有跨线程同步的复杂性。
    • 工作机制:
      • 它只在调用它的那个线程上运行一个事件循环。
      • 所有任务都在这一个线程上被调度和执行。
      • 因为它不涉及多线程,所以它内部的数据结构更简单,没有工作窃取的逻辑,性能开销也更低。

所以这个异步运行时更多的是流水的任务,铁打的线程

# 提问: loop { async_op(); } 会不会导致线程池被占用完?

答案是:它不会导致线程池被“任务”占用完,但它会导致一个 CPU 核心被 100% 占用,从而“饿死”该线程上所有其他的任务,使程序失去响应。

  1. async fn 只是创建了一个 Future

    • 当定义一个 async fn async_op() 时,实际上是在定义一个函数,这个函数的返回值是一个实现了 Future trait 的状态机结构体
    • 调用 async_op() 仅仅是创建了这个结构体的实例。它不会执行函数体内的任何代码。
  2. .await 才是执行的驱动力

    • 只有当你对一个 Future 使用 .await 时,你才是在告诉 Tokio 的调度器:“请开始执行这个‘计划’,如果它还没准备好(比如在等网络数据),就先暂停它,去做点别的事,等它感兴趣的事件到了在唤醒”
    • .await 是一个让出点 (yield point),它将执行控制权交还给调度器。

分析代码 loop { async_op(); }

  • 第一步: 循环开始,async_op() 被调用。这在内存中创建了一个 Future 对象。
  • 第二步: 这个 Future 对象没有被 await,也没有被 spawn
  • 第三步: 循环立即进入下一次迭代。在上一次迭代中创建的那个 Future 对象因为超出了作用域,被立即销毁 (dropped)
  • 第四步: 循环回到第一步,不断重复“创建然后立即销毁”这个过程。

结论和后果:

  • 没有异步操作发生: async_op 函数体内的任何代码(比如网络请求、文件读写)都永远不会被执行,因为它从未 .await
  • 没有任务提交给线程池: 没有使用 tokio::spawn,所以没有向 Tokio 的调度器提交任何新的任务。线程池里的任务数量没有增加。
  • 主任务阻塞了工作线程: main 函数本身就是一个异步任务,它运行在 Tokio 的一个工作线程上。 loop 这个循环里没有任何 .await,所以它永远不会把控制权交还给调度器。
  • 结果: 运行 main 任务的那个工作线程会陷入这个无限循环,导致其所在的 CPU 核心使用率飙升到 100%。这个线程再也无法去执行调度器分配给它的任何其他任务。

note: spawn 这个动作本身是非常轻量级的,它基本上就是一次内存分配和一次入队操作。

  • 接收一个 Future(也就是一个 async 代码块或 async fn 的返回值)。
  • 将这个 Future 包装成一个可执行的单元 Task。
  • 将这个 Task 提交给 Tokio 的调度器,让调度器把它放入一个就绪队列中。
  • 立即返回一个 JoinHandle,可以用它来等待这个 Task 完成(通过 .await)或中止它。

# 提问: 如何决定一个 Task 加入哪个线程的调度队列?

首先,#[tokio::main] 宏会启动 Tokio 运行时,并将 async fn main 函数体作为第一个任务,放到其中一个工作线程(Worker Thread)上去执行。所以,从 async main 函数开始,代码就已经运行在一个 Tokio 的工作线程上了,而不是传统意义上的操作系统“主线程”。

spawn 的行为取决于调用 spawn 的代码当前正在哪个线程上运行。 决策逻辑

  • 场景一(最常见):在 Tokio 工作线程内部调用 spawn

    假设 async main 正在工作线程 W1 上运行。 当在 main 函数里调用 tokio::spawn(my_new_task) 时,这个 spawn 调用本身就是在 W1 上执行的。

    Tokio 的调度器知道当前代码是在 W1 上运行的。因此,它会把 my_new_task 这个新任务放入 W1 自己的本地就绪队列 (Local Ready Queue) 中。

    将任务放入本地队列完全不需要任何跨线程的锁或同步,速度极快。这最大化了数据的“亲和性”,新创建的任务很可能马上就会被同一个线程执行。(thread_local)

    • 嵌套 spawn 的情况 继续上面的例子,假设 W1 后来开始执行 my_new_task。 在 my_new_task 内部,调用 tokio::spawn(another_task)。 此时,spawn 调用仍然是在 W1 上执行的(因为 my_new_task 正在 W1 上运行)。 因此,another_task 也会被放入 W1 的本地队列。
    • 工作窃取 (Work-Stealing) 现在,假设 W1 的本地队列里堆积了很多任务(main 剩下的部分, my_new_task, another_task...),而另一个工作线程 W2 已经完成了它自己本地队列的所有任务,现在处于空闲状态。 W2 不会闲着,它会尝试从 W1 本地队列的尾部“偷”走一半的任务,放到它自己的本地队列中。 此时,another_task 可能就被 W2 偷走了。 现在,W2 开始执行 another_task。如果 another_task 内部又调用了 tokio::spawn(final_task),那么这次 spawn 调用就是在 W2 上执行的,所以 final_task 会被放入 W2 的本地队列。
  • 场景二:在 Tokio 运行时外部调用 spawn

    这种情况比较少见,但也是存在的。比如有一个由 std::thread::spawn 创建的标准库线程,想从这个线程向 Tokio 运行时提交一个任务。 当从这个外部线程调用 tokio::spawn 时,Tokio 调度器检测到不在任何一个工作线程的上下文里 在这种情况下,新任务会被放入一个全局注入队列 (Global Injection Queue)。 所有工作线程(W1, W2 等)在处理完自己的本地任务后,都会去这个全局队列里检查是否有新任务,并把它们拿到自己的本地队列来执行。

# join 的场景(有助于理解底层的唤醒)

假设当前任务正在执行这段代码: let (res1, res2) = tokio::join!(fut1, fut2);

  1. 调度器 poll 当前任务

    • 调度器决定执行任务。它调用任务的根 Futurepoll 方法,并传入一个 Context(里面包含了指向这个任务的 Waker)。
  2. poll 链式传递

    • 这个 poll 调用会沿着 async 代码的逻辑往下走,最终到达 join! 宏生成的那个 JoinFutureJoinFuturepoll 方法被调用,它收到了那个代表整个任务的 Context
  3. JoinFuture 代理 poll

    • JoinFuture 的工作是轮询它的子 Future。它首先调用 fut1.poll(),并且将它收到的同一个 Context 原封不动地传递给 fut1
  4. fut1 无法立即完成

    • fut1(比如一个网络请求)发现它现在无法完成。
    • 它的关键职责有两个:
      • A) 注册 Waker 它从传递给它的 Context 中取出 Waker。然后它告诉 Tokio 的 I/O 系统:“当这个网络连接上有事件发生时,请调用这个 Waker。”
      • B) 返回 Pending 它向它的调用者(也就是 JoinFuture)返回 Poll::Pending
  5. JoinFuture 的反应

    • JoinFuture 收到 fut1 返回的 Pending。它知道 fut1 还没好。
    • 不会停在这里。它会继续去 poll(fut2)(同样传递那个 Context)。
    • 假设 fut2 也因为类似的原因返回了 Pending
  6. 任务整体挂起

    • 现在 JoinFuture 知道它的所有子 Future 都还没准备好。
    • 因此,JoinFuture 自己也向它的调用者(最终是调度器)返回 Poll::Pending
    • 调度器收到 Pending 后,将当前任务从执行状态移开,然后去执行其他就绪的任务。此时,当前任务陷入睡眠,被调度走,但线程本身是活跃的。
  7. 外部事件发生

    • 一段时间后,fut1 等待的那个网络事件发生了。
    • Tokio 的 I/O 系统被操作系统通知,它找到了之前 fut1 注册的那个 Waker,并调用了 wake() 方法。
  8. 任务重回就绪队列

    • wake() 的调用,其唯一效果就是把整个任务重新放回调度器的就绪队列
  9. 调度器再次 poll 整个任务

    • 在未来的某个时间点,调度器从就绪队列中拿出你的任务。
    • 从头开始,再次调用任务的根 Futurepoll 方法,并传入一个新的 Context
  10. 再次执行 JoinFuture

    • poll 的调用链再次一路向下,最终又一次到达了那个 JoinFuture
    • JoinFuture 再次 poll(fut1)。因为这次网络事件已经发生,fut1 终于可以完成它的工作,并返回 Poll::Ready(result1)
    • JoinFuture 收到 Ready,将 result1 存起来。然后它继续 poll(fut2)。如果 fut2 仍然是 PendingJoinFuture 就会再次返回 Pending,整个任务再次挂起,等待下一次由 fut2 的事件触发的唤醒。

这个循环会一直持续,直到 JoinFuture 发现它所有的子 Future 都已经返回了 Ready,它才会最终向调度器返回 Poll::Ready((result1, result2)),任务也才得以从 join!.await 点继续向下执行。

//todo 其他的补充