天天看點

随便幾張圖讓你徹底了解回調函數就是這麼爽快,年輕人,耗子喂汁

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

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

一切要從這樣的需求說起

假設你們公司要開發下一代國民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部分”。是以我們可以看到,現在一項任務是由不同的子產品來協作完成的。即:正常模式:調用完S服務後後我去執行X任務,回調模式:調用完S服務後你接着再去執行X任務,其中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) {

....

});

});

});

});我想不需要再強調什麼了吧,你覺得這兩種寫法哪個更容易了解,代碼更容易維護呢?部落客有幸曾經維護過這種類型的代碼,不得不說每次增加新功能的時候恨不得自己化為兩個分身,一個不得不去重讀一邊代碼;另一個在一旁罵自己為什麼當初選擇維護這個項目。異步回調代碼稍不留意就會跌到回調陷阱中,那麼有沒有一種更好的辦法既能結合異步回調的高效又能結合同步編碼的簡單易讀呢?幸運的是,答案是肯定的,我們會在後續文章中詳細講解這一技術。

總結

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