Rust in Everything
基于 pingora-runtime 0.9.0

在钩子里阻塞:新连接全部卡住

主线位置:请求主线里的每一个 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),不要用它。

💬 关联讨论

发起讨论
加载中...