写 Parquet 时的随机段错误与悬垂 Builder
写 Parquet 时的随机段错误与悬垂 Builder
Arrow,Parquet,悬垂指针。
快照聚合链路重构之后,远程生产机上会在写 Parquet 时随机段错误,进程退出码是 \(139\)。同一次运行里的逐笔写入却一直正常。两边写文件用的 Arrow 代码几乎一样,这个差异让排查绕了不少路。
当时的运行环境是 CentOS 7.9、GCC 12.2.0、Apache Arrow/Parquet 21.0.0,Arrow 使用静态库链接。读取、排序和写入各 \(4\) 个线程,日志使用 fmtlog,轮询间隔 \(1\) ms,flushDelay 为 \(0\)。
聚合阶段本身看起来没有问题,最后能看到的只有两条日志:聚合阶段结束,以及聚合结果开始写文件。日志里带的模块名、条数和输出文件名这里不展开。
后面应该出现的 write cost: ... 一直没有打印,进程直接退出。fmtlog 的日志延迟很低,所以基本可以把崩溃范围放在开始写文件那条日志之后、写完成日志之前的写函数内部。
同一次运行中逐笔写入是成功的,两次分别记录到 write cost: 1166ms 和 826ms。只有快照写入会崩,而且它和逐笔写入调用的几乎是同一套 Arrow 代码。
先从 dmesg 看系统留下的信息:
segfault at 1420 ip 00007f0761e4aee7 sp 00007ffc75e8eaf0 error 4 in libWriter.so
错误地址是 0x1420,数值很小,很像空指针加了一个成员偏移。崩溃位置在 libWriter.so。
再看 gdb 调用栈:
#0 arrow::internal::Executor::Submit
#1 arrow::internal::ParallelFor<FileWriterImpl::WriteRecordBatch lambda>
#2 parquet::arrow::FileWriterImpl::WriteRecordBatch
#3 parquet::arrow::FileWriterImpl::WriteRecordBatch
栈下面几帧是内部快照写入的调用,名字这里不展开。栈顶已经进到了 Arrow 内部的线程池,写入在 set_use_threads(true) 之后会走 ParallelFor 并行编码列。只看这里,Arrow 线程池很像元凶,不过后面 ASAN 给出的结果说明,它更像是被破坏栈内存之后撞上来的受害者。
中间查过两个方向。
第一个怀疑是跨 DSO 的 Arrow 全局状态分裂。Arrow 静态库被主程序和另外 \(5\) 个动态库分别链接,如果线程池或内存池出现多份实例,跨库释放确实可能延迟崩溃。用 nm -D 看符号后,大部分引用都归到同一个导出副本,其他位置显示为未定义引用,没有看到多份并行存在的定义,于是暂时没再往这个方向查。
第二个怀疑是聚合阶段把堆写坏了。快照聚合在内存里处理 \(2473\) 万行,峰值大约 \(6\) GB,多线程合并和重采样只要有一处越界,也可能到写 Parquet 时才炸。把多线程聚合里共享数据的写入边界看了一遍,没有找到明确的越界点。这里只能说当时没有发现证据,不能据此证明聚合阶段一定没有问题。
后来编了 ASAN 版本复现,崩溃先出现在逐笔写入路径:
ERROR: AddressSanitizer: stack-use-after-scope on address 0x7ffe22cf6ae8
WRITE of size 1 at 0x7ffe22cf6ae8 thread T0
#0 parquet::ArrowWriterProperties::Builder::set_use_threads(bool) properties.h:1246
#1 writeSummary writer.cpp:706
#2 operator() writer.cpp:946
...
stack-use-after-scope 表示在写一块作用域已经结束的栈内存。ASAN 的帧信息里,set_use_threads 的 \(\text{this}\) 指向的栈槽已经被标记为释放。
对应的代码是:
auto arrowPropsBuilder = ::parquet::ArrowWriterProperties::Builder().store_schema();
if (writerUseThreads) arrowPropsBuilder->set_use_threads(true);
auto arrowProps = arrowPropsBuilder->build();
问题在第一行。Builder() 先创建一个临时对象,store_schema() 返回的是这个对象的 \(\text{this}\) 指针。走到分号时完整表达式结束,临时对象析构,arrowPropsBuilder 就变成了悬垂指针。后面的 set_use_threads() 和 build() 仍然通过它写内存,实际上操作的是一块已经销毁的栈空间。
函数返回 \(\text{this}\) 时,指针本身不会延长临时对象的生命周期。这里和用引用绑定临时对象不是一回事,引用有延长规则,裸指针没有[1] 。
逐笔和快照两条写路径都有这段代码,快照那边大概率是从逐笔代码复制改写过来的。没有 ASAN 时,这段 UB 的表现就取决于栈槽后来有没有被复用。逐笔运行时那块内存暂时没被覆盖,所以看起来正常;快照路径继续调用了一些函数,原来的栈槽被改写,最后在 Arrow 的线程池附近崩掉。
修的时候把 Builder 改成具名对象,让它的生命周期覆盖后续调用:
auto arrowPropsBuilder = ::parquet::ArrowWriterProperties::Builder();
arrowPropsBuilder.store_schema();
if (writerUseThreads) arrowPropsBuilder.set_use_threads(true);
auto arrowProps = arrowPropsBuilder.build();
逐笔和快照两处一起改了,也全局搜过 Builder().xxx() 这种写法,没有留下同类代码。
ASAN 版本随后把三个流程都跑通了。第一组逐笔数据是 \(1708\) 万行,写入耗时 \(8960\) ms,对比通过;第二组是 \(1245\) 万行,写入耗时 \(6779\) ms,对比通过。原来会崩的快照路径处理了 \(2474\) 万桶,记录到的两次写入耗时分别是 \(11396\) ms 和 \(9983\) ms,也都成功落盘,没有再次报错。这些时间来自 ASAN 构建,只能说明流程跑完,不适合直接拿去和正常构建比较性能。
现在两处写入路径都使用具名 Builder,ASAN 下也完整跑过了。后来再遇到随机段错误,我会先把 ASAN 构建排进流程里;gdb 栈仍然要看,不过它如果指到 Arrow 线程池这种公共组件,不一定就是库本身出了问题。

浙公网安备 33010602011771号