Skip to Content

現代化的 JavaScript 併發 - Async Functions

在前一篇介紹 JavaScript Concurrency 的文章中,Promise 提供開發者安全統一的標準 API,透過 thenable 減少 callback hell,巨幅降低開發非同步程式的門檻,大大提升可維護性。不過,Promise 仍沒達到 JS 社群的目標「Write async code synchronously」。本篇文章將簡單最新的 Concurrency Solution「Async Functions」,利用同步的語法寫非同步的程式,整個人都變潮了呢!

(撰於 2017-06-17,基於 ECMAScript 7+)

Introduction

Async Functions 在去年進入 Stage 4,正式成為 ECMAScript 7 標準,這對 JS 社群無疑是一大利多。截至目前為止(2017.6),實作 Async Functions 的環境有:

  • Node.js 7.6.0 (without --harmony)
  • Chrome 55
  • Firefox 52
  • Safari 10.1
  • Edge 15

可以看到當前 Release 版的 Desktop browser 都可以用了

從此我們不會在 callback hell 中迷失自我,不需在 then 中塞一堆 console.log,也不需使用蹩足的 generator 語法。ES7 的 async function 完成我們對非同步程式的想像。

真的有這麼好康嗎?

MDN 點出 async function 的定位。

The purpose of async/await functions is to simplify the behavior of using promises synchronously and to perform some behavior on a group of Promises. Just like Promises are similar to structured callbacks, async/await is similar to combining generators and promises.

Async functions 的目的在於簡化多個 promise 操作,不需要再串聯一堆 then。如果我們將 Promises 比喻為好讀版的 callbacks,那 async/await 就是 generator + promise 的綜合體,因此,我們仍需學習 promise 以及 generator 等概念

話不多說,一起快速了解 generator 吧!

Generator With Async Operations

在 async/await 還沒出世之前,generator function 是非同步程式設計的最新潮的替代品,TJ 的 co 與 Facebook 的 regenerator 這兩個 libraries 都擁有高人氣。藉由 Coroutine(協程) suspend/resume 的機制,讓開發非同步 JS 可以避開多線程煩人的 context-switch、dead lock,能用很直觀的方式撰寫程式。當然,coroutine 仍是在同個 thread 上面執行,並非真實的 parallel computing,不過 browser 這種常出現 I/O 的場景中,coroutine 已綽綽有餘了。

我只是想了解 async function 怎麼用,為什麼還要學 coroutine 和 generator?不會太複雜嗎?

別緊張,接下來將淺淺帶過 generator 概念。本人同樣不喜歡 generator 醜陋的 ** 只該留給最愛的 pointer。介紹 generator 之前,先來了解 ES6 的 IterableIterator 吧 XD!(不是說好快速瞭解 generator 嗎⋯⋯)

Iterable & Iterator

ES6 除了引入標準的 Promise object 以外,另一重大的語法變革就是 IteratorGenerator。熟悉 C++、Swift、Java 或 Python 3 的朋友應該非常熟悉 iterable/iterator 等名詞。在 JavaScript中,名詞解釋大致如下:

  • iterable 是指可迭代的物件,也就是可丟進 loop 運作的物件,會產生 iterator,但不一定是 sequencecontainer
  • iterator 就是鼎鼎大名的迭代器,如同指針般迭代東西(通常是 iterable 提供的值)。

日常會遇到的 iterable 應該會長這樣:

for (let obj of iterable) {}
iterable.forEach()
iterable.map()

這些 iterable 有共通特性,就像下面的 list 一樣又臭又長。

  • 有一個 [Symbol.iterator] method,
    • [Symbol.iterator] 會回傳一個 iterator object,
    • iterator 上要有 next method,
      • next method 會回傳一個 object 記錄 iteration 當前狀態,其有兩個 property,
      • done:當次 iteration 是否結束的 boolean flag
      • value:當次 iteration 的 return value

看了這種 nested list,是不是眼睛都茫了,想立馬關掉這篇廢文呢?別怕,常用的 Array 和 ES6 的 MapSet 等都是內建的 iterable,我們不需自己辛苦實作;而 plain Object 雖非 iterable,但也有 Object.entries 等方法供我們轉換成 iterable 呢。

我們來看一個簡單 Iterator 實作:

const infiniteLoop = {}
infiniteLoop[Symbol.iterator] = function () { // 實作 iterator 建構 funtion
  let v = 0
  return { // 回傳有 `next` method 的 object
    next: () => ({ // 回傳記錄當前 iteration state 的 object
      value: ++v,
      done: false
    })
  }
}

// 無限迴圈
for (let v of infiniteLoop) {
  console.log(v)
}

有興趣了解相關 protocols,可以看此

Generator

上一節最後,我們從頭打造了一個 Iterator,看起來十分不易,其實 iterator 實作繁瑣,是每種語言皆然,連 Python 3 也不例外,於是,generator 就誕生了。 Generator 基於 iterator 概念之上,利用簡單明瞭的語法,讓建構 iterator 不必這麼痛苦。我們將前一節的例子改成 generator 試試看。

function* genFunc () { // `*` 宣告一個 generator function,即是 iterator factory
  let v = 0
  while (true) {
    yield ++v // generator function 的 body 內才能使用 `yield` keyword
  }
}

// 無限迴圈
const g = genFunc() // 產生一個 iterator
let v = g.next() // 取得 iterator 下一個 state
while (!v.done) {
  console.log(v.value)
  v = g.next()
}

乍看之下,似乎沒比 iterator 簡單,而且又多了 function*yield,是要嚇死人嗎?這裡的重點並非語法,而是一開始提及的 Coroutine概念。這種 coroutine 的概念與一般逐行執行的程式不同,運作流程如下:

  1. 執行到 yield,暫停。
  2. 將執行權交給外部,等待外部 call next
  3. 外部 call next,回到步驟一繼續執行。

套在我們的例子中,就是:

  • 當每次 genFunc 執行到 yield 時,會停下來,
  • 將程式執行權交給外部 caller,
  • 等到外部調用者再次 call next method,genFunc 再接著執行。

簡而言之,「Generator 是 coroutine 的一種形式,是一個 pause/resume 的執行流程」。但這樣到底與非同步程式設計有啥鬼關係?

重點在於「透過 coroutine 將程式執行權交給外部 caller」,這可好玩了,如果我們讓 yield 返回一個 promise,程式執行權就會回到 caller 上,而 caller 不僅有程式執行權,可以執行其他程式片段,也可以取得非同步操作的結果。想像中的程式碼長這樣:

// 想像中的程式碼
// fetch 是一個 promise-based 非同步 function
function* asyncFunc () {
  const a = yield fetch('a')
  console.log(a)
  const b = yield fetch('b')
  console.log(b)
}

看起來非常驚人!可惜這是虛擬的程式碼,ab 都是一個 Promise,需要利用 then method 取得 resolve value。不過,JS 社群當然不會放過 generator 這個裝逼的好工具,大神們透過各種奇淫巧技,讓我得以利用 generator 優美地寫出 synchronously asynchronous code,以下這個例子便是透過 spawn 自動執行 next 的 helper function 達成。

// Helper Function
// from: Jake Archibald - JavaScript Promises: an Introduction
function spawn (generatorFunc) {
  function continuer (verb, arg) {
    var result
    try {
      result = generator[verb](arg)
    } catch (err) {
      return Promise.reject(err)
    }
    if (result.done) {
      return result.value
    }
    return Promise.resolve(result.value).then(onFulfilled, onRejected)
  }
  var generator = generatorFunc()
  var onFulfilled = continuer.bind(continuer, 'next')
  var onRejected = continuer.bind(continuer, 'throw')
  return onFulfilled()
}

// 利用 spawn 包裹,自動執行 generator function
// 這是一個 sequential(serial) operation
spawn(function* () {
  const a = yield fetch('a') // 等待 fetch('a'),並將結果 assign to a
  console.log(a) // a 是一個
  const b = yield fetch('b') // 等待 fetch('b'),並將結果 assign to b
  console.log(b)
})

這就是 async generator 的實作,很美,但過程十分嚇人。

Note:Coroutine(協程)比較明確的定義是,指執行權從一個 coroutine 交至另外一個 coroutine,不過概念上類似,這裡借用一下,特此說明。

Debut of Async Functions

使用上面這些燒腦的東西,雖達成任務,不過太疊床架屋,抽象概念難以消化。這時候就該主角 Async Function 登場!可以將 async functions 想像為 generator + promise,不過更直接的講法是:「Async Function 是內置 spawn 的 generator function」

接下來,將前面使用 spawn 執行的 generator function 改寫成 async function。

// 利用 `async` keyword 宣告 一個 async function
async function run () {
  const a = await fetch('a') // 使用 `await` 等待 fetch('a'),並將結果 assign to a
  console.log(a)
  const b = await fetch('b') // 使用 `await` 等待 fetch('b'),並將結果 assign to b
  console.log(b)
}

// 執行 async function,不需自己寫 `spawn`,如同正常的 function call。
run()

Async functions 使用語意清楚的 asyncawait 取代 function*yield,除此之外,幾乎與 generator 版本一模一樣。其差異如下:

  • 不需額外的 helper function 來執行,一般的 function call 即可。
  • 回傳值為 promise。(相比於 generator 回傳 iterator,async function 復用/組合性較高)

Usage

使用方法如下,非常簡單:

async function myAsyncFunc () {
  let result
  try { // 慣例使用 try-catch 處理錯誤
    const a = await fetchA() // async operation
    const b = await fetchB() // async operation
    result = {a, b}
  } catch (e) {
    // 將 error 吃掉
    console.log(`Error occurred: ${e}`)
  }
  return result
}

Await

await 是用來等待 Promise resolution 的運算子,語法與特性如下:

[rv] = await expression
// rv -> return value of await expression
  • 僅能用在 async function 內部。
  • expression 若是 promise 以外的 value,直接返回該 value
  • expression 若接 promise object,則等待該 promise resolution。
    • 若 promise fulfilled,直接返回該 value。
    • 若 promise rejected,拋出 Error 到 async function context 中(不被 promise 本身吃掉)。

這裡要注意的是,await 會等待 promise resolution 後才 return value,所以行為與 synchronous code 一致。

// async arrow function
const fun = async () => {
  const a = await Promise.resolve()
  // 等待 a 處理完畢,再往下執行
  const b = await a.process()
  // 等待 b 處理完畢,再往下執行
  const c = await b.process()
  // 返回最終的結果 c
  return c
}

Return Value

我們知道,Async function 的 return value 會是一個 promise,那這個 promise 什麼時候會 fulfill,什麼時候會被 reject 呢?

情況其實不複雜,整理如下:

  1. return 語句會使 async function 直接 resolve,不再往下執行。
    • return 一個 promise,則以該 promise 為 return value。
    • return 一個非 promise 的 value,則以 Promise.resolve 包裹該 value。
  2. function context 內部拋出任何 error,都會直接 reject,不再往下執行。

舉幾個簡單例子:

// 直接拋出 Error
// Return 的 promise 是 rejected 狀態
async function throwDirectly () {
  throw new Error('Rejected!')
}

// 從 await 表達式中拋出錯誤
// await 會把 promise 中的 error 向外傳遞到 async function context 中
// Return 的 promise 是 rejected 狀態
async function throwFromAwait () {
  await Promise.reject(new Error('Rejected!'))
}

// 透過 try-catch 處理錯誤,錯誤不繼續傳遞
// Return 的 promise 不會被 reject
async function handleError () {
  try {
    await Promise.reject(new Error('Rejected!'))
  } catch (e) {
    // Explicitly swallow errors
    console.log(`Got error: ${e}`)
  }
}

// 利用其他流程控制語句,達到 early return 的效果
async function earlyReturn () {
  if (someRuleFulfill) {
    // 直接 return value,外部會收到 `Promise.resolve('Return Value')`
    return 'Return Value'
  }
  // 由於原本即是 return a promise,所以不需使用 `await` 等待結果
  // No need to: `return await heavyAsyncProcess()`
  return heavyAsyncProcess()
}

看完範例,我們可以得知 Async function 的錯誤處理模式與一般的 function 如出一轍,即「由 context 的執行情形來決定何時 return,何時該 throw Error」。

另外,Async function 的 return value 與 Error 傳到 caller context 時會以 promise 包裹。不會讓整個 call frame 掛掉,但也如同 promise 對錯誤比較 silent,所以再次提醒,慣例會在 async function 內部透過 try-catch 處理錯誤,不讓錯誤傳遞過遠,好比 promise 最後必會掛個 catch 處理錯誤。

Declarations

Async function 既然是 function,想必有許多不同的宣告方式,在此將常用的方式列出來。

// function declaration
async function asyncFunc () {}

// IIFE (Immediately Invoked Function Expression)
(async function () {/* ... */}())

// ES6 Arrow Function
const myAsync = async () => {}

// An object props
var obj = {
  async myAsync () {},
  otherProp: 1234
}

// A method of a class
class MyClass {
  async myAsync () {}
}

Advanced Usage

Sequential Operation

還記得上一篇文章,我們利用 Array#reduceArray#forEach 實作 serial operation 嗎?雖然 promise + functional programming 看起來很有逼格,但又多了一層抽象理解層次。藉由 async function await 的特性,我們可以寫出很直觀 sequential operations,避開那些 hack。

// 直觀的同步操作,每個 await 皆會等 promise resolution 再往下執行
const processInSequence = async (url) => {
  const resA = await getAsyncResult('a')
  const resB = await getAsyncResult('b')
  return {resA, resB}
}

透過 for loop,也可達成 sequential 的效果。

// 利用 for loop,iterate 所有 url,逐一等待 promise resolution
const fetchInSequence = async urls => {
  const result = []
  for (let url of urls) {
    const res = await fetch(url)
    const json = await res.json()
    result.push(json)
  }
}

Parallel Operation

如果 await 會阻塞該 context(正確說來是轉移執行權),那我如何設計 parallel operation 呢?

非常簡單,那就提前讓 promise 開始執行嘛!

// 平行執行兩個 promise
const operationInParallel = async () => {
  // 不加 await,一次執行兩個 promise
  const pA = getAsyncResult('a')
  const pB = getAsyncResult('b')
  // 加上 await,耐心等待 promise 的結果
  const resA = await pA
  const resB = await pB
  return [resA, resB]
}

自幹兩個 promises 很不直觀?不然我們改用 Promise.all

// 使用 Promise.all await 多個 promises
const promiseAllInParallel = async () => {
  return await Promise.all([
    getAsyncResult('a'),
    getAsyncResult('b')
  ])
}

蛤!Promise.all 又出現了!使用 async function 不就是為了拋棄 promise 嗎?很抱歉,在有更清楚的語言特性出現前,只能選擇這種方式,畢竟 async funciton 整個 tech stack 就是建立於 promise 之上。

如果是多個 promise,也可以利用 Array#map 建立新的 context(function scope)來實作 parallel operation,這種方式看起來也稍微 hack,不過仍是本著 async function 的概念。

// 利用 `Array#map` 實作 parallel operations
const fetchInParallel = async urls => {
  const jsonPromises = urls.map(async url => { // 建立新的 async function context
    const res = await fetch(rul) // await 只會在這個 context 內等待
    return res.json()
  })
  // 我們可以按順序地印出結果
  for (let jsonPromise of jsonPromises) {
    console.log(await jsonPromise) // 等待個別 promise resolution
  }
}

Promise.race

想模擬 Promise.race,該如何實作?就直接用 Promise.race 吧。

const getFirstResolutionInParallel = async urls => {
  // 利用 Promise.race 取得第一個 resolution (either reject or fulfill) 的 promise
  return Promise.race(urls.map(async url => {
    const res = await fetch(url)
    return res.json()
  }))
}

First Fulfillment

在前一篇 Promise 文章中,我們 invert onRejected onFulfilled 兩個 callback,取得首個 fullfillment result。那在 async function 該如何實作呢?

當然,我們可以直接拿 Promise 做一樣的事,不過時代在走,人要進步,讓我們嘗試使用 try-catch 吧!

// from Stackoverflow https://stackoverflow.com/a/39941616
const invert = p => new Promise((res, rej) => p.then(rej, res))

// 利用 try catch 實作 get first fullfillment(但仍須借助 Promise 的 API)
const firstFulfillmentInParallel = async urls => {
  try {
    await Promise.all(
      urls.map(async url => {
        const res = await fetch(url)
        return res.json()
      }).map(invert)
    )
  } catch (e) {
    return e // 直接返回 inversion 的 fulfillment
  }
}

雖然有達到目的,但 fulfillment 卻是從 catch block 返回,實作漸漸不直觀了。

Async Interation

我們知道,async function 原理上是 generator 的 syntax sugar,利用 iterator 與 yield 轉換控制權,達成 asynchronous operation 效果,但是 iterator.next 這個 method 卻只有 synchronous 版本,有些場景(例如 streaming)需要非同步的 iterator 來取得 streaming data,這時候就該 async interator 出場了。

這個提議目前已在 Stage 3 了,即將納入標準,可以開始瞭解它了。

話不多說,附上提議的範例程式碼,感受一下吧!

// for-await-of loop
for await (const line of readLines(filePath)) {
  console.log(line);
}

// Async generator functions
async function* readLines(path) {
  let file = await fileOpen(path);

  try {
    while (!file.EOF) {
      yield await file.readLine();
    }
  } finally {
    await file.close();
  }
}

Further Reading

比起 promises,async functions 相對沒這麼多文獻供參考,想要入門,依然推薦阮大大的 ECMAScript 6 入门:async 函数,真的是非常豐富的 ES6 大全。當然,Google Web Fundamentals 也值得一看,但沒有 Promise 篇含金量這麼高就是了。

Medium 上也有許多作者在評論 async functions,Hacker Noon 這篇推坑文比較了 promise 與 async function 的優劣,算是蠻清楚的入門文,看看 async function 是否符合妳的期待。另外,也有人寫了不少篇批判反思 async function 的文章,這篇告訴你還是需要理解 promise 才能以正確的姿勢使用 async/await,另一篇則告訴你,promise 還是比較厲害的啦。

當然,沒有任何 unit tests,就算程式碼可讀性再高,仍然非常脆弱,所以,別花太多時間看這些新技術,乖乖地補上缺漏的 tests 比較實在 XD。

Reference