不知你是不是也有這樣的疑惑,我們為什么需要回調函數這個概念呢?直接調用函數不就可以了?回調函數到底有什么作用?程序員到底該如何理解回調函數?
這篇文章就來為你解答這些問題,讀完這篇文章后你的武器庫將新增一件功能強大的利器。
一切要從這樣的需求說起
假設你們公司要開發下一代國民App“明日油條”,一款主打解決國民早餐問題的App,為了加快開發進度,這款應用由A小組和B小組協同開發。
其中有一個核心模塊由A小組開發然后供B小組調用,這個核心模塊被封裝成了一個函數,這個函數就叫make_youtiao()。
如果make_youtiao()這個函數執行的很快並可以立即返回,那么B小組的同學只需要:
- 調用make_youtiao()
- 等待該函數執行完成
- 該函數執行完后繼續后續流程
從程序執行的角度看這個過程是這樣的:
- 保存當前被執行函數的上下文
- 開始執行make_youtiao()這個函數
- 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嗎?這個問題該怎么解決呢?關於這個問題的答案,你可以參考這里。
異步回調
故事到這里還沒有結束。
在上面的示例中,雖然我們使用了回調這一概念,也就是調用方實現回調函數然后再將該函數當做參數傳遞給其它模塊調用。
但是,這里依然有一個問題,那就是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分鍾,這就是同步調用的問題所在。
如果你真的理解了上一節的話應該能想到一種更好的方法了。
沒錯,那就是異步調用。
關於異步回調,你可以參考這里。
新的編程思維模式
讓我們再來仔細的看一下這個過程。
程序員最熟悉的思維模式是這樣的:
- 調用某個函數,獲取結果
- 處理獲取到的結果
res = request();
handle(res);
這就是函數的同步調用,只有request()函數返回拿到結果后,才能調用handle函數進行處理,request函數返回前我們必須等待,這就是同步調用,其控制流是這樣的:
但是如果我們想更加高效的話,那么就需要異步調用了,我們不去直接調用handle函數,而是作為參數傳遞給request:
request(handle);
我們根本就不關心request什么時候真正的獲取的結果,這是request該關心的事情,我們只需要把獲取到結果后該怎么處理告訴request就可以了,因此request函數可以立刻返回,真的獲取結果的處理可能是在另一個線程、進程、甚至另一台機器上完成。
這就是異步調用,其控制流是這樣的:
從編程思維上看,異步調用和同步有很大的差別,如果我們把處理流程當做一個任務來的話,那么同步下整個任務都是我們來實現的,但是異步情況下任務的處理流程被分為了兩部分:
- 第一部分是我們來處理的,也就是調用request之前的部分
- 第二部分不是我們處理的,而是在其它線程、進程、甚至另一個機器上處理的。
我們可以看到由於任務被分成了兩部分,第二部分的調用不在我們的掌控范圍內,同時只有調用方才知道該做什么,因此在這種情況下回調函數就是一種必要的機制了。
也就是說回調函數的本質就是“只有我們才知道做些什么,但是我們並不清楚什么時候去做這些,只有其它模塊才知道,因此我們必須把我們知道的封裝成回調函數告訴其它模塊”。
現在你應該能看出異步回調這種編程思維模式和同步的差異了吧。
接下來我們給回調一個較為學術的定義
正式定義
在計算機科學中,回調函數是指一段以參數的形式傳遞給其它代碼的可執行代碼。
這就是回調函數的定義了。
回調函數就是一個函數,和其它函數沒有任何區別。
注意,回調函數是一種軟件設計上的概念,和某個編程語言沒有關系,幾乎所有的編程語言都能實現回調函數。
對於一般的函數來說,我們自己編寫的函數會在自己的程序內部調用,也就是說函數的編寫方是我們自己,調用方也是我們自己。
但回調函數不是這樣的,雖然函數編寫方是我們自己,但是函數調用方不是我們,而是我們引用的其它模塊,也就是第三方庫,我們調用第三方庫中的函數,並把回調函數傳遞給第三方庫,第三方庫中的函數調用我們編寫的回調函數,如圖所示:
而之所以需要給第三方庫指定回調函數,是因為第三方庫的編寫者並不清楚在某些特定節點,比如我們舉的例子油條制作完成、接收到網絡數據、文件讀取完成等之后該做什么,這些只有庫的使用方才知道,因此第三方庫的編寫者無法針對具體的實現來寫代碼,而只能對外提供一個回調函數,庫的使用方來實現該函數,第三方庫在特定的節點調用該回調函數就可以了。
另一點值得注意的是,從圖中我們可以看出回調函數和我們的主程序位於同一層中,我們只負責編寫該回調函數,但並不是我們來調用的。
最后值得注意的一點就是回調函數被調用的時間節點,回調函數只在某些特定的節點被調用,就像上面說的油條制作完成、接收到網絡數據、文件讀取完成等,這些都是事件,也就是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) {
....
});
});
});
});
我想不需要再強調什么了吧,你覺得這兩種寫法哪個更容易理解,代碼更容易維護呢?
博主有幸曾經維護過這種類型的代碼,不得不說每次增加新功能的時候恨不得自己化為兩個分身,一個不得不去重讀一邊代碼;另一個在一旁罵自己為什么當初選擇維護這個項目。
異步回調代碼稍不留意就會跌到回調陷阱中,那么有沒有一種更好的辦法既能結合異步回調的高效又能結合同步編碼的簡單易讀呢?
幸運的是,答案是肯定的,關於答案你可以參考這里。
總結
在這篇文章中,我們從一個實際的例子出發詳細講解了回調函數這種機制的來龍去脈,這是應對高並發、高性能場景的一種極其重要的編碼機制,異步加回調可以充分利用機器資源,實際上異步回調最本質上就是事件驅動編程,這是我們接下來要重點講解的內容。
