詳解回調函數——以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,來解決異步編程的問題。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM