一个 5ms 的 P99 尖峰,逼我写了个“烧水壶哨子“
先说这个故事的开头Courierust 的服务器刚能跑起来的时候我跑基准测试结果怎么都消不掉一个现象每次请求的延迟P99 永远有大概 5ms 的尖峰。P50一半请求是 100µs 左右P99 却稳定地飙到 5ms。这不是偶发是每次都有。看起来就像每个请求都被什么东西卡了一下。我当时一度怀疑是调度问题、是锁竞争、是 GC……折腾了很久最后才找到真凶。这个真凶逼出了一个挺有意思的设计——一个自制的烧水壶哨子。先把背景讲清楚。服务器并发模型的演进用一家餐厅来想假设你开了一家餐厅要接待很多客人网络连接。怎么安排服务员处理线程历史上大概有三代做法。第一代一连接一服务员每个客人进门你就派一个专属服务员全程跟着。好处简单一个服务员只管一个客人不会搞混坏处客人吃完饭坐着不走呢1000 个客人坐在那里刷手机这就是 keep-alive 连接、SSE 长连接你就得养 1000 个服务员站桩。餐厅破产。这就是一连接一线程模型。10 万条空闲连接 10 万个线程光线程栈内存就能吃几个 GB。第二代服务员共享工作窃取池服务员不再专属改成谁有空谁接客的共享池。好处服务员数量可控了多核也能用上坏处还是那句话——客人坐着不走服务员也得在旁边陪着。1000 个半截话没说完整的客人Slowloris照样把服务员全占满新客人进门没人理。第三代我们的客人按铃服务员才来关键洞察客人大多数时间并不需要服务员他们只是待在座位上。所以新一代做法是客人坐下连接建立后不占任何服务员自己待着客人需要服务时数据到达按铃socket 就绪服务员听到铃响才过来服务服务完客人回座位服务员去忙别人。这就像餐厅给每桌装了个呼叫铃客人按铃才有服务员来不按铃服务员就去忙别的桌。用技术话说就是空闲连接挂在轮询器poll上就绪了才交给 worker 处理。一个 worker 能同时陪几千个待着的客人因为大部分时间它只是在睡觉等铃响。新连接 按一下铃读端注册进 pollerpollselect/poll等铃响分类TLS/h2/h1TLS / h2按 16 人一批叫服务员服务完 → 重新登记 按铃超时未动 → 请走accept 线程只负责接客绝不阻塞self-pipe烧水壶哨子前台事件循环持有 poller 分类器铃响的客人集合H1 客人 → EventConn服务员池服务员 ×N慢客人回收死结前台每 5 分钟才看一次铃架构想清楚了代码也写出来了然后那个5ms 的 P99 尖峰就来了。我把架构再细化一层你就明白坑在哪了。事件循环前台的经典写法是这样的loop { poll(5ms) // 前台盯着铃最多盯 5ms // 处理就绪的客人 // 处理控制消息新客人、服务员登记…… }注意服务员和前台之间是靠一个消息队列channel通信的而前台的眼睛poll只盯着客人的铃不盯着消息队列。问题来了服务员服务完一个客人想把客人放回座位、并通知前台这位客人可以继续接待了——它把消息丢进队列然后呢前台要等当前这轮 poll 结束才看队列。而 poll 最多要等 5ms。所以每个 keep-alive 请求服务员处理完、通知前台、前台重新把客人挂回去——这一整个交接都要等一个完整的 5ms poll 超时。这就是 P99 5ms 尖峰的来源。不是锁不是调度就是前台每 5ms 才睁一次眼而每次交接都必须等它睁眼。用生活中的话说你给前台写了个纸条3 号桌可以上菜了扔进信箱但前台每 5 分钟才开一次信箱——那 3 号桌就得干等 5 分钟。为什么不能把 poll 超时设成 0你可能会想那把 5ms 改成 0 不就行了让前台永远睁着眼。不行。poll 超时 0 就是忙轮询——前台每时每刻都在扫视所有客人CPU 直接烧满一个核而且没客人按铃时前台该干嘛这个问题又回来了它还是得干等只是干等的姿势变成了空转。我们要的是没人的时候前台能踏实睡觉有人的时候前台能立刻醒来。既要省电又要秒回。解法给自己做个烧水壶哨子这个问题的经典解法在 Unix 世界里有个很老的名字叫self-pipe trick自管道技巧Windows 上也有对应的做法。通俗点讲就是给前台装一个哨子。任何要通知前台的人不必等前台睁眼直接吹哨子。哨子一响前台立刻醒来。具体实现很朴素——一对回环 TCP socket自己连自己的 loopback 连接/// 创建一对回环 socket 作为 self-pipe烧水壶哨子。/// Windows 没有原生 socketpair回环对是可移植的等价物。pub(crate)fnwakeup_pair()-std::io::Result(TcpStream,TcpStream){letlistenerstd::net::TcpListener::bind(127.0.0.1:0)?;letwriterTcpStream::connect(listener.local_addr()?)?;let(reader,_)listener.accept()?;// 关键中的关键关掉 Nagle// 哨子就吹一声1 个字节必须立刻到达前台耳朵里。// Nagle 会把这个字节攒在缓冲区里等更多数据——// 那哨子就不响了等于又回到等 5 分钟的老路。reader.set_nonblocking(true)?;writer.set_nonblocking(true)?;let_reader.set_nodelay(true);let_writer.set_nodelay(true);Ok((reader,writer))}/// 吹哨子写一个字节。尽力而为写失败只损失一次优化绝不损失正确性。pub(crate)fnwake_nudge(w:TcpStream){letmuts:TcpStreamw;let_std::io::Write::write(muts,[1]);}/// 把哨声排空防止哨子卡住反复假响。pub(crate)fndrain_wake(r:TcpStream){letmutbuf[0u8;64];loop{letmuts:TcpStreamr;matchstd::io::Read::read(muts,mutbuf){Ok(0)|Err(_)break,Ok(_){}}}}然后把这个哨子的耳朵reader 端也注册进前台的眼睛poller里// 事件循环主循环节选loop{// 1. 先睁眼看看信箱里有没有纸条排空控制消息letmutdrained0;loop{matchmsg_rx.try_recv(){Ok(msg){drained1;handle_msg(msg,...);}Err(TryRecvError::Empty)break,Err(TryRecvError::Disconnected)return,}}// 2. 一个客人都没有时直接睡觉等纸条避免空转ifpoller.is_empty(){matchmsg_rx.recv(){Ok(msg)handle_msg(msg,...),Err(_)return,}continue;}// 3. 睡觉但耳朵里塞着哨子poll 时一起监视 wake 描述符letreadymatchpoller.wait(wait_ms,Some(wake_fd)){Ok(r)r,Err(_)continue,};// 4. 哨子响了 有纸条在信箱里 → 立刻醒先排空哨声再处理纸条ifready.contains(WAKE_ID){drain_wake(wake_reader);loop{matchmsg_rx.try_recv(){Ok(msg)handle_msg(msg,...),Err(TryRecvError::Empty)break,Err(TryRecvError::Disconnected)return,}}}// 5. 分类并按批派发就绪客人……// 6. 回收超时没动静的客人……}这一步做完整个系统的性质就变了客人按铃数据到达→ socket 就绪 → poll 立刻返回这是本来就有的服务员吹哨要登记客人→ 哨子字节到达 →poll 立刻返回接客线程吹哨来了新客人→ 哨子字节到达 →poll 立刻返回。poll 超时现在只在一个场景起作用真的什么都没有发生。它彻底退出了请求延迟路径。那个 5ms 的 P99 尖峰就这么消失了。这就回到了题目你不必每 5 分钟去厨房看一眼水开没开轮询水开了哨子会响事件驱动——但前提是你得让哨子能真正吵醒你。self-pipe 就是那个能吵醒你的哨子。顺手做的几件小事每个都踩过坑1. 按批叫服务员而不是一个一个叫就绪的客人如果一次来 100 个你要是一人一条消息往 channel 里塞就是 100 次锁竞争。我改成按 16 个一批打包constDISPATCH_BATCH:usize16;// 前台侧就绪客人按 16 个一组发if!to_dispatch.is_empty(){forchunkinto_dispatch.chunks(DISPATCH_BATCH){let_ready_tx.send(chunk.to_vec());}}就像餐厅铃响了前台一次叫3、4、5、6 号桌一起来而不是一桌一桌喊。2. Windows 的坑一次最多盯 64 个铃Windows 的select有个硬限制一次最多监视 64 个 socketFD_SETSIZE。所以 poller 得把客人分批盯。这里藏着一个新手必踩的坑如果每一批都用完整超时比如 5ms那么第 3 批的客人要等前两批各 5ms一共 15ms 才轮到。批越多延迟越大还是乘法增长。我改成只有第一批用完整超时后面所有批都用零超时——扫一眼就走有就收没有立刻下一批// 第一批用完整超时之后每批零超时// 第 k 批就绪的 socket绝不被前 k-1 批的超时拖累letbatchesself.fds.len().div_ceil(FD_SETSIZE).max(1);forbin0..batches{lettvifb0{full_tv}else{zero_tv};letnselect(0,mutreadset,mutwriteset,null_mut(),tv);// ...}顺带说一句哨子的耳朵被加进了每一批的读集合里所以哨子永远能打断第一批的睡眠。3. Windows 的另一个坑系统定时器太粗Winsockselect的唤醒粒度受系统定时器对齐影响。Windows 默认的定时器分辨率可能到 15.6ms——也就是说哪怕数据已经到了你的select也可能因为对齐到下一个定时器 tick而多等十几毫秒。解法是把进程定时器分辨率提到 1ms#[cfg(windows)]pub(crate)fnensure_high_resolution_timer(){staticINIT:std::sync::OnceOnce::new();INIT.call_once(||{unsafe{timeBeginPeriod(1);}// winmm进程级提升到 1ms});}这个坑很隐蔽它不会让你的程序出错只会让延迟莫名其妙地粗。你不测延迟尾部P99根本发现不了。慢客人服务员介入之前就把问题挡掉餐厅里最烦人的客人有两种点菜说半句就停住的Slowloris一个字一个字蹦请求和吃完赖着不走的keep-alive 羊群。我们的架构对这两种人天生免疫因为原则是客人没有真正按铃服务员就永远不来。新客人进门先是非阻塞的挂在 poller 上——不占服务员只有数据真的到了按铃才派服务员服务员用增量解析器处理——请求没读完就回到待着状态重新挂回 poller不占服务员分类TLS / h2 / h1用peek看前几个字节不消费数据——再慢的客户端也卡不住接客线程idle_timeout定期清走超时没动静的客人max_connections从门口就限流。// 增量解析器所有解析状态都住在这里// 半截请求可以挂起下次唤醒时原样恢复。structIncrRequest{buf:Vecu8,// 没消费的原始字节pos:usize,// 消费到哪了line:Vecu8,// 当前半截行phase:Phase,// 请求行 / 头 / 体 / 完成// ...}证据这些不是设计稿上的话是 CI 里跑出来的这是Github_Action_Benchmark.md里的真实数据每 PR 都跑失败挂流水线200 条点菜点一半的客人 只有 2 个服务员模型结果快速路径探测事件驱动哨子probe_ok295µs完成一次新请求旧模型一连接一池任务probe_blocked读超时——服务员全被半截客人占满了16 条 Slowloris逐字节蹦请求头10ms 一个字 2 个服务员模型结果探测事件驱动16/16 全部完成532µs完成新请求同样的思路用在 HTTP/3 的 UDP reactor 上把周期性select()唤醒停顿消除了P99 从 10.7ms 降到 ~0.3ms。原始日志长这样CONCURRENCY|caseidle_partial_herd|modelevent|platformlinux|statusprobe_ok|connections200|worker_threads2|probe_us295.48 CONCURRENCY|caseidle_partial_herd|modelpool|platformlinux|statusprobe_blocked|connections200|worker_threads2|probe_usna CONCURRENCY|caseslow_sender_herd|modelevent|platformlinux|statusok|connections16|worker_threads2|probe_us532.82|byte_delay_us10000|wall_ms395.22最后说点心里话这个设计的技术含量说实话不在自管道本身——这个技巧 Unix 程序员用了三四十年了。真正的价值在于那个 5ms 的 P99 尖峰教会我的事你花在等下一个 tick上的时间就是用户请求延迟的一部分。除非你专门设计一个机制让有事发生这件事本身能打断你的等待。很多性能问题不是代码太慢而是**“没事做的时候太慢”**。你把循环写得多快都没用因为瓶颈是它多久醒一次。self-pipe 解决的正是醒来这件事。而慢连接羊群、keep-alive、SSE——这些大多数时间没事做的连接才是真实互联网上占比最高的东西。一个服务器的真实水平往往取决于它怎么对待没事做的连接。这个调度器就是我们对这个问题的回答。上一篇一个 WINDOW_UPDATE 的小问题