【計算機內功心法】六:10張圖讓你徹底理解回撥函式

碼農的荒島求生發表於2021-02-01

不知你是不是也有這樣的疑惑,我們為什麼需要回撥函式這個概念呢?直接呼叫函式不就可以了?回撥函式到底有什麼作用?程式設計師到底該如何理解回撥函式?

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

一切要從這樣的需求說起

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

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

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

  1. 呼叫make_youtiao()
  2. 等待該函式執行完成
  3. 該函式執行完後繼續後續流程

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

  1. 儲存當前被執行函式的上下文
  2. 開始執行make_youtiao()這個函式
  3. make_youtiao()執行完後,控制轉回到呼叫函式中
1602596924819
1602596924819

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

現實情況並不容易

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

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

可能有的同學就像把頭埋在沙子裡的鴕鳥一樣:和剛才一樣直接呼叫不可以嗎,這樣多簡單。

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

想一想直接呼叫會有什麼問題?

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

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

沒有一個程式設計師想死盯著螢幕10分鐘後才能得到結果。

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

想一想我們上一篇中那個一直盯著你寫程式碼的老闆(見《從小白到高手,你需要理解同步與非同步》),我們已經知道了這種一直等待直到另一個任務完成的模式叫做同步。

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

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

1602597258523
1602597258523

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

為什麼我們需要回撥callback

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

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

make_youtiao(10000);
sell();

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

1602597572027
1602597572027

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

make_youtiao(10000, sell);

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

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

1602598090503
1602598090503

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嗎?這個問題該怎麼解決呢?關於這個問題的答案,你可以參考這裡

非同步回撥

故事到這裡還沒有結束。

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

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

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

我們可以看到,呼叫方必須等待make_youtiao函式返回後才可以繼續後續流程,我們再來看下make_youtiao函式的實現:

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

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

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

沒錯,那就是非同步呼叫。

關於非同步回撥,你可以參考這裡

新的程式設計思維模式

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

程式設計師最熟悉的思維模式是這樣的:

  1. 呼叫某個函式,獲取結果
  2. 處理獲取到的結果
res = request();
handle(res);

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

1602683285172
1602683285172

但是如果我們想更加高效的話,那麼就需要非同步呼叫了,我們不去直接呼叫handle函式,而是作為引數傳遞給request:

request(handle);

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

這就是非同步呼叫,其控制流是這樣的:

1602684355493
1602684355493

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

  1. 第一部分是我們來處理的,也就是呼叫request之前的部分
  2. 第二部分不是我們處理的,而是在其它執行緒、程式、甚至另一個機器上處理的。

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

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

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

接下來我們給回撥一個較為學術的定義

正式定義

在電腦科學中,回撥函式是指一段以引數的形式傳遞給其它程式碼的可執行程式碼。

這就是回撥函式的定義了。

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

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

對於一般的函式來說,我們自己編寫的函式會在自己的程式內部呼叫,也就是說函式的編寫方是我們自己,呼叫方也是我們自己。

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

1601686717372
1601686717372

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

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

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

為什麼非同步回撥這種思維模式正變得的越來越重要

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

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

回撥地獄,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{
                ....
            });
        });
    });
});

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

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

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

幸運的是,答案是肯定的,關於答案你可以參考這裡

總結

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

相關文章