深入 C++ 回调

许多面试官会问:你知道回调吗?你在写回调的时候遇到哪些坑?你知道对象生命周期管理吗?为什么这里会崩溃,那里会泄漏? 在设计 C++ 回调时,你是否想过:同步还是异步?回调时(弱引用)上下文是否会失效?一次还是多次?如何销毁/传递(强引用)上下文? 这篇文章给你详细解答!
本文深入分析 Chromium 的 Bind/Callback 机制 ,并讨论设计 C++ 回调时你可能不知道的一些问题。
背景阅读
-
如果你还不知道什么是 回调 (callback) ,欢迎阅读 如何浅显的解释回调函数
-
如果你还不知道什么是 回调上下文 (callback context) 和 闭包 (closure) ,欢迎阅读 对编程范式的简单思考 (本文主要讨论基于 闭包 的回调,而不是基于 C 语言函数指针的回调)
-
如果你还不清楚 可调用对象 (callable object) 和 回调接口 (callback interface) 的区别,欢迎阅读 回调 vs 接口 (本文主要讨论类似
std::function
的 可调用对象 ,而不是基于接口的回调) -
如果你还不知道对象的 所有权 (ownership) 和 生命周期管理 (lifetime management) ,欢迎阅读 资源管理小记
回调是被广泛应用的概念:
-
图形界面客户端 常用 事件循环 (event loop) 有条不紊的处理 用户输入/计时器/系统处理/跨进程通信 等事件,一般采用回调响应事件
-
I/O 密集型程序 常用 异步 I/O (asynchronous I/O) 协调各模块处理速率,提高吞吐率,进一步引申出 设计上的 Reactor 、语言上的 协程 (coroutine) 、系统上的 纤程 (fiber) 等概念,一般采用回调处理 I/O 完成的返回结果(参考: 从时空维度看 I/O 模型 )
从语言上看,回调是一个调用函数的过程,涉及两个角色:计算和数据。其中,回调的计算是一个函数,而回调的数据来源于两部分:
-
绑定 (bound) 的数据,即回调的 上下文
-
未绑定 (unbound) 的数据,即执行回调时需要额外传入的数据
捕获了上下文的回调函数就成为了闭包,即 闭包 = 函数 + 上下文 。
在面向对象语言中,一等公民是对象,而不是函数;所以在实现上:
-
闭包 一般通过 对象 实现(例如
std::function
) -
上下文 一般作为闭包对象的 数据成员 ,和闭包属于 关联/组合/聚合 的关系
从对象所有权的角度看,上下文进一步分为:
-
不变(immutable) 上下文
-
数值/字符串/结构体 等基本类型,永远 不会失效
-
使用时,一般 不需要考虑 生命周期问题
-
弱引用(weak reference)上下文(可变(mutable)上下文)
-
闭包 不拥有 上下文,所以回调执行时 上下文可能失效
-
如果使用前没有检查,可能会导致 崩溃
-
强引用(strong reference)上下文(可变(mutable)上下文)
-
闭包 拥有 上下文,能保证回调执行时 上下文一直有效
-
如果使用后忘记释放,可能会导致 泄漏
如果你已经熟悉了 std::bind / lambda + std::function ,那么你在设计 C++ 回调时, 是否考虑过这几个问题 :
C++ 回调
1 回调是同步还是异步的
同步回调 (sync callback) 在 构造闭包 的 调用栈 (call stack) 里 局部执行 。例如,累加一组得分(使用 lambda
表达式捕获上下文 total
):
int total = 0; std::for_each(std::begin(scores), std::end(scores), [&total](auto score) { total += score; }); // ^ context variable |total| is always valid
-
绑定的数据 :
total
,局部变量的上下文(弱引用,所有权在闭包外) -
未绑定的数据 :
score
,每次迭代传递的值

异步回调 (async callback) 在构造后存储起来,在 未来某个时刻 (不同的调用栈里) 非局部执行 。例如,用户界面为了不阻塞 UI 线程 响应用户输入,在 后台线程 异步加载背景图片,加载完成后再从 UI 线程 显示到界面上:
// callback code void View::LoadImageCallback(const Image& image) { // WARNING: |this| may be invalid now! if (background_image_view_) background_image_view_->SetImage(image); } // client code FetchImageAsync( filename, base::Bind(&View::LoadImageCallback, this)); // use raw |this| pointer ^
-
绑定的数据 :
base::Bind
绑定了View
对象的this
指针(弱引用) -
未绑定的数据:
View::LoadImageCallback
的参数const Image& image

注:
-
使用 C++ 11 lambda 表达式实现等效为:
FetchImageAsync( filename, base::Bind([this](const Image& image) { // WARNING: |this| may be invalid now! if (background_image_view_) background_image_view_->SetImage(image); }));
-
View::FetchImageAsync
基于 Chromium 的多线程任务模型(参考: Keeping the Browser Responsive | Threading and Tasks in Chrome )
1.1 回调时(弱引用)上下文会不会失效
由于闭包没有 弱引用上下文 的所有权,所以上下文可能失效:
-
对于 同步回调 ,上下文的 生命周期往往比闭包长 ,一般不失效
-
而在 异步回调 调用时,上下文可能已经失效了
例如 异步加载图片 的场景:在等待加载时,用户可能已经退出了界面。所以,在执行 View::LoadImageCallback
时:
-
如果界面还在显示
View
对象仍然有效,则执行ImageView::SetImage
显示背景图片 -
如果界面已经退出
background_image_view_
变成 野指针 (wild pointer) ,调用ImageView::SetImage
导致 崩溃
其实,上述两段代码(包括 C++ 11 lambda 表达式版本)都无法编译(Chromium 做了对应的 静态断言 (static assert) )—— 因为传给 base::Bind
的参数都是 不安全的 :
-
传递普通对象的 裸指针 ,容易导致悬垂引用
-
传递捕获了上下文的 lambda 表达式, 无法检查 lambda 表达式捕获的 弱引用 的 有效性
C++ 核心指南 (C++ Core Guidelines) 也有类似的讨论:
1.2 如何处理失效的(弱引用)上下文
如果弱引用上下文失效,回调应该 及时取消 。例如 异步加载图片 的代码,可以给 base::Bind
传递 View
对象的 弱引用指针 ,即 base::WeakPtr
:
FetchImageAsync( filename, base::Bind(&View::LoadImageCallback, AsWeakPtr())); // use |WeakPtr| rather than raw |this| ^ }
在执行 View::LoadImageCallback
时:
-
如果界面还在显示,
View
对象仍然有效,则执行ImageView::SetImage
显示背景图片 -
否则,弱引用失效, 不执行回调 (因为界面已经退出, 没必要 再设置图片了)
注:
-
`base::WeakPtr` 属于 Chromium 提供的 侵入式 (intrusive) 智能指针,非 线程安全 (thread-safe)
-
base::Bind
针对base::WeakPtr
扩展了base::IsWeakReceiver
检查,调用时增加if (!weak_ptr) return;
的弱引用有效性检查(参考: Customizing the behavior | Callback and Bind() )
基于弱引用指针,Chromium 封装了 可取消 (cancelable)
回调 base::CancelableCallback
,提供 Cancel
/ IsCancelled
接口。
(参考: Cancelling a Task | Threading and Tasks in Chrome )
2. 回调只能执行一次还是可以多次
软件设计里,只有三个数 —— 0
, 1
, ∞
(无穷) 。类似的,不管是同步回调还是异步回调,我们只关心它被执行 0
次, 1
次,还是多次。
根据可调用次数,Chromium 把回调分为两种:

注:
-
写在成员函数后的 引用限定符 _(reference qualifier)_ && / const &
,区分 在对象处于 非 const 右值 / 其他 状态时的成员函数调用
-
base::RepeatingCallback
也支持R Run(Args…) ;
调用,调用后也进入失效状态
2.1 为什么要区分一次和多次回调
我们先举个 反例 —— 基于 C 语言函数指针的回调 :
-
由于 没有闭包 ,需要函数管理上下文生命周期,即 申请/释放上下文
-
由于 资源所有权不明确 ,难以判断指针
T*
表示 强引用还是弱引用
例如,使用 libevent 监听 socket 可写事件,实现 异步/非阻塞发送数据( 例子来源 ):
// callback code void do_send(evutil_socket_t fd, short events, void* context) { char* buffer = (char*)context; // ... send |buffer| via |fd| free(buffer); // free |buffer| here! } // client code char* buffer = malloc(buffer_size); // alloc |buffer| here! // ... fill |buffer| event_new(event_base, fd, EV_WRITE, do_send, buffer);
-
正确情况:
do_send
只执行一次 -
client 代码 申请 发送缓冲区
buffer
资源,并作为context
传入event_new
函数 -
callback 代码从
context
中取出buffer
,发送数据后 释放buffer
资源 -
错误情况:
do_send
没有被执行 -
client 代码申请的
buffer
不会被释放,从而导致 泄漏 -
错误情况:
do_sent
被执行多次 -
callback 代码使用的
buffer
可能已经被释放,从而导致 崩溃
2.2 何时销毁(强引用)上下文
对于面向对象的回调,强引用上下文的 所有权属于闭包 。例如,改写 异步/非阻塞发送数据 的代码:
假设 using Event::Callback = base::OnceCallback;
// callback code void DoSendOnce(std::unique_ptr buffer) { // ... } // free |buffer| via |~unique_ptr()| // client code std::unique_ptr buffer = ...; event->SetCallback(base::BindOnce(&DoSendOnce, std::move(buffer)));
-
构造闭包时:
buffer
移动到base::OnceCallback
内 -
回调执行时:
buffer
从base::OnceCallback
的上下文 移动到DoSendOnce
的参数里,并在回调结束时销毁( 所有权转移 ,DoSendOnce
销毁 强引用参数 ) -
闭包销毁时:如果回调没有执行,
buffer
未被销毁,则此时销毁( 保证销毁且只销毁一次 )
假设 using Event::Callback = base::RepeatingCallback;
// callback code void DoSendRepeating(const Buffer* buffer) { // ... } // DON'T free reusable |buffer| // client code Buffer* buffer = ...; event->SetCallback(base::BindRepeating(&DoSendRepeating, base::Owned(buffer)));
-
构造闭包时:
buffer
移动到base::RepeatingCallback
内 -
回调执行时:每次传递
buffer
指针,DoSendRepeating
只使用buffer
的数据(DoSendRepeating
不销毁 弱引用参数 ) -
闭包销毁时:总是由闭包销毁
buffer
( 有且只有一处销毁的地方 )
注:
-
base::Owned
是 Chromium 提供的 高级绑定方式 ,将在下文提到
由闭包管理所有权,上下文可以保证:
-
被销毁且只销毁一次(避免泄漏)
-
销毁后不会被再使用(避免崩溃)
但这又引入了另一个微妙的问题:由于 一次回调 的 上下文销毁时机不确定 ,上下文对象 析构函数 的调用时机 也不确定 —— 如果上下文中包含了 复杂析构函数 的对象(例如 析构时做数据上报),那么析构时需要检查依赖条件的有效性(例如 检查数据上报环境是否有效),否则会 崩溃 。
2.3 如何传递(强引用)上下文
根据 可拷贝性 ,强引用上下文又分为两类:
-
不可拷贝的 互斥所有权 (exclusive ownership) ,例如
std::unique_ptr
-
可拷贝的 共享所有权 (shared ownership) ,例如
std::shared_ptr
STL 原生的 std::bind
/ lambda
+ std::function
不能完整支持 互斥所有权 语义:
// OK, pass |std::unique_ptr| by move construction auto unique_lambda = [p = std::unique_ptr{new int}]() {}; // OK, pass |std::unique_ptr| by ref unique_lambda(); // Bad, require |unique_lambda| copyable std::function{std::move(unique_lambda)}; // OK, pass |std::unique_ptr| by move auto unique_bind = std::bind([](std::unique_ptr) {}, std::unique_ptr{}); // Bad, failed to copy construct |std::unique_ptr| unique_bind(); // Bad, require |unique_bind| copyable std::function{std::move(unique_bind)};
-
unique_lambda
/unique_bind
-
只能移动,不能拷贝
-
不能构造
std::function
-
unique_lambda
可以执行,上下文在lambda
函数体内作为引用 -
unique_bind
不能执行,因为函数的接收参数要求拷贝std::unique_ptr
类似的,STL 回调在处理 共享所有权 时,会导致多余的拷贝:
auto shared_lambda = [p = std::shared_ptr{}]() {}; std::function{shared_lambda}; // OK, copyable auto shared_func = [](std::shared_ptr ptr) { // (6) assert(ptr.use_count() == 6); }; auto p = std::shared_ptr{new int}; // (1) auto shared_bind = std::bind(shared_func, p); // (2) auto copy_bind = shared_bind; // (3) auto shared_fn = std::function{shared_bind}; // (4) auto copy_fn = shared_fn; // (5) assert(p.use_count() == 5);
-
shared_lambda
/shared_bind
-
可以拷贝,对其拷贝也会拷贝闭包拥有的上下文
-
可以构造
std::function
-
shared_lambda
和对应的std::function
可以执行,上下文在lambda
函数体内作为引用 -
shared_bind
和对应的std::function
可以执行,上下文会拷贝成新的std::shared_ptr
Chromium 的 base::Callback
在各环节优化了上述问题:
注:
-
`scoped_refptr` 也属于 Chromium 提供的 侵入式 (intrusive) 智能指针,通过对象内部引用计数,实现类似
std::shared_ptr
的功能 -
提案 P0228R3 `std::unique_function` 为 STL 添加类似
base::OnceCallback
的支持
目前,Chromium 支持丰富的上下文 绑定方式 :

注:
-
主要参考 Quick reference for advanced binding | Callback and Bind()
-
base::Unretained/Owned/RetainedRef()
类似于std::ref/cref()
,构造特殊类型数据的封装(参考: Customizing the behavior | Callback and Bind() ) -
表格中没有列出的 base::Passed
-
主要用于在
base::RepeatingCallback
回调时,使用std::move
移动上下文(语义上只能执行一次,但实现上无法约束) -
而 Chromium 建议直接使用
base::OnceCallback
明确语义
写在最后
从这篇文章可以看出,C++ 是很复杂的:
-
要求程序员自己管理对象生命周期,对象 从出生到死亡 的各个环节都要想清楚
-
Chromium 的 Bind/Callback 实现基于 现代 C++ 元编程 ,实现起来很复杂(参考: 浅谈 C++ 元编程 )
对于专注内存安全的 Rust 语言 ,在语言层面上支持了本文讨论的概念:
@hghwng 在 2019/3/29 评论:
其实这一系列问题的根源,在我看,就是闭包所捕获变量的所有权的归属。或许是因为最近在写 Rust,编码的思维方式有所改变吧。所有权机制保证了不会有野指针, Fn / FnMut / FnOnce 对应了对闭包捕获变量操作的能力。
前一段时间在写事件驱动的程序,以组合的方式写了大量的 Future,开发(让编译通过)效率很低。最后反而觉得基于 Coroutine 来写异步比较直观(不过这又需要保证闭包引用的对象不可移动,Pin 等一系列问题又出来了)。可能这就是为什么 Go 比较流行的原因吧: Rust 的安全检查再强,C++ 的模板再炫,也需要使用者有较高的水平保证内存安全 (无论是运行时还是编译期)。有了 GC,就可以抛弃底层细节,随手胡写了。
对于原生支持 垃圾回收/协程 的 Go 语言 ,也可能出现 泄漏问题 :
-
Goroutine Leaks – The Forgotten Sender (回调构造后,发送方不开始 —— 回调不执行,也不释放)
-
Goroutine Leaks – The Abandoned Receivers (回调执行后,发送方不结束 —— 回调不结束,也不释放)
