Off-CPU 分析实战:CPU 使用率低但延迟高的四类根因与定位方法
Off-CPU 分析测量的是线程离开 CPU(被阻塞、被抢占、或在等待)期间的时间和调用栈。它解决一类在 top 里看不出病因的问题:CPU 使用率明明很低,延迟却居高不下、吞吐量怎么也上不去——请求在等,不在算。On-CPU profiling 与 off-CPU 分析互补:两者合计覆盖线程的 100% 时间。在一个真实的生产案例中,OpenResty XRay 把进程 99.8% 的 off-CPU 时间追踪到了 io.popen 及其管道句柄上的读取操作,修复后单核吞吐量从 126 RPS 提升到 18,537 RPS——150 倍。
Off-CPU 时间是什么?
当一个线程(或进程)正在 CPU 上执行指令时,我们称它处于 on-CPU 状态;当它因为某种原因被移出 CPU——网络 I/O 等待、锁竞争、子进程等待、文件/管道 I/O 阻塞、甚至调度器抢占——而无法继续执行时,它就处于 off-CPU 状态。
上图展示了一个线程在 on-CPU 与 off-CPU 之间交替切换的过程。off-CPU 时间是所有「暗区」的总和——线程想做事但被卡住的时间。
对于事件驱动的服务器(如 Nginx/OpenResty),worker 线程唯一「应该」阻塞的位置是事件循环自身的等待操作(如 epoll_wait 系统调用)。任何其他位置的 off-CPU 时间都意味着事件循环被卡住了,所有该 worker 上的并发连接都在空等。
症状:CPU 使用率上不去,请求却在排队
Off-CPU 瓶颈在监控面板上有一套典型的三联证据:
top显示 CPU 使用率卡在低位——进程持续运行,但 CPU 栏始终不涨。- access log 持续增长——请求确实在涌入,并非没流量。
- 加压也不涨——无论施加多大压力,CPU 就是上不去。
这个模式在不同语言的应用中反复出现:一个 Perl 进程卡在 13%、一个 Go 服务卡在 2%、一个 Python gunicorn worker 卡在 8%。CPU 使用率的具体数值各不相同,但根因一致:线程在等待,不在计算。
如果你在搜索“为什么 CPU 使用率很低但延迟很高”——答案几乎都在 off-CPU 方向:某个代码路径在执行同步阻塞操作(网络调用、子进程等待、文件读取……),线程被挂起,CPU 空转,请求排队。要找到具体阻塞在哪一行代码,需要 off-CPU 分析。
这样的阻塞同样会表现为 p99 尾延迟的周期性尖刺——在事件驱动的服务器中,单个 worker 被阻塞意味着该 worker 上所有并发请求同时受影响。但并非每次尾延迟尖刺都是 off-CPU 问题:我们在一个 50 万 QPS 的 OpenResty 网关中追踪到了 244 毫秒的延迟尖刺,根因却在 on-CPU 一侧——正则表达式低效的回溯模式在持续消耗 CPU 时间,而不是阻塞等待。两者从外部看症状完全一样;时间究竟花在等待还是计算上,正是 off-CPU 分析与 on-CPU 剖析各自要回答的问题。
四类 Off-CPU 根因的真实根因链
Off-CPU 时间的来源可以归纳为四大类。每类我们给出机制解释和一条真实的根因链——从系统调用一路追踪到业务代码的文件和行号。
1. 同步网络 I/O
机制:线程发出网络请求后同步等待响应,操作系统把线程挂起直到数据到达。
真实根因链:在一个 Perl 进程 CPU 使用率仅 13% 的案例中,off-CPU 火焰图展示的阻塞路径是:
select系统调用 →Perl_pp_select→Net::HTTP::Methods::can_read→read_response_headers→ 业务函数remote_fetch
鼠标悬停在火焰图中的 remote_fetch 函数框上,弹出的工具提示显示源文件路径和行号。跳转到第 66 行,可以看到代码正在发送 HTTP GET 请求并同步等待响应。
2. 子进程与 Shell 调用
机制:程序通过 exec/subprocess 等方式启动外部进程,然后 waitpid 等待其退出。在等待期间,调用方线程被阻塞。
真实根因链一(Go):在一个 Go 服务 CPU 使用率仅 2% 的案例中,off-CPU 报告自动推导出的阻塞路径是:
Syscall6→Process.wait(底层为waitpid)→exec.Cmd.Run→ 业务函数chat.RateLimit
源文件 chat/processor.go 第 46 行——RateLimit 函数调用了 exec.Command("/usr/bin/sleep", "0.01") 并执行 cmd.Run(),同步等待 shell 命令完成。
真实根因链二(Python):在一个 Python gunicorn worker CPU 使用率仅 8% 的案例中,排名第一的 C 级 off-CPU 路径占 96%,Python 级别的 #1 阻塞路径占了 92.4% 的阻塞时间:
poll系统调用 →subprocess.run→ 业务函数handle_by_script
源文件 processor.py 第 12 行——调用 subprocess.run 执行外部 bash 脚本并等待输出。
3. 同步文件与管道 I/O
机制:标准库的同步文件/管道 API(如 Lua 的 io.popen、file:read())会阻塞当前线程,直到 I/O 操作完成。在事件驱动框架中这尤其致命:阻塞的不是一个请求,而是整个事件循环。
真实根因链:在一个生产环境的 OpenResty 应用中,OpenResty XRay 发现了两处阻塞调用点——cfg-utils.lua 第 8 行的 io.popen 调用和第 14 行的 file:read() 调用(后者读取的是前者打开的管道句柄)。OpenResty XRay 将目标进程 93.8% 的 CPU 时间和 99.8% 的 off-CPU 时间追踪到了这两处调用。改用 OpenResty 的非阻塞 lua-resty-shell 库后,单核吞吐量从 126 RPS 提升到 859 RPS(7 倍);进一步用非阻塞 cosocket API 取代管道后,提升到 18,537 RPS(150 倍)。
此外,文件 I/O 的延迟有时也不容忽视。在另一个案例中,APR(Apache Portable Runtime)库函数经 ModSecurity 模块在 Nginx 进程内调用时,apr_generate_random_bytes 的单次读取延迟最高达 1,494 微秒(接近 1.5 毫秒),apr_sdbm_fetch 最高达 953 微秒。对 Nginx 这样以高并发低延迟著称的平台来说,即使一毫秒的同步阻塞也是严重问题。
4. CPU 争用型:就绪但抢不到核
机制:大多数 off-CPU 教程只讲“主动等待”——线程因 I/O 或锁被挂起。但还有一种容易被忽略的情况:线程已经就绪、操作系统也标记它为可运行(runnable),但因为没有空闲的 CPU 核可调度,它被迫等在就绪队列里。这是“非自愿下车”,不是“主动停车”。
真实根因链:在一个 Nginx worker 进程的 C 级别 off-CPU 分析中,OpenResty XRay 发现除了正常的 epoll_wait 等待栈之外,相当多的 off-CPU 时间落在了 mpi_mul_hlp、free 这样的纯 CPU 计算函数上。
一个线程在执行纯计算函数时出现 off-CPU 时间,说明它并非在等 I/O,而是已经就绪但拿不到 CPU 时间片——这是 CPU 资源争用的经典症状。进一步分析发现,根因是 Nginx 配置中缺少 worker_cpu_affinity 指令,导致多个 worker 进程被 Linux 内核在不同 CPU 核间频繁调度,产生不必要的上下文切换开销。该案例中单个 worker 的 RPS 只有 227–286,系统负载却已逼近 4(等于逻辑 CPU 核数)。
这类 off-CPU 根因的识别方法与前三类不同:不是看阻塞在哪个 I/O 系统调用上,而是看 off-CPU 时间是否出现在本应是纯 on-CPU 执行的函数上。
如何量化 Off-CPU 时间
定位 off-CPU 瓶颈需要两层测量手段:
双层调用栈分析:从系统调用到源文件行号
Off-CPU 分析中最核心的手段是对线程阻塞期间的调用栈进行采样。有效的分析通常需要两个层级对照使用:
- C/系统层级:展示系统调用栈(如
select、poll、waitpid、read),帮助判断阻塞的类型。 - 语言层级(Perl/Python/Go/Lua……):展示业务代码的调用栈,把系统调用映射到具体的业务函数、源文件和行号。
单看 C 层级只能知道“卡在 poll 上”——但到底是哪一行业务代码触发的 poll?需要语言层级的调用栈才能回答。在前述三个案例中,off-CPU 分析的终点都是一个可以打开的源文件行:Perl 源文件第 66 行、Go processor.go 第 46 行、Python processor.py 第 12 行。这种“追踪到行号”的可行动性正是 off-CPU 分析的核心价值。
事件循环阻塞延迟分布:量化影响面
对于事件驱动的服务器,仅知道“有阻塞”还不够,还需要量化阻塞的严重程度。OpenResty XRay 可以采样事件循环每次迭代的阻塞时长分布。在一个真实案例中,20 秒采样窗口内捕获了 43,952 个阻塞样本,单次最长阻塞达 75,165 微秒(75 毫秒)。这意味着每次事件循环被阻塞 75 毫秒时,该 worker 上的所有并发连接都在空等——这正是长尾延迟尖刺的直接成因。
On-CPU + Off-CPU = 挂钟时间:全局视角与工具生态
一个线程的挂钟时间(wall-clock time)= on-CPU 时间 + off-CPU 时间。优化 on-CPU 时间提升吞吐量(单位时间做更多有用工作),优化 off-CPU 时间降低延迟(减少无谓等待)。两个维度互补,缺一不可。
业界有多种 off-CPU 分析工具,但在生产环境中使用通常面临一些门槛:需要安装 agent 或内核模块、要求特定的内核版本、需要修改应用的启动参数、或者只支持特定的语言运行时。
OpenResty XRay 提供了一条自动化路径:通过 Guided Analysis 的“Low CPU usage and cannot go up”问题类型直接分析运行中的进程,无需安装 perf/eBPF 工具、无需修改代码、无需重启进程。它同时生成 C 级和语言级(Perl/Python/Go/Lua/Java……)两个层级的 off-CPU 调用栈分析,并自动推导出最显著的阻塞代码路径。Insights 页面还会自动监控在线进程,生成日报和周报,持续追踪 off-CPU 变化趋势。
常见问题
什么是 off-CPU 时间?
Off-CPU 时间是指线程离开 CPU 后、到重新回到 CPU 之间的时间——线程在这段时间内无法执行任何指令。常见原因包括网络 I/O 等待、子进程等待、文件/管道读写阻塞、锁竞争和 CPU 调度争用。On-CPU 时间与 off-CPU 时间之和等于线程的挂钟时间。
为什么 CPU 使用率很低但延迟很高?
因为线程大部分时间被阻塞在等待上,而不是在 CPU 上执行有用的工作。典型的根因包括同步网络调用(如等待 HTTP 响应)、同步子进程调用(如 subprocess.run、exec.Cmd.Run)、同步文件/管道 I/O(如 io.popen),以及 CPU 资源争用(就绪线程抢不到核)。Off-CPU 分析可以把阻塞路径追踪到具体的源文件和行号。
如何在不修改代码的情况下定位程序在等什么?
使用 OpenResty XRay 的 Guided Analysis 功能,选择“Low CPU usage and cannot go up”问题类型,直接分析运行中的未修改进程。系统自动生成 off-CPU 调用栈分析并推导出最显著的阻塞代码路径——鼠标悬停即可看到源文件和行号。全程不需要安装额外工具、不需要修改代码或重启进程。
Off-CPU 分析与挂钟时间 profiling 有什么区别?
挂钟时间(wall-clock)profiling 采样线程的全部时间,不区分是在 CPU 上执行还是在等待。Off-CPU 分析只聚焦于线程被阻塞的时间及其调用栈——它是 on-CPU profiling 的互补视角。当你的问题是“CPU 上不去”或“延迟高但 CPU 不高”时,off-CPU 分析直接对准病因。
关于 OpenResty XRay
OpenResty XRay 是一个动态追踪产品,它可以自动分析运行中的应用,以解决性能问题、行为问题和安全漏洞,并提供可行的建议。在底层实现上,OpenResty XRay 由我们的 Y 语言驱动,可以在不同环境下支持多种不同的运行时,如 Stap+、eBPF+、GDB 和 ODB。
关于作者
章亦春是开源 OpenResty® 项目创始人兼 OpenResty Inc. 公司 CEO 和创始人。
章亦春(Github ID: agentzh),生于中国江苏,现定居美国湾区。他是中国早期开源技术和文化的倡导者和领军人物,曾供职于多家国际知名的高科技企业,如 Cloudflare、雅虎、阿里巴巴, 是“边缘计算”、“动态追踪”和“机器编程”的先驱,拥有超过 22 年的编程及 16 年的开源经验。作为拥有超过 4000 万全球域名用户的开源项目的领导者。他基于其 OpenResty® 开源项目打造的高科技企业 OpenResty Inc. 位于美国硅谷中心。其主打的两个产品 OpenResty XRay(利用动态追踪技术的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最适合微服务和分布式流量的全能型网关软件),广受全球众多上市及大型企业青睐。在 OpenResty 以外,章亦春为多个开源项目贡献了累计超过百万行代码,其中包括,Linux 内核、Nginx、LuaJIT、GDB、SystemTap、LLVM、Perl 等,并编写过 60 多个开源软件库。
关注我们
如果您喜欢本文,欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:
翻译
我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!



















