大概两个月前,我在 Linux ARM 设备上排查一起 Chromium 的偶发崩溃。现象不算复杂:硬件解码播放视频时,用户把页面拖到一半突然关闭标签页,大约每二三十次操作就有一次闪退,崩溃线程不在主线程,而在一个名为 hw_decoder 的专用线程上。第一次抓到的栈回溯指向 PC 值为 0,指针完全无效,我一度怀疑是 Rockchip 平台的驱动或用户态封装又出了什么幺蛾子。查了两天后才明白,这场崩溃跟硬件一毛钱关系没有,问题出在异步回调把对象的生命周期算错了一张多米诺骨牌。这次修复本身改动不到十行,但排查过程把 Chromium 异步编程里几个很容易被忽视的规则全部趟了一遍。这篇博文就把完整链路写下来,给同样在 Chromium 或类似 C++ 异步架构里做开发的同行做个参考。
1. 崩溃现场:一个每二三十次必现的闪退
1.1 复现路径与崩溃特征
先说环境:设备是 RK3588 的 Linux ARM 板子,Chromium 启用 V4L2 硬件视频解码,播放的是 H.264 1080p 视频。复现路径非常明确:
- 打开一个视频播放页面,等待首帧渲染完成;
- 快速拖动进度条两三次,让解码器连续进入 decode 流程;
- 在最后一次 seek 后 500ms 内直接关闭标签页;
- 大约每二三十次操作,浏览器进程就会闪退。
崩溃特征也很有意思。崩溃线程不是浏览器主线程,而是硬件解码线程 hw_decoder。抓到的栈回溯长这样:
text复制Thread 8 (hw_decoder_thread):
#0 0x0000007f9c12ab34 in media::RockchipVideoDecoder::DecodeOnHardwareThread()
#1 0x0000007f9c12cd58 in base::internal::Invoker<base::OnceCallback<void ()>>
#2 0x0000007f9c12e9ef in base::TaskAnnotator::RunTask()
#3 0x0000007f9c12aabc in base::Thread::Run()
#4 0x0000007f9c12bf90 in base::MessagePump::Run()
#5 0x0000007f9c12c1a7 in base::RunLoop::Run()
注意 #0 这一帧,函数还在,但 PC 指向的是一个完全无效的地址,说明当前代码执行的上下文里 this 指针已经指向了被释放的内存。这种崩溃在 release 版本里往往表现为“莫名其妙的空指针”“野指针”“内存踩踏”,如果没有 ASAN 或者 minidump 辅助,很容易被误判成驱动问题。
1.2 第一轮排查:假线索反而最像真的
遇到这种硬件相关崩溃,第一反应自然是怀疑驱动。我们当时的排查顺序是这样的:
- 关闭硬件解码,改用 FFmpeg 软解,同样的操作反复跑了上百次,一次都没崩。这几乎坐实了“硬件解码路径有问题”的判断;
- 查看内核日志,
dmesg里有几条来自媒体驱动模块的报错,但都是解码错误码,没有 Oops,没有内存访问冲突记录; - 怀疑是 V4L2 buffer 在 close 时与解码线程竞争,于是往驱动的
close路径里加了一堆打印和延迟,没有改变崩溃频率; - 怀疑是用户态 buffer 池在解码线程上被并发回收,把
VideoFramePool相关的锁全部查了一遍,也没找到明显的竞态条件。
这一轮下来基本可以确定:问题大概率不在内核态,而在 Chromium 自己的异步调用链里。真正有价值的线索是崩溃线程固定是 hw_decoder,而触发时机固定在“请求还在解码队列里时对象被销毁”。这已经把方向指向了异步生命周期管理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因定位:回调触发时对象已经析构
2.1 用 ASAN 抓出分配的出生地与释放现场
Release 版栈回溯只能告诉你在哪崩的,很难告诉你对象是谁在什么时候释放的。这时候必须上 ASAN 构建。ASAN 的好处是能在崩溃第一时间同时打印出“这块内存的分配栈”和“释放栈”,等于把遗属关系一次性交代清楚。
我们用 ASAN 版本复现后,报错是这样的:
text复制ERROR: AddressSanitizer: heap-use-after-free on address 0x00... at pc 0x...
READ of size 8 at 0x... thread T8 (hw_decoder_thread)
#0 media::RockchipVideoDecoder::DecodeOnHardwareThread()
...
freed by thread T1 (main_thread) here:
#1 media::RockchipVideoDecoder::~RockchipVideoDecoder()
#2 std::default_delete<media::VideoDecoder>::operator()()
#3 media::VideoDecoderFactory::DestroyDecoder()
#4 media::WebMediaPlayerImpl::CloseMediaSource()
#5 content::MediaSession::~MediaSession()
...
previously allocated by thread T1 here:
#1 media::RockchipVideoDecoder::RockchipVideoDecoder()
#2 media::VideoDecoderFactory::CreateVideoDecoder()
...
一切清晰了:分配在主线程,释放也在主线程,访问却在硬件解码线程。释放时机发生在 CloseMediaSource 这条析构链里,而硬件线程还在执行一个引用到这个对象的解码任务。这是典型的 use-after-free,而且是一个标准的“异步回调捕获了裸指针,回调的生命周期跨越了对象生命周期”的案例。
2.2 生命周期断裂的完整时间线
翻到我们自己封装的那一层,问题代码长这样:
cpp复制void RockchipVideoDecoder::Decode(
scoped_refptr<media::DecoderBuffer> buffer,
media::VideoDecoder::DecodeCB decode_cb) {
hw_task_runner_->PostTask(
FROM_HERE,
base::BindOnce(&RockchipVideoDecoder::DecodeOnHardwareThread,
base::Unretained(this), // 问题所在
std::move(buffer),
std::move(decode_cb)));
}
用文字还原一下整条时间线:
- t0:播放器请求解码,
Decode()被调用,任务通过PostTask投递到硬件解码线程; - t1:硬件线程开始处理这个任务,执行
DecodeOnHardwareThread; - t2:用户在解码尚未完成时关闭标签页,主线程走
CloseMediaSource析构链,RockchipVideoDecoder被delete,内存归还; - t3:硬件线程继续执行
DecodeOnHardwareThread,访问this->buffer_pool_等成员,读取到已经释放的内存。
这里每一步看起来都“没毛病”,但串起来就崩了。关键是 base::Unretained(this) 这句:它的意思是“这个指针在整个回调生命周期内保证有效,我不打算额外管理引用计数”。但我们在 PostTask 之后并没有保证对象的析构晚于任务的执行,所以这个承诺被打破了。
2.3 出问题前的那个“合理假设”
写这段代码的同事并不是新手。他当时的假设是:hw_task_runner_ 是解码器自己创建的专用任务线程,析构函数里一定会先停止这个线程,再释放资源。只要线程在对象析构前停下来,Unretained(this) 就是安全的。
问题在于:析构函数里确实有这个逻辑,但执行顺序不是一个原子操作。析构过程是“先执行析构函数体,再释放对象内存”,而 hw_task_runner_ 的停止发生在析构函数的最末尾。也就是说,从对象开始析构到任务线程真正停止之间,存在一个时间窗口。任何在队列里挂起、或者正在执行的任务,都可能在对象已经被部分析构、甚至完全释放之后才继续运行。
这个窗口通常只有几微秒,但这几微秒对一个并发系统来说已经足够致命。而且更隐蔽的是,PostTask 只保证任务在目标线程上按顺序执行,完全不保证任务在 PostTask 调用线程的某个生命周期节点之前执行完。这是 Chromium 异步编程里最容易被低估的一条规则。
3. Chromium 异步回调里三组典型的生命周期陷阱
修好我们自己的问题之后,我把这类崩溃在公司内部做了一次复盘。Chromium 的 base::Callback / base::Bind / PostTask 体系本身设计得相当严谨,但正因为太灵活,反而给了开发者“自由发挥”犯错误的空间。下面三类是我见过出现频率最高的。
3.1 陷阱一:base::Unretained 捕获裸指针
这是最常见也最危险的一类。base::Unretained(this) 的本质是告诉编译器“别帮我做任何生命周期管理,这个指针我保证是活的”,等价于裸指针传递,只是包装得更隐蔽。
回调捕获 this 的几种方式,适用场景和风险如下:
| 捕获方式 | 适用场景 | 风险等级 |
|---|---|---|
base::Unretained(this) |
调用方完全掌控任务线程生命周期,确保回调先于对象析构 | 高,一旦假设被打破就是 UAF |
| 裸指针 | 同上,无任何保护 | 高 |
base::WeakPtr |
回调可能晚于对象析构,且遵守序列亲和性 | 中,跨序列场景需要额外配合 |
scoped_refptr 捕获 |
对象本身是引用计数管理,希望回调持有一份引用 | 低,但可能延长对象寿命 |
通过 WeakPtr 转发 + PostTask 回原序列 |
跨线程访问后还需要回到原序列操作对象 | 低,最推荐 |
很多人以为把 base::Unretained(this) 换成裸指针再包一层就安全了,其实完全没区别。Chromium 的 code review 规范里有一条不成文的规矩:任何出现在 PostTask / Bind 里的 base::Unretained,都必须配一条注释,解释为什么这个裸指针在这个回调生命周期内是安全的。 如果你发现注释写不出来,或者要写很长的分析才能证明,那这个用法大概率就是在赌命。
3.2 陷阱二:BindOnce 和 BindRepeating 的语义误用
base::BindOnce 生成的是只能执行一次的 OnceCallback,执行时会移动绑定参数,适合异步任务的终态回调;base::BindRepeating 生成的是可以多次执行的 RepeatingCallback,适合事件回调、遍历调用等场景。
这个陷阱通常不是崩溃的直接原因,而是“帮凶”。比如有人习惯性地把 BindOnce 写成 BindRepeating,导致本来应该被移动传递的资源被拷贝了多份,或者一个回调被意外执行了两次。反过来,如果把 BindRepeating 的语义用在只需要执行一次的地方,又有可能因为回调没被正确消费而漏掉关键的 decode_cb,让上层 forever 等待,引发疑似卡死的问题。
在我们这次崩溃的代码里,decode_cb 本身是被正确移动的,问题只出在 this 的捕获上。但我在别的项目里真实见过:回调绑定了 base::Unretained(this),同时还用了 BindRepeating,结果一个回调在重入路径里被执行了两次,第二次访问已经失效的成员,崩溃现场和这次几乎一模一样。所以排查这类问题的时候,先确认 Bind 的语义是否正确,再往下看生命周期,顺序不能反。
3.3 陷阱三:跨序列投递时,把“顺序”误解为“时机”
Chromium 的 PostTask 模型有一个非常精巧的保证:在同一个任务序列上投递的任务,先进先出,顺序严格。很多人把这条规则理解成“我在序列 A 上 PostTask 了一个回调,那这个回调在执行前,序列 A 上的对象一定没问题”,这是错误的。
顺序保证只保证任务之间的相对顺序,不保证任务相对于对象析构的执行时机。对象在哪个序列析构,任务在哪个序列执行,这两件事如果不显式串行化,永远存在竞态。跨线程投递尤其危险:一个任务在硬件线程上执行,对象在主线程上析构,无论 PostTask 的顺序多么严格,都无法避免“任务执行到一半,对象没了”的情况。
要真正解决跨序列生命周期问题,Chromium 给出了几个标准姿势:
- 对象在哪个序列上工作,就在哪个序列上析构。用
base::SequenceBound<T>或base::OnTaskRunnerDeleter把对象的所有权和生命周期绑定到工作序列,这是最彻底的做法; - 先停线程,再析构对象。如果对象自己持有任务线程,析构函数里先
Stop()线程,再释放成员; - 用
WeakPtr做回调检查,同时保证回调执行序列与工厂一致。
我们最终采用的方案,就是在“先停线程”和“WeakPtr 检查”之间做了一次组合取舍。
4. 修复落地:从 Unretained 到 WeakPtr 的完整改造
4.1 最小改动方案与配套改动
修复分两步。第一步,把捕获的裸指针改成 WeakPtr,并加上有效性检查:
cpp复制void RockchipVideoDecoder::Decode(
scoped_refptr<media::DecoderBuffer> buffer,
media::VideoDecoder::DecodeCB decode_cb) {
hw_task_runner_->PostTask(
FROM_HERE,
base::BindOnce(&RockchipVideoDecoder::DecodeOnHardwareThread,
weak_factory_.GetWeakPtr(),
std::move(buffer),
std::move(decode_cb)));
}
void RockchipVideoDecoder::DecodeOnHardwareThread(
scoped_refptr<media::DecoderBuffer> buffer,
media::VideoDecoder::DecodeCB decode_cb) {
if (!weak_this_) {
// 对象已被析构,直接丢弃该任务
std::move(decode_cb).Run(media::DecodeStatus::ABORTED);
return;
}
// 正常解码流程...
}
这里有个细节值得单独强调:如果任务丢弃时连回调也不执行,上层 pipeline 可能会永远挂起。 所以正确的做法是即使对象已经失效,也要把 decode_cb 以 ABORTED 状态跑掉,让上层有机会清理自己的状态。这是很多人在改同类 bug 时会漏掉的关键点。
第二步,给析构行为补一道保险。单纯用 WeakPtr 还不够,因为 WeakPtr 的失效发生在 WeakPtrFactory 析构那一刻,也就是对象析构链的最末尾。如果硬件线程在对象析构进行到一半时访问成员,依然有风险。
所以我们对析构函数做了调整:先停止硬件任务线程,再调用 WeakPtrFactory 的析构。这样保证了在对象成员被释放之前,所有挂起的任务要么已经执行完,要么被 PostTask 系统拒绝。整个析构链变成:
cpp复制RockchipVideoDecoder::~RockchipVideoDecoder() {
// 先停线程,确保没有任务还在执行
hw_task_runner_->Shutdown();
// 再让 weak factory 失效,阻止后续回调访问
}
关于 WeakPtr 的序列亲和性,这里多说一句:base::WeakPtr 有序列亲和性,它应该在创建它的序列上访问和失效。我们这里之所以能安全地让硬件线程访问 weak_this_,是因为在真正访问之前,先通过 Shutdown() 把硬件线程和主线程的任务执行串行化了,相当于把跨序列访问变成了“先停车、再检查”。如果你的场景做不到这种串行化,那就得考虑把对象整个托管到对方序列上,例如 base::SequenceBound,而不是裸用 WeakPtr。
4.2 压测结果与回归验证
修复做完以后,验证工作分了三步:
- ASAN 复现回归:在 ASAN 构建下反复执行“播放-拖动-关闭”操作 200 次,崩溃不再出现。同时把关闭窗口的时机用脚本随机化,拉长到 1000 次,仍然全绿;
- 单元测试补充:新增了一个测试用例,模拟“解码任务在途时直接析构解码器”的场景。测试要点是析构后任务回调必须以
ABORTED状态返回,而不是挂起或崩溃; - 线上灰度:灰度环境跑了三天,崩溃率从原来的约 0.2% 降到了零。
这个结果算不上惊艳,但很能说明问题:生命周期 bug 一旦修复,崩溃率会呈现出“要么完全消失、要么还在撞同一个点”的清晰分界。 如果修完后崩溃率只是从 0.2% 变成 0.1%,那基本可以断定没修干净,还有另一个竞态点在等着。
5. 从一次修复总结出一套排查与预防方法
5.1 排查这类崩溃的有效工具链
每次遇到类似崩溃,我都会按下面这个顺序走,基本能覆盖 90% 的场景:
- ASAN 构建是第一位。UAF 类问题没有 ASAN,全靠栈回溯猜,效率极低;
- 复现路径要标准化。像“播放-拖动-关闭”这种操作,必须写成自动化脚本,用随机延时跑,才能覆盖到那个不确定的竞态窗口;
- minidump 一定要配好 symbol 化。线上崩溃很多是 release 版,没有 symbol 根本看不到具体函数;
- 利用 Chromium 自带的任务调试工具。
TaskAnnotator会记录任务的来源调用栈,--enable-logging=stderr加上--vmodule=*task*=2可以输出任务投递和执行的细节,定位跨线程投递的链路时非常有用。
排查过程中还有一个容易被忽略的点:不要被“关了硬件解码就不崩了”误导。 这类结论只能说明崩溃在异步硬件路径上,不能说明是驱动问题。Chromium 的软件解码和硬件解码走了完全不同的异步任务编排,后者多了专用线程、buffer 池、回调转发,生命周期管理的复杂度高出一个量级,出问题的概率自然也高得多。
5.2 代码评审与编码习惯上的防线
修复这个 bug 之后,我在团队里立了几条简单的规矩,现在每次 code review 都会重点看:
- 凡是
PostTask+Bind里出现base::Unretained的,必须附注释说明安全性依据。写不出来的,一律改用WeakPtr或重新设计对象所有权; - 析构函数里如果有 “先停线程再释放成员” 的逻辑,必须把停止线程放在第一步,成员释放放在最后,顺序不允许调换;
- 跨序列访问对象的,默认要求对象生命周期绑定到执行序列,而不是在一端 PostTask、另一端裸用指针;
- 回调被丢弃时,必须考虑上层是否还在等这个回调。宁可返回一个失败状态,也不要默默吞掉。
这些规矩不一定能消灭所有生命周期 bug,但能显著提高这类问题在 code review 阶段被发现的概率。回到这次崩溃本身,它给我的最大感悟是:在 Chromium 这种高度异步的 C++ 架构里,对象的生命周期不是“写代码时考虑一下”的事情,而是“每次 PostTask 都默认反问一遍”的事情。 异步回调的灵活性和破坏性是一体两面的,越是用得顺手,越要对它保持警惕。
