接口卡死排查实录-缺失return的UB死循环

18 阅读7分钟

接口卡死排查实录:一个缺失的 return 0,被 -O3 优化成了死循环

一次"昨天还好好的,今天就卡死"的经典排障。现象诡异、定位曲折,最后根因却简单得让人后怕——一个声明返回 int 却漏写 return 的函数,在高优化等级下被编译器利用未定义行为,优化成了无限循环

本文按排查顺序记录全过程,涉及的关键技术点:未定义行为(UB)、GCC 优化、调用栈分析、反汇编核实。


一、背景:昨天加了新功能,今天接口就卡了

我们的图像处理服务(下文称 ImageSvc)跑在 Docker 容器里,一组实例对外提供图像优化接口。业务侧反馈:调用图像优化接口特别慢,请求发出去几十秒都不返回

时间点很可疑——就在昨天,我们刚做了两件事:

  1. 支持苹果图片(HEIC,伪装成 .jpg 的文件)解码,接入 libheif;
  2. 顺手把构建体系从"命令行一长串 -D 参数"收敛成 CMakePresets + 构建脚本,编译器从旧 GCC 换成了 g++-9,优化等级 -O3,并改成静态链接。

直觉判断:回归大概率出在这两件事里。业务侧还给了个重要线索:"关闭算法也慢"——也就是说,接口里就算不做任何图像处理,请求照样卡。那问题基本可以锁定在读图链路,而不是算法本身。


二、现象复现:CPU 200%,永不返回

用一张普通 JPG(1984×2808)打接口,参数全部关掉算法:

  • 新二进制:请求  >60s 不返回,进程 CPU 200% (两个线程各占满一个核);
  • 同机器上保留的旧版二进制:同样的请求 0.26s 返回,CPU 正常。

新旧对照,回归面锁定。再配合 top 看 CPU 飙满 + 无密集 IO,基本判断是用户态死循环或忙等(不是卡在磁盘、网络或锁上)。

顺带一提,接口对 HEIC 伪装 JPG 和普通 JPG 都卡,说明问题不在 libheif 解码本身,而在两者必经的公共路径上。


三、定位:调用链 → 抓栈 → 反汇编

1. 先读懂调用链

从路由入口一路追下去:

1/api/opt2  → OptApiHandler::handleRequest3    → autoOpt(图片参数)4openImageFile(读取原图 + 解析 DPI/旋转信息)   ← 读图入口

openImageFile() 就是读图入口,业务侧"怀疑读图部分有问题"的判断方向是对的。

2. gdb attach 抓调用栈

进程 CPU 满负荷但 strace 看不到忙系统调用,需要看用户态在干什么。gdb attach 上去,thread apply all bt

1Thread N (worker):2  #0  easyexif::EXIFInfo::~EXIFInfo()          ← 析构函数附近3  #1  openImageFile()4  #2  autoOpt(...)5  #3  OptApiHandler::handleRequest(...)

栈顶停在 easyexif::EXIFInfo 析构附近——这是一段解析 EXIF 的代码。但奇怪的是:正常解析不该花几个 CPU 核。栈帧只说明"此刻在这",不代表"卡在这"。gdb 没有调试符号,栈可能被内联折叠,还得上更硬的证据。

3. 反汇编:铁证出现

对 openImageFile 的机器码做反汇编(objdump),盯着函数尾部看:

1   ...写 dpi 字段...2   call  ~EXIFInfo()          ; 析构临时对象3   ...dpi 比例计算...4   jmp   上一段             ; ← 没有 ret!跳回去重来

正常路径的尾部没有 ret 指令,而是一个 jmp 跳回前面,形成一个无限循环。也就是说,openImageFile() 每次走到函数末尾就"再来一遍",永远不返回——这就是 CPU 200% 的来源。

为什么会有这种代码?答案指向源码本身。


四、根因:一个缺失的 return,一次 UB 的爆发

回到源码,openImageFile() 的声明是:

1int openImageFile(char* file, cv::Mat& mat, int& dpi_x, int& dpi_y)2{3    ...4    // 函数末尾:写完 dpi、算完比例……就结束了5    // 没有 return 0;  ← 问题所在6}

函数声明返回 int,但主路径末尾没有 return——在 C++ 里,这属于未定义行为(UB) 。大多数编译器会"宽大处理",随便返回个寄存器里的残留值,程序照常跑,所以这么多年都没出事。

但昨天我们换了 g++-9 + -O3。优化器遇到 UB 时,可以假设任何不可能的事情都成立,于是它把函数尾部的代码重排成了:

写 dpi → 析构 EXIF 对象 → 算 dpi 比例 → 跳回去再执行一遍 → …

一个没有 ret 的尾部,配合 jmp 回跳,openImageFile() 被优化成一台"永动机"——这正是 GCC 对"函数无返回值的 UB"的典型"优化成果":它假定函数永远不会返回(因为按 UB 语义它想干嘛都行),于是干脆不给它生成返回路径

验证方法很简单、也很有说服力:

编译方式结果
原代码 + -O3卡死(CPU 200%,永不返回)
原代码 + -O1恢复正常(规避优化,但 UB 还在)
原代码 + -O3 + 补 return 0;恢复正常(正解,消除 UB)

-Wreturn-type 其实早就警告过我们:

1warning: control reaches end of non-void function [-Wreturn-type]

只是这条警告太常见,一直没当回事。


五、修复:删死代码 + 补 return,diff 只有 +2 / -42

修复做了三件事,都在 openImageFile() 这一个函数里:

  1. 删掉整段 EXIF 解析"死代码" (约 40 行):这段代码会把整个图片文件读进内存再丢给 easyexif 解析出 Orientation,但解析结果 nOrientation 从来没有被使用过(按它做的旋转代码早就被注释掉了)——纯浪费一次全文件 IO,还引进了这次 UB 的导火索(那个要析构的 EXIFInfo 对象);
  2. 删除无用的 easyexif::EXIFInfo m_exif; 局部对象
  3. 函数末尾补上 return 0; ——消除 UB 的根本手段。
1 commonUtil.cpp | 44 +-----------------------------------2 1 file changed, 2 insertions(+), 42 deletions(-)

修复后同一张图实测:

场景修复前修复后
普通 JPG(1984×2808)卡死 >60s0.25s(连续 5 次压测 0.23~0.25s 稳定)
HEIC 伪装 JPG卡死 >60s0.06s

顺带一提,HEIC 首次访问会经 youtuImread 解码并原地转成真 JPG(文件头从 ftyp 变成 ffd8ffe0),大图首次访问多花 1~2s 属于预期,第二次起走常规路径。


六、经验教训

  1. 编译告警要当真,尤其是 -Wreturn-type / -Wall。"control reaches end of non-void function" 不是噪音——在 UB 世界里,它可能是你代码里的定时炸弹。建议 CI 里把这条告警升级为错误(-Werror=return-type)。
  2. UB 的可怕之处在于"换编译器/升优化等级才爆发" 。同一份代码跑了几年没事,换 g++-9 -O3 后一夜之间变成死循环——这不是编译器"抽风",而是它开始认真地利用你代码里的未定义行为做优化。升级编译链或优化等级后,一定要做性能/行为回归
  3. "卡在析构函数附近"的栈是假象。gdb 抓到的栈顶只能说明"此刻执行到哪",配合反汇编看控制流才能下结论——栈会骗人,机器码不会。
  4. 死代码是隐患的温床。这段 EXIF 解析不仅从未生效,还每次请求白读一遍整个文件(大图场景就是纯浪费)。删代码不亏。
  5. 排查方法论值得复用:现象复现(新旧对照锁定回归面)→ 调用链梳理 → 抓栈缩小范围 → 反汇编下结论 → 最小修复(补 return)→ 双路径验证(-O1 规避 vs 正解)。每步都有独立证据,不靠猜。

附:一句话总结

一个漏写的 return 0,在高优化编译器眼里等于"这个函数永不返回",于是它真的让函数永不返回。  写 C/C++,-Wall -Werror=return-type 从第一天就该开。