## 5.1 基礎知識
1. 什么是回調?
回調是異步編程時的基礎,將后續邏輯封裝成起始函數的參數,逐層嵌套。
2. 什么是同步/異步?
同步:發送方發出數據后,等接收方發回響應以后才發下一個數據包的通訊方式。
異步:發送方發出數據后,不等接收方發回響應,接著發送下個數據包的通訊方式。
3. 什么是I/O?
磁盤的寫入(in)磁盤的讀取(out)
4. 什么的單線程/多線程?
一次只能執行一個程序叫做單線程
一次能執行多個程序叫多線程
5. 什么是阻塞/非阻塞?
阻塞:前一個程序未執行完就得一直等待
非阻塞:前一個程序未執行完時可以掛起,繼續執行其他程序,等到使用時再執行
6. 什么是事件?
一個觸發動作(例如點擊按鈕)
7. 什么是事件驅動?
一個觸發動作引起的操作(例如點擊按鈕后彈出一個對話框)
8. 什么是基于事件驅動的回調?
為了某個事件注冊了回調函數,但是這個回調函數不是馬上執行,只有當事件發生的時候,才會調用回調函數,這種函數執行的方式叫做事件驅動~這種注冊回調就是基于事件驅動的回調,如果這些回調和異步I/O(數據寫入、讀取)操作有關,可以看作是基于回調的異步I/O,只不過這種回調在nodejs中是有事件來驅動的
9. 什么是事件循環?
Event Loop:回調函數隊列。異步執行的函數會被壓入這個隊列; 隊列被循環查詢。
倘若有大量的異步操作,一些I/O的耗時操作,甚至是一些定時器控制的延時操作,它們完成的時候都要調用相應的回調函數,從而來完成一些密集的任務,而又不會阻塞整個程序執行的流程,此時需要一種機制來管理,這種機制叫做事件循環.
總而言之就是:管理大量異步操作的機制叫做事件循環
## 5.2 回調
NodeJS最大的賣點——事件機制和異步IO,對開發者并不是透明的。開發者需要按異步方式編寫代碼才用得上這個賣點,而這一點也遭到了一些NodeJS反對者的抨擊。但不管怎樣,異步編程確實是NodeJS最大的特點,沒有掌握異步編程就不能說是真正學會了NodeJS。
在代碼中,異步編程的直接體現就是回調。異步編程依托于回調來實現,但不能說使用了回調后程序就異步化了。
~~~javascript
function heavyCompute(n, callback) {
var count = 0,
i, j;
for (i = n; i > 0; --i) {
for (j = n; j > 0; --j) {
count += 1;
}
}
callback(count);
}
heavyCompute(10000, (count) => {
console.log(count);
});
console.log('hello');
/*=>
100000000
hello*/
~~~
可以看到,以上代碼中的回調函數仍然先于后續代碼執行。JS本身是單線程運行的,不可能在一段代碼還未結束運行時去運行別的代碼,因此也就不存在異步執行的概念。
但是,如果某個函數做的事情是創建一個別的線程或進程,并與JS主線程并行地做一些事情,并在事情做完后通知JS主線程,那情況又不一樣了。
~~~javascript
setTimeout(function () {
console.log('world');
}, 1000);
console.log('hello');
/*=>
hello
world*/
~~~
這次可以看到,回調函數后于后續代碼執行了。如同上邊所說,JS本身是單線程的,無法異步執行,因此可以認為`setTimeout`這類JS規范之外的由運行環境提供的特殊函數做的事情是創建一個平行線程后立即返回,讓JS主進程可以接著執行后續代碼,并在收到平行進程的通知后再執行回調函數。除了`setTimeout、setInterval`這些常見的,這類函數還包括NodeJS提供的諸如`fs.readFile`之類的異步API。
另外,我們仍然回到JS是單線程運行的這個事實上,這決定了JS在執行完一段代碼之前無法執行包括回調函數在內的別的代碼。也就是說,即使平行線程完成工作了,通知JS主線程執行回調函數了,回調函數也要等到JS主線程空閑時才能開始執行。
~~~javascript
function heavyCompute(n) {
var count = 0,
i, j;
for (i = n; i > 0; --i) {
for (j = n; j > 0; --j) {
count += 1;
}
}
}
var t = new Date();
setTimeout(() => {
console.log(new Date() - t);
}, 1000);
heavyCompute(50000);
// 2571
~~~
可以看到,本來應該在1秒后被調用的回調函數因為JS主線程忙于運行其它代碼,實際執行時間被延遲。
## 5.3 代碼設計模式
異步編程有很多特有的代碼設計模式,為了實現同樣的功能,使用同步方式和異步方式編寫的代碼會有很大差異。
### 5.3.1 函數返回值
使用一個函數的輸出作為另一個函數的輸入是很常見的需求,在同步方式下一般按以下方式編寫代碼:
~~~javascript
var output = fn1(fn2('input'));
// Do something.
~~~
而在異步方式下,由于函數執行結果不是通過返回值,而是通過回調函數傳遞,因此一般按以下方式編寫代碼:
~~~javascript
fn2('input', (output2) => {
fn1(output2, (output1) => {
// Do something.
});
});
~~~
可以看到,這種方式就是一個回調函數嵌套一個回調函數,套得太多了很容易寫出`>`形狀的代碼。
### 5.3.2 遍歷數組
在遍歷數組時,使用某個函數依次對數據成員做一些處理也是常見的需求。如果函數是同步執行的,一般就會寫出以下代碼:
~~~javascript
var len = arr.length,
i = 0;
for (; i < len; ++i) {
arr[i] = sync(arr[i]);
}
// All array items have processed.
~~~
如果函數是異步執行的,以上代碼就無法保證循環結束后所有數組成員都處理完畢了。如果數組成員必須一個接一個串行處理,則一般按照以下方式編寫異步代碼:
~~~javascript
(function next(i, len, callback) {
if (i < len) {
async(arr[i], (value) => {
arr[i] = value;
next(i + 1, len, callback);
});
} else {
callback();
}
}(0, arr.length, function () {
// All array items have processed.
}));
~~~
可以看到,以上代碼在異步函數執行一次并返回執行結果后才傳入下一個數組成員并開始下一輪執行,直到所有數組成員處理完畢后,通過回調的方式觸發后續代碼的執行。
如果數組成員可以并行處理,但后續代碼仍然需要所有數組成員處理完畢后才能執行的話,則異步代碼會調整成以下形式:
~~~javascript
(function (i, len, count, callback) {
for (; i < len; ++i) {
(function (i) {
async(arr[i], function (value) {
arr[i] = value;
if (++count === len) {
callback();
}
});
}(i));
}
}(0, arr.length, 0, function () {
// All array items have processed.
}));
~~~
可以看到,與異步串行遍歷的版本相比,以上代碼并行處理所有數組成員,并通過計數器變量來判斷什么時候所有數組成員都處理完畢了。
### 5.3.3 異常處理
JS自身提供的異常捕獲和處理機制——`try..catch..`,只能用于同步執行的代碼。
~~~javascript
function sync(fn) {
return fn();
}
try {
sync(null);
// Do something.
} catch (err) {
console.log('Error: %s', err.message);
}
-- Console ------------------------------
Error: object is not a function
~~~
可以看到,異常會沿著代碼執行路徑一直冒泡,直到遇到第一個try語句時被捕獲住。但由于異步函數會打斷代碼執行路徑,異步函數執行過程中以及執行之后產生的異常冒泡到執行路徑被打斷的位置時,如果一直沒有遇到try語句,就作為一個全局異常拋出。以下是一個例子。
~~~javascript
function async(fn, callback) {
// Code execution path breaks here.
setTimeout(function () {
callback(fn());
}, 0);
}
try {
async(null, function (data) {
// Do something.
});
} catch (err) {
console.log('Error: %s', err.message);
}
-- Console ------------------------------
/home/user/test.js:4
callback(fn());
^
TypeError: object is not a function
at null._onTimeout (/home/user/test.js:4:13)
at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)
~~~
因為代碼執行路徑被打斷了,我們就需要在異常冒泡到斷點之前用try語句把異常捕獲住,并通過回調函數傳遞被捕獲的異常。于是我們可以像下邊這樣改造上邊的例子。
~~~javascript
function async(fn, callback) {
// Code execution path breaks here.
setTimeout(function () {
try {
callback(null, fn());
} catch (err) {
callback(err);
}
}, 0);
}
async(null, function (err, data) {
if (err) {
console.log('Error: %s', err.message);
} else {
// Do something.
}
});
-- Console ------------------------------
Error: object is not a function
~~~
可以看到,異常再次被捕獲住了。在NodeJS中,幾乎所有異步API都按照以上方式設計,回調函數中第一個參數都是err。因此我們在編寫自己的異步函數時,也可以按照這種方式來處理異常,與NodeJS的設計風格保持一致。
有了異常處理方式后,接著可以想一想一般我們是怎么寫代碼的。基本上,我們的代碼都是做一些事情,然后調用一個函數,然后再做一些事情,然后再調用一個函數,如此循環。如果我們寫的是同步代碼,只需要在代碼入口點寫一個try語句就能捕獲所有冒泡上來的異常,示例如下。
~~~javascript
function main() {
// Do something.
syncA();
// Do something.
syncB();
// Do something.
syncC();
}
try {
main();
} catch (err) {
// Deal with exception.
}
~~~
但是,如果我們寫的是異步代碼,就比較惡心了。由于每次異步函數調用都會打斷代碼執行路徑,只能通過回調函數來傳遞異常,于是我們就需要在每個回調函數里判斷是否有異常發生,于是只用三次異步函數調用,就會產生下邊這種代碼。
~~~javascript
function main(callback) {
// Do something.
asyncA(function (err, data) {
if (err) {
callback(err);
} else {
// Do something
asyncB(function (err, data) {
if (err) {
callback(err);
} else {
// Do something
asyncC(function (err, data) {
if (err) {
callback(err);
} else {
// Do something
callback(null);
}
});
}
});
}
});
}
main(function (err) {
if (err) {
// Deal with exception.
}
});
~~~
可以看到,回調函數已經讓代碼變得復雜了,而異步方式下對異常的處理更加劇了代碼的復雜度。如果NodeJS的最大賣點最后變成這個樣子,那就沒人愿意用NodeJS了,因此接下來會介紹NodeJS提供的一些解決方案。
## 5.4 域(Domain)
NodeJS提供了domain模塊,可以簡化異步代碼的異常處理。在介紹該模塊之前,需要首先理解“域”的概念。簡單的講,一個域就是一個JS運行環境,在一個運行環境中,如果一個異常沒有被捕獲,將作為一個全局異常被拋出。NodeJS通過`process`對象提供了捕獲全局異常的方法,示例代碼如下
~~~javascript
process.on('uncaughtException', function (err) {
console.log('Error: %s', err.message);
});
setTimeout(function (fn) {
fn();
});
-- Console ------------------------------
Error: undefined is not a function
~~~
雖然全局異常有個地方可以捕獲了,但是對于大多數異常,我們希望盡早捕獲,并根據結果決定代碼的執行路徑。用以下HTTP服務器代碼作為例子:
~~~javascript
function async(request, callback) {
// Do something.
asyncA(request, function (err, data) {
if (err) {
callback(err);
} else {
// Do something
asyncB(request, function (err, data) {
if (err) {
callback(err);
} else {
// Do something
asyncC(request, function (err, data) {
if (err) {
callback(err);
} else {
// Do something
callback(null, data);
}
});
}
});
}
});
}
http.createServer(function (request, response) {
async(request, function (err, data) {
if (err) {
response.writeHead(500);
response.end();
} else {
response.writeHead(200);
response.end(data);
}
});
});
~~~
以上代碼將請求對象交給異步函數處理后,再根據處理結果返回響應。這里采用了使用回調函數傳遞異常的方案,因此async函數內部如果再多幾個異步函數調用的話,代碼就變成上邊這副鬼樣子了。為了讓代碼好看點,我們可以在每處理一個請求時,使用domain模塊創建一個子域(JS子運行環境)。在子域內運行的代碼可以隨意拋出異常,而這些異常可以通過子域對象的error事件統一捕獲。于是以上代碼可以做如下改造:
~~~javascript
function async(request, callback) {
// Do something.
asyncA(request, function (data) {
// Do something
asyncB(request, function (data) {
// Do something
asyncC(request, function (data) {
// Do something
callback(data);
});
});
});
}
http.createServer(function (request, response) {
var d = domain.create();
d.on('error', function () {
response.writeHead(500);
response.end();
});
d.run(function () {
async(request, function (data) {
response.writeHead(200);
response.end(data);
});
});
});
~~~
可以看到,我們使用`.create`方法創建了一個子域對象,并通過`.run`方法進入需要在子域中運行的代碼的入口點。而位于子域中的異步函數回調函數由于不再需要捕獲異常,代碼一下子瘦身很多。
> 無論是通過process對象的uncaughtException事件捕獲到全局異常,還是通過子域對象的error事件捕獲到了子域異常,在NodeJS官方文檔里都強烈建議處理完異常后立即重啟程序,而不是讓程序繼續運行。按照官方文檔的說法,發生異常后的程序處于一個不確定的運行狀態,如果不立即退出的話,程序可能會發生嚴重內存泄漏,也可能表現得很奇怪。
> 但這里需要澄清一些事實。JS本身的throw..try..catch異常處理機制并不會導致內存泄漏,也不會讓程序的執行結果出乎意料,但NodeJS并不是存粹的JS。NodeJS里大量的API內部是用C/C++實現的,因此NodeJS程序的運行過程中,代碼執行路徑穿梭于JS引擎內部和外部,而JS的異常拋出機制可能會打斷正常的代碼執行流程,導致C/C++部分的代碼表現異常,進而導致內存泄漏等問題。
> 因此,使用uncaughtException或domain捕獲異常,代碼執行路徑里涉及到了C/C++部分的代碼時,如果不能確定是否會導致內存泄漏等問題,最好在處理完異常后重啟程序比較妥當。而使用try語句捕獲異常時一般捕獲到的都是JS本身的異常,不用擔心上訴問題。