iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

現在就學C# 與 ASP.NET Core系列 第 23 篇

Day 23|Async 實務:多個非同步工作怎麼處理?

  • 分享至 

  • xImage
  •  

Day 22 我們學會:

Task
→ 表示可能尚未完成的操作

Task<T>
→ 完成後會得到 T

await
→ 等待 Task 完成

也知道:

啟動非同步操作,和等待它完成,不一定要發生在同一個時間點。

今天繼續處理三個實務問題:

多個工作怎麼等待?
→ Task.WhenAll()

工作失敗怎麼辦?
→ Exception Handling

工作已經不需要了?
→ CancellationToken

1. 一個一個 await 會發生什麼?

先建立:

async Task WorkAsync(
    string name
)
{
    Console.WriteLine(
        $"{name} 開始"
    );

    await Task.Delay(2000);

    Console.WriteLine(
        $"{name} 完成"
    );
}

依序執行:

await WorkAsync("A");

await WorkAsync("B");

流程:

A 開始
↓
等待約 2 秒
↓
A 完成
↓
B 開始
↓
等待約 2 秒
↓
B 完成

整體大約:

4 秒

因為 A 完成以前,B 還沒有開始。

這就是:

Sequential await
→ 依序等待

要注意:

依序 await 仍然是非同步等待,不代表 Thread 被同步阻塞。


2. 如果 A 和 B 彼此獨立呢?

假設:

A 不需要 B 的結果
B 也不需要 A 的結果

可以先啟動兩個非同步操作:

Task taskA =
    WorkAsync("A");

Task taskB =
    WorkAsync("B");

此時:

WorkAsync("A")
→ A 已經開始
→ 得到 taskA

WorkAsync("B")
→ B 已經開始
→ 得到 taskB

兩個工作都已經開始,只是可能還沒完成。


3. 使用 Task.WhenAll()

接著:

await Task.WhenAll(
    taskA,
    taskB
);

意思是:

等待 taskA 和 taskB 全部完成。

完整寫法:

Task taskA =
    WorkAsync("A");

Task taskB =
    WorkAsync("B");

await Task.WhenAll(
    taskA,
    taskB
);

流程:

A 開始 ─────────→ A 完成

B 開始 ─────────→ B 完成

                 ↓
              全部完成

兩個工作都等待約 2 秒,所以整體大約:

2 秒

因為等待時間重疊了。

最重要的是:

Task.WhenAll() 負責等待全部完成,不負責啟動這些工作。


4. Task.WhenAll() 不等於多執行緒

不要把:

Task.WhenAll(
    taskA,
    taskB
);

理解成:

建立 Thread A
建立 Thread B

Task.WhenAll() 不負責建立 Thread。

今天先理解成:

多個 Task
↓
Task.WhenAll()
↓
等待全部完成

5. Task.WhenAll() 也可以取得結果

如果 Method 會回傳資料:

async Task<string> GetMessageAsync(
    string message
)
{
    await Task.Delay(1000);

    return message;
}

建立兩個 Task:

Task<string> taskA =
    GetMessageAsync("Hello");

Task<string> taskB =
    GetMessageAsync("World");

可以:

string[] results =
    await Task.WhenAll(
        taskA,
        taskB
    );

結果:

["Hello", "World"]

也就是:

Task<string>
Task<string>
↓
Task.WhenAll()
↓
await
↓
string[]

6. 什麼時候適合使用 Task.WhenAll()?

最重要的判斷:

第二個工作需要第一個工作的結果嗎?

例如:

string userId =
    await GetUserIdAsync();

string orders =
    await GetOrdersAsync(
        userId
    );

因為:

GetOrdersAsync()
↓
需要 userId

所以一定要:

先取得 userId
↓
再查詢 orders

這種情況:

Sequential await

如果兩個工作彼此獨立:

取得使用者資料
取得商品資料

就可以:

Task<string> userTask =
    GetUserAsync();

Task<string> productTask =
    GetProductAsync();

await Task.WhenAll(
    userTask,
    productTask
);

整理:

情況 做法
B 需要 A 的結果 依序 await
A、B 彼此獨立 可以考慮 Task.WhenAll()

7. 真正呼叫 API 時

例如:

Task<string> userTask =
    httpClient.GetStringAsync(
        "https://example.com/api/user"
    );

Task<string> productTask =
    httpClient.GetStringAsync(
        "https://example.com/api/products"
    );

await Task.WhenAll(
    userTask,
    productTask
);

GetStringAsync() 可以先理解成:

送出 HTTP GET Request,等待 Response,最後取得 Response Body 的字串內容。

所以:

GetStringAsync()
↓
送出 HTTP Request
↓
得到 Task<string>
↓
等待 Response
↓
完成後得到 string

8. Async Method 發生 Exception 怎麼辦?

Async Method 一樣可能失敗:

async Task GetDataAsync()
{
    await Task.Delay(1000);

    throw new InvalidOperationException(
        "取得資料失敗"
    );
}

可以:

try
{
    await GetDataAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(
        ex.Message
    );
}

流程:

Async Method
↓
發生 Exception
↓
Task 失敗
↓
await
↓
catch

所以:

非同步工作發生 Exception 時,通常會在 await 該 Task 時進入 Exception Handling。


9. Task.WhenAll() 裡有工作失敗呢?

例如:

Task A
→ 成功

Task B
→ 失敗

可以:

try
{
    await Task.WhenAll(
        taskA,
        taskB
    );
}
catch (Exception ex)
{
    Console.WriteLine(
        ex.Message
    );
}

如果其中有 Task 失敗:

Task.WhenAll()
↓
無法成功完成
↓
await
↓
catch

另外要注意:

其中一個 Task 失敗
≠
其他 Task 自動取消

這就帶出下一個問題:

如果工作還沒完成,但我們已經不需要它了呢?


10. Cancellation:工作不需要了怎麼辦?

先不要急著看程式碼。

先想一個情境。

使用者搜尋:

「台北餐廳」

程式開始呼叫 API:

送出 Request
↓
等待台北餐廳資料

但資料還沒回來,使用者已經改搜尋:

「桃園餐廳」

這時:

原本「台北餐廳」的工作
→ 還在進行

但是
→ 結果已經不需要

我們希望可以告訴原本的工作:

這個工作不用繼續了,可以取消。

這就是:

Cancellation

要解決的問題。


Cancellation 的基本角色

Cancellation 主要會看到兩個東西:

CancellationTokenSource
CancellationToken

可以先這樣理解:

CancellationTokenSource
→ 負責提出取消要求

CancellationToken
→ 讓工作知道有人要求取消

整體概念:

CancellationTokenSource
↓
提供 Token
↓
Token 傳給工作
↓
工作開始

之後如果不需要
↓
source.Cancel()
↓
提出取消要求
↓
工作配合取消

先有這個整體概念,再來看完整程式。


11. 先看完整的 Cancellation 範例

using CancellationTokenSource source =
    new CancellationTokenSource();

Task task =
    WorkAsync(
        source.Token
    );

await Task.Delay(1000);

source.Cancel();

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine(
        "工作已取消"
    );
}

async Task WorkAsync(
    CancellationToken cancellationToken
)
{
    Console.WriteLine(
        "工作開始"
    );

    await Task.Delay(
        5000,
        cancellationToken
    );

    Console.WriteLine(
        "工作完成"
    );
}

先不用急著每一行都懂。

先看整體流程:

建立 CancellationTokenSource
↓
把 Token 傳給 WorkAsync()
↓
WorkAsync 開始工作
↓
原本要等待 5 秒

1 秒後
↓
source.Cancel()
↓
提出取消要求

WorkAsync 收到取消
↓
停止等待

await task
↓
得知工作已取消
↓
catch

接下來再一段一段拆解。


12. 第一步:建立 CancellationTokenSource

第一段:

using CancellationTokenSource source =
    new CancellationTokenSource();

這裡建立:

CancellationTokenSource

可以先理解成:

取消功能的控制端。

它最重要的工作有兩個:

提供 Token
+
提出取消要求

之後會用:

source.Token

取得 Token。

以及:

source.Cancel();

提出取消要求。

所以:

source.Token
→ 給工作使用

source.Cancel()
→ 要求取消

13. 第二步:把 Token 傳給工作

接著:

Task task =
    WorkAsync(
        source.Token
    );

這裡做兩件事:

呼叫 WorkAsync()
+
把 source.Token 傳進去

WorkAsync():

async Task WorkAsync(
    CancellationToken cancellationToken
)

會收到:

CancellationToken

可以先把 Token 理解成:

讓工作知道「是否有人要求取消」的管道。

現在關係建立了:

source
↓
source.Token
↓
WorkAsync()

所以外面的 Source 和裡面的工作才有辦法透過 Cancellation 機制連起來。


14. 第三步:真正的工作也要收到 Token

在 WorkAsync() 裡:

await Task.Delay(
    5000,
    cancellationToken
);

原本:

await Task.Delay(5000);

只是:

等待 5 秒

現在多傳:

cancellationToken

意思是:

等待 5 秒,但如果期間收到取消要求,可以提早取消。

所以:

WorkAsync()
↓
CancellationToken
↓
Task.Delay()

Token 最後要傳到真正執行工作的地方。

這點很重要:

只有外層 Method 收到 Token 還不夠,通常還要把 Token 繼續傳給真正支援 Cancellation 的工作。


15. 第四步:提出取消要求

外面先等待:

await Task.Delay(1000);

所以:

WorkAsync
→ 原本要等 5 秒

外面
→ 先等 1 秒

1 秒後執行:

source.Cancel();

這時很容易誤會:

Cancel()
→ 強制殺掉 Task

不是。

Cancel() 的意思是:

提出「請取消」的要求。

流程:

source.Cancel()
↓
取消已被要求
↓
Token 可以觀察到
↓
Task.Delay 發現取消要求
↓
配合取消

所以:

Cancel()
≠
強制停止 Thread

Cancel()
≠
直接殺掉 Task

16. 為什麼工作一定要收到 Token?

看另一個版本:

async Task WorkAsync()
{
    await Task.Delay(5000);
}

這裡完全沒有:

CancellationToken

就算外面有:

source.Cancel();

也沒有用。

因為:

Source
↓
取消要求

但是

WorkAsync
↓
沒有 Token
↓
不知道有人要求取消

就像:

外面有人喊「取消!」

但是工作沒有收到通知的管道

所以它仍然會繼續。


如果改成:

async Task WorkAsync(
    CancellationToken cancellationToken
)
{
    await Task.Delay(
        5000,
        cancellationToken
    );
}

就變成:

Source
↓
Token
↓
WorkAsync
↓
Task.Delay

整條 Cancellation 的路徑建立起來。

這種方式叫:

Cooperative Cancellation
→ 協作式取消

意思就是:

呼叫端提出取消要求,工作端配合停止。


17. OperationCanceledException 是什麼?

回到:

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine(
        "工作已取消"
    );
}

當:

Task.Delay
↓
因為 Cancellation 而停止

task 不會變成:

成功完成

而是:

被取消

Task 可以先理解成有三種結果:

Task
├─ 成功
├─ 失敗
└─ 取消

當:

await task;

遇到被取消的 Task 時,會透過:

OperationCanceledException

告訴呼叫端:

這個工作是因為 Cancellation 而結束。

所以:

Exception
→ 工作發生錯誤

OperationCanceledException
→ 工作因取消而結束

取消不一定表示程式發生 Bug。

很多時候只是:

使用者已經不需要這個工作

18. 實務上:HTTP Request 也可以取消

前面的:

Task.Delay()

只是方便觀察 Cancellation。

真正實務上常見的是 HTTP Request。

例如:

using CancellationTokenSource source =
    new CancellationTokenSource();

Task<string> requestTask =
    httpClient.GetStringAsync(
        "https://example.com/api/search",
        source.Token
    );

這裡:

GetStringAsync()
→ 送出 HTTP Request

source.Token
→ 讓這個非同步操作可以收到取消要求

如果之後:

source.Cancel();

代表:

這個 HTTP 結果已經不需要
↓
提出取消要求
↓
HTTP 非同步操作配合取消

例如前面的搜尋:

搜尋「台北餐廳」
↓
發出 Request

使用者改搜尋「桃園餐廳」
↓
原本 Request 不需要

Cancel()
↓
取消原本工作

這就是 Cancellation 很實際的使用情境。


19. Token 為什麼常常要一路往下傳?

假設程式有兩層 Method:

async Task SearchAsync(
    CancellationToken cancellationToken
)
{
    await CallApiAsync(
        cancellationToken
    );
}

裡面的:

async Task CallApiAsync(
    CancellationToken cancellationToken
)
{
    await httpClient.GetStringAsync(
        "https://example.com/api/search",
        cancellationToken
    );
}

流程:

呼叫端
↓
CancellationToken
↓
SearchAsync()
↓
CallApiAsync()
↓
GetStringAsync()

為什麼 Token 要一直傳?

因為真正執行 HTTP Request 的是:

GetStringAsync()

如果 Token 沒有傳到這裡:

真正執行工作的地方
→ 不知道有人要求取消

所以:

CancellationToken 通常要一路傳到真正執行 I/O 的地方。


20. Cancellation 最重要的 Mental Model

現在把剛才的程式重新整理一次:

① 建立 Source
↓
CancellationTokenSource

② 取得 Token
↓
source.Token

③ 把 Token 傳給工作
↓
WorkAsync(source.Token)

④ 工作也把 Token 傳給真正的非同步操作
↓
Task.Delay(..., cancellationToken)

⑤ 工作不需要了
↓
source.Cancel()

⑥ Token 反映取消要求
↓
工作配合取消

⑦ await 被取消的 Task
↓
OperationCanceledException

如果只記最核心的四個角色:

元件 用途
CancellationTokenSource 控制取消
CancellationToken 讓工作知道取消要求
Cancel() 提出取消要求
支援 Token 的工作 配合取消

整個 Cancellation 可以濃縮成:

Source
↓
把 Token 給工作
↓
工作進行

不需要工作
↓
Cancel()
↓
工作收到取消要求
↓
配合停止

最重要的一句:

Cancellation 不是強制殺掉工作,而是先把 Token 交給工作;之後如果不需要這個工作,再透過 Cancel() 提出取消要求,讓工作配合停止。


21. 今天真正要記住的三個問題

工作彼此有依賴嗎?

有
→ Sequential await

沒有
→ 啟動多個非同步操作
→ Task.WhenAll()

工作失敗了嗎?

Exception
↓
await
↓
try / catch

工作已經不需要了嗎?

建立 CancellationTokenSource
↓
把 Token 傳給工作
↓
Cancel()
↓
工作配合取消

今日練習

練習一|Sequential vs Task.WhenAll()

比較:

await WorkAsync("A");

await WorkAsync("B");

與:

Task taskA =
    WorkAsync("A");

Task taskB =
    WorkAsync("B");

await Task.WhenAll(
    taskA,
    taskB
);

回答:

哪一種寫法可以讓 A、B 的等待時間重疊?


練習二|判斷工作依賴

情境:

取得天氣資料
取得新聞資料

兩者彼此沒有依賴。

思考:

是否適合使用 Task.WhenAll()?

再比較:

取得 UserId
↓
使用 UserId 查訂單

思考:

為什麼應該依序 await?


練習三|理解 Cancellation

看:

using CancellationTokenSource source =
    new CancellationTokenSource();

Task task =
    WorkAsync(
        source.Token
    );

source.Cancel();

回答:

CancellationTokenSource
→ 負責什麼?

source.Token
→ 為什麼要傳給 WorkAsync?

source.Cancel()
→ 是強制終止工作嗎?

Day 23 小結

今天最重要的是:

工作有依賴
→ Sequential await

工作彼此獨立
→ Task.WhenAll()

工作失敗
→ Exception Handling

工作不需要
→ Cancellation

關於 Task.WhenAll():

它負責等待多個 Task 全部完成,不負責啟動工作。

關於 Cancellation:

CancellationTokenSource
→ 控制取消

CancellationToken
→ 讓工作知道取消要求

Cancel()
→ 提出取消要求

工作
→ 配合取消

最後記住三句話:

工作有依賴,就依序 await。

工作彼此獨立,可以讓等待時間重疊,再使用 Task.WhenAll() 等待全部完成。

Cancellation 不是強制終止工作,而是透過 CancellationToken 讓工作可以收到取消要求,再由工作配合停止。


上一篇
Day 22|async / await:程式等待工作時,一定要卡住嗎?
下一篇
Day 24|Modern C#:Pattern Matching、Record 與 Immutability
系列文
現在就學C# 與 ASP.NET Core 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言