深入 C++ 回调
許多面試官會問:你知道回調嗎?你在寫回調的時候遇到哪些坑?你知道對象生命周期管理嗎?為什么這里會崩潰,那里會泄漏? 在設計 C++ 回調時,你是否想過:同步還是異步?回調時(弱引用)上下文是否會失效?一次還是多次?如何銷毀/傳遞(強引用)上下文? 這篇文章給你詳細解答!
本文深入分析 Chromium 的 Bind/Callback 機制,并討論設計 C++ 回調時你可能不知道的一些問題。
背景閱讀??如果你還不知道什么是 回調 (callback),歡迎閱讀 如何淺顯的解釋回調函數
如果你還不知道什么是 回調上下文 (callback context) 和 閉包 (closure),歡迎閱讀 對編程范式的簡單思考(本文主要討論基于 閉包 的回調,而不是基于 C 語言函數指針的回調)
如果你還不清楚 可調用對象 (callable object) 和 回調接口 (callback interface) 的區(qū)別,歡迎閱讀 回調 vs 接口(本文主要討論類似 std::function 的 可調用對象,而不是基于接口的回調)
如果你還不知道對象的 所有權 (ownership) 和 生命周期管理 (lifetime management),歡迎閱讀 資源管理小記
回調是被廣泛應用的概念:
圖形界面客戶端 常用 事件循環(huán) (event loop) 有條不紊的處理 用戶輸入/計時器/系統處理/跨進程通信 等事件,一般采用回調響應事件
I/O 密集型程序 常用 異步 I/O (asynchronous I/O) 協調各模塊處理速率,提高吞吐率,進一步引申出 設計上的 Reactor、語言上的 協程 (coroutine)、系統上的 纖程 (fiber) 等概念,一般采用回調處理 I/O 完成的返回結果(參考:從時空維度看 I/O 模型)
從語言上看,回調是一個調用函數的過程,涉及兩個角色:計算和數據。其中,回調的計算是一個函數,而回調的數據來源于兩部分:
綁定 (bound) 的數據,即回調的 上下文
未綁定 (unbound) 的數據,即執(zhí)行回調時需要額外傳入的數據
捕獲了上下文的回調函數就成為了閉包,即 閉包 = 函數 + 上下文。
在面向對象語言中,一等公民是對象,而不是函數;所以在實現上:
閉包 一般通過 對象 實現(例如 std::function)
上下文 一般作為閉包對象的 數據成員,和閉包屬于 關聯/組合/聚合 的關系
從對象所有權的角度看,上下文進一步分為:
不變 (immutable) 上下文
數值/字符串/結構體 等基本類型,永遠 不會失效
使用時,一般 不需要考慮 生命周期問題
弱引用 (weak reference)上下文(可變(mutable)上下文)
閉包 不擁有 上下文,所以回調執(zhí)行時 上下文可能失效
如果使用前沒有檢查,可能會導致 崩潰
強引用 (strong reference)上下文(可變(mutable)上下文)
閉包 擁有 上下文,能保證回調執(zhí)行時 上下文一直有效
如果使用后忘記釋放,可能會導致 泄漏
如果你已經熟悉了 std::bind/lambda + std::function,那么你在設計 C++ 回調時,是否考慮過這幾個問題:
1. 回調是同步還是異步的
1.1 回調時(弱引用)上下文會不會失效
1.2 如何處理失效的(弱引用)上下文
2 回調只能執(zhí)行一次還是可以多次
2.1 為什么要區(qū)分一次和多次回調
2.2 何時銷毀(強引用)上下文
2.3 如何傳遞(強引用)上下文
C++ 回調??本文分析 Chromium 的 base::Bind + base::Callback 回調機制,帶你領略回調設計的精妙之處。(參考:Callback<> and Bind() | Chromium Docs)
1 回調是同步還是異步的
同步回調 (sync callback) 在 構造閉包 的 調用棧 (call stack) 里 局部執(zhí)行。例如,累加一組得分(使用 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) 在構造后存儲起來,在 未來某個時刻(不同的調用棧里)非局部執(zhí)行。例如,用戶界面為了不阻塞 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 表達式實現等效為:
View::FetchImageAsync 基于 Chromium 的多線程任務模型(參考:Keeping the Browser Responsive | Threading and Tasks in Chrome)
1.1 回調時(弱引用)上下文會不會失效
由于閉包沒有 弱引用上下文 的所有權,所以上下文可能失效:
對于 同步回調,上下文的 生命周期往往比閉包長,一般不失效
而在 異步回調 調用時,上下文可能已經失效了
例如 異步加載圖片 的場景:在等待加載時,用戶可能已經退出了界面。所以,在執(zhí)行 View::LoadImageCallback 時:
如果界面還在顯示
View 對象仍然有效,則執(zhí)行 ImageView::SetImage顯示背景圖片
如果界面已經退出
background_image_view_ 變成 野指針 (wild pointer),調用 ImageView::SetImage 導致 崩潰
其實,上述兩段代碼(包括 C++ 11 lambda 表達式版本)都無法編譯(Chromium 做了對應的 靜態(tài)斷言 (static assert))—— 因為傳給 base::Bind 的參數都是 不安全的:
傳遞普通對象的 裸指針,容易導致懸垂引用
傳遞捕獲了上下文的 lambda 表達式,無法檢查 lambda 表達式捕獲的 弱引用 的 有效性
C++ 核心指南 (C++ Core Guidelines) 也有類似的討論:
F.52: Prefer capturing by reference in lambdas that will be used locally, including passed to algorithms
F.53: Avoid capturing by reference in lambdas that will be used nonlocally, including returned, stored on the heap, or passed to another thread
1.2 如何處理失效的(弱引用)上下文
如果弱引用上下文失效,回調應該 及時取消。例如 異步加載圖片 的代碼,可以給 base::Bind 傳遞 View 對象的 弱引用指針,即 base::WeakPtr<View>:
FetchImageAsync(filename,base::Bind(&View::LoadImageCallback,?AsWeakPtr()));//?use?|WeakPtr|?rather?than?raw?|this|?^ }在執(zhí)行 View::LoadImageCallback 時:
如果界面還在顯示,View 對象仍然有效,則執(zhí)行 ImageView::SetImage顯示背景圖片
否則,弱引用失效,不執(zhí)行回調(因為界面已經退出,沒必要 再設置圖片了)
注:
`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. 回調只能執(zhí)行一次還是可以多次
軟件設計里,只有三個數 —— 0,1,∞(無窮)。類似的,不管是同步回調還是異步回調,我們只關心它被執(zhí)行 0 次,1 次,還是多次。
根據可調用次數,Chromium 把回調分為兩種:
注:
寫在成員函數后的 引用限定符? _(reference qualifier)_ && / const &,區(qū)分 在對象處于 非 const 右值 / 其他 狀態(tài)時的成員函數調用
base::RepeatingCallback 也支持 R Run(Args…) ; 調用,調用后也進入失效狀態(tài)
2.1 為什么要區(qū)分一次和多次回調
我們先舉個 反例 —— 基于 C 語言函數指針的回調:
由于 沒有閉包,需要函數管理上下文生命周期,即 申請/釋放上下文
由于 資源所有權不明確,難以判斷指針 T* 表示 強引用還是弱引用
例如,使用 libevent 監(jiān)聽 socket 可寫事件,實現 異步/非阻塞發(fā)送數據(例子來源):
//?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只執(zhí)行一次
client 代碼 申請 發(fā)送緩沖區(qū) buffer 資源,并作為 context 傳入 event_new 函數
callback 代碼從 context 中取出 buffer,發(fā)送數據后 釋放buffer 資源
錯誤情況:do_send沒有被執(zhí)行
client 代碼申請的 buffer 不會被釋放,從而導致 泄漏
錯誤情況:do_sent被執(zhí)行多次
callback 代碼使用的 buffer 可能已經被釋放,從而導致 崩潰
2.2 何時銷毀(強引用)上下文
對于面向對象的回調,強引用上下文的 所有權屬于閉包。例如,改寫 異步/非阻塞發(fā)送數據 的代碼:
假設 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 內
回調執(zhí)行時:buffer 從 base::OnceCallback 的上下文 移動到DoSendOnce 的參數里,并在回調結束時銷毀(所有權轉移,DoSendOnce 銷毀 強引用參數)
閉包銷毀時:如果回調沒有執(zhí)行,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 內
回調執(zhí)行時:每次傳遞 buffer 指針,DoSendRepeating 只使用 buffer的數據(DoSendRepeating 不銷毀 弱引用參數)
閉包銷毀時:總是由閉包銷毀 buffer(有且只有一處銷毀的地方)
注:
base::Owned 是 Chromium 提供的 高級綁定方式,將在下文提到
由閉包管理所有權,上下文可以保證:
被銷毀且只銷毀一次(避免泄漏)
銷毀后不會被再使用(避免崩潰)
但這又引入了另一個微妙的問題:由于 一次回調 的 上下文銷毀時機不確定,上下文對象 析構函數 的調用時機 也不確定 —— 如果上下文中包含了 復雜析構函數 的對象(例如 析構時做數據上報),那么析構時需要檢查依賴條件的有效性(例如 檢查數據上報環(huán)境是否有效),否則會 崩潰。
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 可以執(zhí)行,上下文在 lambda 函數體內作為引用
unique_bind 不能執(zhí)行,因為函數的接收參數要求拷貝 std::unique_ptr
類似的,STL 回調在處理 共享所有權 時,會導致多余的拷貝:
auto?shared_lambda?=?[p?=?std::shared_ptr<int>{}]()?{}; std::function<void()>{shared_lambda};??//?OK,?copyableauto?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 可以執(zhí)行,上下文在 lambda函數體內作為引用
shared_bind 和對應的 std::function 可以執(zhí)行,上下文會拷貝成新的 std::shared_ptr
Chromium 的 base::Callback 在各環(huán)節(jié)優(yōu)化了上述問題:
注:
`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 移動上下文(語義上只能執(zhí)行一次,但實現上無法約束)
而 Chromium 建議直接使用 base::OnceCallback 明確語義
寫在最后??
從這篇文章可以看出,C++ 是很復雜的:
要求程序員自己管理對象生命周期,對象 從出生到死亡 的各個環(huán)節(jié)都要想清楚
Chromium 的 Bind/Callback 實現基于 現代 C++ 元編程,實現起來很復雜(參考:淺談 C++ 元編程)
對于專注內存安全的 Rust 語言,在語言層面上支持了本文討論的概念:
@hghwng 在 2019/3/29 評論:
其實這一系列問題的根源,在我看,就是閉包所捕獲變量的所有權的歸屬?;蛟S是因為最近在寫 Rust,編碼的思維方式有所改變吧。所有權機制保證了不會有野指針,Fn/FnMut/FnOnce 對應了對閉包捕獲變量操作的能力。
前一段時間在寫事件驅動的程序,以組合的方式寫了大量的 Future,開發(fā)(讓編譯通過)效率很低。最后反而覺得基于 Coroutine 來寫異步比較直觀(不過這又需要保證閉包引用的對象不可移動,Pin 等一系列問題又出來了)。可能這就是為什么 Go 比較流行的原因吧:Rust 的安全檢查再強,C++ 的模板再炫,也需要使用者有較高的水平保證內存安全(無論是運行時還是編譯期)。有了 GC,就可以拋棄底層細節(jié),隨手胡寫了。
對于原生支持 垃圾回收/協程 的 Go 語言,也可能出現 泄漏問題:
Goroutine Leaks - The Forgotten Sender(回調構造后,發(fā)送方不開始 —— 回調不執(zhí)行,也不釋放)
Goroutine Leaks - The Abandoned Receivers(回調執(zhí)行后,發(fā)送方不結束 —— 回調不結束,也不釋放)
總結
- 上一篇: 腾讯成本优化黑科技:整机CPU利用率最高
- 下一篇: 报名|腾讯技术开放日·5G技术专场