1. 程式人生 > >詳解回調函數——以JS為例解讀異步、回調和EventLoop

詳解回調函數——以JS為例解讀異步、回調和EventLoop

num csdn 指向 瀏覽器中 都是 truct 輪詢 技術 通過

回調,是非常基本的概念,尤其在現今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