深入 C++ 回调
source link: https://www.tuicool.com/articles/bEFFryU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
许多面试官会问:你知道回调吗?你在写回调的时候遇到哪些坑?你知道对象生命周期管理吗?为什么这里会崩溃,那里会泄漏? 在设计 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<View>
:
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<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 提供的 高级绑定方式 ,将在下文提到
由闭包管理所有权,上下文可以保证:
-
被销毁且只销毁一次(避免泄漏)
-
销毁后不会被再使用(避免崩溃)
但这又引入了另一个微妙的问题:由于 一次回调 的 上下文销毁时机不确定 ,上下文对象 析构函数 的调用时机 也不确定 —— 如果上下文中包含了 复杂析构函数 的对象(例如 析构时做数据上报),那么析构时需要检查依赖条件的有效性(例如 检查数据上报环境是否有效),否则会 崩溃 。
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<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
在各环节优化了上述问题:
注:
-
`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 (回调执行后,发送方不结束 —— 回调不结束,也不释放)
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK