在钩子里阻塞:新连接全部卡住
主线位置:请求主线里的每一个
async钩子(request_filter、upstream_peer、response_filter……),它们都在服务运行时的工作线程上执行。服务有几个线程、Steal 和 NoSteal 怎么分配连接、NoSteal 下 socket 注册在谁的反应器上,见运行时。本页量一次阻塞调用到底拖住了谁。
flowchart TB
hook["钩子里 thread sleep"] --> w["工作线程卡住"]
w --> s{"运行时"}
s -->|Steal| drv["没人去查 IO 驱动"]
drv --> all["新连接一起等"]
s -->|NoSteal| which{"卡的是哪个线程"}
which -->|accept 线程| all2["整个服务都等"]
which -->|其他线程| same["只有同线程的等"]
fix["await 或 spawn_blocking"] --> okk["其他连接不受影响"]
实验 crate exp-pingora/pita-lab:bin px 是一个反代,request_filter 里对 /block 按 PX_BLOCK 做不同的等待 2 秒,对 /fast 立刻回 200,两者都打出处理它的线程 id;tools/blocking.py 先发一个 /block,0.2 秒后同时发 N 个 /fast(每个都是新连接),记录每个的耗时。bin nosteal_io 照抄 server-lab 的同名程序。M2 Max、macOS 27、rustc 1.99.0,release,tokio 1.53.2,uptime 负载约 2.5 到 2.7。
现象
钩子里写了一个同步的阻塞调用,比如同步的文件读写、reqwest::blocking、同步的数据库驱动,这里用 std::thread::sleep 代表:
async fn request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result<bool> {
// ...
if path == "/block" {
std::thread::sleep(Duration::from_secs(2));
}
// ... write a 200 response, return Ok(true)
}
默认配置(threads: 1,Steal):
== threads 1, std::thread::sleep
/block: 2.011s [/block ThreadId(2) hook 2.004s]
/fast: 1.805s [/fast ThreadId(2) hook 0.000s]
/fast: 1.805s [/fast ThreadId(2) hook 0.000s]
/fast: 1.805s [/fast ThreadId(2) hook 0.000s]
只有一个线程,/fast 要等 /block 睡完,这在意料之中。多给几个线程,threads: 4,work_stealing 保持默认的 true,3 次、每次 6 个 /fast:
== Steal 4 rep1
/block: 2.008s [/block ThreadId(5) hook 2.004s]
/fast: 1.802s [/fast ThreadId(3) hook 0.000s]
/fast: 1.802s [/fast ThreadId(5) hook 0.000s]
/fast: 1.802s [/fast ThreadId(5) hook 0.000s]
/fast: 1.803s [/fast ThreadId(4) hook 0.000s]
/fast: 1.803s [/fast ThreadId(2) hook 0.000s]
/fast: 1.803s [/fast ThreadId(2) hook 0.000s]
rep2、rep3 一样:18 个 /fast 全部在 1.80 秒左右完成,钩子本身耗时 0,最后由四个不同的线程处理。三个线程空着,新连接还是一起等到那个阻塞的线程醒来。
NoSteal(threads: 4、work_stealing: false)跑了 14 次,结果分成两类:
== NoSteal 4 rep1
/block: 2.006s [/block ThreadId(4) hook 2.001s]
/fast: 0.001s [/fast ThreadId(3) hook 0.000s]
/fast: 0.001s [/fast ThreadId(5) hook 0.000s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
/fast: 0.001s [/fast ThreadId(3) hook 0.000s]
/fast: 1.803s [/fast ThreadId(4) hook 0.000s]
...
rep5 block on ThreadId(3): slow /fast 6/6
- 8 次只有和
/block落在同一个线程上的/fast变慢(0 到 2 个)。 - 6 次 6 个
/fast全部变慢,处理它们的线程各不相同。 - 每次都是新进程,线程 id 和 accept 线程每次都不同。
出处
pingora 的钩子是普通的 async fn,在服务运行时的工作线程上被 poll,pingora 不会替你把阻塞调用挪走。阻塞的代价要看运行时:
- NoSteal:每个线程是一个独立的单线程运行时,阻塞只卡住自己那一个线程上的任务。但下游 socket 是在 accept 线程上注册的,
run_endpoint只把连接任务current_handle().spawn到别的线程,socket 的 IO 事件仍由 accept 线程的反应器收。/block落在 accept 线程上时,所有连接的 IO 都停,这就是那 6 次 6/6。机制和单独的复现见运行时的「64/64:一个忙线程拖慢所有连接」。nosteal_io重跑一遍(线程 0 accept,连接在线程 1–3 上回显):
block=false rereg=false: n=299042 p50=117us p90=139us p99=195us max=790us
block=true rereg=false: n=17694 p50=2015us p90=2054us p99=2930us max=16883us
block=true rereg=true: n=271073 p50=126us p90=158us p99=227us max=4479us
- Steal:tokio 的多线程运行时只有一个 IO 驱动,哪个工作线程空下来要睡,谁先锁到驱动谁就在驱动上等事件,其余的睡在条件变量上(driver 与 park、worker 主循环的
park_internal)。驱动收到/block的读事件,在那个线程上唤醒连接任务,任务进的是这个线程的 LIFO 槽;should_notify_others要本地多于 1 个任务才叫醒别的线程。于是这个线程拿着这一个任务去跑,跑进了thread::sleep,此时没有任何线程在驱动上等事件,另外三个睡在条件变量上,没人叫醒它们。0.2 秒后到达的新连接,事件要等到这个线程睡完、回到 park 才被取出来。所以 18 个/fast的耗时都是 1.80 秒(2 秒减去 0.2 秒),之后才被分给各个线程。这一段是按 tokio 卷讲的 park 规则推断的,实验能确认的是现象:空闲的 Steal 运行时,一个阻塞的工作线程能让所有新连接一起停。负载高、其他线程一直有活干的时候,它们会在维护时去查驱动,情况会好一些,本页没有量。
修法
等待用 .await,阻塞的工作交给阻塞线程池:
Some("async") => tokio::time::sleep(Duration::from_secs(2)).await,
Some("blocking") => {
let _ = tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_secs(2))).await;
}
threads: 1 下各跑一次:
== threads 1, tokio::time::sleep
/block: 2.006s [/block ThreadId(2) hook 2.002s]
/fast: 0.000s [/fast ThreadId(2) hook 0.000s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
== threads 1, spawn_blocking
/block: 2.006s [/block ThreadId(2) hook 2.001s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
一个线程也够:/block 仍然要 2 秒,它让出了线程,/fast 在 1 毫秒内完成。spawn_blocking 的线程池上限是 max_blocking_threads(不配就是 tokio 的 512),见配置与命令行。
tokio::task::block_in_place 也能把当前工作线程让出去,但它只能用在多线程运行时上:
== block_in_place threads=4 steal=1
/block: 2.011s [/block ThreadId(3) hook 2.005s]
/fast: 0.001s [/fast ThreadId(8) hook 0.000s]
/fast: 0.002s [/fast ThreadId(5) hook 0.000s]
/fast: 0.002s [/fast ThreadId(4) hook 0.000s]
== block_in_place threads=4 steal=0
/block: 0.007s []
/fast: 0.001s [/fast ThreadId(2) hook 0.000s]
...
thread 'Pingora HTTP Proxy Service' (5965124) panicked at src/bin/px.rs:62:28:
can call blocking only when running on the multi-threaded runtime
Steal 上它有效(ThreadId(8) 是 tokio 接替出来的新工作线程);NoSteal 的每个线程都是单线程运行时,block_in_place 直接 panic,/block 那个连接没有得到响应。同一份代码,改一行 work_stealing: false 就会坏,用 spawn_blocking 不会。
怎么确认没中招
- 搜钩子和它们调用的函数里的同步调用:
std::thread::sleep、std::fs::、std::net::、reqwest::blocking、同步数据库客户端、持有时间长的std::sync::Mutex,以及 CPU 密集的计算(压缩、加解密、大 JSON 的解析)。 - 像上面那样做一次对照:一个慢请求在途时,另起几个新连接发轻请求,看它们的延迟是否跟着变长。线程数多不代表没事:Steal 4 线程照样全停。
- 用了
block_in_place的,确认配置里没有work_stealing: false。
小结
- 钩子里的同步阻塞会卡住它所在的工作线程。空闲的 Steal 运行时开 4 个线程,所有新连接仍然一起等了 1.80 秒;NoSteal 下卡住的若是 accept 线程,整个服务的下游 IO 都停(14 次里 6 次)。
- 等待写成
.await,阻塞的活交给spawn_blocking;实测一个线程时其他请求的延迟回到 1 毫秒。 block_in_place在 NoSteal 上 panic(can call blocking only when running on the multi-threaded runtime),不要用它。