41

深入 C++ 回调

 4 years ago
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.
AnEbqia.gif

许多面试官会问:你知道回调吗?你在写回调的时候遇到哪些坑?你知道对象生命周期管理吗?为什么这里会崩溃,那里会泄漏? 在设计 C++ 回调时,你是否想过:同步还是异步?回调时(弱引用)上下文是否会失效?一次还是多次?如何销毁/传递(强引用)上下文? 这篇文章给你详细解答!

本文深入分析 Chromium 的 Bind/Callback 机制 ,并讨论设计 C++ 回调时你可能不知道的一些问题。

背景阅读   

  • 如果你还不知道什么是 回调 (callback) ,欢迎阅读 如何浅显的解释回调函数

  • 如果你还不知道什么是 回调上下文 (callback context)闭包 (closure) ,欢迎阅读 对编程范式的简单思考 (本文主要讨论基于 闭包 的回调,而不是基于 C 语言函数指针的回调)

  • 如果你还不清楚 可调用对象 (callable object)回调接口 (callback interface) 的区别,欢迎阅读 回调 vs 接口 (本文主要讨论类似 std::function 可调用对象 ,而不是基于接口的回调)

  • 如果你还不知道对象的 所有权 (ownership)生命周期管理 (lifetime management) ,欢迎阅读 资源管理小记

回调是被广泛应用的概念:

从语言上看,回调是一个调用函数的过程,涉及两个角色:计算和数据。其中,回调的计算是一个函数,而回调的数据来源于两部分:

  • 绑定 (bound) 的数据,即回调的 上下文

  • 未绑定 (unbound) 的数据,即执行回调时需要额外传入的数据

捕获了上下文的回调函数就成为了闭包,即 闭包 = 函数 + 上下文

在面向对象语言中,一等公民是对象,而不是函数;所以在实现上:

  • 闭包 一般通过 对象 实现(例如 std::function

  • 上下文 一般作为闭包对象的 数据成员 ,和闭包属于 关联/组合/聚合 的关系

从对象所有权的角度看,上下文进一步分为:

  • 不变(immutable) 上下文

    • 数值/字符串/结构体 等基本类型,永远 不会失效

    • 使用时,一般 不需要考虑 生命周期问题

  • 弱引用(weak reference)上下文(可变(mutable)上下文)

    • 闭包 不拥有 上下文,所以回调执行时 上下文可能失效

    • 如果使用前没有检查,可能会导致 崩溃

  • 强引用(strong reference)上下文(可变(mutable)上下文)

    • 闭包 拥有 上下文,能保证回调执行时 上下文一直有效

    • 如果使用后忘记释放,可能会导致 泄漏

如果你已经熟悉了 std::bind / lambda + std::function ,那么你在设计 C++ 回调时, 是否考虑过这几个问题

1. 回调是同步还是异步的

1.1 回调时(弱引用)上下文会不会失效

1.2 如何处理失效的(弱引用)上下文

2 回调只能执行一次还是可以多次

2.1 为什么要区分一次和多次回调

2.2 何时销毁(强引用)上下文

2.3 如何传递(强引用)上下文

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 ,每次迭代传递的值

I7NJvyN.png!web 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

Ene26nA.png!web Fetch Image Async

注:

  • 使用 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);
 }));

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 显示背景图片

  • 否则,弱引用失效, 不执行回调 (因为界面已经退出, 没必要 再设置图片了)

注:

基于弱引用指针,Chromium 封装了 可取消 (cancelable)

回调 base::CancelableCallback ,提供 Cancel / IsCancelled 接口。

(参考: Cancelling a Task | Threading and Tasks in Chrome

2. 回调只能执行一次还是可以多次

软件设计里,只有三个数 —— 01 (无穷) 。类似的,不管是同步回调还是异步回调,我们只关心它被执行 0 次, 1 次,还是多次。

根据可调用次数,Chromium 把回调分为两种:

feA7rme.png!web

注:

  • 写在成员函数后的 引用限定符  _(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 在各环节优化了上述问题:

3UNR3qI.jpg!web

注:

目前,Chromium 支持丰富的上下文 绑定方式

fYfeyqZ.png!web

注:

写在最后   

从这篇文章可以看出,C++ 是很复杂的:

  • 要求程序员自己管理对象生命周期,对象 从出生到死亡 的各个环节都要想清楚

  • Chromium 的 Bind/Callback 实现基于 现代 C++ 元编程 ,实现起来很复杂(参考: 浅谈 C++ 元编程

对于专注内存安全的 Rust 语言 ,在语言层面上支持了本文讨论的概念:

@hghwng 在 2019/3/29 评论:

其实这一系列问题的根源,在我看,就是闭包所捕获变量的所有权的归属。或许是因为最近在写 Rust,编码的思维方式有所改变吧。所有权机制保证了不会有野指针, Fn / FnMut / FnOnce 对应了对闭包捕获变量操作的能力。

前一段时间在写事件驱动的程序,以组合的方式写了大量的 Future,开发(让编译通过)效率很低。最后反而觉得基于 Coroutine 来写异步比较直观(不过这又需要保证闭包引用的对象不可移动,Pin 等一系列问题又出来了)。可能这就是为什么 Go 比较流行的原因吧: Rust 的安全检查再强,C++ 的模板再炫,也需要使用者有较高的水平保证内存安全 (无论是运行时还是编译期)。有了 GC,就可以抛弃底层细节,随手胡写了。

对于原生支持 垃圾回收/协程 的 Go 语言 ,也可能出现 泄漏问题

EBNV3uF.gif

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK