10 張圖讓你徹底理解回調函數

不知你是不是也有這樣的疑惑,我們爲什麼需要回調函數這個概念呢?直接調用函數不就可以了?回調函數到底有什麼作用?程序員到底該如何理解回調函數?

這篇文章就來爲你解答這些問題,讀完這篇文章後你的武器庫將新增一件功能強大的利器

一切要從這樣的需求說起

假設你們公司要開發下一代國民 App“明日油條”,一款主打解決國民早餐問題的 App,爲了加快開發進度,這款應用由 A 小組和 B 小組協同開發。

其中有一個核心模塊由 A 小組開發然後供 B 小組調用,這個核心模塊被封裝成了一個函數,這個函數就叫 make_youtiao()。

如果 make_youtiao() 這個函數執行的很快並可以立即返回,那麼 B 小組的同學只需要:

  1. 調用 make_youtiao()

  2. 等待該函數執行完成

  3. 該函數執行完後繼續後續流程

從程序執行的角度看這個過程是這樣的:

  1. 保存當前被執行函數的上下文

  2. 開始執行 make_youtiao() 這個函數

  3. make_youtiao() 執行完後,控制轉回到調用函數中

圖片

如果世界上所有的函數都像 make_youtiao() 這麼簡單,那麼程序員大概率就要失業了,還好程序的世界是複雜的,這樣程序員纔有了存在的價值。

現實並不容易

現實中 make_youtiao() 這個函數需要處理的數據非常龐大,假設有 10000 個,那麼 make_youtiao(10000) 不會立刻返回,而是可能需要 10 分鐘才執行完成並返回。

這時你該怎麼辦呢?想一想這個問題。

可能有的同學會問,和剛纔一樣直接調用不可以嗎,這樣多簡單。

是的,這樣做沒有問題,但就像愛因斯坦說的那樣 “一切都應該儘可能簡單,但是不能過於簡單”。

想一想直接調用會有什麼問題?

顯然直接調用的話,那麼調用線程會被阻塞暫停,在等待 10 分鐘後才能繼續運行。在這 10 分鐘內該線程不會被操作系統分配 CPU,也就是說該線程得不到任何推進。

這並不是一種高效的做法。

沒有一個程序員想死盯着屏幕 10 分鐘後才能得到結果。

那麼有沒有一種更加高效的做法呢?

如果你是老闆的話你會什麼都不幹一直盯着員工寫代碼嗎?因此一種更好的做法是程序員在代碼的時候老闆該幹啥幹啥,程序員寫完後自然會通知老闆,這樣老闆和程序員都不需要相互等待,這種模式被稱爲異步。

回到我們的主題,這裏一種更好的方式是調用 make_youtiao() 這個函數後不再等待這個函數執行完成,而是直接返回繼續後續流程,這樣 A 小組的程序就可以和 make_youtiao() 這個函數同時進行了,就像這樣:

圖片

在這種情況下,回調 (callback) 就必須出場了。

爲什麼我們需要回調 callback

有的同學可能還沒有明白爲什麼在這種情況下需要回調,彆着急,我們慢慢講。

假設我們 “明日油條”App 代碼第一版是這樣寫的:

make_youtiao(10000);
sell();

可以看到這是最簡單的寫法,意思很簡單,製作好油條後賣出去。

圖片

我們已經知道了由於 make_youtiao(10000) 這個函數 10 分鐘才能返回,你不想一直死盯着屏幕 10 分鐘等待結果,那麼一種更好的方法是讓 make_youtiao() 這個函數知道製作完油條後該幹什麼,即,更好的調用 make_youtiao 的方式是這樣的:“製作 10000 個油條,炸好後賣出去”,因此調用 make_youtiao 就變出這樣了:

make_youtiao(10000, sell);

看到了吧,現在 make_youtiao 這個函數多了一個參數,除了指定製作油條的數量外還可以指定製作好後該幹什麼,第二個被 make_youtiao 這個函數調用的函數就叫回調,callback。

現在你應該看出來了吧,雖然 sell 函數是你定義的,但是這個函數卻是被其它模塊調用執行的,就像這樣:

圖片

make_youtiao 這個函數是怎麼實現的呢,很簡單:

void make_youtiao(int num, func call_back) {
    // 製作油條
    call_back(); //執行回調 
}

這樣你就不用死盯着屏幕了,因爲你把 make_youtiao 這個函數執行完後該做的任務交代給 make_youtiao 這個函數了,該函數製作完油條後知道該幹些什麼,這樣就解放了你的程序。

有的同學可能還是有疑問,爲什麼編寫 make_youtiao 這個小組不直接定義 sell 函數然後調用呢?

不要忘了明日油條這個 App 是由 A 小組和 B 小組同時開發的,A 小組在編寫 make_youtiao 時怎麼知道 B 小組要怎麼用這個模塊,假設 A 小組真的自己定義 sell 函數就會這樣寫:

void make_youtiao(int num) {
    real_make_youtiao(num);
    sell(); //執行回調 
}

同時 A 小組設計的模塊非常好用,這時 C 小組也想用這個模塊,然而 C 小組的需求是製作完油條後放到倉庫而不是不是直接賣掉,要滿足這一需求那麼 A 小組該怎麼寫呢?

void make_youtiao(int num) {
    real_make_youtiao(num);
    if (Team_B) {
       sell(); // 執行回調
    } else if (Team_D) {
       store(); // 放到倉庫
    }
}

故事還沒完,假設這時 D 小組又想使用呢,難道還要接着添加 if else 嗎?這樣的話 A 小組的同學只需要維護 make_youtiao 這個函數就能做到工作量飽滿了,顯然這是一種非常糟糕的設計。

所以你會看到,製作完油條後接下來該做什麼不是實現 make_youtiao 的 A 小組該關心的事情,很明顯只有調用 make_youtiao 這個函數的使用方纔知道。

因此 make_youtiao 的 A 小組完全可以通過回調函數將接下來該幹什麼交給調用方實現,A 小組的同學只需要針對回調函數這一抽象概念進行編程就好了,這樣調用方在製作完油條後不管是賣掉、放到庫存還是自己喫掉等等想做什麼都可以,A 小組的 make_youtiao 函數根本不用做任何改動,因爲 A 小組是針對回調函數這一抽象概念來編程的。

以上就是回調函數的作用,當然這也是針對抽象而不是具體實現進行編程這一思想的威力所在。面向對象中的多態本質上就是讓你用來針對抽象而不是針對實現來編程的。

異步回調

故事到這裏還沒有結束。

在上面的示例中,雖然我們使用了回調這一概念,也就是調用方實現回調函數然後再將該函數當做參數傳遞給其它模塊調用。

但是,這裏依然有一個問題,那就是 make_youtiao 函數的調用方式依然是同步的,關於同步異步請參考《從小白到高手,你需要理解同步與異步》,也就是說調用方是這樣實現的:

make_youtiao(10000, sell);
// make_youtiao函數返回前什麼都做不了

圖片

我們可以看到,調用方必須等待 make_youtiao 函數返回後纔可以繼續後續流程,我們再來看下 make_youtiao 函數的實現:

void make_youtiao(int num, func call_back) {
    real_make_youtiao(num);
    call_back(); //執行回調 
}

看到了吧,由於我們要製作 10000 個油條,make_youtiao 函數執行完需要 10 分鐘,也就是說即便我們使用了回調,調用方完全不需要關心製作完油條後的後續流程,但是調用方依然會被阻塞 10 分鐘,這就是同步調用的問題所在。

如果你真的理解了上一節的話應該能想到一種更好的方法了。

沒錯,那就是異步調用

反正製作完油條後的後續流程並不是調用方該關心的,也就是說調用方並不關心 make_youtiao 這一函數的返回值,那麼一種更好的方式是:把製作油條的這一任務放到另一個線程 (進程)、甚至另一臺機器上

如果用線程實現的話,那麼 make_youtiao 就是這樣實現了:

void make_youtiao(int num, func call_back) {
    // 在新的線程中執行處理邏輯
    create_thread(real_make_youtiao,
                  num,
                  call_back);
}

圖片

看到了吧,這時當我們調用 make_youtiao 時就會立刻返回,即使油條還沒有真正開始製作,而調用方也完全無需等待制作油條的過程,可以立刻執行後流程:

make_youtiao(10000, sell);
// 立刻返回
// 執行後續流程

這時調用方的後續流程可以和製作油條同時進行,這就是函數的異步調用,當然這也是異步的高效之處。

新的編程思維模式

讓我們再來仔細的看一下這個過程。

程序員最熟悉的思維模式是這樣的:

res = request();
handle(res);

這就是函數的同步調用,只有 request() 函數返回拿到結果後,才能調用 handle 函數進行處理,request 函數返回前我們必須等待,這就是同步調用,其控制流是這樣的:

圖片

但是如果我們想更加高效的話,那麼就需要異步調用了,我們不去直接調用 handle 函數,而是作爲參數傳遞給 request:

request(handle);

我們根本就不關心 request 什麼時候真正的獲取的結果,這是 request 該關心的事情,我們只需要把獲取到結果後該怎麼處理告訴 request 就可以了,因此 request 函數可以立刻返回,真的獲取結果的處理可能是在另一個線程、進程、甚至另一臺機器上完成。

這就是異步調用,其控制流是這樣的:

圖片

從編程思維上看,異步調用和同步有很大的差別,如果我們把處理流程當做一個任務來的話,那麼同步下整個任務都是我們來實現的,但是異步情況下任務的處理流程被分爲了兩部分:

  1. 第一部分是我們來處理的,也就是調用 request 之前的部分

  2. 第二部分不是我們處理的,而是在其它線程、進程、甚至另一個機器上處理的。

我們可以看到由於任務被分成了兩部分,第二部分的調用不在我們的掌控範圍內,同時只有調用方纔知道該做什麼,因此在這種情況下回調函數就是一種必要的機制了。

也就是說回調函數的本質就是 “只有我們才知道做些什麼,但是我們並不清楚什麼時候去做這些,只有其它模塊才知道,因此我們必須把我們知道的封裝成回調函數告訴其它模塊”。

現在你應該能看出異步回調這種編程思維模式和同步的差異了吧。

接下來我們給回調一個較爲學術的定義

正式定義

在計算機科學中,回調函數是指一段以參數的形式傳遞給其它代碼的可執行代碼。

這就是回調函數的定義了。

回調函數就是一個函數,和其它函數沒有任何區別。

注意,回調函數是一種軟件設計上的概念,和某個編程語言沒有關係,幾乎所有的編程語言都能實現回調函數。

對於一般的函數來說,我們自己編寫的函數會在自己的程序內部調用,也就是說函數的編寫方是我們自己,調用方也是我們自己。

但回調函數不是這樣的,雖然函數編寫方是我們自己,但是函數調用方不是我們,而是我們引用的其它模塊,也就是第三方庫,我們調用第三方庫中的函數,並把回調函數傳遞給第三方庫,第三方庫中的函數調用我們編寫的回調函數,如圖所示:

圖片

而之所以需要給第三方庫指定回調函數,是因爲第三方庫的編寫者並不清楚在某些特定節點,比如我們舉的例子油條製作完成、接收到網絡數據、文件讀取完成等之後該做什麼,這些只有庫的使用方纔知道,因此第三方庫的編寫者無法針對具體的實現來寫代碼,而只能對外提供一個回調函數,庫的使用方來實現該函數,第三方庫在特定的節點調用該回調函數就可以了。

另一點值得注意的是,從圖中我們可以看出回調函數和我們的主程序位於同一層中,我們只負責編寫該回調函數,但並不是我們來調用的。

最後值得注意的一點就是回調函數被調用的時間節點,回調函數只在某些特定的節點被調用,就像上面說的油條製作完成、接收到網絡數據、文件讀取完成等,這些都是事件,也就是 event,本質上我們編寫的回調函數就是用來處理 event 的,因此從這個角度看回調函數不過就是 event handler,因此回調函數天然適用於事件驅動編程 event-driven,我們將會在後續文章中再次回到這一主題。

回調的類型

我們已經知道有兩種類型的回調,這兩種類型的回調區別在於回調函數被調用的時機。

注意,接下來會用到同步和異步的概念,對這兩個概念不熟悉的同學可以參考上一盤文章《從小白到高手,你需要理解同步和異步》。

同步回調

這種回調就是通常所說的同步回調 synchronous callbacks、也有的將其稱爲阻塞式回調 blocking callbacks,或者什麼修飾都沒有,就是回調,callback,這是我們最爲熟悉的回調方式。

當我們調用某個函數 A 並以參數的形式傳入回調函數後,在 A 返回之前回調函數會被執行,也就是說我們的主程序會等待回調函數執行完成,這就是所謂的同步回調。

圖片

有同步回調就有異步回調。

異步回調

不同於同步回調, 當我們調用某個函數 A 並以參數的形式傳入回調函數後,A 函數會立刻返回,也就是說函數 A 並不會阻塞我們的主程序,一段時間後回調函數開始被執行,此時我們的主程序可能在忙其它任務,回調函數的執行和我們主程序的運行同時進行。

既然我們的主程序和回調函數的執行可以同時發生,因此一般情況下,主程序和回調函數的執行位於不同的線程或者進程中。

圖片

這就是所謂的異步回調,asynchronous callbacks,也有的資料將其稱爲 deferred callbacks ,名字很形象,延遲迴調。

從上面這兩張圖中我們也可以看到,異步回調要比同步回調更能充分的利用機器資源,原因就在於在同步模式下主程序會 “偷懶”,因爲調用其它函數被阻塞而暫停運行,但是異步調用不存在這個問題,主程序會一直運行下去。

因此,異步回調更常見於 I/O 操作,天然適用於 Web 服務這種高併發場景。

回調對應的編程思維模式

讓我們用簡單的幾句話來總結一下回調下與常規編程思維模式的不同。

假設我們想處理某項任務,這項任務需要依賴某項服務 S,我們可以將任務的處理分爲兩部分,調用服務 S 前的部分 PA,和調用服務 S 後的部分 PB。

在常規模式下,PA 和 PB 都是服務調用方來執行的,也就是我們自己來執行 PA 部分,等待服務 S 返回後再執行 PB 部分。

但在回調這種方式下就不一樣了。

在這種情況下,我們自己來執行 PA 部分,然後告訴服務 S:“等你完成服務後執行 PB 部分”。

因此我們可以看到,現在一項任務是由不同的模塊來協作完成的。

即:

其中 X 是服務調用方制定的,區別在於誰來執行。

爲什麼異步回調越來越重要

在同步模式下,服務調用方會因服務執行而被阻塞暫停執行,這會導致整個線程被阻塞,因此這種編程方式天然不適用於高並發動輒幾萬幾十萬的併發連接場景,

針對高併發這一場景,異步其實是更加高效的,原因很簡單,你不需要在原地等待,因此從而更好的利用機器資源,而回調函數又是異步下不可或缺的一種機制。

**回調地獄,callback hell **

有的同學可能認爲有了異步回調這種機制應付起一切高併發場景就可以高枕無憂了。

實際上在計算機科學中還沒有任何一種可以橫掃一切包治百病的技術,現在沒有,在可預見的將來也不會有,一切都是妥協的結果。

那麼異步回調這種機制有什麼問題呢?

實際上我們已經看到了,異步回調這種機制和程序員最熟悉的同步模式不一樣,在可理解性上比不過同步,而如果業務邏輯相對複雜,比如我們處理某項任務時不止需要調用一項服務,而是幾項甚至十幾項,如果這些服務調用都採用異步回調的方式來處理的話,那麼很有可能我們就陷入回調地獄中。

舉個例子,假設處理某項任務我們需要調用四個服務,每一個服務都需要依賴上一個服務的結果,如果用同步方式來實現的話可能是這樣的:

a = GetServiceA();
b = GetServiceB(a);
c = GetServiceC(b);
d = GetServiceD(c);

代碼很清晰,很容易理解有沒有。

我們知道異步回調的方式會更加高效,那麼使用異步回調的方式來寫將會是什麼樣的呢?

GetServiceA(function(a){
    GetServiceB(a, function(b){
        GetServiceC(b, function(c){
            GetServiceD(c, function(d) {
                ....
            });
        });
    });
});

我想不需要再強調什麼了吧,你覺得這兩種寫法哪個更容易理解,代碼更容易維護呢?

博主有幸曾經維護過這種類型的代碼,不得不說每次增加新功能的時候恨不得自己化爲兩個分身,一個不得不去重讀一邊代碼;另一個在一旁罵自己爲什麼當初選擇維護這個項目。

異步回調代碼稍不留意就會跌到回調陷阱中,那麼有沒有一種更好的辦法既能結合異步回調的高效又能結合同步編碼的簡單易讀呢?

幸運的是,答案是肯定的,我們會在後續文章中詳細講解這一技術。

總結

在這篇文章中,我們從一個實際的例子出發詳細講解了回調函數這種機制的來龍去脈,這是應對高併發、高性能場景的一種極其重要的編碼機制,異步加回調可以充分利用機器資源,實際上異步回調最本質上就是事件驅動編程,這是我們接下來要重點講解的內容。

本文經修改後已收錄到我的新書《計算機底層的祕密》:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Fe3mqykKRkp5ydF_h9N9LQ