本文首發在個人部落格:muyunyun.cn/posts/7b9fd…
提到 Node.js, 我們腦海就會浮現非同步、非阻塞、單執行緒等關鍵詞,進一步我們還會想到 buffer、模組機制、事件迴圈、程式、V8、libuv 等知識點。本文起初旨在理順 Node.js 以上易混淆概念,然而一入非同步深似海,本文嘗試基於 Node.js 的非同步展開討論,其他的主題只能日後慢慢補上了。(附:亦可以把本文當作是樸靈老師所著的《深入淺出 Node.js》一書的小結)。
非同步 I/O
Node.js 正是依靠構建了一套完善的高效能非同步 I/O 框架,從而打破了 JavaScript 在伺服器端止步不前的局面。
非同步 I/O VS 非阻塞 I/O
聽起來非同步和非阻塞,同步和阻塞是相互對應的,從實際效果而言,非同步和非阻塞都達到了我們並行 I/O 的目的,但是從計算機核心 I/O 而言,非同步/同步和阻塞/非阻塞實際上是兩回事。
注意,作業系統核心對於 I/O 只有兩種方式:阻塞與非阻塞。
呼叫阻塞 I/O 的過程:
呼叫非阻塞 I/O 的過程:
在此先引人一個叫作輪詢
的技術。輪詢不同於回撥,舉個生活例子,你有事去隔壁寢室找同學,發現人不在,你怎麼辦呢?方法1,每隔幾分鐘再去趟隔壁寢室,看人在不;方法2,拜託與他同寢室的人,看到他回來時叫一下你;那麼前者是輪詢,後者是回撥。
再回到主題,阻塞 I/O 造成 CPU 等待浪費,非阻塞 I/O 帶來的麻煩卻是需要輪詢去確認是否完全完成資料獲取。從作業系統的這個層面上看,對於應用程式而言,不管是阻塞 I/O 亦或是 非阻塞 I/O,它們都只能是一種同步
,因為儘管使用了輪詢技術,應用程式仍然需要等待 I/O 完全返回。
Node 的非同步 I/O
完成整個非同步 I/O 環節的有事件迴圈、觀察者、請求物件以及 I/O 執行緒池。
事件迴圈
在程式啟動的時候,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。趁著非同步程式設計這個主題剛好把它們串起來理理。
非同步 VS 回撥
對於剛接觸非同步的新人,很大機率會混淆回撥 (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')複製程式碼
至此,一個簡易的訂閱釋出模式就實現了。然而釋出/訂閱模式也存在一些缺點,建立訂閱本身會消耗一定的時間與記憶體,也許當你訂閱一個訊息之後,之後可能就不會發生。釋出-訂閱模式雖然它弱化了物件與物件之間的關係,但是如果過度使用,物件與物件的必要聯絡就會被深埋,會導致程式難以跟蹤與維護。
Promise/Deferred 模式
想象一下,如果某個操作需要經過多個非阻塞的 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 操作只會處在 3 種狀態的一種:未完成態、完成態和失敗態。
- Promise 的狀態只會出現從未完成態向完成態或失敗態轉化,不能逆反。完成態和失敗態不能相互轉化。
- Promise 的狀態一旦轉化,將不能被更改。
Promise 的狀態轉化示意圖如下:
除此之外,Promise 物件的另一個關鍵就是需要具備 then() 方法,對於 then() 方法,有以下簡單的要求:
- 接受完成態、錯誤態的回撥方法。在操作完成或出現錯誤時,將會呼叫對應方法。
- 可選地支援 progress 事件回撥作為第三個方法。
- then() 方法只接受 function 物件,其餘物件將被忽略。
- then() 方法繼續返回 Promise 物件,已實現鏈式呼叫。
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 完成時,逐個執行回撥,一旦檢測到返回了新的 Promise 物件,停止執行,然後將當前 Deferred 物件的 promise 引用改變為新的 Promise 物件,並將佇列中餘下的回撥轉交給它;
至此,實現了 Promise/Deferred 的完整邏輯,Promise 的其他知識未來也會繼續探究。
Generator
儘管 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 的基礎知識:
- Generator 生成迭代器後,等待迭代器的
next()
指令啟動。 - 啟動迭代器後,程式碼會執行到
yield
處停止。並返回一個 {value: AnyType, done: Boolean} 物件,value 是這次執行的結果,done 是迭代是否結束。並等待下一次的 next() 指令。 - next() 再次啟動,若 done 的屬性不為 true,則可以繼續從上一次停止的地方繼續迭代。
- 一直重複 2,3 步驟,直到 done 為 true。
- 通過呼叫方式二,我們可看到 next 方法可以帶一個引數,該引數就會被當作
上一個 yield 語句的返回值
。
另外我們注意到,上述程式碼中的第一種呼叫方式中的 y 值是 undefined,如果我們真想拿到 y 值,就需要通過 g.next(); g.next().value
這種方式取出。可以看出,Generator 函式將非同步操作表示得很簡潔,但是流程管理卻不方便。這時候用於 Generator 函式的自動執行的 co 函式庫 登場了。為什麼 co 可以自動執行 Generator 函式呢?我們知道,Generator 函式就是一個非同步操作的容器。它的自動執行需要一種機制,當非同步操作有了結果,能夠自動交回執行權。
兩種方法可以做到這一點:
- Thunk 函式。將非同步操作包裝成 Thunk 函式,在回撥函式裡面交回執行權。
- Promise 物件。將非同步操作包裝成 Promise 物件,用 then 方法交回執行權。
co 函式庫其實就是將兩種自動自動執行器(Thunk 函式和 Promise 物件),包裝成一個庫。使用 co 的前提條件是,Generator 函式的 yield 命令後面,只能是 Thunk 函式或者是 Promise 物件
。下面分別用以上兩種方法對 co 進行一個簡單的實現。
基於 Thunk 函式的自動執行
在 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。
基於 Promise 物件的自動執行
基於 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 函式設計思路幾乎一致,也因此呼應了它們共同的本質 —— 當非同步操作有了結果,自動交回執行權。
async
看上去 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 函式更優秀的幾點:
- 內建執行器。Generator 函式的執行必須依靠執行器,而 Aysnc 函式自帶執行器,呼叫方式跟普通函式的呼叫一樣;
- 更好的語義。async 和 await 相較於 * 和 yield 更加語義化;
- 更廣的適用性。前文提到的 co 模組約定,yield 命令後面只能是 Thunk 函式或 Promise 物件,而 async 函式的 await 命令後面則可以是 Promise 或者原始型別的值;
- 返回值是 Promise。async 函式返回值是 Promise 物件,比 Generator 函式返回的 Iterator 物件方便,因此可以直接使用 then() 方法進行呼叫;