詳解回調函數——以JS為例解讀異步、回調和EventLoop
回調,是非常基本的概念,尤其在現今NodeJS誕生與蓬勃發展中變得更加被人們重視。很多朋友學NodeJS,學很久一直摸不著門道,覺得最後在用Express寫Web程序,有這樣的感覺只能說明沒有學懂NodeJS,本質上說不理解回調,就不理解NodeJS。
NodeJS有三大核心:
- CallBack回調
- Event事件
- Stream流
先來看什麽不叫回調,下面是很多網友誤認為的回調:
//代碼示例1 //Foo函數意在接收兩個參數,任意類型a,和函數類型cb,在結尾要調用cb() function Foo(a, cb){ console.log(a); // do something else // Maybe get some parameters for cb var param = Math.random(); cb(param); } //定義一個叫CallBack的函數,將作為參數傳給Foo var CallBack = function(num){ console.log(num); } //調用Foo Foo(2, CallBack);
以上代碼不是回調,以下指出這裏哪些概念容易混淆:
- 變量CallBack,被賦值為一個匿名函數,但是不因為它名字叫CallBack,就稱知為回調
_ Foo函數的第二個形式參數名為cb,同理叫cb,和是不是回調沒關系
_ cb在Foo函數代碼最後被以cb(param)的形式調用,不因為cb在另一個函數中被調用,而將其稱之為回調
直白來講,以上代碼就是普通的函數調用,唯一特殊一點的地方是,因為JS有函數式語言的特性,可以接收函數作為參數。在C語言裏可以用指向函數的指針來達到類似效果。
講到這裏先停一下,大家註意到本文的標題是解讀異步、回調和EventLoop,回調之前還有異步呢,這個順序對於理解很有幫助,可以說理解回調的前提,是理解異步。
說到異步,什麽是異步呢?和分布、並行有什麽區別?
回歸原始,追根溯源是我們學習編程的好方法,不去想有什麽高級的工具和概念,而去想如果我們只有一個瀏覽器做編譯器和一個記事本,用plain JS寫一段異步代碼,怎麽寫?不能用事件系統,不能用瀏覽器特性。
小明:剛才上面那段代碼是異步的嗎?
老袁:當然不是,即便把Foo改為AsyncFoo也不是。這裏比較迷惑的是cb(param)是在Foo函數的最後被調用的。
小明:好像覺得異步的代碼,確實應該在最後調一個callback函數,因為之後的代碼不會被執行到了。
老袁:異步的一個定義是函數調用不返回原來代碼調用處,而cb(params)調用完後,依舊返回到Foo的尾部,即便cb(params)後還有代碼,它們也可以被執行到,這是個同步調用。
Plain JS 異步的寫法有很多,以經典的為例:
//代碼示例2 // ====同步的加法 function Add(a, b){ return a+b; } Add(1, 2) // => 3 // ====異步的加法 function LazyAdd(a){ return function(b){ return a+b; } } var result = LazyAdd(1); // result等於一個匿名函數,實際是閉包 //我們的目的是做一個加法,result中保存了加法的一部分,即第一個參數和之後的運算規則, //通過返回一個持有外層參數a的匿名函數構成的閉包保存至變量result中,這部是異步的關鍵。 //極端的情況var result = LazyAdd(1)(2);這種極端情況又不屬於異步了,它和同步沒有區別。 // 現在可以寫一些別的代碼了 console.log(‘wait some time, doing some fun‘); // 實際生產中不會這麽簡單,它可能在等待一些條件成立,再去執行另外一半。 result = result(2) // => 3
上述代碼展示了,最簡單的異步。我們要強調的事,異步是異步,回調是回調,他倆半毛錢關系都沒有。
Ok,下面把代碼改一改,看什麽叫回調:
//代碼示例3 //註意還是那個Add,精髓也在這裏,隨後說到 function Add(a, b){ return a+b; } //LazyAdd改變了,多了一個參數cb function LazyAdd(a, cb){ return function(b){ cb(a, b); } } //將Add傳給形參cb var result = LazyAdd(1, Add) // doing something else result = result(2); // => 3
這段代碼,看似簡單,實則並不平凡。
小明:這代碼給人的第一感覺就是脫褲子放屁,明明一個a+b,先是變成異步的寫法就多了很多代碼,人都看不懂了,現在的這個加了所謂的“回調”,更啰嗦了,最後得到的結果都是1+2=3,尼瑪這不有病嗎?
老袁:你只看到了結果,卻不知道為什麽人家這麽寫,這樣寫為了什麽。代碼示例2和3中,同樣的Add函數,作為參數傳到LazyAdd中,此時它是回調。那為什麽代碼示例1中,Foo中傳入的cb不是回調呢?要仔細體會這句話,需要帶狀態的才叫回調函數,own state,這裏通過閉包保存的a就是狀態。
小明:我夥呆
老袁:現在再說為什麽要有回調,單看輸出結果,回調除了啰嗦和難於理解之外沒有任何意義。但是!!!
現在說吧,CallBack的好處是:保證API不撕裂
也就是說,異步是很有需求的,處理的好能使計算效率提高,不至於卡在某處一直等待。但是異步的寫法,我們看到了非常難看,把一個加法變成異步,都如此難看,何況其他。那麽CallBack的妙處就是“保證API不撕裂”,代碼中寫到的精髓所在,還是那個Add,對,讓程序員在寫異步程序的時候,還能夠像同步寫法那樣好理解,Add作為CallBack傳入,保證的是Add這個方法好理解,作為API設計中的重要一個環節,保證開發者用起來方便,代碼可讀性高。
以NodeJS的readFile API為例進一步說明:
fs.readFile(filename, [options], callback)
有兩個必填的參數filename和callback
callback是實際程序員要寫代碼的地方,寫它的時候假設文件已經讀取到了,該怎麽寫還怎麽寫,是API歷史上的一次大進步。
//讀取文件‘etc/passwd‘,讀取完成後將返回值,傳入function(err, data) 這個回調函數。 fs.readFile(‘/etc/passwd‘, function (err, data) { if (err) throw err; console.log(data); });
回調和閉包有一個共同的特性:在最終“回調 ”調用以前,前面所有的狀態都得存著。
這段代碼對於人們的疑惑常常是,我怎麽知道callback要接收幾個參數,參數的類型是什麽?
答:是API提供者事先設計好的,它需要在文檔中說明callback接收什麽參數。
如代碼3展示的那樣,API設計者通過種種技巧,實現了回調的形式,這種種技巧寫起來很痛苦。而fs.readFile看起來寫的很輕巧,這是因為它不光包含異步、回調,還引入的新的概念EventLoop。
EventLoop是很早前就有的概念,如MFC中的消息循環,瀏覽器中的事件機制等等。
那為什麽要有EventLoop,它的目的是什麽呢?
我們用一個簡單的偽示例,看沒有EventLoop時是怎麽工作:
//代碼示例4 function Add(a, b){ return a+b; } function LazyAdd(a, cb){ return function(b){ cb(a, b); } } var result = LazyAdd(1, Add) // 假設有一個變量button為false,我們繼續調用result的條件是,當button為true的時候。 var button = false; // 常用的辦法是觀察者模式,派一個人不斷的看button的值, //只要變了就開始執行result(2), 當然得有別人去改變button的值, //這裏假設有人有這個能力,比如起了另外一個線程去做。 while(true){ if(button){ result = result(2); break; } } result = result(2); // => 3
所以如果有很多這樣的函數,每一個都要跑一個觀察者模式,在一定條件下看上去比較費計算。這時EventLoop誕生了,派一個人來輪詢所有的,其他人都可以把觀察條件和回調函數註冊在EventLoop上,它進行統一的輪詢,註冊的人越多,輪詢一圈的時間越長。但是簡化了編程,不用每個人都寫輪詢了,提供API變得方便,就像fs.readFile一樣簡單明白,fs.readFile讀取文件’/etc/passwd’,將其註冊到EventLoop上,當文件讀取完畢的時候,EventLoop通過輪詢感知到它,並調用readFile註冊時帶的回調函數,這裏是funtion(err, data)
換一個說法再說一遍:在特定條件下,單臺機器上用空間換計算。原本callback執行了就不等了,存在一個地方,其他依賴它的,用觀察著模式一直盯著它,各自輪詢各自的。現在有人出來替大家統一輪詢。
再換一個說法說一遍,重要的事情,換著方法說3遍:在單臺機器上,統一輪詢看上去比較省,也帶來了很多問題,比如NodeJS中單線程情況下,如果一個函數計算量非常復雜,會阻礙所有其他的事件,所以這種情況要將復雜計算交給其他線程或者是服務來做。
我們一直在強調單臺機器,如果是多臺,用一個統一的人來輪詢,就比較恐怖了,大家把事件都註冊到一臺機器上,它負責輪詢所有的,這個namenode就容易崩潰。所以在多臺機器上,又適合,每天機器各自輪詢各自的,帶來的問題是狀態不一致了。好的,這才是程序有意思的地方,我們需要知道為什麽發明EventLoop,也需要知道EventLoop在什麽地方遇到問題。那些天才的程序員,又提出了各種一致性算法來解決這個問題,本文暫不討論。
到目前為止,我們梳理了他們之間的關系:
異步 –> 回調 –> EventLoop
每一次進步都是上一個臺階,都需要智慧來解決。
回調還產生了很多問題,最嚴重的問題是callback hell回調地獄。
fs.readFile(‘/etc/password‘, function(err, data){ // do something fs.readFile(‘xxxx‘, function(err, data){ //do something fs.readFile(‘xxxxx‘, function(err, data){ // do something }) }) })
這個例子可能不恰當,但也能理解,在類似這種情況會出現一層套一層的代碼,可讀性、維護性差。
在ES6 裏面給出了Generator,來解決異步編程的問題。
詳解回調函數——以JS為例解讀異步、回調和EventLoop