深入 C++ 回調

2021-02-20 騰訊技術工程

許多面試官會問:你知道回調嗎?你在寫回調的時候遇到哪些坑?你知道對象生命周期管理嗎?為什麼這裡會崩潰,那裡會洩漏? 在設計 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; });
            

Accumulate Sync

異步回調 (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));
               

Fetch Image Async

註:

FetchImageAsync(
 filename,
 base::Bind([this](const Image& image) {
   
   if (background_image_view_)
     background_image_view_->SetImage(image);
 }));

 
1.1 回調時(弱引用)上下文會不會失效

由於閉包沒有 弱引用上下文 的所有權,所以上下文可能失效:

例如 異步加載圖片 的場景:在等待加載時,用戶可能已經退出了界面。所以,在執行 View::LoadImageCallback 時:

其實,上述兩段代碼(包括 C++ 11 lambda 表達式版本)都無法編譯(Chromium 做了對應的 靜態斷言 (static assert))—— 因為傳給 base::Bind 的參數都是 不安全的

C++ 核心指南 (C++ Core Guidelines) 也有類似的討論:


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

如果弱引用上下文失效,回調應該 及時取消。例如 異步加載圖片 的代碼,可以給 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)


2. 回調只能執行一次還是可以多次

軟體設計裡,只有三個數 —— 0,1,∞(無窮)。類似的,不管是同步回調還是異步回調,我們只關心它被執行 0 次,1 次,還是多次。

根據可調用次數,Chromium 把回調分為兩種:

註:


2.1 為什麼要區分一次和多次回調

我們先舉個 反例 —— 基於 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 可能已經被釋放,從而導致 崩潰


2.2 何時銷毀(強引用)上下文

對於面向對象的回調,強引用上下文的 所有權屬於閉包。例如,改寫 異步/非阻塞發送數據 的代碼:

假設 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(有且只有一處銷毀的地方

註:

由閉包管理所有權,上下文可以保證:

被銷毀且只銷毀一次(避免洩漏)

銷毀後不會被再使用(避免崩潰)

但這又引入了另一個微妙的問題:由於 一次回調上下文銷毀時機不確定,上下文對象 析構函數 的調用時機 也不確定 —— 如果上下文中包含了 複雜析構函數 的對象(例如 析構時做數據上報),那麼析構時需要檢查依賴條件的有效性(例如 檢查數據上報環境是否有效),否則會 崩潰


2.3 如何傳遞(強引用)上下文

根據 可拷貝性,強引用上下文又分為兩類:

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 語言,也可能出現 洩漏問題

相關焦點

  • C++類與回調函數
    在C++中的一個重要概念就是類,所以我們一般想讓類的成員函數作為回調函數(如果直接用非類的成員函數作為回調函數,其實就和C語言中的方法一樣),但是想實現這樣的功能,還是存在一些限制的。另外,如果想要採用這種方法實現回調,那麼在定義回調函數的時候就得知道誰會調用它,顯然,這個要求是不合理的,所以,這種方法很不常用,只是介紹這種方法。
  • C/C++編程筆記:如何理解C語言中的回調函數,零基礎也看得懂
    在電腦程式設計中,回調函數,或簡稱回調,是指通過函數參數傳遞到其它代碼的,某一塊可執行代碼的引用。
  • 還在用回調函數?快來學習怎麼將回調函數轉為成Promise吧!
    本文只是簡單的以實例講解如何將回調函數轉化為ES6中的Promise,並不會深入分析回調的優缺點,以及Promise和async/await的原理,如果你想了解這些,請關注我。我將在以後的文章中詳細講解這些知識點。
  • c++的輸入與輸出
    c++輸入與輸出C++ 標準庫提供了一組豐富的輸入/輸出功能,本章將討論 C++ 編程中最基本和最常見的 I/O 操作。輸入輸出並不是c++語言的正式組成成分,c和c++沒有為輸入輸出提供專門的結構。在c語言中輸入輸出是通過調用scanf和printf 實現的,在c++中是通過調用流對象cin和cout實現的。
  • 深入淺出剖析C語言函數指針與回調函數
    回調函數就是一個通過函數指針調用的函數。如果你把函數的指針(地址)作為參數傳遞給另一個函數,當這個指針被用來調用其所指向的函數時,我們就說這是回調函數。回調函數不是由該函數的實現方直接調用,而是在特定的事件或條件發生時由另外的一方調用的,用於對該事件或條件進行響應。
  • C++、java 和 C 的區別
    一、基礎類型c++:** java:** C#:1.以java為準,c++裡面的int short long 像這樣的整型 一般都有unsigned 和signed的區分 ,這個跟java和c# 的區別比較大,但c#裡面有unit ulong ushort 這三種就相當於c++的修飾詞unsigned,當c++李明的變量類型定義unsigned,就默認是整數。
  • 九大程式語言優缺點第四期:c++
    C++:c++誕生於1983年,緊隨c語言的步伐,c++是C語言的超集,大家所知道的C語言是面向過程的,java是面向對象的,那麼C語言為了面向對象,所以誕生出現在大家所熟知的c++,被廣泛視為大規模應用構建軟體。
  • Android Multimedia框架總結(六)C++中MediaPlayer的C/S架構
    轉載請把頭部出處連結和尾部二維碼一起轉載,本文出自: http://blog.csdn.net/hejjunlin/article/details/52435789前面幾節中,都是通過java層調用到jni中,jni向下到c++
  • C++伺服器開發完整學習路線(含免費學習資料下載地址)
    基礎階段1. c/c++程式語言c語言必備的入門書籍就是這本《C程序設計語言》  surl=eQAdygU  密碼:6mhv學了c++基礎後,為了寫出更高效的c++代碼,那麼就須要看這本書《Effective C++》
  • c++11新特性,所有知識點都在這了!
    c++11新特性吧,你是怎麼回答的呢?本文基本上涵蓋了c++11的所有新特性,並有詳細代碼介紹其用法,對關鍵知識點做了深入分析,對重要的知識點我單獨寫了相關文章並附上了相關連結,我整理了完備的c++新特性腦圖(由於圖片太大,我沒有放在文章裡,同學可以在後臺回復消息「新特性」,即可下載完整圖片)。
  • 學習c++筆記——標準輸出流cout
    前和往常一樣,一邊喝早茶,一邊上網和女粉絲侃大山,在手機和平板電腦上整理修改《html5》、《javascript》、《css3》、《c語言》等多年前寫的教程(c++
  • 使用Promise模式來簡化JavaScript的異步回調
    下面再來看看如何添加回調。showMsg().then(function( str ){// 回調添加到這裡來了callback( str );});這樣就將回調函數和原來的異步函數徹底的分離了,從代碼組織上看,優雅了很多。
  • 那些容易犯錯的c++保留字
    本文首發 | 公眾號:lunvey目前正在學習vc++6.0開發,而這裡面使用的是c++98標準。
  • 股票回調是什麼意思 2017股票回調怎麼炒股?
    股票回調指的是某隻股票在連續上漲過程中出現短暫下降,下跌幅度可大可小。這種現象就是股票回調。那麼,2017股票回調怎麼炒股呢?下面隨小編來簡單的了解一下股票回調相關知識吧。股票回調怎麼炒股?1、不要盲目殺跌。在股市暴跌中不計成本的盲目斬倉,是不明智的。
  • C++ 優先隊列priority_queue
    { cout << q.top() << endl; q.pop(); }}priority_queue<int> 默認構建的是一個大根堆,所以每次從頭取數據得到的是一個從大到小的隊列排序albert@home-pc:/mnt/c++
  • 跟我學C++中級篇——STL的學習
    一、c++標準庫C++的標準庫主要包含兩大類,首先是包含C的標準庫的,當然,為了適應c++對一些C庫進行了少許的修改和增加。最重要的當然是面向對象的c++庫;而c++庫又可以分成兩大類,即面向對象的c++庫和標準模板庫,也就是題目中的STL。
  • C++機器學習庫介紹
    #include <shark/ObjectiveFunctions/Loss/SquaredLoss.h>#include <shark/Algorithms/Trainers/LinearRegression.h>要編譯,我們需要連結到以下庫:-std=c++
  • 聊一聊C++bind函數使用
    短短的一行代碼,實際上考驗了一個人對C++的掌握深度,好了話不多說,進入今天的介紹,c++ bind綁定函數。綁定一個成員函數:bind最常用的功能之一,是由類成員函數構造bind對象;想想看,如何由類成員函數(非static成員函數)構造回調函數?答案是很難,而通過bind,卻可以很容易做到。
  • c++ fstream + string 處理大數據
    (4)上面兩點算是自己的誤解吧,因為c++裡面也有也有與之對應的fstream類,c++map容器類,詳見c++ map簡介(5)c++裡面也有相對比較成熟的string類,裡面的函數也大部分很靈活,沒有的也可以很容易的實現split,strim等,詳見c++string實現(6)最近從網上,看到了一句很經典的話,c++的風fstream類 + string
  • python+C、C++混合編程的應用
    有的語言專注於簡單高效,比如python,內建的list,dict結構比c/c++易用太多,但同樣為了安全、易用,語言也犧牲了部分性能。在有些領域,比如通信,性能很關鍵,但並不意味這個領域的coder只能苦苦掙扎於c/c++的陷阱中,比如可以使用多種語言混合編程。