第一章:延迟的根源

做低延迟系统,第一件事就是搞清楚——延迟到底从哪来?

我见过不少团队,一上来就撸起袖子优化代码。结果折腾半天,延迟只降了5%。为什么?因为他们根本没找到真正的瓶颈。今天我们就来拆解延迟的六大根源。

1. 网络延迟

网络延迟是最直观的。数据从A到B,光速是绕不过去的物理极限。

举个例子。北京到上海,光纤距离约1300公里。光在光纤中的速度大约是真空的2/3,也就是每秒20万公里。算下来,单程至少6.5毫秒。这还没算路由器转发、交换机排队的时间。

我在项目中遇到过最头疼的情况:两个机房之间延迟只有1.2毫秒,但业务要求端到端延迟低于500微秒。怎么办?物理距离摆在那,光速改不了。最后只能把两个服务部署到同一台物理机上,用共享内存通信。

关键结论:网络延迟的物理下限 = 距离 / 光速。想突破?要么缩短距离,要么换介质。

实际项目中,网络延迟还包括:

  • 网卡中断处理时间
  • 协议栈处理时间(TCP vs UDP)
  • 交换机/路由器排队延迟
  • 重传导致的尾部延迟

我个人习惯:做延迟敏感系统,优先用UDP + 应用层可靠传输。TCP的拥塞控制和重传机制,在低延迟场景下就是灾难。

2. 系统调用开销

系统调用,说白了就是用户态切到内核态。这个切换本身不贵,大概几十到几百纳秒。但架不住次数多啊。

你想想看,每次recv()、send()、epoll_wait()都是系统调用。一个高频交易系统,每秒处理百万级消息,光系统调用的开销就能吃掉几个核。

我曾经优化过一个网关程序。业务逻辑很简单,就是收包、解析、转发。但CPU跑满了,吞吐上不去。一分析,发现每秒做了30万次系统调用。后来用批处理 + 大页内存,系统调用降到了5万次,吞吐直接翻倍。

避坑指南:我曾经以为mmap能绕过系统调用。后来发现,mmap的缺页中断和TLB miss,在某些场景下比系统调用还贵。别迷信某个技术,要实测。

常见的优化手段:

  • 批处理:一次系统调用处理多个请求
  • io_uring:异步IO,减少系统调用次数
  • DPDK:用户态网卡驱动,彻底绕过内核
  • 共享内存:进程间通信不走内核

3. 数据拷贝

数据拷贝是隐藏的杀手。你写代码时可能没感觉,但数据在内存里搬来搬去,CPU时间就这么被吃掉了。

一个典型的网络收包流程:

  1. 网卡DMA到内核缓冲区
  2. 内核拷贝到用户态缓冲区
  3. 应用层解析时可能再次拷贝
  4. 序列化/反序列化又是一次拷贝

你看,一次收包,数据被拷贝了3-4次。每次拷贝都占用CPU和内存带宽。对于100Gbps的网卡,这开销相当可观。

核心原则:零拷贝不是玄学,是实打实的性能优化。能传指针就别传数据,能共享内存就别走网络。

我记得有一次做行情解析系统。原始方案用protobuf序列化,每次解析都要拷贝。后来改成直接内存映射 + 指针操作,延迟从10微秒降到了2微秒。嗯,这就是零拷贝的威力。

4. 上下文切换

上下文切换,就是CPU从一个线程切到另一个线程。每次切换,要保存寄存器、刷新TLB、加载新线程的上下文。一次切换大概1-10微秒。

听起来不多?但如果你有100个线程在竞争CPU,每个线程每次只跑几十微秒就切走,那CPU大部分时间都在做切换,而不是做正事。

我见过最夸张的一个案例:某团队用200个线程处理网络请求,每个请求处理时间只有50微秒。结果CPU利用率80%都花在了上下文切换上。后来改成8个线程 + 异步IO,同样的硬件,吞吐提升了5倍。

我个人建议:低延迟系统里,线程数不要超过CPU核数。最好就是1:1绑定。别让操作系统帮你调度,你自己控制。

上下文切换的优化方向:

  • 减少线程数:用协程或异步模型
  • CPU亲和性:线程绑定到固定核
  • 避免锁竞争:锁会导致线程阻塞和切换
  • 使用忙等:在极低延迟场景,忙等比阻塞更可控

5. 锁竞争

锁竞争,说白了就是多个线程抢同一份资源。抢不到的就等着,等的时候CPU在干嘛?要么忙等(自旋锁),要么切走(互斥锁)。

不管是哪种,都是延迟的根源。而且锁竞争有个特点:随着线程数增加,性能不是线性下降,而是断崖式下跌。

为什么会这样?因为锁竞争会导致缓存行 bouncing。CPU核A改了数据,核B的缓存行就失效了。下次核B访问,得重新从内存加载。这个开销,比锁本身大得多。

经验之谈:我做过一个测试,无锁队列和加锁队列的对比。在8核机器上,无锁队列的吞吐是加锁队列的10倍。锁,能不用就不用。

常见的无锁技术:

  • 无锁队列:基于CAS操作
  • 读写分离:读操作不加锁
  • 分区设计:每个线程处理独立的数据分区
  • RCU(Read-Copy-Update):读操作无锁

避坑指南:我曾经在项目里用了一个号称无锁的队列库。结果压测时发现,CAS操作在高并发下会导致ABA问题,修复后性能还不如加锁。嗯,无锁编程比想象中难得多。

6. 内存分配

内存分配,很多人不重视。觉得malloc/free就是调个函数,能有多慢?

实际上,一次malloc可能触发:

  • 用户态堆管理器的锁竞争
  • 系统调用(brk/mmap)
  • 缺页中断
  • TLB miss

这些加起来,一次内存分配可能耗时几微秒。在高频场景下,几微秒就是灾难。

我记得有个实时风控系统,每次请求都要分配和释放内存。压测时发现,内存分配占了总延迟的30%。后来改成对象池 + 预分配,延迟直接降了40%。

我常用的做法:启动时一次性分配好所有内存,运行时只复用,不释放。说白了,就是自己管理内存。

内存分配的优化策略:

  • 对象池:复用对象,避免频繁分配
  • 栈分配:小对象用栈,避免堆分配
  • 大页内存:减少TLB miss
  • 预分配:启动时分配好,运行时零分配

知识体系总览

下面这张图,把六大延迟根源的关系梳理清楚了。你可以看到,它们不是孤立的,而是相互影响的。

延迟的六大根源 延迟 根源 网络延迟 物理距离/协议栈 交换机排队 系统调用开销 用户态/内核态切换 上下文保存恢复 数据拷贝 内存带宽消耗 CPU缓存污染 上下文切换 寄存器保存/恢复 TLB刷新 锁竞争 缓存行bouncing 线程阻塞/忙等 内存分配 堆管理/系统调用 缺页中断/TLB miss 六大延迟根源相互影响,优化时需全局考虑

总结

六大延迟根源,每个都值得深入钻研。但记住一点:不要盲目优化。先测量,找到真正的瓶颈,再动手。

我见过太多团队,花了几周优化网络延迟,结果发现瓶颈在锁竞争。嗯,方向错了,努力白费。

下一章,我们会深入网络延迟,聊聊网卡、协议栈和DPDK。到时候见。

一句话记住:延迟优化的第一步,不是写代码,是找到延迟在哪。


公众号:蓝海资料掘金营,微信deep3321