Tokio 协作式调度的自愿让出:yield_now 的使用边界

封面信息图

在 Rust 异步运行时(如 Tokio)的调度哲学中,异步任务(Task)的执行属于典型的 协作式多任务调度(Cooperative Multitasking),而不是操作系统内核那种强制性的抢占式调度(Preemptive Scheduling)。

协作式调度的核心契约是:调度器假定每个异步 Task 在执行一小段 CPU 计算后,都会自愿在某个 .await 点(如等待网络 Socket、等待定时器、等待通道)交出 CPU 控制权,让同一 Worker 线程能够去推进排队的其他任务

然而,如果某个异步任务中包含了一段耗时较长的 CPU 密集型循环(例如:遍历解析一个包含数万行的大 JSON 数组、或者在内存中执行一段排序):

  • 该任务在单次 poll() 调用中会持续霸占 Worker 线程达数十毫秒甚至数秒
  • 在这期间,绑定在同一个 Worker 本地队列上的数百个轻量网络 I/O 任务会被活活饿死(Task Starvation),导致 API 接口的 P99 响应延迟急剧恶化!

很多开发者知道可以使用 tokio::task::yield_now().await 进行自愿让出。

yield_now 的底层调度流转是怎样的?它与 spawn_blocking 的物理边界在哪里?盲目滥用 yield_now 会引发怎样的性能反噬?

+--------------------------------------------------------------------------+
|                       Tokio yield_now() 任务让出与重新排队全景                  |
+--------------------------------------------------------------------------+
| [Worker 线程正在执行耗时任务 Task A]                                       |
|                                                                          |
| 1. Task A 执行完一个分批迭代后调用: tokio::task::yield_now().await         |
|                                                                          |
| 2. yield_now 内部返回 Poll::Pending,并立即主动将自身 Waker 重新注册        |
|                                                                          |
| 3. Task A 被放回当前 Worker 本地调度队列的末尾 (Local Queue Tail)          |
|                                                                          |
| 4. 🚀 Worker 线程得以立即调度执行排在前面的 Task B, Task C (网络 I/O 响应!)  |
|                                                                          |
| 5. 当排在前面的任务处理完毕后,Worker 再次轮询到 Task A 继续执行下一分批   |
+--------------------------------------------------------------------------+

1. yield_now() 的底层机制解密

tokio::task::yield_now() 的实现极度精巧:

pub async fn yield_now() {
    struct YieldNow {
        yielded: bool,
    }

    impl Future for YieldNow {
        type Output = ();

        fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
            if self.yielded {
                Poll::Ready(())
            } else {
                self.yielded = true;
                // ⚠️ 关键点:主动唤醒自己,将任务重新推入就绪队列末尾!
                cx.waker().wake_by_ref();
                Poll::Pending
            }
        }
    }

    YieldNow { yielded: false }.await
}
  • 当第一次被轮询时,它主动调用 cx.waker().wake_by_ref(),将当前 Task 重新放入调度就绪队列的末尾;
  • 随后立即返回 Poll::Pending,迫使当前的 poll() 调用中断并退回调度器;
  • 调度器 Worker 线程得以去执行队列中的下一个任务;
  • 当再次轮询到当前 Task 时,由于 yielded == true,直接返回 Poll::Ready(()) 并继续向下执行。

2. 经典落地场景:大循环的防饥饿切片(Fair Batch Slicing)

当必须在异步任务中处理一个包含 100,000 个元素的数组时:

async fn process_large_array(items: Vec<Record>) {
    for (i, item) in items.into_iter().enumerate() {
        heavy_cpu_transform(item);

        // 核心防线:每连续处理 256 个元素,主动自愿让出一次 CPU!
        if (i + 1) % 256 == 0 {
            tokio::task::yield_now().await;
        }
    }
}

通过每隔 256 次迭代自愿让出一次,计算任务被优雅地切分为了数十个微秒级的执行切片,彻底消除了单任务长时间霸占 Worker 导致的全局饥饿。

3. 使用边界:yield_now vs spawn_blocking 决策矩阵

很多开发者容易走入极端,把所有的计算任务都用 yield_now 强行切片。

系统工程师必须清醒识别物理边界:

任务负载特征推荐方案核心决策原因
轻度可切片计算(总耗时 1~5ms,循环结构明晰)yield_now().await零跨线程通信开销,完全在本地队列平滑轮转
重度不可切片计算(如加密哈希、图像压缩、耗时 > 10ms)spawn_blocking移出异步 Worker 池,彻底避免阻塞网络事件驱动
同步阻塞系统调用(如本地同步磁盘文件读取)spawn_blocking内核级阻塞无法通过 yield_now 切片,必须走阻塞池

精准把握协作让出的节奏,让轻量 I/O 与密集计算在单线程循环中和谐共生,这是异步系统性能工程的精妙平衡。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐