「譯」更快的 async 函數和 promises

翻譯自:Faster async functions and promisesjavascript

JavaScript 的異步過程一直被認爲是不夠快的,更糟糕的是,在 NodeJS 等實時性要求高的場景下調試堪比噩夢。不過,這一切正在改變,這篇文章會詳細解釋咱們是如何優化 V8 引擎(也會涉及一些其它引擎)裏的 async 函數和 promises 的,以及伴隨着的開發體驗的優化。html

舒適提示: 這裏有個 視頻,你能夠結合着文章看。java

異步編程的新方案

從 callbacks 到 promises,再到 async 函數

在 promises 正式成爲 JavaScript 標準的一部分以前,回調被大量用在異步編程中,下面是個例子:node

function handler(done) {
  validateParams((error) => {
    if (error) return done(error);
    dbQuery((error, dbResults) => {
      if (error) return done(error);
      serviceCall(dbResults, (error, serviceResults) => {
        console.log(result);
        done(error, serviceResults);
      });
    });
  });
}

相似以上深度嵌套的回調一般被稱爲「回調黑洞」,由於它讓代碼可讀性變差且不易維護。react

幸運地是,如今 promises 成爲了 JavaScript 語言的一部分,如下實現了跟上面一樣的功能:git

function handler() {
  return validateParams()
    .then(dbQuery)
    .then(serviceCall)
    .then(result => {
      console.log(result);
      return result;
    });
}

最近,JavaScript 支持了 async 函數,上面的異步代碼能夠寫成像下面這樣的同步的代碼:github

async function handler() {
  await validateParams();
  const dbResults = await dbQuery();
  const results = await serviceCall(dbResults);
  console.log(results);
  return results;
}

藉助 async 函數,代碼變得更簡潔,代碼的邏輯和數據流都變得更可控,固然其實底層實現仍是異步。(注意,JavaScript 仍是單線程執行,async 函數並不會開新的線程。)web

從事件監聽回調到 async 迭代器

NodeJS 裏 ReadableStreams 做爲另外一種形式的異步也特別常見,下面是個例子:chrome

const http = require('http');

http.createServer((req, res) => {
  let body = '';
  req.setEncoding('utf8');
  req.on('data', (chunk) => {
    body += chunk;
  });
  req.on('end', () => {
    res.write(body);
    res.end();
  });
}).listen(1337);

這段代碼有一點難理解:只能經過回調去拿 chunks 裏的數據流,並且數據流的結束也必須在回調裏處理。若是你沒能理解到函數是當即結束但實際處理必須在回調裏進行,可能就會引入 bug。編程

一樣很幸運,ES2018 特性裏引入的一個很酷的 async 迭代器 能夠簡化上面的代碼:

const http = require('http');

http.createServer(async (req, res) => {
  try {
    let body = '';
    req.setEncoding('utf8');
    for await (const chunk of req) {
      body += chunk;
    }
    res.write(body);
    res.end();
  } catch {
    res.statusCode = 500;
    res.end();
  }
}).listen(1337);

你能夠把全部數據處理邏輯都放到一個 async 函數裏使用 for await…of 去迭代 chunks,而不是分別在 'data''end' 回調裏處理,並且咱們還加了 try-catch 塊來避免 unhandledRejection 問題。

以上這些特性你今天就能夠在生成環境使用!async 函數從 Node.js 8 (V8 v6.2 / Chrome 62) 開始就已全面支持,async 迭代器從 Node.js 10 (V8 v6.8 / Chrome 68) 開始支持

async 性能優化

從 V8 v5.5 (Chrome 55 & Node.js 7) 到 V8 v6.8 (Chrome 68 & Node.js 10),咱們致力於異步代碼的性能優化,目前的效果還不錯,你能夠放心地使用這些新特性。

上面的是 doxbee 基準測試,用於反應重度使用 promise 的性能,圖中縱座標表示執行時間,因此越小越好。

另外一方面,parallel 基準測試 反應的是重度使用 Promise.all() 的性能狀況,結果以下:

Promise.all 的性能提升了八倍

而後,上面的測試僅僅是小的 DEMO 級別的測試,V8 團隊更關心的是 實際用戶代碼的優化效果

上面是基於市場上流行的 HTTP 框架作的測試,這些框架大量使用了 promises 和 async 函數,這個表展現的是每秒請求數,因此跟以前的表不同,這個是數值越大越好。從表能夠看出,從 Node.js 7 (V8 v5.5) 到 Node.js 10 (V8 v6.8) 性能提高了很多。

性能提高取決於如下三個因素:

  • TurboFan,新的優化編譯器 🎉
  • Orinoco,新的垃圾回收器 🚛
  • 一個 Node.js 8 的 bug 致使 await 跳過了一些微 tick(microticks) 🐛

當咱們在 Node.js 8啓用 TurboFan 的後,性能獲得了巨大的提高。

同時咱們引入了一個新的垃圾回收器,叫做 Orinoco,它把垃圾回收從主線程中移走,所以對請求響應速度提高有很大幫助。

最後,Node.js 8 中引入了一個 bug 在某些時候會讓 await 跳過一些微 tick,這反而讓性能變好了。這個 bug 是由於無心中違反了規範致使的,可是卻給了咱們優化的一些思路。這裏咱們稍微解釋下:

const p = Promise.resolve();

(async () => {
  await p; console.log('after:await');
})();

p.then(() => console.log('tick:a'))
 .then(() => console.log('tick:b'));

上面代碼一開始建立了一個已經完成狀態的 promise p,而後 await 出其結果,又同時鏈了兩個 then,那最終的 console.log 打印的結果會是什麼呢?

由於 p 是已完成的,你可能認爲其會先打印 'after:await',而後是剩下兩個 tick, 事實上 Node.js 8 裏的結果是:

雖然以上結果符合預期,可是卻不符合規範。Node.js 10 糾正了這個行爲,會先執行 then 鏈裏的,而後纔是 async 函數。

這個「正確的行爲」看起來並不正常,甚至會讓不少 JavaScript 開發者感到吃驚,仍是有必要再詳細解釋下。在解釋以前,咱們先從一些基礎開始。

任務(tasks)vs. 微任務(microtasks)

從某層面上來講,JavaScript 裏存在任務和微任務。任務處理 I/O 和計時器等事件,一次只處理一個。微任務是爲了 async/await 和 promise 的延遲執行設計的,每次任務最後執行。在返回事件循環(event loop)前,微任務的隊列會被清空。

能夠經過 Jake Archibald 的 tasks, microtasks, queues, and schedules in the browser 瞭解更多。Node.js 裏任務模型與此很是相似。

async 函數

根據 MDN,async 函數是一個經過異步執行並隱式返回 promise 做爲結果的函數。從開發者角度看,async 函數讓異步代碼看起來像同步代碼。

一個最簡單的 async 函數:

async function computeAnswer() {
  return 42;
}

函數執行後會返回一個 promise,你能夠像使用其它 promise 同樣用其返回的值。

const p = computeAnswer();
// → Promise

p.then(console.log);
// prints 42 on the next turn

你只能在下一個微任務執行後才能獲得 promise p 返回的值,換句話說,上面的代碼語義上等價於使用 Promise.resolve 獲得的結果:

function computeAnswer() {
  return Promise.resolve(42);
}

async 函數真正強大的地方來源於 await 表達式,它可讓一個函數執行暫停直到一個 promise 已接受(resolved),而後等到已完成(fulfilled)後恢復執行。已完成的 promise 會做爲 await 的值。這裏的例子會解釋這個行爲:

async function fetchStatus(url) {
  const response = await fetch(url);
  return response.status;
}

fetchStatus 在遇到 await 時會暫停,當 fetch 這個 promise 已完成後會恢復執行,這跟直接鏈式處理 fetch 返回的 promise 某種程度上等價。

function fetchStatus(url) {
  return fetch(url).then(response => response.status);
}

鏈式處理函數裏包含了以前跟在 await 後面的代碼。

正常來講你應該在 await 後面放一個 Promise,不過其實後面能夠跟任意 JavaScript 的值,若是跟的不是 promise,會被制轉爲 promise,因此 await 42 效果以下:

async function foo() {
  const v = await 42;
  return v;
}

const p = foo();
// → Promise

p.then(console.log);
// prints `42` eventually

更有趣的是,await 後能夠跟任何 「thenable」,例如任何含有 then 方法的對象,就算不是 promise 均可以。所以你能夠實現一個有意思的 類來記錄執行時間的消耗:

class Sleep {
  constructor(timeout) {
    this.timeout = timeout;
  }
  then(resolve, reject) {
    const startTime = Date.now();
    setTimeout(() => resolve(Date.now() - startTime),
               this.timeout);
  }
}

(async () => {
  const actualTime = await new Sleep(1000);
  console.log(actualTime);
})();

一塊兒來看看 V8 規範 裏是如何處理 await 的。下面是很簡單的 async 函數 foo

async function foo(v) {
  const w = await v;
  return w;
}

執行時,它把參數 v 封裝成一個 promise,而後會暫停直到 promise 完成,而後 w 賦值爲已完成的 promise,最後 async 返回了這個值。

神祕的 await

首先,V8 會把這個函數標記爲可恢復的,意味着執行能夠被暫停並恢復(從 await 角度看是這樣的)。而後,會建立一個所謂的 implicit_promise(用於把 async 函數裏產生的值轉爲 promise)。

而後是有意思的東西來了:真正的 await。首先,跟在 await 後面的值被轉爲 promise。而後,處理函數會綁定這個 promise 用於在 promise 完成後恢復主函數,此時 async 函數被暫停了,返回 implicit_promise 給調用者。一旦 promise 完成了,函數會恢復並拿到從 promise 獲得值 w,最後,implicit_promise 會用 w 標記爲已接受。

簡單說,await v 初始化步驟有如下組成:

  1. v 轉成一個 promise(跟在 await 後面的)。
  2. 綁定處理函數用於後期恢復。
  3. 暫停 async 函數並返回 implicit_promise 給掉用者。

咱們一步步來看,假設 await 後是一個 promise,且最終已完成狀態的值是 42。而後,引擎會建立一個新的 promise 而且把 await 後的值做爲 resolve 的值。藉助標準裏的 PromiseResolveThenableJob 這些 promise 會被放到下個週期執行。

而後,引擎建立了另外一個叫作 throwaway 的 promise。之因此叫這個名字,由於沒有其它東西鏈過它,僅僅是引擎內部用的。throwaway promise 會鏈到含有恢復處理函數的 promise 上。這裏 performPromiseThen 操做其實內部就是 Promise.prototype.then()。最終,該 async 函數會暫停,並把控制權交給調用者。

調用者會繼續執行,最終調用棧會清空,而後引擎會開始執行微任務:運行以前已準備就緒的 PromiseResolveThenableJob,首先是一個 PromiseReactionJob,它的工做僅僅是在傳遞給 await 的值上封裝一層 promise。而後,引擎回到微任務隊列,由於在回到事件循環以前微任務隊列必需要清空。

而後是另外一個 PromiseReactionJob,等待咱們正在 await(咱們這裏指的是 42)這個 promise 完成,而後把這個動做安排到 throwaway promise 裏。引擎繼續回到微任務隊列,由於還有最後一個微任務。

如今這第二個 PromiseReactionJob 把決定傳達給 throwaway promise,並恢復 async 函數的執行,最後返回從 await 獲得的 42

總結下,對於每個 await 引擎都會建立兩個額外的 promise(即便右值已是一個 promise),而且須要至少三個微任務。誰會想到一個簡單的 await 居然會有如此多冗餘的運算?!

咱們來看看究竟是什麼引發冗餘。第一行的做用是封裝一個 promise,第二行爲了 resolve 封裝後的 promose await 以後的值 v。這兩行產生個冗餘的 promise 和兩個冗餘的微任務。若是 v 已是 promise 的話就很不划算了(大多時候確實也是如此)。在某些特殊場景 await42 的話,那確實仍是須要封裝成 promise 的。

所以,這裏可使用 promiseResolve 操做來處理,只有必要的時候纔會進行 promise 的封裝:

若是入參是 promise,則原封不動地返回,只封裝必要的 promise。這個操做在值已是 promose 的狀況下能夠省去一個額外的 promise 和兩個微任務。此特性能夠經過 --harmony-await-optimization 參數在 V8(從 v7.1 開始)中開啓,同時咱們 向 ECMAScript 發起了一個提案,目測很快會合並。

下面是簡化後的 await 執行過程:

感謝神奇的 promiseResolve,如今咱們只須要傳 v 便可而不用關心它是什麼。以後跟以前同樣,引擎會建立一個 throwaway promise 並放到 PromiseReactionJob 裏爲了在下一個 tick 時恢復該 async 函數,它會先暫停函數,把自身返回給掉用者。

當最後全部執行完畢,引擎會跑微任務隊列,會執行 PromiseReactionJob。這個任務會傳遞 promise 結果給 throwaway,而且恢復 async 函數,從 await 拿到 42

儘管是內部使用,引擎建立 throwaway promise 可能仍是會讓人以爲哪裏不對。事實證實,throwaway promise 僅僅是爲了知足規範裏 performPromiseThen 的須要。

這是最近提議給 ECMAScript 的 變動,引擎大多數時候再也不須要建立 throwaway 了。

對比 await 在 Node.js 10 和優化後(應該會放到 Node.js 12 上)的表現:

async/await 性能超過了手寫的 promise 代碼。關鍵就是咱們減小了 async 函數裏一些沒必要要的開銷,不只僅是 V8 引擎,其它 JavaScript 引擎都經過這個 補丁 實現了優化。

開發體驗優化

除了性能,JavaScript 開發者也很關心問題定位和修復,這在異步代碼裏一直不是件容易的事。Chrome DevTools 如今支持了異步棧追蹤:

在本地開發時這是個頗有用的特性,不過一旦應用部署了就沒啥用了。調試時,你只能看到日誌文件裏的 Error#stack 信息,這些並不會包含任何異步信息。

最近咱們搞的 零成本異步棧追蹤 使得 Error#stack 包含了 async 函數的調用信息。「零成本」聽起來很讓人興奮,對吧?當 Chrome DevTools 功能帶來重大開銷時,它如何才能實現零成本?舉個例子,foo 裏調用 barbar 在 await 一個 promise 後拋一個異常:

async function foo() {
  await bar();
  return 42;
}

async function bar() {
  await Promise.resolve();
  throw new Error('BEEP BEEP');
}

foo().catch(error => console.log(error.stack));

這段代碼在 Node.js 8 或 Node.js 10 運行結果以下:

$ node index.js
Error: BEEP BEEP
    at bar (index.js:8:9)
    at process._tickCallback (internal/process/next_tick.js:68:7)
    at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
    at startup (internal/bootstrap/node.js:266:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)

注意到,儘管是 foo() 裏的調用拋的錯,foo 自己卻不在棧追蹤信息裏。若是應用是部署在雲容器裏,這會讓開發者很難去定位問題。

有意思的是,引擎是知道 bar 結束後應該繼續執行什麼的:即 foo 函數裏 await 後。剛好,這裏也正是 foo 暫停的地方。引擎能夠利用這些信息重建異步的棧追蹤信息。有了以上優化,輸出就會變成這樣:

$ node --async-stack-traces index.js
Error: BEEP BEEP
    at bar (index.js:8:9)
    at process._tickCallback (internal/process/next_tick.js:68:7)
    at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
    at startup (internal/bootstrap/node.js:266:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
    at async foo (index.js:2:3)

在棧追蹤信息裏,最上層的函數出如今第一個,以後是一些異步調用棧,再後面是 foo 裏面 bar 上下文的棧信息。這個特性的啓用能夠經過 V8 的 --async-stack-traces 參數啓用。

然而,若是你跟上面 Chrome DevTools 裏的棧信息對比,你會發現棧追蹤裏異步部分缺失了 foo 的調用點信息。這裏利用了 await 恢復和暫停位置是同樣的特性,但 Promise#then()Promise#catch() 就不是這樣的。能夠看 Mathias Bynens 的文章 await beats Promise#then() 瞭解更多。

結論

async 函數變快少不了如下兩個優化:

  • 移除了額外的兩個微任務
  • 移除了 throwaway promise

除此以外,咱們經過 零成本異步棧追蹤 提高了 awaitPromise.all() 開發調試體驗。

咱們還有些對 JavaScript 開發者友好的性能建議:

多使用 asyncawait 而不是手寫 promise 代碼,多使用 JavaScript 引擎提供的 promise 而不是本身去實現。

文章可隨意轉載,但請保留此 原文連接
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj(at)alibaba-inc.com 。
相關文章
相關標籤/搜索