許多面試官會問:你知道回調嗎?你在寫回調的時候遇到哪些坑?你知道對象生命周期管理嗎?為什麼這裡會崩潰,那裡會洩漏? 在設計 C++ 回調時,你是否想過:同步還是異步?回調時(弱引用)上下文是否會失效?一次還是多次?如何銷毀/傳遞(強引用)上下文? 這篇文章給你詳細解答!
本文深入分析 Chromium 的 Bind/Callback 機制,並討論設計 C++ 回調時你可能不知道的一些問題。
如果你還不知道什麼是 回調 (callback),歡迎閱讀 如何淺顯的解釋回調函數
如果你還不知道什麼是 回調上下文 (callback context) 和 閉包 (closure),歡迎閱讀 對編程範式的簡單思考(本文主要討論基於 閉包 的回調,而不是基於 C 語言函數指針的回調)
如果你還不清楚 可調用對象 (callable object) 和 回調接口 (callback interface) 的區別,歡迎閱讀 回調 vs 接口(本文主要討論類似 std::function 的 可調用對象,而不是基於接口的回調)
如果你還不知道對象的 所有權 (ownership) 和 生命周期管理 (lifetime management),歡迎閱讀 資源管理小記
回調是被廣泛應用的概念:
從語言上看,回調是一個調用函數的過程,涉及兩個角色:計算和數據。其中,回調的計算是一個函數,而回調的數據來源於兩部分:
捕獲了上下文的回調函數就成為了閉包,即 閉包 = 函數 + 上下文。
在面向對象語言中,一等公民是對象,而不是函數;所以在實現上:
從對象所有權的角度看,上下文進一步分為:
如果你已經熟悉了 std::bind/lambda + std::function,那麼你在設計 C++ 回調時,是否考慮過這幾個問題:
1. 回調是同步還是異步的
1.1 回調時(弱引用)上下文會不會失效
1.2 如何處理失效的(弱引用)上下文
2 回調只能執行一次還是可以多次
2.1 為什麼要區分一次和多次回調
2.2 何時銷毀(強引用)上下文
2.3 如何傳遞(強引用)上下文
本文分析 Chromium 的 base::Bind + base::Callback 回調機制,帶你領略回調設計的精妙之處。(參考:Callback<> and Bind() | Chromium Docs)
1 回調是同步還是異步的同步回調 (sync callback) 在 構造閉包 的 調用棧 (call stack) 裡 局部執行。例如,累加一組得分(使用 lambda 表達式捕獲上下文 total):
int total = 0;
std::for_each(std::begin(scores), std::end(scores),
[&total](auto score) { total += score; });
異步回調 (async callback) 在構造後存儲起來,在 未來某個時刻(不同的調用棧裡)非局部執行。例如,用戶界面為了不阻塞 UI 線程 響應用戶輸入,在 後臺線程 異步加載背景圖片,加載完成後再從 UI 線程 顯示到界面上:
void View::LoadImageCallback(const Image& image) {
if (background_image_view_)
background_image_view_->SetImage(image);
}
FetchImageAsync(
filename,
base::Bind(&View::LoadImageCallback, this));
註:
FetchImageAsync(
filename,
base::Bind([this](const Image& image) {
if (background_image_view_)
background_image_view_->SetImage(image);
}));
由於閉包沒有 弱引用上下文 的所有權,所以上下文可能失效:
例如 異步加載圖片 的場景:在等待加載時,用戶可能已經退出了界面。所以,在執行 View::LoadImageCallback 時:
其實,上述兩段代碼(包括 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()));
}
在執行 View::LoadImageCallback 時:
註:
基於弱引用指針,Chromium 封裝了 可取消 (cancelable)
回調 base::CancelableCallback,提供 Cancel/IsCancelled 接口。
(參考:Cancelling a Task | Threading and Tasks in Chrome)
軟體設計裡,只有三個數 —— 0,1,∞(無窮)。類似的,不管是同步回調還是異步回調,我們只關心它被執行 0 次,1 次,還是多次。
根據可調用次數,Chromium 把回調分為兩種:
註:
我們先舉個 反例 —— 基於 C 語言函數指針的回調:
例如,使用 libevent 監聽 socket 可寫事件,實現 異步/非阻塞發送數據(例子來源):
void do_send(evutil_socket_t fd, short events, void* context) {
char* buffer = (char*)context;
free(buffer);
}
char* buffer = malloc(buffer_size);
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 可能已經被釋放,從而導致 崩潰
對於面向對象的回調,強引用上下文的 所有權屬於閉包。例如,改寫 異步/非阻塞發送數據 的代碼:
假設 using Event::Callback = base::OnceCallback<void()>;
void DoSendOnce(std::unique_ptr<Buffer> buffer) {
}
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()>;
void DoSendRepeating(const Buffer* buffer) {
}
Buffer* buffer = ...;
event->SetCallback(base::BindRepeating(&DoSendRepeating,
base::Owned(buffer)));
構造閉包時:buffer 移動到 base::RepeatingCallback 內
回調執行時:每次傳遞 buffer 指針,DoSendRepeating 只使用 buffer的數據(DoSendRepeating 不銷毀 弱引用參數)
閉包銷毀時:總是由閉包銷毀 buffer(有且只有一處銷毀的地方)
註:
由閉包管理所有權,上下文可以保證:
被銷毀且只銷毀一次(避免洩漏)
銷毀後不會被再使用(避免崩潰)
但這又引入了另一個微妙的問題:由於 一次回調 的 上下文銷毀時機不確定,上下文對象 析構函數 的調用時機 也不確定 —— 如果上下文中包含了 複雜析構函數 的對象(例如 析構時做數據上報),那麼析構時需要檢查依賴條件的有效性(例如 檢查數據上報環境是否有效),否則會 崩潰。
根據 可拷貝性,強引用上下文又分為兩類:
STL 原生的 std::bind/lambda + std::function 不能完整支持 互斥所有權語義:
auto unique_lambda = [p = std::unique_ptr<int>{new int}]() {};
unique_lambda();
std::function<void()>{std::move(unique_lambda)};
auto unique_bind = std::bind([](std::unique_ptr<int>) {},
std::unique_ptr<int>{});
unique_bind();
std::function<void()>{std::move(unique_bind)};
unique_lambda/unique_bind
只能移動,不能拷貝
unique_lambda 可以執行,上下文在 lambda 函數體內作為引用
unique_bind 不能執行,因為函數的接收參數要求拷貝 std::unique_ptr
類似的,STL 回調在處理 共享所有權 時,會導致多餘的拷貝:
auto shared_lambda = [p = std::shared_ptr<int>{}]() {};
std::function<void()>{shared_lambda};
auto shared_func = [](std::shared_ptr<int> ptr) {
assert(ptr.use_count() == 6);
};
auto p = std::shared_ptr<int>{new int};
auto shared_bind = std::bind(shared_func, p);
auto copy_bind = shared_bind;
auto shared_fn = std::function<void()>{shared_bind};
auto copy_fn = shared_fn;
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 在各環節優化了上述問題:
註:
目前,Chromium 支持豐富的上下文 綁定方式:
註:
從這篇文章可以看出,C++ 是很複雜的:
對於專注內存安全的 Rust 語言,在語言層面上支持了本文討論的概念:
@hghwng 在 2019/3/29 評論:
其實這一系列問題的根源,在我看,就是閉包所捕獲變量的所有權的歸屬。或許是因為最近在寫 Rust,編碼的思維方式有所改變吧。所有權機制保證了不會有野指針,Fn/FnMut/FnOnce 對應了對閉包捕獲變量操作的能力。
前一段時間在寫事件驅動的程序,以組合的方式寫了大量的 Future,開發(讓編譯通過)效率很低。最後反而覺得基於 Coroutine 來寫異步比較直觀(不過這又需要保證閉包引用的對象不可移動,Pin 等一系列問題又出來了)。可能這就是為什麼 Go 比較流行的原因吧:Rust 的安全檢查再強,C++ 的模板再炫,也需要使用者有較高的水平保證內存安全(無論是運行時還是編譯期)。有了 GC,就可以拋棄底層細節,隨手胡寫了。
對於原生支持 垃圾回收/協程 的 Go 語言,也可能出現 洩漏問題: