Rust in Everything
基于 pingora-timeout 0.9.0

超时

主线位置:请求主线上几乎每一步等待。连上游(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。下面的「动脑筋」量的就是这个。

💡TIP
打个比方:每 10 分钟一班的机场大巴

要赶时间的乘客(超时)不各自叫车,而是买一张大巴票(TimerStub):票上印的发车时间是自己的截止时间往后凑到最近的整十分(round_to)。同一个航站楼(线程)、同一班次的乘客坐同一辆车(共用一个 Timer),车多半早就停在那里,买票只是在名单上加个名字(读锁加 subscribe)。调度员(时钟线程)每隔十分钟出来看一次表(sleep(10 ms)),把到点的车全部发走(fire),车上所有人同时被叫醒。

比方到这里为止:乘客中途不想走了(超时被取消)只是撕掉自己的票,车照样停在那里等到发车时间才离开;调度员「每隔十分钟」是从他上一次干完活算起的,不对齐整点,所以车可能比票上的时间再晚开将近一班。

取消不清理。 Timeout 被 drop 时只 drop 了 TimerStub,Timer 还在树里,直到时钟线程在它的到期时刻把它删掉。一棵树里的条目数上限是「时长 / 10 ms」:时长短的超时(读写超时通常是秒级)随建随删,长的会一直攒着。

📝NOTE
冷知识:15 分钟以上的超时改回用 tokio 的

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。见平滑升级与停机的「守护进程」一节。

❗IMPORTANT
动脑筋:在一个永远 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 登记后取消」不是一回事。

📝NOTE
没有蠢问题

问: 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 from pingora_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 亿次没复现。
  • 它适合大量、秒级、经常被取消的读写超时,不适合要毫秒精度的定时。

💬 关联讨论

发起讨论
加载中...