iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 11

Day 11|await 暫停的到底是什麼?async function 如何交還控制權

  • 分享至 

  • xImage
  •  

摘要

一句摘要await 不會暫停整個 JavaScript;它只暫停目前 async function 中依賴結果的後半段,將控制權交還給呼叫端,並在 Promise settled 後以 microtask 恢復。

前置知識:理解 Promise 會保存成功值或失敗原因,並將後續 reaction 排程執行。

學習路線:從「Promise 表示未來結果」進一步解釋 await 如何交還控制權。

標籤JavaScript async await Promise Microtask Execution Context Event Loop


今天的學習目標

  1. 說明 async function 如何在 await 前後執行、暫停並交還控制權。
  2. 理解即使等待已 fulfilled Promise 或一般值,後續程式仍會透過 microtask 恢復。
  3. 分辨 await 的等待、錯誤傳遞與同步工作仍可能阻塞的邊界。

Day 10 將 Promise 看成未來結果的狀態容器。實際開發時,我們更常用 await 取得這個結果。

在 Day 10,我們看到 .then() handler 會以 reaction job 稍後執行;await 可以理解成把同樣的等待關係,寫進 async function 的控制流程裡。

但「等待」很容易讓人誤會:

fetch() 停住了?
整個瀏覽器停住了?
還是只有目前 async function 中依賴結果的後半段暫時無法繼續?

答案是最後一個:

await 暫停的是目前 async function 中、依賴 await 結果的後續程式;它不會佔住 JavaScript 執行權。

https://ithelp.ithome.com.tw/upload/images/20260913/20145251sXqPLrX3QM.png


一、async function 一開始並不會等

先看最小的例子:

async function prepareScreen() {
  console.log('1. function 開始');

  await Promise.resolve('設定已讀取');

  console.log('3. await 後:更新畫面');
}

prepareScreen();

console.log('2. 呼叫端繼續執行');

輸出順序是:

1. function 開始
2. 呼叫端繼續執行
3. await 後:更新畫面

即使 Promise.resolve() 已經是 fulfilled,await 後面的程式也不會留在目前的 call stack 裡繼續跑。

這說明兩件事:

  1. 呼叫 async function 時,function body 會立刻同步執行到第一個 await
  2. 碰到 await 後,這個 function 把控制權交還給呼叫端;後半段會在 microtask 中再回來。

可以先用這張圖記住:

呼叫 prepareScreen()
  │
  ├─ 印出「function 開始」
  ├─ 遇到 await
  │    └─ 安排「await 後的 continuation」
  └─ 將 Promise 回傳給呼叫端

呼叫端繼續執行
  └─ 印出「呼叫端繼續執行」

目前同步程式結束後
  └─ microtask:回到 prepareScreen(),印出「更新畫面」

所以 async function 不是「呼叫後什麼都晚點再開始」;更準確地說,它會立刻開始,直到遇到第一個 await 才交還控制權。


二、從 ECMAScript 的 Await(value) 看:它不把 Promise 回傳給變數

看到規範裡的 Await(value),容易產生一個誤解:await 是不是「回傳一個 Promise」?

其實不是。程式從 await 恢復執行後,交給變數的是 Promise 完成後的值,不是 Promise 本身。真正會回傳 Promise 的,是整個 async function 呼叫:

async function getCount() {
  const count = await Promise.resolve(42);
  // count 是 42,不是 Promise
  return count + 1;
}

const result = getCount();
// result 是 Promise;fulfilled 後的值是 43

所以這三種寫法都能使用 await

await fetch('/api/profile'); // 既有 Promise
await 42;                    // 先正規化成 fulfilled Promise
await { then(resolve) { resolve('ok'); } }; // thenable

不過使用者不會拿到這個內部 Promise。你在 const value = await ... 取得的是它完成後的值;若 rejected,控制流程則進入 catch


為什麼 await 必須搭配 async

這是我一開始學 JavaScript 使用 asyncawait 時,教科書都很強調,是因為他們做的事有不同嗎?

await 不是建立另一條執行緒,而是透過 async function 可暫停、可恢復的執行機制運作,先看看 TC39 的 Await(value) 規範步驟與白話理解對照成這張表:

TC39 規範步驟 白話理解
取得目前的 asyncContext 記住現在執行到哪裡,之後才能從 await 後面繼續。
PromiseResolve(%Promise%, value) 把一般值、Promise 或 thenable 統一轉成可等待的 Promise。
建立 fulfilledClosurerejectedClosure 準備成功與失敗兩條恢復路徑。
PerformPromiseThen(promise, onFulfilled, onRejected) 向 Promise 登記:結果出現後要恢復哪一條流程。
移除並暫停 asyncContext,恢復 caller context 目前 async function 先停在 await,把控制權交還給呼叫端。
Promise settled 後恢復 asyncContext fulfilled 時讓 await 得到值;rejected 時則像在原地 throw

async 就像先向 JavaScript 宣告:「這個 function 可能在中途暫停,稍後再回來完成。」
解析器因此允許 function body 使用 await;執行引擎也會為這次呼叫準備 Promise 與可暫停、可恢復的執行流程。真正遇到 await 時,才會發生 execution context 的交還與恢復。

因為 async function 可能先交還控制權、未來才完成,呼叫端需要一個東西代表這份尚未完成的工作,就是之前提到的接受未來狀態的容器 Promise 。所以async 所宣告的對外契約,就是讓 function 先回傳 Promise:

async function loadProfile() {
  const response = await fetch('/api/profile');
  return response.json();
}

const pendingProfile = loadProfile();
// 呼叫當下立刻得到 Promise,而不是尚未存在的 profile

這個 Promise 會保存 async function 「稍後會成功或失敗」的結果:function 的 return 會成為 fulfilled value,未處理的 throw 則會成為 rejection reason。普通 function 沒有這個完成契約,因此不能在 function body 裡直接使用 await

function loadProfile() {
  const response = await fetch('/api/profile');
  // SyntaxError:普通 function 沒有 async 的完成契約
  return response;
}

這不是「async 讓 fetch 變成非同步」。fetch 本來就回傳 Promise;async 的作用是讓你的 function 可以使用 await 切開控制流程,並將自己最終的 return/throw 統一包成 Promise。


三、await 暫停的是「依賴結果的後半段」

理解上面的 aync context 轉移概念後,把上面的程式拆成兩半,會更容易在程式碼中看出邊界:

先用一個有兩次 await 的範例,看看 loadProfile() 如何暫停與恢復:

async function loadProfile() {
  console.log('A. 開始 request');

  // 發出 request;等待期間,loadProfile 先暫停
  const response = await fetch('/api/profile');

  console.log('C. 現在才有 response');

  // 等待 response body 讀取並解析完成
  const profile = await response.json();

  console.log('D. 現在才有 profile', profile.name);
}

console.log('0. 呼叫前');
loadProfile();
console.log('B. loadProfile 已交還控制權');
1. 呼叫 loadProfile(),先印出 A。
2. 遇到第一個 await,loadProfile 暫停,因此呼叫端繼續印出 B。
3. response 抵達後,loadProfile 恢復,印出 C;接著在第二個 await 再暫停一次。
4. JSON 資料準備完成後,loadProfile 再次恢復,最後印出 D。

fetch() 沒有啟動另一條 JavaScript 執行流程;等待網路時,是瀏覽器或 Node.js 在處理外部工作。

下一篇 Day 12 會把這個模型延伸到 Async Iterator:每次呼叫 next(),consumer 都等待一個 Promise<IteratorResult>,下一筆就緒後才恢復自己的後續工作。到了 Day 16,Array.fromAsync() 也會運用同樣的概念,逐筆等待並收集資料。


四、兩個 async function 為什麼會交錯?

await 會交還控制權,所以多條 async function 不會各自從頭跑到底。先用一個不需要網路的範例觀察:

async function step(name) {
  console.log(`${name}: start`);
  await null;
  console.log(`${name}: end`);
}

step('first');
step('second');

console.log('script end');

輸出順序為:

first: start
second: start
script end
first: end
second: end

await null 不是真的等待網路;它仍會將 null 正規化成一個 fulfilled Promise。不過 await continuation 還是會進 microtask,因此兩次呼叫都先跑到 await,然後才依排入順序恢復。

目前同步程式
  ├─ step('first')  → 排入 first continuation
  ├─ step('second') → 排入 second continuation
  └─ 印出 script end

microtask queue
  ├─ first continuation
  └─ second continuation

這裡不要把「交錯」理解成兩段 JavaScript 同時在主執行緒執行。

await 會將 function 切成多段,後半段以 Promise Job 的形式交給 host 排程;在瀏覽器中,這類 Job 會進入 microtask queue。目前的同步程式結束後,microtask 才會依序被取出,將對應的 execution context 放回 call stack 執行。每一段仍會跑到結束,下一個 microtask 才能開始。


五、一般值放入 Await(value)

上一節看到,Await(value) 會先用 PromiseResolve 正規化右側的值。因此一般值不需要外部工作才會得到,仍會造成一次非同步切分:

async function example() {
  console.log('before');
  await 42;
  console.log('after');
}

example();
console.log('outside');

輸出是:

before
outside
after

這是很重要的閱讀線索。看到 await 時,不只要問「它在等哪個 API」,也要問「這裡是否真的需要把控制流程切成下一個 microtask?」如果右邊已經是同步可得的值,額外的 await 通常沒有必要,還可能讓 log 順序與錯誤堆疊更難追。


六、await 如何把 rejection 丟回 try...catch

await 會把 Promise 的 rejection 帶回 async function 的暫停點。

async function loadProfile() {
  try {
    const response = await fetch('/api/profile');

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error('profile 載入失敗', error);
    throw error;
  }
}

當等待中的 Promise rejected 時,async function 的 continuation 會在 microtask 恢復;對 await expression 而言,這次恢復就像在原地 throw rejection reason。因此控制流程直接跳到同一條 async function 裡最近的 catch

fetch Promise rejected
  ↓
microtask:恢復 loadProfile()
  ↓
await 表現得像 throw error
  ↓
try...catch 接住 error

範例中的 catch 記錄錯誤後再次 throw,所以 loadProfile() 回傳的 Promise 會變成 rejected。若 function 內根本沒有 catch,未被接住的 rejection 也同樣會成為它所回傳 Promise 的失敗原因,交由呼叫端處理:

loadProfile().catch(reportProfileError);

所以 try...catch 的邊界不是「所有非同步錯誤都會自動被全域接住」,而是:這個 try 所在的 async function 是否正在 await 那個會 rejected 的 Promise。


七、await 不阻塞,長同步工作仍然會卡住

「async / await 不會阻塞」很容易被過度簡化。更完整的說法是:

等待 Promise 的期間
→ async function 交還控制權,不阻塞主執行緒

await 前、await 後每一段同步 JavaScript
→ 仍然必須 run-to-completion,可能卡住主執行緒

例如這段程式在 await 後才開始大量計算:

async function buildReport() {
  const response = await fetch('/api/report');
  const rows = await response.json();

  for (const row of rows) {
    // 很重的同步計算
    calculateExpensiveSummary(row);
  }
}

等待 request 與讀取 body 時,畫面仍能回應;但一旦 response.json() fulfilled,迴圈會在同一個 continuation 裡同步跑到結束。資料量夠大時,使用者點擊與重繪仍必須等待。

因此 await 的價值不是把任何工作自動變快或移到背景,而是讓「尚未有結果」的那段時間不要白白佔住 JavaScript。理解這個邊界後,就能繼續把同一套控制流程模型套用到一筆一筆出現的非同步資料。


今日總結

async function 被呼叫
→ 立即同步執行到第一個 await

遇到 await
→ 暫停目前 function 的後半段、交還控制權

Promise settled
→ 將 continuation 排進 microtask

microtask 執行
→ 回到 await 後;fulfilled 得到值,rejected 像 throw 一樣進入 catch
容易誤解 更準確的說法
呼叫 async function 後,整個 function 都晚點才開始 它會立刻跑到第一個 await
await 暫停整個 JavaScript 它只暫停目前 async function 的後續程式。
已 fulfilled 的 Promise 不會真的等待 await 後續仍透過 microtask 執行。
await 讓程式不會卡住 它只不阻塞等待期間;每段同步工作仍可能卡住。

await 讓 consumer 等待一個未來結果。如果資料不是一次完成,或是後端持續發送事件需要在未來才能收到結果,但 consumer 每次都要再問「下一筆好了嗎?」,就可以前面學習到的 Iterator 與 Promise 接起來,變成一段非同步過程。

下一篇就可以運用這兩天的基礎,進入 Async Iterator:next() 理解為什麼會從 { value, done } 變成 Promise<IteratorResult>囉!


參考資料


上一篇
Day 10|重新看 Promise:它不是非同步魔法,而是結果的狀態容器
下一篇
Day 12|從 async Iterator 協議到理解 for await...of
系列文
30 天新世代 JavaScript 自我學習指南14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言