Rust in Everything
基于 pingora-runtime 0.9.0

运行时

主线位置:服务主线的「run_forever:每个服务建自己的运行时」。run 按拓扑序给每个服务调 run_service,run_service 的第一行就是 create_runtime;请求主线里一个连接落在哪个线程、它的 IO 事件由谁收,也由本页决定。

flowchart TB
  run["Server run"] --> q{"work_stealing"}
  q -->|"true 默认"| steal["Steal 一个多线程运行时"]
  q -->|"false"| ns["NoSteal N 个单线程运行时"]
  steal --> h1["Handle current"]
  ns --> pick["current_handle 随机挑"]
  pick --> t0["线程 0"]
  pick --> t1["线程 1"]
  pick --> tn["线程 N-1"]
  run --> srv["Server 运行时 1 线程"]
  srv --> ml["main_loop 等信号"]

pingora-runtime/src/lib.rs 一共 782 行,其中 dial9 遥测和各种选项占了一半,Steal/NoSteal 本身不到 300 行。实验在 exp-pingora/server-lab(M2 Max、macOS 27、rustc 1.99.0,release,tokio 1.53.2);本机同时有别的实验在跑,每组数据旁边记了 uptime 的负载。

两种运行时

Runtime 是一个两臂的枚举:

/// Pingora async multi-threaded runtime
///
/// The `Steal` flavor is effectively tokio multi-threaded runtime.
///
/// The `NoSteal` flavor is backed by multiple tokio single-threaded runtime.
pub enum Runtime {
    Steal {
        runtime: tokio::runtime::Runtime,
        #[cfg(feature = "dial9")]
        dial9_guard: Option<dial9_tokio_telemetry::telemetry::TelemetryGuard>,
    },
    NoSteal(NoStealRuntime),
}

RuntimeBuilder::build 按 work_steal 二选一。Steal 就是 Builder::new_multi_thread().worker_threads(threads).thread_name(name),tokio 自己的工作窃取调度器(见 tokio 卷的任务窃取与空闲)。NoSteal 是 threads 个 Builder::new_current_thread() 运行时,每个各占一个 std::thread,线程名同样是服务名。init_pools 里每个线程做的事:

            let join = std::thread::Builder::new()
                .name(self.name.clone())
                .spawn(move || {
                    CURRENT_HANDLE.get_or(|| pools_ref);
                    if let Ok(timeout) = rt.block_on(rx) {
                        rt.shutdown_timeout(timeout);
                    } // else Err(_): tx is dropped, just exit
                })
                .unwrap();

线程 block_on 一个 oneshot 接收端,什么也不干,只是让这个单线程运行时一直转着、执行别人 spawn 进来的任务。停机时 shutdown_timeout 往每个 oneshot 里发超时值,线程醒来调 tokio 的 shutdown_timeout。线程是懒创建的(第一次 get_pools),字段注释说「Lazily init the runtimes so that they are created after pingora daemonize itself. Otherwise the runtime threads are lost」:fork 只保留调用线程。

两者的选项也不完全一样:runtime_enable_alt_timer 和 dial9 只作用于 Steal,NoSteal 下 run 打一条 warn 后忽略;poll 时间直方图两种都支持,但都要 --cfg tokio_unstable。

默认是 Steal。 ServerConf 的 work_stealing 默认 true,RuntimeBuilder::new 的 work_steal 也默认 true,见配置与命令行。NoSteal 要显式关掉工作窃取才会用到。

current_handle:NoSteal 怎么挑线程

current_handle 是 pingora 代码里 spawn 任务的统一入口:

pub fn current_handle() -> Handle {
    if let Some(pools) = CURRENT_HANDLE.get() {
        // safety: the CURRENT_HANDLE is set when the pool is being initialized in init_pools()
        let pools = pools.get().unwrap();
        let mut rng = rand::thread_rng();
        let index = rng.gen_range(0..pools.len());
        pools[index].clone()
    } else {
        // not NoStealRuntime, just check the current tokio runtime
        Handle::current()
    }
}

CURRENT_HANDLE 是一个 ThreadLocal,只有 NoSteal 的线程在启动时往里放了「本运行时所有线程的句柄」。所以:

  • 在 NoSteal 线程上调用,返回均匀随机的一个线程的句柄,可能是自己,也可能是别人。不是轮转,也不看负载。
  • 在其他线程上(Steal 的工作线程、Server 主运行时)调用,退化成 Handle::current()。
  • Runtime::get_handle 对 NoSteal 同样随机。run_service 用它 spawn 服务的启动任务,所以 start_service 在哪个线程上跑也是随机的。

用到它的地方:accept 循环为每个新连接 spawn 连接任务(服务与监听器);HTTP/2 为每个流 spawn 一个任务。keep-alive 连接后续的请求不再挑,留在原线程上。

实测分布:threads: 4,用 curl 依次发 1000 个请求,每个都是新连接,/tid 返回处理请求的线程 id;再用一个 keep-alive 连接发 1000 个:

== nosteal4
 258 ThreadId(4)
 230 ThreadId(5)
 248 ThreadId(6)
 264 ThreadId(7)
-- one keep-alive conn, 1000 requests:
1000 ThreadId(7)
💡TIP
打个比方:四个收银台,一个门口叫号员

NoSteal 的四个线程是超市里四个收银台,每个收银台有自己的队伍(单线程运行时的任务队列),收银员不会去别的队伍拉人(不窃取)。顾客进门时,门口的叫号员掷骰子决定他排哪一队(current_handle 随机挑),排进去就不能换(keep-alive 连接留在原线程)。Steal 则是一个大队伍,空下来的收银员自己去别的队伍里拉人。

比方到这里为止:真实的 NoSteal 里,顾客的「有货要结」这个消息并不是直接告诉收银员的,而是先告诉门口的叫号员,叫号员再去拍对应收银员的肩膀。下一节的实验说明,叫号员要是被一个大单占住了,四个队伍都会一起慢下来。

线程数

threads: 4,一个监听服务 lab、一个后台服务 watcher。ps -M 数线程,sample 看线程名:

== steal4
threads idle: 8
threads after load: 9
    637 Thread_5406638   DispatchQueue_1: com.apple.main-thread  (serial)
    637 Thread_5406639
    637 Thread_5406640: BG watcher
    637 Thread_5406641: lab
    637 Thread_5406642: lab
    637 Thread_5406643: lab
    637 Thread_5406644: lab
    637 Thread_5406645: Server
    637 Thread_5406711: Timer thread
== nosteal4
threads idle: 8
threads after load: 9
...

NoSteal 的输出和 Steal 一模一样(只是线程号不同)。逐个对应:

  • 主线程:run 里用 Server::create_runtime("Server", 1, true, ..) 建一个 1 个工作线程的多线程运行时,然后在主线程上 block_on(main_loop)。源码注释「Only work steal runtime can use block_on()」解释了为什么这里固定用 Steal:NoSteal 没有一个能 block_on 的入口。名为 Server 的是这个运行时唯一的工作线程。
  • 没有名字的一个是实验程序自己开的线程,用来打印 ExecutionPhase,不属于 pingora。
  • BG watcher 1 个,lab 4 个:每个服务各自的运行时。
  • Timer thread 在第一次请求之后才出现,是 pingora-timeout 的时钟线程(10 ms 粒度),见超时。

所以一个 pingora 进程的线程数是 1 + 1 + Σ 每个服务的 threads + 1,Steal 和 NoSteal 一样;用了 spawn_blocking 的服务还会再多出阻塞线程池的线程。

实测:同一负载下的延迟

负载客户端是 src/bin/load.rs:另一个进程、4 个 tokio 工作线程,每个连接上循环发 GET /、读完响应再发下一个,记录每个请求的耗时。服务端 threads: 4,/ 立即返回,/spin?us=2000 原地忙等 2 ms 再返回。每组跑 8 秒,换运行时之间重启服务。

场景 A:64 个连接,全部是 /(3 次,负载 9.17 → 17.18):

运行时rpsp50p99p99.9
Steal89732 / 89572 / 894120.689 / 0.691 / 0.695 ms0.953 / 0.950 / 0.948 ms2.152 / 1.008 / 1.039 ms
NoSteal84443 / 82670 / 776250.720 / 0.733 / 0.747 ms1.148 / 1.187 / 1.787 ms2.269 / 2.154 / 10.950 ms

在这台机器、这个负载下 NoSteal 没有更快。pingora-runtime/benches/hello.rs 末尾的注释记录了上游在 M1 上用 wrk(40 线程、1000 连接)测的裸 TCP 回显服务:NoSteal Requests/sec: 281689.27、99% 5.38ms,Steal Requests/sec: 216918.71、99% 65.16ms。两者条件差得很远(裸 TCP 对 pingora 的 HTTP/1、1000 连接对 64 连接),这里只能说本页的场景复现不出那个差距。

场景 B:64 个 / 连接,外加 2 个连接不停请求 /spin?us=2000。这是「少数请求很重」的情形:

== steal4 B rep1
light conns with median > 1.5ms: 0/64
light: n=677658 rps=84707 p50=0.709ms p90=0.856ms p99=2.297ms p99.9=2.837ms max=4.476ms
heavy: n=5882 rps=735 p50=2.709ms p90=2.830ms p99=4.614ms p99.9=4.999ms max=6.455ms
...
== nosteal4 B rep2
light conns with median > 1.5ms: 14/64
light: n=715124 rps=89390 p50=0.565ms p90=0.729ms p99=4.316ms p99.9=4.441ms max=10.546ms
heavy: n=3726 rps=466 p50=4.287ms p90=4.359ms p99=4.552ms p99.9=6.132ms max=9.085ms
== nosteal4 B rep3
light conns with median > 1.5ms: 36/64
light: n=710444 rps=88806 p50=0.400ms p90=2.434ms p99=2.555ms p99.9=2.644ms max=3.188ms
heavy: n=6515 rps=814 p50=2.451ms p90=2.525ms p99=2.602ms p99.9=2.708ms max=2.887ms

Steal 跑了 5 次,每次都是 0/64 个轻连接中位数超过 1.5 ms,轻请求 p50 在 0.709 到 0.719 ms 之间:哪个工作线程被 2 ms 的请求占住,它队列里的任务就被别的线程偷走。NoSteal 的 5 次是 64/64、14/64、36/64、32/64、38/64,结果取决于两个重连接被随机分到了哪里。rep2 两个重连接落在同一个线程上(重请求 p50 4.287 ms,正好是两个 2 ms 排队),和它们同线程的 14 个轻连接每个请求都要等;其余线程上的轻连接比 Steal 还快(p50 0.565 ms)。

64/64:一个忙线程拖慢所有连接

64/64 的那几次说不通:两个重连接最多占两个线程,另外两个线程上的轻连接为什么也慢?给客户端加上「每个连接最后一次响应来自哪个线程」(PER_THREAD=1,负载 3.90 左右):

== nosteal4 B tid1
light conns with median > 1.5ms: 15/64
  ThreadId(4): light=15 slow=15 heavy=2
  ThreadId(5): light=22 slow=0 heavy=0
  ThreadId(6): light=14 slow=0 heavy=0
  ThreadId(7): light=13 slow=0 heavy=0
...
== nosteal4 B tid3
light conns with median > 1.5ms: 64/64
  ThreadId(4): light=9 slow=9 heavy=1
  ThreadId(5): light=22 slow=22 heavy=1
  ThreadId(6): light=11 slow=11 heavy=0
  ThreadId(7): light=22 slow=22 heavy=0

tid3 里线程 6、7 上一个重连接都没有,它们的轻连接全慢了。原因在 tokio:一个 socket 的 IO 事件由它注册时所在运行时的反应器收。TcpListener::accept 把新连接包成 TcpStream 时,PollEvented::new_with_interest 用的是 scheduler::Handle::current(),也就是正在执行 accept 的那个运行时(反应器的结构见 tokio 卷的反应器)。pingora 随后把连接任务 spawn 到另一个线程,但 socket 不会跟着搬家:它可读了,是 accept 线程的单线程运行时在 kevent 里收到事件,再跨线程唤醒任务。而上一页讲过,NoSteal 下一个服务的所有 accept 任务都在 start_service 取到的同一个线程上。于是:

  • 这个服务所有下游连接的读写就绪事件,都由那一个线程的反应器分发;
  • 那个线程只有在空下来(没有任务可跑)或者 tokio 的 event_interval(默认每 61 次 poll)时才去查反应器;
  • 一个 2 ms 的重请求落在这个线程上,所有连接的唤醒都被推迟到 2 ms 之后,正好是 64/64 时轻请求 p50 约 2.4 到 2.5 ms 的来源。tid1 里两个重连接都在线程 4、accept 不在线程 4,所以只有线程 4 的连接慢。

为了把这条单独拎出来,src/bin/nosteal_io.rs 不经过 pingora 的服务,直接用 pingora_runtime::Runtime::new_no_steal(4, ..):线程 0 上跑 accept,连接 spawn 到线程 1–3 上做 1 字节回显,12 个客户端连接 ping-pong 3 秒。block=1 时线程 0 上另有一个任务反复「忙等 2 ms、yield_now」;rereg=1 时连接任务先 into_std 再 TcpStream::from_std,把 socket 重新注册到自己线程的反应器上。三轮结果一致,这里是第一轮(负载 3.14):

block=false rereg=false: n=275082 p50=125us p90=162us p99=217us max=504us
block=false rereg=true: n=278654 p50=120us p90=164us p99=231us max=282us
block=true rereg=false: n=17869 p50=2013us p90=2068us p99=2128us max=3919us
block=true rereg=true: n=268131 p50=126us p90=170us p99=234us max=1720us

线程 1–3 上一点额外负担都没有,只是 accept 所在的线程忙,回显延迟就从 125 µs 变成 2013 µs;把 socket 搬到自己的反应器上以后,同样的忙碌完全不影响它们。pingora 0.9.0 的 pingora-core 里没有任何地方对下游连接做这种重新注册(git grep into_std 只有监听 socket 的转换),0.9.0 之后的 main 也没有。listener_tasks_per_fd: 4 改变不了这一点,实测 5 次里仍有 2 次 64/64。上游连接是在连接任务里新建的,注册在处理请求的线程上,不受影响。

这对 NoSteal 的使用者意味着:在钩子里做阻塞或长时间计算,坏处不止是「同线程上的连接停顿」,运气不好时是整个服务的下游连接一起停顿。Steal 运行时所有工作线程共用一个 IO 驱动,哪个工作线程空着就由它去查反应器,没有这个问题。

❗IMPORTANT
动脑筋:Steal 上 1000 个新连接分到几个线程?

同样 threads: 4、work_stealing: true,curl 依次发 1000 个请求,每个都是新连接,前一个结束才发下一个。/tid 返回的线程 id 会有几种?

为什么有 NoSteal

源码能给的理由只有 crate 文档这一段:

//! Tokio runtime comes in two flavors: a single-threaded runtime
//! and a multi-threaded one which provides work stealing.
//! Benchmark shows that, compared to the single-threaded runtime, the multi-threaded one
//! has some overhead due to its more sophisticated work steal scheduling.
//!
//! This crate provides a third flavor: a multi-threaded runtime without work stealing.
//! This flavor is as efficient as the single-threaded runtime while allows the async
//! program to use multiple cores.

再加上 benches/hello.rs 末尾那两段 wrk 输出。docs/user_guide/internals.md 只说服务的运行时「may be work-stealing (tokio-default), or non-work-stealing (N isolated single threaded runtimes)」,conf.md 里 work_stealing 一行指向的「Pingora runtime (WIP) section」在 docs/ 里不存在。本页的两个实验说明了取舍的另一面:NoSteal 把连接随机钉在线程上,负载不均时无法纠正;而且这些线程并不像文档说的那样「isolated」,下游 socket 的 IO 由 accept 线程统一分发。默认值选 Steal,与这些观察是一致的。

0.9.0 之后 main 上的 5dc98c4(2026-10-15,「Add runtime groups so services can share one runtime」)允许多个服务共用一个运行时,还没有发版。

动脑筋答案

只有一种:

== steal4
1000 ThreadId(5)
-- one keep-alive conn, 1000 requests:
1000 ThreadId(5)
  • accept 循环这个任务在某个工作线程(这里是 ThreadId(5))上运行。它 spawn 出的连接任务进入当前工作线程的本地队列(tokio 卷的本地队列讲过 LIFO 槽),accept 循环让出后,同一个线程接着就执行它。
  • 连接一个接一个地来,每个连接的工作量都很小,在其他工作线程被唤醒并找到可偷的任务之前,它已经做完了。工作窃取只在某个线程有积压时才起作用,没有积压就没有东西可偷。
  • 所以「Steal 会把连接均匀分到所有线程」是错的预期;它只保证有积压时不让别的线程闲着。NoSteal 的随机分配反而让 1000 个连接平均分到了四个线程上。

小结

  • 默认 work_stealing: true:每个服务一个 tokio 多线程运行时;NoSteal 是 N 个单线程运行时,线程名都是服务名。
  • current_handle 在 NoSteal 线程上均匀随机地挑一个线程,keep-alive 连接从此留在那里;在其他地方等于 Handle::current()。
  • 一个进程的线程:主线程、Server 运行时 1 个、每个服务各自的线程、pingora-timeout 的时钟线程;两种运行时数量相同。
  • 有一个重请求时,Steal 把其他连接的延迟保持在原位;NoSteal 视随机分配而定,最坏时所有连接一起变慢。
  • NoSteal 下,下游 socket 注册在 accept 线程的反应器上;accept 线程一忙,整个服务的下游 IO 都被推迟。

💬 关联讨论

发起讨论
加载中...