本文首發在我的博客:muyunyun.cn/posts/7b9fd…html
提到 Node.js, 咱們腦海就會浮現異步、非阻塞、單線程等關鍵詞,進一步咱們還會想到 buffer、模塊機制、事件循環、進程、V八、libuv 等知識點。本文起初旨在理順 Node.js 以上易混淆概念,然而一入異步深似海,本文嘗試基於 Node.js 的異步展開討論,其餘的主題只能往後慢慢補上了。(附:亦能夠把本文看成是樸靈老師所著的《深刻淺出 Node.js》一書的小結)。node
Node.js 正是依靠構建了一套完善的高性能異步 I/O 框架,從而打破了 JavaScript 在服務器端止步不前的局面。c++
聽起來異步和非阻塞,同步和阻塞是相互對應的,從實際效果而言,異步和非阻塞都達到了咱們並行 I/O 的目的,可是從計算機內核 I/O 而言,異步/同步和阻塞/非阻塞其實是兩回事。git
注意,操做系統內核對於 I/O 只有兩種方式:阻塞與非阻塞。程序員
調用阻塞 I/O 的過程:github
調用非阻塞 I/O 的過程:編程
在此先引人一個叫做輪詢
的技術。輪詢不一樣於回調,舉個生活例子,你有事去隔壁寢室找同窗,發現人不在,你怎麼辦呢?方法1,每隔幾分鐘再去趟隔壁寢室,看人在不;方法2,拜託與他同寢室的人,看到他回來時叫一下你;那麼前者是輪詢,後者是回調。segmentfault
再回到主題,阻塞 I/O 形成 CPU 等待浪費,非阻塞 I/O 帶來的麻煩倒是須要輪詢去確認是否徹底完成數據獲取。從操做系統的這個層面上看,對於應用程序而言,無論是阻塞 I/O 亦或是 非阻塞 I/O,它們都只能是一種同步
,由於儘管使用了輪詢技術,應用程序仍然須要等待 I/O 徹底返回。設計模式
完成整個異步 I/O 環節的有事件循環、觀察者、請求對象以及 I/O 線程池。promise
在進程啓動的時候,Node 會建立一個相似於 whlie(true) 的循環,每一次執行循環體的過程咱們稱爲 Tick。
每一個 Tick 的過程就是查看是否有事件待處理,若是有,就取出事件及其相關的回調函數。若是存在相關的回調函數,就執行他們。而後進入下一個循環,若是再也不有事件處理,就退出進程。
僞代碼以下:
while(ture) {
const event = eventQueue.pop()
if (event && event.handler) {
event.handler.execute() // execute the callback in Javascript thread
} else {
sleep() // sleep some time to release the CPU do other stuff
}
}複製代碼
每一個 Tick 的過程當中,如何判斷是否有事件須要處理,這裏就須要引入觀察者這個概念。
每一個事件循環中有一個或多個觀察者,而判斷是否有事件須要處理的過程就是向這些觀察者詢問是否有要處理的事件。
在 Node 中,事件主要來源於網絡請求、文件 I/O 等,這些事件都有對應的觀察者。
對於 Node 中的異步 I/O 而言,回調函數不禁開發者來調用,在 JavaScript 發起調用到內核執行完 id 操做的過渡過程當中,存在一種中間產物,它叫做請求對象。
請求對象是異步 I/O 過程當中的重要中間產物,全部狀態都保存在這個對象中,包括送入線程池等待執行以及 I/O 操做完後的回調處理
以 fs.open()
爲例:
fs.open = function(path, flags, mode, callback) {
bingding.open(
pathModule._makeLong(path),
stringToFlags(flags),
mode,
callback
)
}複製代碼
fs.open
的做用就是根據指定路徑和參數去打開一個文件,從而獲得一個文件描述符。
從前面的代碼中能夠看到,JavaScript 層面的代碼經過調用 C++ 核心模塊進行下層的操做。
從 JavaScript 調用 Node 的核心模塊,核心模塊調用 C++ 內建模塊,內建模塊經過 libuv 進行系統調用,這是 Node 裏經典的調用方式。
libuv 做爲封裝層,有兩個平臺的實現,實質上是調用了 uv_fs_open 方法,在 uv_fs_open 的調用過程當中,會建立一個 FSReqWrap 請求對象,從 JavaScript 層傳入的參數和當前方法都被封裝在這個請求對象中。回調函數則被設置在這個對象的 oncomplete_sym 屬性上。
req_wrap -> object_ -> Set(oncomplete_sym, callback)複製代碼
對象包裝完畢後,在 Windows 下,則調用 QueueUserWorkItem() 方法將這個 FSReqWrap 對象推人線程池中等待執行。
至此,JavaScript 調用當即返回,由 JavaScript 層面發起的異步調用的第一階段就此結束(即上圖所註釋的異步 I/O 第一部分)。JavaScript 線程能夠繼續執行當前任務的後續操做,當前的 I/O 操做在線程池中等待執行,無論它是否阻塞 I/O,都不會影響到 JavaScript 線程的後續操做,如此達到了異步的目的。
組裝好請求對象、送入 I/O 線程池等待執行,其實是完成了異步 I/O 的第一部分,回調通知是第二部分。
線程池中的 I/O 操做調用完畢以後,會將獲取的結果儲存在 req -> result
屬性上,而後調用 PostQueuedCompletionStatus()
通知 IOCP
,告知當前對象操做已經完成,並將線程歸還線程池。
在這個過程當中,咱們動用了事件循環的 I/O 觀察者,在每次 Tick
的執行過程當中,它會調用 IOCP
相關的 GetQueuedCompletionStatus
方法檢查線程池中是否有執行完的請求,若是存在,會將請求對象加入到 I/O 觀察者的隊列中,而後將其當作事件處理。
I/O 觀察者回調函數的行爲就是取出請求對象的 result
屬性做爲參數,取出 oncomplete_sym
屬性做爲方法,而後調用執行,以此達到調用 JavaScript 中傳入的回調函數的目的。
經過介紹完整個異步 I/O 後,有個須要重視的觀點是 JavaScript 是單線程的,Node 自己實際上是多線程的
,只是 I/O 線程使用的 CPU 比較少;還有個重要的觀點是,除了用戶的代碼沒法並行執行外,全部的 I/O (磁盤 I/O 和網絡 I/O) 則是能夠並行起來的。
Node 是首個將異步大規模帶到應用層面的平臺。經過上文所述咱們瞭解了 Node 如何經過事件循環實現異步 I/O,有異步 I/O 必然存在異步編程。異步編程的路經歷了太多坎坷,從回調函數、發佈訂閱模式、Promise 對象,到 generator、asycn/await。趁着異步編程這個主題恰好把它們串起來理理。
對於剛接觸異步的新人,很大概率會混淆回調 (callback) 和異步 (asynchronous) 的概念。先來看看維基的 Callback) 條目:
In computer programming, a callback is any executable code that is passed as an argument to other code
所以,回調本質上是一種設計模式,而且 jQuery (包括其餘框架)的設計原則遵循了這個模式。
在 JavaScript 中,回調函數具體的定義爲:函數 A 做爲參數(函數引用)傳遞到另外一個函數 B 中,而且這個函數 B 執行函數 A。咱們就說函數 A 叫作回調函數。若是沒有名稱(函數表達式),就叫作匿名回調函數。
所以 callback 不必定用於異步,通常同步(阻塞)的場景下也常常用到回調,好比要求執行某些操做後執行回調函數。講了這麼多讓咱們來看下同步回調和異步回調的例子:
同步回調:
function f2() {
console.log('f2 finished')
}
function f1(cb) {
cb()
console.log('f1 finished')
}
f1(f2) // 獲得的結果是 f2 finished, f1 finished複製代碼
異步回調:
function f2() {
console.log('f2 finished')
}
function f1(cb) {
setTimeout(cb, 1000) // 經過 setTimeout() 來模擬耗時操做
console.log('f1 finished')
}
f1(f2) // 獲得的結果是 f1 finished, f2 finished複製代碼
小結:回調能夠進行同步也能夠異步調用,可是 Node.js 提供的 API 大多都是異步回調的,好比 buffer、http、cluster 等模塊。
事件發佈/訂閱模式 (PubSub) 自身並沒有同步和異步調用的問題,但在 Node 的 events 模塊的調用中多半伴隨事件循環而異步觸發的,因此咱們說事件發佈/訂閱普遍應用於異步編程。它的應用很是普遍,能夠在異步編程中幫助咱們完成更鬆的解耦,甚至在 MVC、MVVC 的架構中以及設計模式中也少不了發佈-訂閱模式的參與。
以 jQuery 事件監聽爲例
$('#btn').on('myEvent', function(e) { // 訂閱事件
console.log('I am an Event')
})
$('#btn').trigger('myEvent') // 觸發事件複製代碼
能夠看到,訂閱事件就是一個高階函數的應用。事件發佈/訂閱模式能夠實現一個事件與多個回調函數的關聯,這些回調函數又稱爲事件偵聽器。下面咱們來看看發佈/訂閱模式的簡易實現。
var PubSub = function() {
this.handlers = {}
}
PubSub.prototype.subscribe = function(eventType, handler) { // 註冊函數邏輯
if (!(eventType in this.handlers)) {
this.handlers[eventType] = []
}
this.handlers[eventType].push(handler) // 添加事件監聽器
return this // 返回上下文環境以實現鏈式調用
}
PubSub.prototype.publish = function(eventType) { // 發佈函數邏輯
var _args = Array.prototype.slice.call(arguments, 1)
for (var i = 0, _handlers = this.handlers[eventType]; i < _handlers.length; i++) { // 遍歷事件監聽器
_handlers[i].apply(this, _args) // 調用事件監聽器
}
}
var event = new PubSub // 構造 PubSub 實例
event.subscribe('name', function(msg) {
console.log('my name is ' + msg) // my name is muyy
})
event.publish('name', 'muyy')複製代碼
至此,一個簡易的訂閱發佈模式就實現了。然而發佈/訂閱模式也存在一些缺點,建立訂閱自己會消耗必定的時間與內存,也許當你訂閱一個消息以後,以後可能就不會發生。發佈-訂閱模式雖然它弱化了對象與對象之間的關係,可是若是過分使用,對象與對象的必要聯繫就會被深埋,會致使程序難以跟蹤與維護。
想象一下,若是某個操做須要通過多個非阻塞的 IO 操做,每個結果都是經過回調,程序有可能會看上去像這個樣子。這樣的代碼很難維護。這樣的狀況更多的會發生在 server side 的狀況下。代碼片斷以下:
operation1(function(err, result1) {
operation2(result1, function(err, result2) {
operation3(result2, function(err, result3) {
operation4(result3, function(err, result4) {
callback(result4) // do something useful
})
})
})
})複製代碼
這時候,Promise 出現了,其出現的目的就是爲了解決所謂的回調地獄的問題。讓咱們看下使用 Promise 後的代碼片斷:
promise()
.then(operation1)
.then(operation2)
.then(operation3)
.then(operation4)
.then(function(value4) {
// Do something with value4
}, function (error) {
// Handle any error from step1 through step4
})
.done()複製代碼
能夠看到,使用了第二種編程模式後能極大地提升咱們的編程體驗,接着就讓咱們本身動手實現一個支持序列執行的 Promise。(附:爲了直觀的在瀏覽器上也能感覺到 Promise,爲此也寫了一段瀏覽器上的 Promise 用法示例)
在此以前,咱們先要了解 Promise/A 提議中對單個異步操做所做的抽象定義,定義具體以下所示:
Promise 的狀態轉化示意圖以下:
除此以外,Promise 對象的另外一個關鍵就是須要具有 then() 方法,對於 then() 方法,有如下簡單的要求:
then() 方法的定義以下:
then(fulfilledHandler, errorHandler, progressHandler)複製代碼
有了這些核心知識,接着進入 Promise/Deferred 核心代碼環節:
var Promise = function() { // 構建 Promise 對象
// 隊列用於存儲執行的回調函數
this.queue = []
this.isPromise = true
}
Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) { // 構建 Progress 的 then 方法
var handler = {}
if (typeof fulfilledHandler === 'function') {
handler.fulfilled = fulfilledHandler
}
if (typeof errorHandler === 'function') {
handler.error = errorHandler
}
this.queue.push(handler)
return this
}複製代碼
如上 Promise 的代碼就完成了,可是別忘了 Promise/Deferred 中的後者 Deferred,爲了完成 Promise 的整個流程,咱們還須要觸發執行上述回調函數的地方,實現這些功能的對象就叫做 Deferred,即延遲對象。
Promise 和 Deferred 的總體關係以下圖所示,從中可知,Deferred 主要用於內部來維護異步模型的狀態;而 Promise 則做用於外部,經過 then() 方法暴露給外部以添加自定義邏輯。
接着來看 Deferred 代碼部分的實現:
var Deferred = function() {
this.promise = new Promise()
}
// 完成態
Deferred.prototype.resolve = function(obj) {
var promise = this.promise
var handler
while(handler = promise.queue.shift()) {
if (handler && handler.fulfilled) {
var ret = handler.fulfilled(obj)
if (ret && ret.isPromise) { // 這一行以及後面3行的意思是:一旦檢測到返回了新的 Promise 對象,中止執行,而後將當前 Deferred 對象的 promise 引用改變爲新的 Promise 對象,並將隊列中餘下的回調轉交給它
ret.queue = promise.queue
this.promise = ret
return
}
}
}
}
// 失敗態
Deferred.prototype.reject = function(err) {
var promise = this.promise
var handler
while (handler = promise.queue.shift()) {
if (handler && handler.error) {
var ret = handler.error(err)
if (ret && ret.isPromise) {
ret.queue = promise.queue
this.promise = ret
return
}
}
}
}
// 生成回調函數
Deferred.prototype.callback = function() {
var that = this
return function(err, file) {
if(err) {
return that.reject(err)
}
that.resolve(file)
}
}複製代碼
接着咱們以兩次文件讀取做爲例子,來驗證該設計的可行性。這裏假設第二個文件讀取依賴於第一個文件中的內容,相關代碼以下:
var readFile1 = function(file, encoding) {
var deferred = new Deferred()
fs.readFile(file, encoding, deferred.callback())
return deferred.promise
}
var readFile2 = function(file, encoding) {
var deferred = new Deferred()
fs.readFile(file, encoding, deferred.callback())
return deferred.promise
}
readFile1('./file1.txt', 'utf8').then(function(file1) { // 這裏經過 then 把兩個回調存進隊列中
return readFile2(file1, 'utf8')
}).then(function(file2) {
console.log(file2) // I am file2.
})複製代碼
最後能夠看到控制檯輸出 I am file2
,驗證成功~,這個案例的完整代碼能夠點這裏查看,並建議使用 node-inspector 進行斷點觀察,(這段代碼裏面有些邏輯確實很繞,經過斷點調試就能較容易理解了)。
從 Promise 鏈式調用能夠清晰地看到隊列(先進先出)的知識,其有以下兩個核心步驟:
至此,實現了 Promise/Deferred 的完整邏輯,Promise 的其餘知識將來也會繼續探究。
儘管 Promise 必定程度解決了回調地獄的問題,可是對於喜歡簡潔的程序員來講,一大堆的模板代碼 .then(data => {...})
顯得不是很友好。因此愛折騰的開發者們在 ES6 中引人了 Generator 這種數據類型。仍然以讀取文件爲例,先上一段很是簡潔的 Generator + co 的代碼:
co(function* () {
const file1 = yield readFile('./file1.txt')
const file2 = yield readFile('./file2.txt')
console.log(file1)
console.log(file2)
})複製代碼
能夠看到比 Promise 的寫法簡潔了許多。後文會給出 co 庫的實現原理。在此以前,先概括下什麼是 Generator。能夠把 Generator 理解爲一個能夠遍歷的狀態機,調用 next 就能夠切換到下一個狀態,其最大特色就是能夠交出函數的執行權(即暫停執行),讓咱們看以下代碼:
function* gen(x) {
yield (function() {return 1})()
var y = yield x + 2
return y
}
// 調用方式一
var g = gen(1)
g.next() // { value: 1, done: false }
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
// 調用方式二
var g = gen(1)
g.next() // { value: 1, done: false }
g.next() // { value: 3, done: false }
g.next(10) // { value: 10, done: true }複製代碼
由此咱們概括下 Generator 的基礎知識:
next()
指令啓動。yield
處中止。並返回一個 {value: AnyType, done: Boolean} 對象,value 是此次執行的結果,done 是迭代是否結束。並等待下一次的 next() 指令。上一個 yield 語句的返回值
。另外咱們注意到,上述代碼中的第一種調用方式中的 y 值是 undefined,若是咱們真想拿到 y 值,就須要經過 g.next(); g.next().value
這種方式取出。能夠看出,Generator 函數將異步操做表示得很簡潔,可是流程管理卻不方便。這時候用於 Generator 函數的自動執行的 co 函數庫 登場了。爲何 co 能夠自動執行 Generator 函數呢?咱們知道,Generator 函數就是一個異步操做的容器。它的自動執行須要一種機制,當異步操做有告終果,可以自動交回執行權。
兩種方法能夠作到這一點:
co 函數庫其實就是將兩種自動自動執行器(Thunk 函數和 Promise 對象),包裝成一個庫。使用 co 的前提條件是,Generator 函數的 yield 命令後面,只能是 Thunk 函數或者是 Promise 對象
。下面分別用以上兩種方法對 co 進行一個簡單的實現。
在 JavaScript 中,Thunk 函數就是指將多參數函數替換成單參數的形式,而且其只接受回調函數做爲參數的函數。Thunk 函數的例子以下:
// 正常版本的 readFile(多參數)
fs.readFile(filename, 'utf8', callback)
// Thunk 版本的 readFile(單參數)
function readFile(filename) {
return function(callback) {
fs.readFile(filename, 'utf8', callback);
};
}複製代碼
在基於 Thunk 函數和 Generator 的知識上,接着咱們來看看 co 基於 Thunk 函數的實現。(附:代碼參考自co最簡版實現)
function co(generator) {
return function(fn) {
var gen = generator()
function next(err, result) {
if(err) {
return fn(err)
}
var step = gen.next(result)
if (!step.done) {
step.value(next) // 這裏能夠把它聯想成遞歸;將異步操做包裝成 Thunk 函數,在回調函數裏面交回執行權。
} else {
fn(null, step.value)
}
}
next()
}
}複製代碼
用法以下:
co(function* () { // 把 function*() 做爲參數 generator 傳入 co 函數
var file1 = yield readFile('./file1.txt')
var file2 = yield readFile('./file2.txt')
console.log(file1) // I'm file1
console.log(file2) // I'm file2
return 'done'
})(function(err, result) { // 這部分的 function 做爲 co 函數內的 fn 的實參傳入
console.log(result) // done
})複製代碼
上述部分關鍵代碼已進行註釋,下面對 co 函數裏的幾個難點進行說明:
var step = gen.next(result)
, 前文提到的一句話在這裏就頗有用處了:next方法能夠帶一個參數,該參數就會被看成上一個yield語句的返回值
;在上述代碼的運行中一共會通過這個地方 3 次,result 的值第一次是空值,第二次是 file1.txt 的內容 I'm file1,第三次是 file2.txt 的內容 I'm file2。根據上述關鍵語句的提醒,因此第二次的內容會做爲 file1 的值(看成上一個yield語句的返回值),同理第三次的內容會做爲 file2 的值。step.value(next)
, step.value 就是前面提到的 thunk 函數返回的 function(callback) {}, next 就是傳入 thunk 函數的 callback。這句代碼是條遞歸語句,是這個簡易版 co 函數能自動調用 Generator 的關鍵語句。建議親自跑一遍代碼,多打斷點,從而更好地理解,代碼已上傳github。
基於 Thunk 函數的自動執行中,yield 後面需跟上 Thunk 函數,在基於 Promise 對象的自動執行中,yield 後面天然要跟 Promise 對象了,讓咱們先構建一個 readFile 的
Promise 對象:
function readFile(fileName) {
return new Promise(function(resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) reject(error)
resolve(data)
})
})
}複製代碼
在基於前文 Promise 對象和 Generator 的知識上,接着咱們來看看 co 基於 Promise 函數的實現:
function co(generator) {
var gen = generator()
function next(data) {
var result = gen.next(data) // 同上,經歷了 3 次,第一次是 undefined,第二次是 I'm file1,第三次是 I'm file2
if (result.done) return result.value
result.value.then(function(data) { // 將異步操做包裝成 Promise 對象,用 then 方法交回執行權
next(data)
})
}
next()
}複製代碼
用法以下:
co(function* generator() {
var file1 = yield readFile('./file1.txt')
var file2 = yield readFile('./file2.txt')
console.log(file1.toString()) // I'm file1
console.log(file2.toString()) // I'm file2
})複製代碼
這一部分的代碼上傳在這裏,經過觀察能夠發現基於 Thunk 函數和基於 Promise 對象的自動執行方案的 co 函數設計思路幾乎一致,也所以呼應了它們共同的本質 —— 當異步操做有告終果,自動交回執行權。
看上去 Generator 已經足夠好用了,可是使用 Generator 處理異步必須得依賴 tj/co,因而 asycn 出來了。本質上 async 函數就是 Generator 函數的語法糖,這樣說是由於 async 函數的實現,就是將 Generator 函數和自動執行器,包裝進一個函數中。僞代碼以下,(注:其中 automatic 的實現能夠參考 async 函數的含義和用法中的實現)
async function fn(args){
// ...
}
// 等同於
function fn(args) {
return automatic(function*() { // automatic 函數就是自動執行器,其的實現能夠仿照 co 庫自動運行方案來實現,這裏就不展開了
// ...
})
}複製代碼
接着仍然以上文的讀取文件爲例,來比較 Generator 和 async 函數的寫法差別:
// Generator
var genReadFile = co(function*() {
var file1 = yield readFile('./file1.txt')
var file2 = yield readFile('./file2.txt')
})
// 改用 async 函數
var asyncReadFile = async function() {
var file1 = await readFile('./file1.txt')
var file2 = await 1 // 等同於同步操做(若是跟上原始類型的值)
}複製代碼
整體來講 async/await 看上去和使用 co 庫後的 generator 看上去很類似,不過相較於 Generator,能夠看到 Async 函數更優秀的幾點: