许多面试官会问:你知道回调吗?你在写回调的时候遇到哪些坑?你知道对象生命周期管理吗?为什么这里会崩溃,那里会泄漏? 在设计 C++ 回调时,你是否想过:同步还是异步?回调时(弱引用)上下文是否会失效?一次还是多次?如何销毁/传递(强引用)上下文? 这篇文章给你详细解答! 本文深入分析 Chromium 的 Bind/Callback 机制,并讨论设计 C++ 回调时你可能不知道的一些问题。
背景阅读
std::function
的 可调用对象,而不是基于接口的回调)回调是被广泛应用的概念:
从语言上看,回调是一个调用函数的过程,涉及两个角色:计算和数据。其中,回调的计算是一个函数,而回调的数据来源于两部分:
捕获了上下文的回调函数就成为了闭包,即 闭包 = 函数 + 上下文。
在面向对象语言中,一等公民是对象,而不是函数;所以在实现上:
std::function
)从对象所有权的角度看,上下文进一步分为:
如果你已经熟悉了 std::bind/lambda + std::function,那么你在设计 C++ 回调时,是否考虑过这几个问题:
C++ 回调
本文分析 Chromium 的 base::Bind
+ base::Callback
回调机制,带你领略回调设计的精妙之处。(参考:Callback<> and Bind() | Chromium Docs)
同步回调 (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
,每次迭代传递的值Accumulate Sync
异步回调 (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
Fetch Image Async
注:
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)由于闭包没有 弱引用上下文 的所有权,所以上下文可能失效:
例如 异步加载图片 的场景:在等待加载时,用户可能已经退出了界面。所以,在执行 View::LoadImageCallback
时:
View
对象仍然有效,则执行 ImageView::SetImage
显示背景图片background_image_view_
变成 野指针 (wild pointer),调用 ImageView::SetImage
导致 崩溃其实,上述两段代码(包括 C++ 11 lambda 表达式版本)都无法编译(Chromium 做了对应的 静态断言 (static assert))—— 因为传给 base::Bind
的参数都是 不安全的:
C++ 核心指南 (C++ Core Guidelines) 也有类似的讨论:
如果弱引用上下文失效,回调应该 及时取消。例如 异步加载图片 的代码,可以给 base::Bind
传递 View
对象的 弱引用指针,即 base::WeakPtr<View>
:
FetchImageAsync(
filename,
base::Bind(&View::LoadImageCallback, AsWeakPtr()));
// use |WeakPtr| rather than raw |this| ^
}
在执行 View::LoadImageCallback
时:
View
对象仍然有效,则执行 ImageView::SetImage
显示背景图片注:
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)
软件设计里,只有三个数 —— 0
,1
,∞
(无穷)。类似的,不管是同步回调还是异步回调,我们只关心它被执行 0
次,1
次,还是多次。
根据可调用次数,Chromium 把回调分为两种:
注:
base::RepeatingCallback
也支持 R Run(Args…) ;
调用,调用后也进入失效状态我们先举个 反例 —— 基于 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
只执行一次buffer
资源,并作为 context
传入 event_new
函数context
中取出 buffer
,发送数据后 释放buffer
资源do_send
没有被执行buffer
不会被释放,从而导致 泄漏do_sent
被执行多次buffer
可能已经被释放,从而导致 崩溃对于面向对象的回调,强引用上下文的 所有权属于闭包。例如,改写 异步/非阻塞发送数据 的代码:
假设
using Event::Callback = base::OnceCallback<void()>;
// callback code
void DoSendOnce(std::unique_ptr<Buffer> buffer) {
// ...
} // free |buffer| via |~unique_ptr()|
// client code
std::unique_ptr<Buffer> buffer = ...;
event->SetCallback(base::BindOnce(&DoSendOnce,
std::move(buffer)));
buffer
移动到 base::OnceCallback
内buffer
从 base::OnceCallback
的上下文 移动到DoSendOnce
的参数里,并在回调结束时销毁(所有权转移,DoSendOnce
销毁 强引用参数)buffer
未被销毁,则此时销毁(保证销毁且只销毁一次)假设
using Event::Callback = base::RepeatingCallback<void()>;
// 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 提供的 高级绑定方式,将在下文提到由闭包管理所有权,上下文可以保证:
但这又引入了另一个微妙的问题:由于 一次回调 的 上下文销毁时机不确定,上下文对象 析构函数 的调用时机 也不确定 —— 如果上下文中包含了 复杂析构函数 的对象(例如 析构时做数据上报),那么析构时需要检查依赖条件的有效性(例如 检查数据上报环境是否有效),否则会 崩溃。
根据 可拷贝性,强引用上下文又分为两类:
std::unique_ptr
std::shared_ptr
STL 原生的 std::bind
/lambda
+ std::function
不能完整支持 互斥所有权语义:
// OK, pass |std::unique_ptr| by move construction
auto unique_lambda = [p = std::unique_ptr<int>{new int}]() {};
// OK, pass |std::unique_ptr| by ref
unique_lambda();
// Bad, require |unique_lambda| copyable
std::function<void()>{std::move(unique_lambda)};
// OK, pass |std::unique_ptr| by move
auto unique_bind = std::bind([](std::unique_ptr<int>) {},
std::unique_ptr<int>{});
// Bad, failed to copy construct |std::unique_ptr|
unique_bind();
// Bad, require |unique_bind| copyable
std::function<void()>{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<int>{}]() {};
std::function<void()>{shared_lambda}; // OK, copyable
auto shared_func = [](std::shared_ptr<int> ptr) { // (6)
assert(ptr.use_count() == 6);
};
auto p = std::shared_ptr<int>{new int}; // (1)
auto shared_bind = std::bind(shared_func, p); // (2)
auto copy_bind = shared_bind; // (3)
auto shared_fn = std::function<void()>{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
在各环节优化了上述问题:
注:
std::shared_ptr
的功能base::OnceCallback
的支持目前,Chromium 支持丰富的上下文 绑定方式:
注:
base::Unretained/Owned/RetainedRef()
类似于 std::ref/cref()
,构造特殊类型数据的封装(参考:Customizing the behavior | Callback<> and Bind())base::RepeatingCallback
回调时,使用 std::move
移动上下文(语义上只能执行一次,但实现上无法约束)base::OnceCallback
明确语义写在最后
从这篇文章可以看出,C++ 是很复杂的:
对于专注内存安全的 Rust 语言,在语言层面上支持了本文讨论的概念:
@hghwng 在 2019/3/29 评论: 其实这一系列问题的根源,在我看,就是闭包所捕获变量的所有权的归属。或许是因为最近在写 Rust,编码的思维方式有所改变吧。所有权机制保证了不会有野指针,Fn/FnMut/FnOnce 对应了对闭包捕获变量操作的能力。 前一段时间在写事件驱动的程序,以组合的方式写了大量的 Future,开发(让编译通过)效率很低。最后反而觉得基于 Coroutine 来写异步比较直观(不过这又需要保证闭包引用的对象不可移动,Pin 等一系列问题又出来了)。可能这就是为什么 Go 比较流行的原因吧:Rust 的安全检查再强,C++ 的模板再炫,也需要使用者有较高的水平保证内存安全(无论是运行时还是编译期)。有了 GC,就可以抛弃底层细节,随手胡写了。
对于原生支持 垃圾回收/协程 的 Go 语言,也可能出现 泄漏问题: