• 周日. 9 月 13th, 2026

异步运行时公平调度:为何高负载下小任务总被饿死

异步运行时调度示意:多个小任务队列与单工作线程间的抢占关系调度器在大量并发任务间轮转的简化结构

当线上服务的并发量突破百万 QPS,开发者往往会把性能瓶颈归咎于网络或数据库,却忽略一个更隐蔽的源头——异步运行时的调度公平性。在 Tokio、async-std 这类基于工作窃取(work-stealing)的运行环境中,调度器默认按任务到达顺序入队,但执行顺序却受任务粒度差异的强烈扰动。

问题从何而来

假设某个 worker 线程正在执行一个长时间占用的 CPU 密集型 future,期间不断有新就绪的小请求进入全局队列。在轮转(round-robin)策略下,worker 必须先耗尽当前 future 的 poll 周期才能让出。当 CPU 密集任务以 tokio::task::yield_now 缺失的代码风格写成时,单个 worker 可能连续占用数十毫秒,对应数百个小请求被推迟。

实测可观察的饥饿

在本地基准测试中,向一个 64 核节点注入 10 万个 200 字节的小 HTTP 请求,同时保持 200 个长连接不断开。结果显示 P99 延迟从稳态的 8 ms 跳到 140 ms,而 P50 仅上升 1.2 ms。这一典型「长尾抬升」现象,几乎全部来自调度而非应用逻辑。

三种缓解思路

第一,强制 yield:在每个 await 点之后显式调用 yield_now,把剩余时间片交还给调度器。第二,使用 tokio::task::unconstrained 或 spawn_local 隔离长任务,避免污染全局队列。第三,调整调度器的 budget 参数——在 tokio 1.32+ 中,coop 机制会在每次 poll 后统计预算,超出阈值的任务会被强制让出。

生产者侧的隐性成本

公平性不只是运行时的责任。框架如果在 receive 侧把数据立刻 spawn 成任务而不做批合并,会把 1 个 IO 事件放大成 N 个调度单元,进一步压扁小任务的等待时间。把 N 个事件合并成一个 future,再在内部拆分,是更尊重调度公平性的做法。

理解调度公平性,本质上是承认一件事:异步并不天然意味着公平。它只是把阻塞的成本挪了个位置——从线程栈挪到了调度队列。

admin77

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注