超时
主线位置:请求主线上几乎每一步等待。连上游(TCP 和 TLS 握手)、读下游请求头和请求体、读上游响应、写回下游,外面套的都是
pingora_timeout::timeout;它也是缓存锁等待、RTCache等锁用的超时。服务主线里它出现两次:Server::run启动时设一个全局阈值;以守护进程方式启动时,run在 fork 之前暂停它的时钟线程。
sequenceDiagram participant T as 任务 participant TO as Timeout participant TM as 本线程的树 participant CK as 时钟线程 T->>TO: poll TO->>TO: 先 poll 内部 future TO->>TM: Pending 时才登记 TM-->>TO: 同一格就共用 Timer CK->>CK: 每次睡 10 ms CK->>TM: 到期的格子 fire TM-->>T: notify_waiters 唤醒 T->>TO: 再 poll 得到 Elapsed
pingora-timeout 的 src/ 只有三个文件 745 行:lib.rs(Timeout future 和 tokio_timeout)、fast_timeout.rs(fast_timeout 和回退到 tokio 的阈值)、timer.rs(TimerManager 和时钟线程)。它是 pingora-core 的无条件依赖,门面 crate 的 time feature 只多一个 pingora::time 路径(见怎么读这一卷)。
实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷),release 构建,实验 crate 在 exp-pingora/comp-timeout。本机同时有别的编译任务,每组测量前后记了 uptime。
两个 timeout,一个 Timeout
lib.rs 把 fast_timeout::fast_timeout 重新导出成 timeout,pingora-core 里写的 pingora_timeout::timeout(t, fut) 都是它。另一个 tokio_timeout 用 tokio::time::sleep 当定时器,只多了下面这一层「懒登记」。两者返回同一个 Timeout<T, F>,F 是一个 ToTimeout,负责在需要时造出定时器 future:
pub struct Timeout<T, F> {
#[pin]
value: T,
#[pin]
delay: Option<Pin<Box<dyn Future<Output = ()> + Send + Sync>>>,
callback: F, // callback to create the timer
}
poll:
fn poll(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Self::Output> {
let mut me = self.project();
// First, try polling the future
if let Poll::Ready(v) = me.value.poll(cx) {
return Poll::Ready(Ok(v));
}
let delay = me
.delay
.get_or_insert_with(|| Box::pin(me.callback.timeout()));
match delay.as_mut().poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(()) => Poll::Ready(Err(Elapsed {})),
}
}
- 先看内部 future。 第一次 poll 就就绪(比如 socket 接收缓冲区里已经有数据),定时器根本不建。tokio 的
Timeout也是先 poll 内部 future,Sleep也是第一次 poll 才登记进时间轮,所以这一点两边已经没有差别。 - 两者同时就绪时,值赢。 每次 poll 都先 poll
value,定时器已经到期也一样。 - 两次堆分配。
callback.timeout()已经返回一个Pin<Box<dyn Future>>,外面又Box::pin了一次。计数分配器量出来,每个「登记后取消」的fast_timeout正好 2.00 次分配,tokio::time::timeout是 0 次(out/allocs.txt)。
fast_timeout 创建时先看时长:超过阈值(默认 DEFAULT_FAST_TIMEOUT_TO_TOKIO_THRESHOLD = 15 分钟,按整秒比较)的用 tokio 的 sleep,否则用 FastTimeout,再调一次 check_clock_thread 确保时钟线程活着。
时钟线程和 10 ms 的格子
FastTimeout 的定时器来自全局的 TimerManager:
pub struct TimerManager {
// each thread insert into its local timer tree to avoid lock contention
timers: ThreadLocal<RwLock<BTreeMap<Time, Timer>>>,
zero: Instant, // the reference zero point of Timestamp
// Start a new clock thread if this is -1 or staled. The clock thread should keep updating this
clock_watchdog: AtomicI64,
paused: AtomicBool,
}
每个线程一棵 BTreeMap,键是到期时刻,值是一个 Timer(一个 Arc<Notify> 加一个 Arc<AtomicBool>)。
登记。 register_timer 把 now + duration 换成相对 zero 的毫秒数,用 round_to 向上取整到 10 的倍数(RESOLUTION_MS = 10)。先拿本线程那棵树的读锁查这一格有没有 Timer,有就 subscribe(克隆两个 Arc)返回;没有才建一个新的、拿写锁插进去。同一个线程上、同一个 10 ms 格子里到期的所有超时共用一个 Timer。只有本线程往自己的树里插,所以插之前不用再查一次;另一个碰这棵树的是时钟线程,它只删。
触发。 clock_thread 是一个名为 Timer thread 的普通 OS 线程,第一次用到 fast_timeout 时由 check_clock_thread 启动(所以运行时那一页里它在第一个请求之后才出现):
pub(crate) fn clock_thread(&self) {
loop {
std::thread::sleep(RESOLUTION_DURATION);
let now = Instant::now() - self.zero;
self.clock_watchdog
.store(now.as_secs() as i64, Ordering::Relaxed);
if self.is_paused_for_fork() {
// just stop acquiring the locks, waiting for fork to happen
continue;
}
let now = now.as_millis();
// iterate through the timer tree for all threads
for thread_timer in self.timers.iter() {
let mut timers = thread_timer.write();
// Fire all timers until now
loop {
let key_to_remove = timers.iter().next().and_then(|(k, _)| {
if k.not_after(now) {
// ...
睡 10 ms,依次拿每个线程那棵树的写锁,把到期的格子全部删掉并 fire:先把 AtomicBool 置真,再 notify_waiters() 叫醒所有等在这个 Notify 上的任务。clock_watchdog 记时钟线程最近一次醒来的秒数,should_i_start_clock 发现它停了 2 秒以上就再起一个线程。
从这几行能推出它会晚多少:到期时刻先向上取整,最多晚 10 ms;时钟线程每次睡 10 ms 再加上干活的时间,到期之后最多再等一轮。反过来,as_millis 是截断,登记时 now + duration 的不足 1 ms 的部分被丢掉了,所以也可能早不到 1 ms。下面的「动脑筋」量的就是这个。
要赶时间的乘客(超时)不各自叫车,而是买一张大巴票(TimerStub):票上印的发车时间是自己的截止时间往后凑到最近的整十分(round_to)。同一个航站楼(线程)、同一班次的乘客坐同一辆车(共用一个 Timer),车多半早就停在那里,买票只是在名单上加个名字(读锁加 subscribe)。调度员(时钟线程)每隔十分钟出来看一次表(sleep(10 ms)),把到点的车全部发走(fire),车上所有人同时被叫醒。
取消不清理。 Timeout 被 drop 时只 drop 了 TimerStub,Timer 还在树里,直到时钟线程在它的到期时刻把它删掉。一棵树里的条目数上限是「时长 / 10 ms」:时长短的超时(读写超时通常是秒级)随建随删,长的会一直攒着。
0.9.0 之前,fast_timeout 直接是 check_clock_thread(&TIMER_MANAGER); Timeout::new_with_delay(future, duration),返回类型是 Timeout<T, FastTimeout>,多长的超时都进 TimerManager。7fa8d39(2026-05-18,「Use Tokio timeout for long fast timeouts」)加了 FastOrTokioTimeout,时长超过阈值的改用 tokio 的 sleep,返回类型随之变成 Timeout<T, FastOrTokioTimeout>。set_fast_timeout_to_tokio_threshold 的文档注释写了原因:长的 fast-timeout 定时器在到期之前一直留在 TimerManager 里,而 tokio 的超时取消时会清理,「a better fit for long durations that are often cancelled before expiry」。同一个提交在 ServerConf 里加了 fast_timeout_to_tokio_threshold_seconds(默认 900,写 null 关掉回退),Server::run 启动时把它写进全局阈值(见配置与命令行)。首次发布在 0.9.0,CHANGELOG 的说法是「pingora-timeout now uses Tokio timeouts for (configurably) long intervals to avoid memory accumulation.」
还有一处和进程管理有关:-d 以守护进程方式启动时 run 要 fork,RwLock 跨 fork 是未定义行为,所以 fork 之前调 pause_for_fork:置暂停标志,再睡两个 10 ms 等时钟线程放掉所有锁;暂停期间 register_timer 返回一个已经 fire 过的定时器,也就是立刻超时;fork 完调 unpause。见平滑升级与停机的「守护进程」一节。
Pending 的 future 外面套 pingora_timeout::timeout(Duration::from_millis(1), ..),从创建到返回 Elapsed 大约要多久?tokio::time::timeout 呢?按上面推出来的上下界先猜,再看文末的实测。实验:建一个超时要多少钱
criterion 测两种情形(benches/timeout.rs),超时一律 1 s,在一个 current-thread 运行时的上下文里、用 Waker::noop() 手动 poll:
ready_future:内部 future 第一次 poll 就就绪;armed_then_cancelled:内部 future 第一次Pending、第二次就绪,也就是定时器登记了、然后被取消。这是代理里最常见的情形:读 socket 时数据还没到,等了一会儿到了。
第二次运行(uptime 负载 4.9 到 5.5,out/criterion-2.txt):
ready_future/pingora_fast_timeout
time: [23.548 ns 23.727 ns 23.943 ns]
ready_future/tokio_timeout
time: [30.026 ns 30.070 ns 30.117 ns]
armed_then_cancelled/pingora_fast_timeout
time: [144.20 ns 144.44 ns 144.69 ns]
armed_then_cancelled/pingora_tokio_timeout
time: [289.87 ns 290.22 ns 290.62 ns]
armed_then_cancelled/tokio_timeout
time: [273.68 ns 274.12 ns 274.60 ns]
第一次运行时负载到过 33,armed_then_cancelled 三项是 144.92、288.49、274.41 ns,基本不变;ready_future 两项抖到 32.6 和 42.6 ns。
- 定时器不建时,两边只差 6 ns。 剩下的开销是
Instant::now()和几个原子读。lib.rs的 crate 文档里写的是 4 ns 对 107 ns,那是旧版 tokio 的数字;tokio 1.53.2 的Sleep创建时不碰时间轮,差距已经没有了。 - 登记加取消,pingora 快 1.9 倍。 144 ns 里有两次堆分配、两次
Instant::now()、一次读锁、一次BTreeMap查找、两次Arc克隆和一次Notify等待登记;tokio 那边,Sleep第一次 poll 时init登记、drop 时cancel注销,各拿一次时间驱动的锁。 pingora_tokio_timeout(懒登记加 tokio 的sleep)比tokio::time::timeout还慢 16 ns:它也走Timeout的那两次Box::pin,tokio 的Sleep直接内嵌在Timeout里,不分配。
单线程比不出「没有全局锁」。src/bin/mt.rs 在 1、4、8 个工作线程的多线程运行时里各 spawn 同样多的任务,每个任务做 100 万次「登记后取消」,取 5 次里最快的一次,换算成每个任务每次的耗时(负载 4.9 到 7.7,out/mt-2.txt):
threads impl best of 5 (ns per arm+cancel, per thread)
1 pingora 132
1 tokio 271
4 pingora 351
4 tokio 870
8 pingora 372
8 tokio 3127
tokio 1.53.2 的时间驱动是一把 Mutex<InnerState> 管整个时间轮(tokio 卷的分层时间轮),8 个线程同时登记、取消时每次要 3.1 µs,是单线程的 11.5 倍。pingora 每个线程登记进自己的树,8 个线程时是 372 ns,是单线程的 2.8 倍,这部分的增长这里没有拆开量。tokio 自己也有一个每 worker 一个时间轮、不用全局锁的实现 time_alt(tokio 卷:time_alt),pingora 0.9.0 用 runtime_enable_alt_timer 打开,要 tokio_unstable。
timer.rs 开头的注释还有一组数:登记 78 ns 对 343 ns、drop 13 ns 对 106 ns。那是只建不 poll 的 sleep 和直接调 register_timer 的比较,和上面「整个 Timeout 登记后取消」不是一回事。
问: tokio 卷讲 Notify 时说,notify_waiters 不存许可,只叫醒「已经创建」的 Notified,所以要先创建 Notified 再检查条件。pingora 的 TimerStub::poll 是先 self.1.load(SeqCst) 看标志、再 self.0.notified().await,顺序反了,会不会丢通知?
答: 从代码看会:标志读到 false 之后、notified() 创建之前,时钟线程要是正好把这一格 fire 掉(置标志、notify_waiters),这个 Notified 就再也等不到通知,格子已经从树里删了,这个超时永远不会触发,外层看到的就是一个不会超时的读写。窗口只有两条指令之间那一点。src/bin/lost_wakeup.rs 用 8 个工作线程、5 万个任务循环 timeout(Duration::ZERO, pending()),跑 60 秒共完成 105951264 次超时,没有一个任务停住(out/lost_wakeup-2.txt)。所以这是一个读代码得出、本机没有复现的窗口;按 tokio 文档的写法,把 notified() 提到 load 之前就能关掉它。顺序的规则见 tokio 卷的 oneshot、watch、broadcast、Notify。
谁在用它
pingora-core 里套 pingora_timeout::timeout 的地方,就是 ServerConf、PeerOptions 里那些超时生效的地方:
- 连上游:
connectors/l4.rs的 TCP 连接、connectors/mod.rs的整段连接、各 TLS 后端的握手(见 L4 连接); - HTTP/1 下游:
read_request_buf读请求头时的 keep-alive 超时和读超时,读请求体、写响应体;HTTP/1 上游:写请求、读响应头和响应体; - 0.9.0 新加的 body 写任务(
poll_write_current_body_task,proxy tasks 用)不套Timeout,而是照着它的写法先写、写不完才建一个pingora_timeout::sleep(fast_sleep,同一套TimerManager),注释里说的就是「This follows the pattern frompingora_timeout::Timeout」; - pingora-cache 的缓存锁等待,
pingora-memory-cache的RTCache等锁。
这些超时多半是秒级,到期晚十几毫秒无关紧要,而每个请求要建、取消好几个,正是 fast_timeout.rs 开头那段注释说的适用场景:每秒 100 次以上的 timeout() 调用,低于这个量用 tokio 的就行。要毫秒级精度的定时(比如限速的令牌桶)不该用它。
动脑筋答案
src/bin/accuracy.rs:两个工作线程的多线程运行时,每种时长取 200 个样本(100 ms 取 50 个),每个样本前先睡一个 0 到 10 ms 之间错开的时长,让起点均匀落在 10 ms 的格子上;记录「实际耗时 − 请求时长」,单位毫秒,负数表示提前(负载 7.7,out/accuracy-2.txt):
requested impl n min p50 p99 max (ms late; negative = early)
1 ms pingora 200 +1.199 +9.834 +21.471 +22.712
1 ms tokio 200 +0.266 +1.521 +1.672 +1.845
5 ms pingora 200 +0.087 +12.021 +22.336 +23.722
5 ms tokio 200 +0.128 +2.518 +2.906 +4.686
10 ms pingora 200 -0.323 +10.433 +22.051 +22.708
10 ms tokio 200 +0.469 +2.866 +4.763 +8.033
25 ms pingora 200 +0.212 +11.778 +24.839 +26.509
25 ms tokio 200 +0.461 +3.074 +8.378 +11.530
100 ms pingora 50 +0.825 +13.121 +24.323 +24.323
100 ms tokio 50 +0.760 +3.051 +9.264 +9.264
timeout(1 ms)在 pingora 这边中位数是 10.8 ms 返回(晚 9.8 ms),最慢 23.7 ms;tokio 中位数 2.5 ms(晚 1.5 ms),最慢 2.8 ms。- pingora 一列的分布和推出来的一样:晚 0 到 20 ms,中位数在 10 ms 上下。超过 20 ms 的几毫秒是时钟线程每轮「睡 10 ms 再干活」的累积加上本机负载(第一次运行负载 21,
out/accuracy-1.txt的最大值也在 23 ms 左右)。 - 10 ms 那一行最小值是 −0.323:提前了 0.3 ms 返回,就是
as_millis截断造成的。第一次运行里 1 ms 和 5 ms 也各出现过 −0.158、−0.251。 - tokio 也会晚,它的时间轮精度是 1 ms,还要等 worker 醒来;负载下 p99 到了几毫秒。
小结
pingora_timeout::timeout就是fast_timeout:先 poll 内部 future,挂起时才登记定时器,值和超时同时就绪时值赢。- 定时器按到期时刻向上取整到 10 ms,同一线程同一格共用一个
Timer;每线程一棵BTreeMap,时钟线程每睡 10 ms 扫一遍、notify_waiters叫醒。 - 实际到期晚 0 到 20 多毫秒,也可能早不到 1 ms;取消不清理,长超时会攒在树里,所以 0.9.0 起超过 900 秒的改用 tokio(
fast_timeout_to_tokio_threshold_seconds)。 - 实测登记加取消 144 ns 对 tokio 274 ns;8 线程并发时 372 ns 对 3127 ns,差在 tokio 1.53.2 时间驱动的全局锁;不登记时两边只差 6 ns,crate 文档里的 4 ns 对 107 ns 已经过时。
TimerStub::poll先读标志后建Notified,读代码有一个丢通知的窗口,1 亿次没复现。- 它适合大量、秒级、经常被取消的读写超时,不适合要毫秒精度的定时。