一、先把痛点摆出来
你写了一个 Actix Web 服务,平时跑得挺快,延迟在几个毫秒上下。可一到稍微有点流量,延迟就开始“跳舞”:一会儿 5ms,一会儿 200ms,偶尔还能蹦到 1 秒。你在终端里盯着 curl 的结果,感觉像在看心电图。不是偶发,而是忽高忽低,非常规律。
这种延迟抖动不是你的业务逻辑慢,也不是数据库瓶颈。很多时候,问题出在你没看见的调度层。Actix Web 跑在 Tokio 上,而 Tokio 的多线程调度器有自己的脾气。再加上 Actix 自己搞了一套 Actor 运行时,两者叠加,就会产生一种类似“抢车道”的竞态效应。今天咱们就用大白话把这些事唠清楚,顺便给你能落地验证的 Rust 代码。
二、先认识两个基础角色
2.1 Tokio:你的异步任务“交通调度员”
Tokio 是 Rust 生态里最常用的异步运行时。你可以把它想象成一个大型交通指挥中心。它手里有一堆工作线程(默认等于 CPU 核数),每个线程维护着自己的任务队列。当你用 tokio::spawn 丢出去一个任务,Tokio 会决定把它放到哪个线程上。
Tokio 的工作方式是“工作窃取”。每个线程先干自己队列里的活,如果自己的队列空了,就去别的线程的队列里“偷”任务过来执行。这种机制的好处是能极大减少空闲,坏处是:一旦有某个任务卡住,而其他任务被“偷”来偷去,线程切换和队列竞争就会变多,延迟自然开始抖动。
2.2 Actix 的 Actor 运行时
Actix Web 并不是纯 Tokio 的“亲儿子”。它自己有一套 Actor 系统。每个 Actor 是一个独立的执行单元,有自己的状态和消息箱。Actix Web 的每个 HTTP worker 其实也是一个 Actor,它们被绑定到一组 Arbiter(仲裁者)上。Arbiter 内部再驱动自己的事件循环。这套设计和 Tokio 的调度器互相嵌套,于是就有了两个“调度员”在同时指挥。
平时它们配合得还行,可一旦你在请求处理里干了某些“坏事儿”,这两个调度员就会互相打架,延迟就开始上蹿下跳。
三、延迟忽高忽低的真正原因
3.1 阻塞任务把地铁闸机卡住了
最常见的一个坑:你在异步处理器里用了同步阻塞操作,比如 std::thread::sleep、读取大文件、调用同步数据库驱动。Tokio 的工作线程最怕这种事。假设你让线程 A 睡 100 毫秒,Tokio 不会等它睡醒,而是让线程 A 继续执行下一个任务。可问题是,如果这个睡着的线程本来正在执行一个非常短的请求任务,那个任务就会被卡住直到线程 A 醒来。你说这延迟能不高吗?
更有意思的是,Actix 的 Arbiter 线程往往比 Tokio 的普通工作线程更“娇气”。因为 Arbiter 要维护 Actor 的状态,一旦它被阻塞,所有挂在这上面的 Actor 请求都会排队。于是你看到的现象是:某个请求慢得离谱,其他请求却正常,而且这种慢会间歇性出现。
3.2 锁竞争和任务调度的“死锁”效应
如果你在请求处理中使用了 Mutex 或 RwLock,并且持锁时间稍微长一点,那就更热闹了。Tokio 的调度器并不知道你在等锁。它只知道“这个任务现在不能继续执行”。于是它会挂起这个任务,去执行别的任务。这个过程中,如果另一个任务也想拿同一把锁,两个任务就会互相谦让,让 Tokio 调度器反复地唤醒、挂起。每一次唤醒都需要系统调用和上下文切换,延迟自然就涨上去了。
在 Actix 里还有一个隐蔽的竞态:Actor 的 mailbox 和 Tokio 的 task 之间,可能会有队列头占用的竞争。当多个请求同时到达同一个 Actor,消息箱要线程安全地入队、出队。如果某个消息处理时间较长,后面的消息就会在队列里堆着,看起来就是“延迟越来越高,然后突然降下来”,因为队列被清空了。
3.3 任务饥饿与 CPU 超订
如果你用了 tokio::task::spawn_blocking 来处理 cpu 密集的任务,却没控制数量,就会把线程池打满。Tokio 的 blocking 池默认有几百个线程上限,大量阻塞任务会抢占 CPU 时间,让异步任务的响应能力大幅下降。再叠加 Actix 自己的 worker 线程,整个进程的线程数可能多得吓人。操作系统在线程之间切换的时间都快赶上业务执行时间了,延迟不抖才怪。
四、用代码实地验证一下
为了让你看明白,咱们直接用 Rust + Actix Web 写一个最小示例。这个示例故意制造延迟抖动:每个请求都随机 sleep 或者读文件,模拟“坏行为”。技术栈:Rust 2021 + actix-web 4 + tokio 1。
// 技术栈:Rust + actix-web 4 + tokio 1
// 这个示例演示了阻塞操作如何让 Actix 服务的延迟出现剧烈波动。
use actix_web::{web, App, HttpServer, HttpResponse};
use std::time::{Duration, Instant};
use tokio::task;
/// 一个模拟的“坏接口”:随机睡 0~50ms
/// 注意:这里直接用了 std::thread::sleep,它会阻塞当前线程
/// Tokio 的工作线程被阻塞后,调度器没法让其他任务利用这个线程。
async fn bad_random_sleep() -> HttpResponse {
let millis = rand::random::<u64>() % 50;
std::thread::sleep(Duration::from_millis(millis)); // 阻塞!极不推荐
HttpResponse::Ok().body(format!("睡了{}毫秒", millis))
}
/// 一个相对好的接口:用 tokio::time::sleep 异步等待
/// 这样不会阻塞工作线程,Tokio 可以自由去执行其他任务。
async fn good_async_sleep() -> HttpResponse {
let millis = rand::random::<u64>() % 50;
tokio::time::sleep(Duration::from_millis(millis)).await; // 异步等待
HttpResponse::Ok().body(format!("异步睡了{}毫秒", millis))
}
/// 演示共享锁竞争:用 Mutex 保护一个计数器
/// 每个请求先拿锁,再模拟做点事。
/// 锁的持有时间很长,会导致其他等待锁的请求被调度器反复挂起。
async fn with_shared_lock(counter: web::Data<tokio::sync::Mutex<u64>>) -> HttpResponse {
let start = Instant::now();
let mut guard = counter.lock().await; // 等待锁
tokio::time::sleep(Duration::from_millis(10)).await; // 持锁等待
*guard += 1;
drop(guard);
HttpResponse::Ok().body(format!("锁操作耗时 {:?}", start.elapsed()))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let counter = tokio::sync::Mutex::new(0u64);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(counter.clone()))
.route("/bad-sleep", web::get().to(bad_random_sleep))
.route("/good-sleep", web::get().to(good_async_sleep))
.route("/lock", web::get().to(with_shared_lock))
})
.workers(4) // Actix worker 线程数
.bind(("127.0.0.1", 8080))?
.run()
.await
}
你看,/bad-sleep 接口里直接用了 std::thread::sleep。虽然 Actix 的处理器是异步函数,可里面出现同步阻塞,依然会把当前线程卡死。你并发打这个接口,延迟曲线会非常难看。而 /good-sleep 用 tokio::time::sleep,线程不会被阻塞,延迟就稳得多。
再说 /lock 接口,它用的还是 Tokio 的 Mutex,这已经比标准库的 std::sync::Mutex 好了。因为标准库锁在异步环境下会让线程阻塞,而 Tokio 的锁在无法获取时会挂起任务,所以不会卡死线程。但你看到了,锁的持有时间仍然有 10ms,并且所有请求都抢同一把锁——那从“请求进去”到“拿到锁”的时间就会不断累积。当一瞬间来了几十个请求,后面的请求等锁就等了几十毫秒,延迟就“翘”起来了。
你可以用 AB 工具或者 wrk 压测一下。以下是压测命令示例,注意别在 Windows 直接跑,Linux 或 macOS 可用:
# 安装 wrk 后,对 bad-sleep 接口做 50 并发 10 秒压测
wrk -t4 -c50 -d10s http://127.0.0.1:8080/bad-sleep
# 对 good-sleep 接口做同样压测
wrk -t4 -c50 -d10s http://127.0.0.1:8080/good-sleep
# 对锁竞争接口压测
wrk -t4 -c50 -d10s http://127.0.0.1:8080/lock
你会看到 bad-sleep 的平均延迟很高,而且方差极大。good-sleep 的延迟则相对平稳。锁竞争接口的平均延迟会随并发数升高而显著上升。
五、深入 Tokio 调度器的内部细节
5.1 工作窃取如何影响延迟
Tokio 把一次“调度”做成无锁队列,每个线程有自己的本地队列。当任务被 spawn 时,它会优先放到当前线程的队列里。只有当前线程的队列满了,才会丢到全局队列。工作线程空闲时,会从本地队列 pop 任务,如果本地空,就从全局队列取,再空,就去偷其他线程的队列。
这个过程本身有开销。如果任务特别小且数量巨大,反复的偷取操作会导致缓存不命中、锁竞争。你的服务延迟表现就是“高延迟 + 高抖动”。解决办法是尽量减少任务粒度的细碎程度,或者用 tokio::task::LocalSet 固定某些任务在指定线程上。
5.2 Actor 运行时与 Tokio 的交互
Actix 的 Arbiter 其实是一个执行上下文,它内部可以运行多个 Actor。每个 Actor 的消息处理默认是在同一个异步任务里执行的。也就是说,一个 Actor 的消息循环本质上是 Tokio 的一个长时间运行的任务。当这个 Actor 在处理一条消息时,如果它 .await 了一个还没完成的 Future,调度器就会挂起整个消息循环,其他消息排着队等。这是一种“头阻塞”效应。
更隐蔽的是,Actix Web 的 HttpServer 自己有一组系统 arbiter,用来处理新连接和 socket 事件。系统 arbiter 上的任务如果被阻塞,新连接就进不来,表现就是“连接超时”,延迟直接爆表。
5.3 线程数配置的黄金比例
Actix Web 的 .workers(n) 控制的是 HttpServer 的 worker 线程数。但你要知道,Tokio 是运行在 worker 线程内部的。默认情况下,Tokio 的工作线程数和 CPU 核数一致。如果 .workers(4) 而你的机器是 16 核,Tokio 会为每个 worker 再拉一个运行时吗?不是的,Actix Web 底层会为一个 worker 绑定一个 Tokio 运行时。这些运行时之间是独立地做任务调度的。
问题来了:当你用 tokio::spawn 时,你的任务会被放到当前 worker 的运行时里。如果某一个 worker 的运行时特别忙,比如在跑大量阻塞任务,那这个 worker 上的所有请求都会遭殃。而其他 worker 却可能空闲。这就会产生“单点卡顿”而不是整体变慢。所以,.workers 设置多少,需要根据你的 IO 密集型和 CPU 密集型任务比例来调优。一般建议设置为 CPU 核心数,而不是越多越好。
六、一个更完整的实战示例:优化前后对比
我们写一个更真实的小服务:它要处理“读本地文件 + 做一点计算 + 返回结果”。优化前用同步文件读取,优化后用 tokio::fs 异步读取。同时我们偶尔用 spawn_blocking 处理计算部分,模拟 CPU 密集任务。完整代码如下:
// 技术栈:Rust + actix-web 4 + tokio 1 + serde_json
// 这是一个演示“如何把同步阻塞改成异步或 spawn_blocking”的完整示例。
use actix_web::{web, App, HttpResponse, HttpServer, Result};
use serde_json::json;
use std::time::Instant;
/// 坏例子:同步读取文件,然后做计算
/// 这个接口在读到慢盘时会卡住整个工作线程
async fn bad_file_read() -> Result<HttpResponse> {
let start = Instant::now();
// 同步读取文件,假设文件很大,或者磁盘很慢
let content = std::fs::read_to_string("./data.txt").unwrap_or_default();
// 模拟 CPU 密集计算:暴力循环
let mut sum: u64 = 0;
for i in 0..1_000_000 {
sum = sum.wrapping_add(i);
}
let elapsed = start.elapsed().as_millis();
Ok(HttpResponse::Ok().json(json!({
"len": content.len(),
"sum": sum,
"elapsed_ms": elapsed,
"mode": "bad"
})))
}
/// 好例子:异步读文件 + spawn_blocking 做计算
/// 异步读文件不会卡线程;计算扔到专门线程池,不影响异步任务
async fn good_file_read() -> Result<HttpResponse> {
let start = Instant::now();
// 异步方式读文件
let content = tokio::fs::read_to_string("./data.txt").await.unwrap_or_default();
// 把计算部分交给 spawn_blocking,它会放到一个专用的阻塞线程池
let sum = task::spawn_blocking(move || {
let mut s: u64 = 0;
for i in 0..1_000_000 {
s = s.wrapping_add(i);
}
s
})
.await
.unwrap_or_default();
let elapsed = start.elapsed().as_millis();
Ok(HttpResponse::Ok().json(json!({
"len": content.len(),
"sum": sum,
"elapsed_ms": elapsed,
"mode": "good"
})))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// 先写一个测试用文件内容
std::fs::write("./data.txt", "Hello, Latency!\n").expect("write file");
HttpServer::new(|| {
App::new()
.route("/bad", web::get().to(bad_file_read))
.route("/good", web::get().to(good_file_read))
})
.workers(4)
.bind(("127.0.0.1", 8081))?
.run()
.await
}
这个例子强调了两点:
- 文件读取换成
tokio::fs::read_to_string后,真正的文件 IO 操作是交给阻塞线程池完成的,这样就不会占住异步线程做无谓等待。 - 计算任务用
spawn_blocking派发到专用线程池。注意,spawn_blocking默认的线程池上限很大,但也不意味着你可以无限往里扔任务。如果你的计算任务非常多,阻塞线程池也会排队,这时候延迟照样变高。所以正确做法是用rayon或者自建一个有界线程池控制并发数。
七、从“玄学”到“对症下药”:常见排查手段
7.1 看延迟分布而不是只看平均值
延迟“忽高忽低”最怕只盯着平均响应时间。你应该统计 P50、P95、P99。如果 P95 是 10ms,P99 是 300ms,那说明有 1% 的请求被拖垮了。这种长尾延迟往往就是调度的抖动造成的。在 Actix 里可以加中间件记录耗时。
下面是一个简单的计时中间件示例:
// 技术栈:Rust + actix-web 4
use actix_web::{dev::Service, middleware::Logger, web, App, HttpResponse, HttpServer};
use std::time::Instant;
// 计时中间件:记录每个请求处理耗时并打日志
async fn timing_middleware(
req: actix_web::dev::ServiceRequest,
srv: impl Service<
actix_web::dev::ServiceRequest,
Response = actix_web::dev::ServiceResponse,
Error = actix_web::Error,
>,
) -> Result<actix_web::dev::ServiceResponse, actix_web::Error> {
let start = Instant::now();
let res = srv.call(req).await?;
let elapsed = start.elapsed().as_millis();
println!("请求处理耗时: {}ms", elapsed);
Ok(res)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.wrap_fn(timing_middleware)
.wrap(Logger::default())
.route("/", web::get().to(|| async { HttpResponse::Ok().body("ok") }))
})
.workers(4)
.bind(("127.0.0.1", 8082))?
.run()
.await
}
你可以用这个中间件跑压力测试,观察耗时是不是周期性飙高。如果你看到“每 10 个请求就有一个 100ms 以上的”,基本可以断定是某些任务在阻塞或锁竞争。
7.2 用 tokio-console 查看任务状态
tokio-console 是 Tokio 提供的可视化监控工具。它能让你看到当前运行时里所有异步任务的状态,包括正在运行、挂起、阻塞等。你可以在 Cargo.toml 里加依赖,然后启动服务后连接控制台。观察延迟高的时候,是不是有任务长期处于 polling 或 blocked 状态。当然,Actix 和 tokio-console 的兼容性需要一点配置,但非常值得尝试。
7.3 检查你的 Cargo.toml 特性
有时候延迟高不是代码问题,而是你启用了 Tokio 的多线程特性,却没有正确地设置线程数。Tokio 的 rt-multi-thread 特性会启用多线程调度,但你需要通过环境变量或 Builder 配置线程数。Actix Web 的 #[actix_web::main] 默认会创建多线程运行时。如果只写 tokio::main 但没开启 rt-multi-thread,可能就落在单线程了。单线程下,任何一个 await 挂起都会让所有请求排队,延迟当然会高。
八、应用场景与方案优缺点
8.1 什么场景容易中招
- 中间件里用了同步 IO,比如日志写入到磁盘用
std::fs::File::write_all。 - 数据库驱动是同步的,比如用
mysqlcrate 而不是sqlx。 - 在异步处理器里调用了
thread::sleep来限速。 - 使用
RwLock保护可变全局状态,并且写锁很少释放。 - 你的 CPU 核数很多,但 Actix worker 数设置得过于激进,导致线程切换剧烈。
8.2 各种解决方案的优缺点
上面提到过,把 std::fs 换成 tokio::fs 是解决办法之一。它的优点是改动小,符合异步生态;缺点是没有解决阻塞线程池的容量问题。tokio::fs 内部实际上还是用一个阻塞线程池来读文件,只是把调用包装成了 spawn_blocking 而已。如果你并发请求太多,那个内部线程池也会饱和。
用 spawn_blocking 处理 CPU 密集任务,最大的好处是隔离,缺点是要手动管理任务边界,并且阻塞线程池上限默认是 512,可能被大量任务打满。更精细的控制是用 rayon 配合 tokio,但这就引入了第二个技术栈,如果你想保持单一技术栈,可以自己实现一个有界信号量来控制并发阻塞任务数。
还有一个选项是调整 Tokio 的线程数。通过 Runtime::builder().worker_threads(n) 手动创建运行时。在 Actix 中,你可以不用 #[actix_web::main] 而是自己创建 System,但这很麻烦,不推荐。更实际的做法是设置环境变量 TOKIO_WORKER_THREADS,但这也只能控制 Tokio 运行时线程数,跟 Actix 的 worker 没有直接关系。
8.3 注意事项
- 不要盲目地把所有
sleep都换成tokio::time::sleep。如果你是模拟 cpu 密集操作,那用sleep本来就该用阻塞,但要放到spawn_blocking里。 Mutex尽量用tokio::sync::Mutex而不是std::sync::Mutex。前者在异步环境下不会阻塞线程,后者如果持锁时间较长,会导致其他等待锁的异步任务挂起,但可能会让调度器误判为忙等。- 如果你用
std::sync::Mutex保护一个只在单线程内访问的状态,其实并发性能更好,因为不涉及异步唤醒。但如果多个线程访问,要确保持锁时间很短,否则照样出问题。 - Actix 的
Data默认是Arc包装。如果你用Arc<Mutex<T>>,注意锁的类型。用标准库锁也可以,但要配合blocking_lock吗?不,在异步函数里永远不要用标准库锁的.lock(),要用try_lock或扔到spawn_blocking里。
8.4 最佳实践组合拳
我们最终建议这样设计你的 Actix 服务:
- 所有 IO 都用异步库。比如 SQLx、reqwest、tokio::fs。
- 如果某个 CPU 密集函数无法避免,用
tokio::task::spawn_blocking并加上信号量限制并发数。 - 共享状态优先用
tokio::sync::RwLock或Mutex,并且尽量缩小临界区,不要在持锁时做 IO。 - 合理设置 Actix 的
.workers(n)。通常 n 等于 CPU 核心数。如果请求都是短小异步任务,可以略高于核心数;如果有较多 CPU 计算,则等于核心数最好。 - 注意设置
backlog和keep_alive参数,防止连接积压。
下面是加信号量的例子:
// 技术栈:Rust + actix-web 4 + tokio
use actix_web::{web, App, HttpResponse, HttpServer};
use std::sync::Arc;
use tokio::sync::Semaphore;
// 限制并发 CPU 计算任务数最多为 4
async fn limited_cpu_task(sem: web::Data<Arc<Semaphore>>) -> HttpResponse {
// 获取许可,如果没有可用许可就等待(异步等待)
let _permit = sem.acquire().await;
// 模拟 CPU 密集
let result = tokio::task::spawn_blocking(|| {
let mut sum: u64 = 0;
for i in 0..2_000_000u64 {
sum = sum.wrapping_add(i);
}
sum
})
.await
.unwrap_or_default();
HttpResponse::Ok().body(result.to_string())
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let semaphore = Arc::new(Semaphore::new(4));
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(semaphore.clone()))
.route("/cpu", web::get().to(limited_cpu_task))
})
.workers(4)
.bind(("127.0.0.1", 8083))?
.run()
.await
}
九、动手优化延迟抖动的步骤总结
第一,用压测工具复现,确认延迟是否真的是“有节奏的抖动”。第二,添加中间件记录单次请求耗时,看看耗时分布。第三,用 tops 或 perf 检查 CPU 使用率是否高于 100%,有没有明显的线程切换开销。第四,检查代码里是否有同步阻塞、锁竞争、无界 spawn。第五,针对问题逐一替换成异步版本或用 spawn_blocking。第六,重新压测对比。
记住,延迟忽高忽低的根因多半不是某个库的问题,而是你如何把阻塞、异步、线程池组合在了一起。Tokio 的任务调度确实很强大,但它的优化目标是“整体吞吐”,而不是“单请求低延迟”。当你的请求之间有依赖竞争时,延迟就必然出现波动。你要做的就是把这种竞争尽量分散,或者用有界并发把压力控制在设计范围内。
十、文章总结
回到最初的问题:为什么 Actix Web 服务延迟忽高忽低?简单说,是因为你的异步任务在 Tokio 的调度器里被阻塞、被抢占、被锁等待,而 Actix 的 Actor 运行时又加了一层消息队列竞争。想要稳,就得让所有任务都“天然不阻塞”,并且把 CPU 密集操作挡在异步线程之外。同时要合理配置 worker 数、锁类型、并发控制。延迟抖动不是玄学,它背后有清晰的任务调度和竞态原理。搞懂了这些,你就能从“看别人代码是玄学,调自己代码是炼狱”的状态里解脱出来。
记住一个核心口诀:异步线程绝不干慢活,慢活专走特定小房间(spawn_blocking),共享锁越小越好,队列越短越稳。 这样你的延迟曲线就会从心电图变成一条直线。
评论
围绕“为什么我的Actix Web服务延迟忽高忽低?深入Tokio任务调度与Actor运行时竞态分析”参与讨论