返回

文章详情

Tokio 提供进展,而非排序:调度 100 万任务

Hacker News2026年7月27日 15:10

这是我之前文章 "你的 Rust 服务并没有泄漏 — 可能是分配器" 的前篇。在那篇文章中,我写到了几种内存分配器在我们的工作负载下表现出的不同。我们在弄清楚内存行为与分配器有关之前,首先尝试从应用程序端减少内存使用。我们的服务是事件驱动的:从消息队列(Kafka/Redis Streams/NATS)读取事件。对于每个事件,生成一个 Tokio 任务来处理它。我们的任务模式在我们的工作负载中,每个事件最多有 1000 个用户令牌。对于每个用户令牌,我们必须进行一次外部调用,等待 I/O,并收集与该事件相关的所有响应。以下是一个简化版本的代码,我们为用户令牌生成并发的 tokio 任务,合并响应,并生成响应事件。结构体 Event { payload: Bytes, // ~4KB user_tokens: Vec<String>, // 最多 1000 个令牌 // 其他字段 } ... // 在主循环中 { let event: Event = fetch_next_event().await; tokio::spawn(async move { let data = event.payload.clone(); let mut tasks = JoinSet::new(); for token in &event.user_tokens { let token = token.clone(); let data = data.clone(); tasks.spawn(async move { // 调用外部 API 并返回响应 process(token, data).await }); } let mut responses = Vec::with_capacity(event.user_tokens.len()); while let Some(res) = tasks.join_next().await { responses.push(res); } generate_response_event(event, responses); } ); } 我们的主要关注点是吞吐量。虽然上述的分发是无限制的,我假设在实际中不会有问题。这些 Tokio 任务是短暂的。它们在收到响应后完成并释放,通常在几毫秒之内。因此,我假设早期生成的任务也会早期完成。尽管每个外部调用可能会无序完成,但我预计整体上早期事件会先结束,即使较新的事件仍在进行。我们的要求仅仅是尽可能快速地进行每次外部调用,以便突发能够在预期时间内完成。日志的样子在 1000 个事件的突发过程中,每个事件包含 ~1000 个用户令牌,总共生成 ~100 万任务时,日志的记录如下:开始:事件 1,用户 5 开始:事件 1,用户 8 开始:事件 1,用户 2 开始:事件 2,用户 6 开始:事件 2,用户 4 开始:事件 3,用户 8 ... 完成:事件 779 完成:事件 976 开始:事件 900,用户 42 开始:事件 900,用户 261 开始:事件 1,用户 974 开始:事件 1,用户 831 ... 完成:事件 5 完成:事件 3 虽然突发在预期时间内完成,但日志显示早期事件的令牌任务开始得很晚。我没有期待任务之间有严格的排序。它们可以按任何顺序完成,这没有关系。令我感到惊讶的是一些早期任务在第一次轮询之前偏离它们的提交顺序有多远。Tokio 的调度器 Tokio 的多线程运行时有固定数量的工作线程,每个工作线程都有一个本地队列和一个共享的全局队列。每个工作线程都有一个容量为 256 个任务的本地队列,当溢出时,它会将一半的任务移动到全局队列。工作线程更喜欢首先从自己的本地队列中拉取,偶尔检查全局队列,并在空闲时从其他工作线程窃取任务。当任务变得独立可调度时,Tokio 不再知道是哪个事件创建了它们。现在它们只是竞争被轮询的可运行任务。简化的图像如下:全局队列 +------------------------------+ | * 本地队列的溢出 | | * 远程调度的任务 | | * 混合的旧/新工作 | +--------------+---------------+ | +-------------------------+-------------------------+ | | | v v v 工作线程 0 工作线程 1 工作线程 2 +-------------+ +-------------+ +-------------+ | 本地队列 | | 本地队列 | | 本地队列 | | 最多 256 | | 最多 256 | | 最多 256 | +------+------+ +------+------+ +------+------+ | | | v v v 轮询任务 轮询任务 轮询任务 在这种架构下,一旦我们的事件分发到 1000 个用户令牌任务,这些任务就与来自其他事件的令牌任务混合在一起,等待 JoinSet 的父事件任务和从 I/O 准备就绪唤醒的任务。由于许多任务同时被提交,Tokio 不保证早期任务先被轮询。不同事件的任务在可用的工作线程队列中竞争,并且队列溢出和工作窃取等调度决策可以改变任务被选取的顺序。结果,一些早期事件的任务在第一次轮询时得到了晚得多的响应。核心区别在于:任务创建 != 任务轮询 != 任务完成 虽然 Tokio 在每个任务上持续推进,但内存受同时存在的任务数量的影响。每个 Tokio 任务都携带一些状态,尽管该状态单独并不庞大,但当数量达到一定程度时,会影响内存。

赞助内容

NordVPN Next-gen Antivirus

本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。

请我喝杯咖啡