CancellationToken:程序员必读的悬空指针灾难案例
在现代多线程和异步编程中,CancellationToken 是一个常见的工具,用于及时、安全地取消长时间运行的任务或操作。然而,在处理此类对象时,如果不慎操作,也可能导致悬空指针这一严重问题,进而引发程序崩溃或者难以预测的行为。本文将通过分析一个 Rust 环境下的实际案例,探讨由使用 tokio_util::sync::CancellationToken 所引起的悬空指针问题。
CancellationToken:程序员必读的悬空指针灾难案例
案情重现与分析
在多线程异步任务程序开发过程中,我们遭遇了一场由于 CancellationToken 的使用而导致的悬空指针问题。该问题的核心在于,当一个线程持有指向某个正在执行任务对象的指针,并通过 CancellationToken 请求取消该任务时,如果取消动作触发了对象的提前析构,而其他线程仍试图访问这个已被析构的对象,则会产生悬空指针异常。
在上述例子程序中启动一个异步任务读取文件,然后在主线程中等待 100ms 取消任务。请注意读取文件的方式采用:tokio::task::spawn_blocking。同时准备一个超过 2G 大小的文件,确保 100ms 内读取不会完成。
运行程序,在函数 read_data_from_file()中会发生如下现象 async fn read_data_from_file(file: Arcstd::fs::File) -> usize {let mut buf: Vec<u8> = vec![0_u8; 210241024*1024];
}这说明异步任务被取消后,并没有等待 read_file()执行完成后再取消,而是直接打断了 read_data_from_file 函数的执行。聪明的你有没有发现这将导致函数中局部变量 buf 的提前释放,但后面的异步读取文件还会继续使用这块内存,造成悬空指针问题。通过用文章后面附录提供的内存分配、释放跟踪可以证明这一点:异步任务被取消后局部内存立马释放。
我们可以尝试用阻塞式读取文件,而不是用 tokio::task::spawn_blocking;也就是把读取相关代码改成如下方式,观察会发生什么现象。
采用此种方式异步任务被取消后,会等待 read_file()执行完成,不会造成悬空指针问题。因为在一个阻塞方法中,只有执行完成后异步任务才会有机会被取消。
重返现场
在 CnosDB 长期稳定性测试环境中我们会经常利用 coredump 文件排查故障。分析 coredump 文件发现每次挂掉的位置都不同,而且还经常挂掉在不同的第三方库,更甚至是一些不可能发生问题的代码处。我们也是怀疑内存写坏导致的,但苦于找不到具体原因,我们同时通过以下方式进行了排查:
分析近期的代码变更、并测试可疑的提交
排查可疑的第三方库
不同的编译、打包环境尝试
使用Valgrind进行测试排查
针对不同程序模块进行排查测试
添加调试、测试代码等
通过 asan 进行测试排查
最终,我们通过 asan 发现如下现象,说明存在内存释放后继续使用的问题。
然后,再通过 addr2line 找到相应调用栈定位到错误使用内存之处,也就是下面函数中的 data 参数被释放了但是继续使用。
虽然,通过工具发现了内存释放后继续使用的问题;但是,一时并没明白这块内存到底是怎么被释放的;最终,把怀疑的目光放到了 CancellationToken 这一块,然后通过测试确认了这一点。下面是具体使用之处,series_iter_closer 是一个 CancellationToken 类型的变量,iter.next()会最终调用上面的 pread()方法。
至此,由于悬空指针导致的惨案已经分析完毕。
解决方案
我们此处短期的解决方案是修改 pread 函数为直接调用 os::pread(fd, pos, len, ptr)变为一个阻塞调用;长期会修改 pread 函数改为内部分配 buffer 的方式,不再通过外部传入参数来避免悬空指针问题。
附录
自定义内存分配释放器
作者简介:
“Hi,我是允哥,一个内向且不善言谈的大厂老鸟,喜欢默默地在代码丛林中捉虫(Bug),目前是 CnosDB 的一名工程师。”
版权声明: 本文为 InfoQ 作者【CnosDB】的原创文章。
原文链接:【http://xie.infoq.cn/article/2f83701001040a483248ad603】。文章转载请联系作者。
评论