前言

欸,各位工程師朋友好啊!今天 Hikari 我要來跟大家聊一個,在現代程式設計中幾乎無所不在,但又常常讓人搞得頭昏腦脹的議題:「非同步程式設計」!特別是當我們有了 async/await 這種語法糖之後,寫非同步程式碼看起來好像變簡單了,程式碼也變得跟同步執行一樣好讀。但俗話說得好,水能載舟亦能覆舟,async/await 在簡化了語法的同時,其實也埋下了不少「地雷區」,讓很多開發者在不知不覺中踩到,導致程式碼不如預期、效能低落,甚至出現難以追查的 bug。

你是不是也曾經寫過 async/await 的程式碼,卻發現:

  • 為什麼這個函式明明有 await,結果卻馬上執行下一行?
  • 奇怪,我發了三個網路請求,怎麼等了這麼久?不是說非同步可以同時做嗎?
  • 啊,明明加了 try/catch,怎麼錯誤還是噴出來了?

別擔心,你不是一個人!這些都是 async/await 非常常見的誤用情境。今天,我們就來好好拆解這些「雷區」,看看背後的原理,以及怎麼正確地使用 async/await,讓你的非同步程式碼既優雅又高效!

快速回顧:async/await 到底是什麼?

在深入探討誤用之前,我們還是快速複習一下 async/await 的核心概念吧!

想像一下,你是一個餐廳服務生(你的程式碼),老闆(系統)給你一張點餐單(一系列任務)。有些菜(任務)會很快做好,有些菜(任務)則需要等待廚師(外部資源,例如資料庫、API、檔案讀寫)處理很久。

  • async 關鍵字:當你把一個函式標記為 async 時,你就是在告訴老闆:「這個函式裡面可能會做一些需要等待很久的『非同步』事情哦!」所以,當你呼叫這個 async 函式時,它不會直接回傳最終結果,而是會回傳一個「承諾」(Promise,或是 C# 的 Task)給你。這個承諾代表著「這個任務未來會給你一個結果」的保證。程式碼執行到這裡並不會卡住,它會繼續往下執行。

  • await 關鍵字:當你在 async 函式裡面,看到某個需要等待的任務(通常是回傳 Promise/Task 的函式)前面加上 await 時,你就是在告訴自己:「好,這個菜還沒好,我先在這裡等一下,直到它做好為止。這段時間我可以去服務其他客人,但等這個菜好了,我再回來處理它後續的事情。」當 await 等待的任務完成後,程式碼會從 await 的地方恢復執行,並且拿到那個任務的最終結果。

簡單來說:async 讓函式變成非同步的,它會回傳一個 Promise/Task;await 則是在 async 函式內部,讓你「暫停」當前函式的執行,等待一個 Promise/Task 完成,然後「恢復」執行並拿到結果。

下面我們用 JavaScript 的例子來示範,但這些概念在 C#、Python 等支援 async/await 的語言中都是相通的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function fetchUserData(userId) {
console.log(`開始抓取使用者 ${userId} 資料...`);
// 模擬網路請求,需要 2 秒
const response = await new Promise(resolve => setTimeout(() => {
resolve({ id: userId, name: `User ${userId}`, email: `user${userId}@example.com` });
}, 2000));
console.log(`使用者 ${userId} 資料抓取完成!`);
return response;
}

async function main() {
console.log('--- 主程式開始 ---');
const user1Data = await fetchUserData(1); // 會在這裡等待 2 秒
console.log('取得 User 1 資料:', user1Data);

const user2Data = await fetchUserData(2); // 會在這裡再等待 2 秒
console.log('取得 User 2 資料:', user2Data);
console.log('--- 主程式結束 ---');
}

main();

這段程式碼會依序執行 fetchUserData(1) 和 fetchUserData(2),總共會花費大約 4 秒。現在,我們來看看常見的誤用情況,以及怎麼改善它!

常見誤用 1:呼叫 async 函式卻忘記 await

這是最最最常見的誤用之一!當你呼叫一個 async 函式時,它會馬上回傳一個 Promise。如果你沒有 await 它,程式碼就會「繼續往下跑」,而不會等待那個 Promise 完成。這就像你點了一道菜,服務生給你一個號碼牌,你卻直接走掉,期望菜能自己送到你手上,甚至你不知道菜到底做好了沒。

問題情境:

假設我們有一個 logAnalytics 的非同步函式,用來紀錄使用者行為,這個動作不影響主要流程,所以我們呼叫它,但「忘了」等待它完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function logAnalytics(event) {
// 模擬發送日誌到遠端伺服器,可能需要一些時間
await new Promise(resolve => setTimeout(resolve, 500));
console.log(`事件 ${event} 已記錄。`);
}

async function processOrder() {
console.log('訂單處理開始...');
logAnalytics('order_placed'); // 這裡忘記 await 了!
console.log('訂單處理完成,但日誌可能還沒送出。');
// 程式碼很快就會結束,logAnalytics 可能還沒執行完
}

processOrder();
// 預期輸出:
// 訂單處理開始...
// 訂單處理完成,但日誌可能還沒送出。
// 事件 order_placed 已記錄。(可能在前面兩行之後才出現,也可能不會出現如果主程式很快結束)

你會發現 事件 order_placed 已記錄。 這行可能會比 訂單處理完成... 還晚出現,甚至如果主程式提早結束,這行可能根本就不會印出來。這會導致資料不一致或遺漏。

解法:

只要你的邏輯需要等待非同步操作完成,就一定要加上 await!

1
2
3
4
5
6
7
8
9
10
11
async function processOrder() {
console.log('訂單處理開始...');
await logAnalytics('order_placed'); // 加上 await,確保日誌送出
console.log('訂單處理完成,且日誌已送出。');
}

processOrder();
// 預期輸出:
// 訂單處理開始...
// 事件 order_placed 已記錄。
// 訂單處理完成,且日誌已送出。

如果 logAnalytics 真的不需要等待,那你可以考慮它是不是一個「fire-and-forget」的任務,或者使用 .catch() 來處理可能的錯誤,避免未被捕捉的 Promise Rejection。

1
2
3
4
// 如果真的不需要等待,至少處理一下潛在錯誤
logAnalytics('order_placed').catch(error => {
console.error('記錄分析事件失敗:', error);
});

常見誤用 2:錯誤的並行處理(全部串行)

async/await 的一個大優勢就是可以讓多個「不相關」的非同步任務並行執行,然後再統一等待它們的結果。但很多開發者在使用 await 時,不小心把所有任務都寫成了串行執行,浪費了並行的優勢。

問題情境:

假設我們需要從兩個不同的 API 抓取資料,這兩個請求之間沒有依賴關係。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function fetchProductDetails(productId) {
console.log(`開始抓取商品 ${productId} 細節...`);
await new Promise(resolve => setTimeout(resolve, 1500)); // 模擬 1.5 秒
return { id: productId, name: `Product ${productId}` };
}

async function fetchRecommendedProducts(userId) {
console.log(`開始抓取使用者 ${userId} 推薦商品...`);
await new Promise(resolve => setTimeout(resolve, 2000)); // 模擬 2 秒
return [{ id: 101, name: '推薦商品 A' }, { id: 102, name: '推薦商品 B' }];
}

async function getProductPageData() {
console.log('--- 抓取商品頁資料開始 (串行) ---');
const productDetails = await fetchProductDetails(123); // 等待 1.5 秒
const recommendedProducts = await fetchRecommendedProducts(456); // 再等待 2 秒
console.log('--- 抓取商品頁資料結束 (串行) ---');
return { productDetails, recommendedProducts };
}

// getProductPageData(); // 總共耗時約 1.5 + 2 = 3.5 秒

這裡的 fetchProductDetails 和 fetchRecommendedProducts 之間沒有依賴,但因為我們用了兩個 await,導致它們會依序執行。總共會花費 3.5 秒。

解法:使用 Promise.all() 來並行執行

當多個非同步任務之間沒有依賴關係時,可以同時啟動它們,然後用 Promise.all() 等待所有任務都完成。這樣總耗時會取決於最慢的那個任務。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function getProductPageDataConcurrent() {
console.log('--- 抓取商品頁資料開始 (並行) ---');
// 同時啟動這兩個非同步任務,它們會立即開始執行
const productDetailsPromise = fetchProductDetails(123);
const recommendedProductsPromise = fetchRecommendedProducts(456);

// 等待所有 Promise 完成,會以陣列形式回傳結果
const [productDetails, recommendedProducts] = await Promise.all([
productDetailsPromise,
recommendedProductsPromise
]);

console.log('--- 抓取商品頁資料結束 (並行) ---');
return { productDetails, recommendedProducts };
}

getProductPageDataConcurrent(); // 總共耗時約 Max(1.5, 2) = 2 秒

這樣一來,總時間就縮短到了最長那個任務的時間(2 秒),大大提升了效率!在 C# 中,對應的函式是 Task.WhenAll()。

常見誤用 3:在迴圈中 await (導致效率低下)

這跟上一個問題很類似,但更常發生在處理列表資料時。很多人習慣在 for 迴圈或 forEach 迴圈中直接使用 await,這會讓迴圈內的每次迭代都變成串行等待,效率極低。

問題情境:

假設我們有一個使用者 ID 列表,需要為每個使用者發送通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function sendNotification(userId) {
console.log(`開始發送通知給使用者 ${userId}...`);
await new Promise(resolve => setTimeout(resolve, 1000)); // 模擬 1 秒
console.log(`通知已發送給使用者 ${userId}。`);
}

async function notifyAllUsers(userIds) {
console.log('--- 開始發送所有通知 (串行) ---');
for (const userId of userIds) {
await sendNotification(userId); // 每個迴圈都等待 1 秒
}
console.log('--- 所有通知發送完成 (串行) ---');
}

const userIds = [1, 2, 3];
// notifyAllUsers(userIds); // 總共耗時約 1 * 3 = 3 秒

如果 userIds 有 100 個使用者,這樣做會耗費 100 秒!這顯然不是我們想要的。

解法:使用 map 搭配 Promise.all() 並行處理

當迴圈內的任務之間沒有依賴時,同樣可以使用 Promise.all() 來並行處理。通常我們會先用 map 把每個任務變成一個 Promise,然後把這些 Promise 組成一個陣列傳給 Promise.all()。

1
2
3
4
5
6
7
8
9
async function notifyAllUsersConcurrent(userIds) {
console.log('--- 開始發送所有通知 (並行) ---');
const notificationPromises = userIds.map(userId => sendNotification(userId));
await Promise.all(notificationPromises);
console.log('--- 所有通知發送完成 (並行) ---');
}

const userIds = [1, 2, 3];
// notifyAllUsersConcurrent(userIds); // 總共耗時約 Max(1,1,1) = 1 秒

這樣,無論 userIds 有多少個,總時間都約等於最慢的那個 sendNotification 的時間(這裡就是 1 秒)。

注意事項:

雖然並行處理能提高效率,但也要注意資源限制。如果你要同時發送 10000 個網路請求,那可能會因為連接數限制、記憶體壓力等導致問題。這時候可能需要考慮「批次處理」或「限制並行數量」(例如使用 p-limit 這類的函式庫)。

常見誤用 4:忽略錯誤處理(把 try/catch 寫錯地方)

錯誤處理在非同步程式碼中是個大魔王!很多人會把 try/catch 寫錯地方,導致非同步操作拋出的錯誤沒有被捕捉到,最後整個程式崩潰。

問題情境:

假設 fetchUserData 有可能失敗,但 try/catch 沒有正確包住 await。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
async function riskyFetchUserData(userId) {
console.log(`嘗試抓取使用者 ${userId} 資料...`);
if (userId === 99) {
throw new Error('使用者 99 不存在!'); // 模擬錯誤
}
await new Promise(resolve => setTimeout(resolve, 1000));
return { id: userId, name: `User ${userId}` };
}

async function getUserDataWithError() {
console.log('--- 錯誤處理開始 ---');
try {
// 這裡只是呼叫函式,拿到 Promise,錯誤還沒發生!
const userDataPromise = riskyFetchUserData(99);
} catch (error) {
console.error('捕捉到錯誤 (這個 catch 區塊抓不到):', error.message);
}

try {
// 如果 Promise.all() 裡面有任何一個失敗,整個 Promise.all() 就會 rejected
const results = await Promise.all([
riskyFetchUserData(1),
riskyFetchUserData(99) // 這個會失敗
]);
console.log('所有資料都抓取成功:', results);
} catch (error) {
// 這個 try/catch 才能捕捉到 Promise.all() 的錯誤
console.error('捕捉到 Promise.all() 的錯誤 (這個可以抓到):', error.message);
}
console.log('--- 錯誤處理結束 ---');
}

// getUserDataWithError();

在第一個 try 區塊中,riskyFetchUserData(99) 雖然會執行並拋出錯誤,但因為 await 不在 try 區塊內,try/catch 並沒有等待那個 Promise 失敗,所以它根本抓不到這個錯誤。錯誤會直接變成一個未處理的 Promise Rejection,導致程式崩潰或錯誤被丟到全域去。

解法:將 await 包在 try 區塊內

正確的錯誤處理方式,是讓 await 語句本身在 try 區塊內執行。這樣,當 await 等待的 Promise 被 rejected 時,try/catch 就能立即捕捉到它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
async function getUserDataWithCorrectErrorHandling() {
console.log('--- 正確錯誤處理開始 ---');
try {
const userData = await riskyFetchUserData(99); // 錯誤會在這裡被 await 拋出
console.log('取得使用者資料:', userData);
} catch (error) {
console.error('捕捉到錯誤 (正確捕捉):', error.message);
}

// Promise.all() 的錯誤處理也是一樣的道理
try {
const results = await Promise.all([
riskyFetchUserData(1),
riskyFetchUserData(99) // 這個會失敗
]);
console.log('所有資料都抓取成功:', results);
} catch (error) {
console.error('捕捉到 Promise.all() 的錯誤 (正確捕捉):', error.message);
}
console.log('--- 正確錯誤處理結束 ---');
}

getUserDataWithCorrectErrorHandling();
// 預期輸出:
// --- 正確錯誤處理開始 ---
// 嘗試抓取使用者 99 資料...
// 捕捉到錯誤 (正確捕捉): 使用者 99 不存在!
// 嘗試抓取使用者 1 資料...
// 嘗試抓取使用者 99 資料...
// 捕捉到 Promise.all() 的錯誤 (正確捕捉): 使用者 99 不存在!
// --- 正確錯誤處理結束 ---

確保你的 await 都在 try 區塊裡面,才能有效地捕捉到非同步操作可能拋出的錯誤!

小結

async/await 確實是現代程式設計中一個非常棒的工具,它讓我們能用接近同步的寫法來處理複雜的非同步流程,大大提升了程式碼的可讀性和可維護性。但正如我們今天聊的,如果不了解它背後的運作原理和一些常見的「眉角」,還是很容易踩到雷,導致程式碼行為不如預期,甚至影響效能和穩定性。

下次你在寫 async/await 的程式碼時,不妨在心中問自己幾個問題:

  1. 這個 async 函式回傳的 Promise,我有確實 await 嗎?
  2. 這幾個非同步任務之間有依賴關係嗎?如果沒有,我是否可以考慮用 Promise.all() 讓它們並行處理來提升效率?
  3. 我在迴圈裡面使用了 await 嗎?這樣做是故意的串行還是不小心的效能瓶頸?
  4. 我的 try/catch 有正確地包住 await 語句,確保能捕捉到非同步錯誤嗎?

多花一點心思去思考這些問題,你就能寫出更強健、更高效的非同步程式碼。希望今天的分享,能幫助大家在 async/await 的世界裡,走得更穩、更遠!下次我們再聊其他工程筆記!掰掰!