当 HotXLS 在一次调用中为大型工作表 XML 部分计算校验和时,Delphi 工作线程可能在没有可捕获异常的情况下崩溃:当输入数据超过约 119 KB 时,zlib-ng 会切换到 Chorba 算法,而该算法的 generic-C 变体会在栈上分配足以耗尽默认 1 MB 线程栈的临时数组。Delphi 没有机会作出响应,因为栈溢出并不是 try/except 所设计用来捕获的异常类型
HotXLS 是一个用于读写 Excel 工作簿的原生 Delphi 和 C++Builder 库,这次崩溃最终追溯到了它的工作表写入器。最初的线索来自一张支持工单:夜间导出作业每周大约崩溃两次,总是在运行中途发生,没有 Delphi 异常对话框,也没有记录错误,只有一个突然消失的进程,以及一条没有提供有用线索的 Windows 错误报告。后来要在开发人员桌面上复现却完全是另一回事。小型工作簿保存正常,大型工作簿也能正常保存,只要保存操作在主线程上运行且调试器已经附加。直到让一批接近生产规模的文件走过真实的多线程导出路径,才终于稳定复现崩溃;在那之前,磁盘 I/O、内存压力和可疑模板都已经分别排除
工作表保存如何变成一次巨大的 CRC32 调用
XLSX 文件是 ZIP 容器,而 ZIP 格式要求为每个条目计算 CRC-32 校验和,并同时记录在本地文件头和中央目录中。HotXLS 通过一个名为 ZLibCRC32 的小型包装器计算该校验和:SaveAs 在内存中完成工作表 XML 的组装后,包装器会调用 zlib-ng 自己的 crc32 例程。很长一段时间里,这个调用会在一次调用中传入完整的未压缩缓冲区。对于小型工作表,这是合理的设计;但对于我们的 HotXLS 大型工作簿性能指南所讨论的那类工作表,单个工作表的 XML 在压缩之前就经常超过几百 KB,此时它会变成一次非常大的调用
zlib-ng 为什么需要巨大的 CRC32 栈缓冲区
zlib-ng 并不会对每次调用都使用同一个 CRC-32 实现。在低于尺寸阈值时,它通过查表和折叠技巧遍历缓冲区,几乎不需要额外内存;超过该阈值时,也就是约 119 KB,具体来说是 HotXLS 所链接构建中的 118,960 字节,它会切换到一种名为 Chorba 的专用快速算法。该路径的 generic-C 实现以空间换速度:它不是在堆上分配,而是在栈上分配临时数组,大小是为了让算法的内层循环运行得更快,并没有考虑调用线程实际拥有的栈预算是否足够。调用方无法从外部看到这些细节。校验和函数通常是叶子调用,读取一些字节后返回一个数字,不会进行值得讨论的分配;绝大多数 zlib-ng 调用都符合这种假设,直到一个足够大的缓冲区跨过 Chorba 阈值
function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
// One call over the whole worksheet XML buffer: fine for a small
// sheet, but a large enough input pushes zlib-ng onto its Chorba
// fast path and that path's stack-hungry scratch buffer
Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;
为什么工作线程会触发,而交互式调试却不会
要触发这次崩溃,必须同时满足两个条件:工作表 XML 部分足够大,跨过 zlib-ng 的 Chorba 阈值;线程只有普通的默认栈,而不是更宽裕的栈。生产导出作业恰好同时满足这两个条件。它们作为将 HotXLS 写入操作分摊到工作线程池中的服务端批处理作业运行,每个线程都使用 Windows 在调用方未要求更大栈时预留的默认 1 MB 栈,并且处理足够大的客户工作簿。桌面调试很难可靠满足任一条件:示例文件通常小于阈值,而单步运行往往发生在主线程上,而不是新创建的工作线程中,因此生产环境中必须同时出现的两个条件几乎不会在开发人员桌面上同时出现
追查一场把错误归咎于错误函数的崩溃
团队能够拿到的崩溃报告指向 zlib-ng 的 deflate 函数内部,既没有指向任何 HotXLS 代码,也没有明显指向 CRC-32 代码。这个细节让调查的第一轮转向压缩路径:传给 deflate 的缓冲区大小、窗口位数、压缩级别,这些都是原生编解码器发生崩溃时最常见的嫌疑对象。但它们都经不起验证
误导性的顶部栈帧
栈溢出是一种很难进行符号化的崩溃,因为报告生成时,栈指针已经越过为它预留的空间。生成该崩溃报告的组件很可能只能把故障地址解析到仍能找到的最近符号,而恰好位于真正故障点旁边的最近导出入口是 deflate。真正的故障发生在 CRC-32 路径中 Chorba 的临时缓冲区分配处,该代码被编译进同一个库,二进制位置又足够接近,因此被误认为是实际正在运行的函数
不用调试器,而用时间戳进行二分定位
一次拖垮整个进程的崩溃不会留下任何东西供普通 Delphi 调试器会话捕获,因此团队改用 GetTickCount,在每个可疑调用前后写入检查点,并沿保存路径手动二分,逐步缩小进程死亡瞬间正在执行的操作范围。同时,一个已知正常的基线构建与当前构建并行处理同一批生产文件,专门用于排除当前这一轮自身改动引入回归的可能。只有两项检查都返回正常后,调查才确定问题来自第三方依赖对完全有效的输入执行了出乎意料的操作
为什么 try/except 无法捕获栈溢出
栈溢出不是 Delphi 代码会主动引发的异常,它的传递方式也不同于 Windows 传递访问冲突或除零错误的方式。它表现为硬件保护页故障,通过 Delphi try/except 所依赖的同一套结构化异常处理机制报告;但故障触发的准确时刻通常已经没有足够的栈空间来运行处理程序、展开清理代码,甚至无法完整地报告故障。在一个只带默认 1 MB 保留栈的工作线程上,如果临时缓冲区已经消耗了大部分剩余空间,运行时就没有可用空间了
procedure TExportWorker.Execute;
var
Workbook: TXLSXWorkbook;
begin
Workbook := TXLSXWorkbook.Create;
try
try
BuildWorksheet(Workbook);
Workbook.SaveAs(FTargetFile); // crashes the process here on a
// large enough sheet: try/except
// never gets a chance to run
except
on E: Exception do
LogError('Export failed: ' + E.Message);
end;
finally
Workbook.Free;
end;
end;
这个 except 代码块看起来像一张安全网,对大多数故障确实如此,但在这里不起作用。团队在实践中确认了这一点:try/except 什么也捕获不到,finally 代码块也没有可靠的机会运行,操作人员看到的是一个已经结束的进程,应用层完全没有日志记录,这与最初支持工单描述的情况完全一致
修复方式:将 CRC32 分成 64 KB 分片,而不是一次性巨型调用
HotXLS 发布的修复既没有改动 zlib-ng 本身,也没有改动写入工作簿时使用的压缩级别。ZLibCRC32 现在会将输入分成固定的 64 KB 分片,每片 65536 字节,每个分片调用一次 zlib-ng 的 crc32,并将正在计算的校验和值从一次调用传递到下一次调用。CRC-32 天然就是增量算法,因此分多个分片累积得到的校验和,与对相同字节进行一次完整调用得到的结果逐位相同:修复改变的是任务拆分方式,而不是计算结果
function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
// 64 KB keeps every call comfortably under the Chorba threshold
CrcChunkSize = 65536;
var
Cursor: PByte;
ThisChunk: Longint;
begin
Result := crc;
Cursor := PByte(@buffer);
while count > 0 do
begin
ThisChunk := count;
if ThisChunk > CrcChunkSize then
ThisChunk := CrcChunkSize;
Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
Inc(Cursor, ThisChunk);
Dec(count, ThisChunk);
end;
end;
外围的 SaveAs 调用无需为此改变,HotXLS 写入的 ZIP 条目也没有任何变化:最终写入本地文件头和中央目录的 CRC-32 值,与一次巨型调用本应产生的值完全相同,只是通过更小的片段逐步组装。降级 zlib-ng,或改用更慢但分配内存更少的 CRC-32 实现,也能避开崩溃,但会让原本根本没有接近阈值的每个文件都付出实际代价,因此这两种方案都没有发布
如果你在自己的工作线程中调用 zlib-ng,这意味着什么
这里描述的栈溢出故障模式并不专属于电子表格。任何应用只要在线程仅拥有平台默认栈的情况下,把大型缓冲区交给 zlib-ng 进行压缩、解压缩或校验和计算,就可能遇到同样的限制,因为库会根据输入大小选择算法,而其中一些算法默认调用栈还有充足空间。有两种防护方式无需改动 zlib-ng:对于天然支持增量处理、且会受输入大小影响的例程,将大型缓冲区固定分块传入,可以直接消除触发条件;如果无法分块,则让调用线程拥有大于平台默认值的栈是另一种手段。无论选择哪一种,都比从一份把错误归咎于错误函数的生产崩溃报告中得知未公开的尺寸阈值更省代价
直到一个足够大的生产工作簿在错误类型的线程上跨过该阈值,这个具体阈值才暴露出来,这正是只有代码面对真实文件而不是小型测试样例时才会出现的故障类型。现在,分块 CRC-32 路径已经作为标准写入流水线的一部分,随面向 Delphi 和 C++Builder 的HotXLS Excel Component一同提供,调用方无需配置,也没有用于启用或禁用它的属性