Day 22 我們學會:
Task
→ 表示可能尚未完成的操作
Task<T>
→ 完成後會得到 T
await
→ 等待 Task 完成
也知道:
啟動非同步操作,和等待它完成,不一定要發生在同一個時間點。
今天繼續處理三個實務問題:
多個工作怎麼等待?
→ Task.WhenAll()
工作失敗怎麼辦?
→ Exception Handling
工作已經不需要了?
→ CancellationToken
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 被同步阻塞。
假設:
A 不需要 B 的結果
B 也不需要 A 的結果
可以先啟動兩個非同步操作:
Task taskA =
WorkAsync("A");
Task taskB =
WorkAsync("B");
此時:
WorkAsync("A")
→ A 已經開始
→ 得到 taskA
WorkAsync("B")
→ B 已經開始
→ 得到 taskB
兩個工作都已經開始,只是可能還沒完成。
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()負責等待全部完成,不負責啟動這些工作。
Task.WhenAll() 不等於多執行緒不要把:
Task.WhenAll(
taskA,
taskB
);
理解成:
建立 Thread A
建立 Thread B
Task.WhenAll() 不負責建立 Thread。
今天先理解成:
多個 Task
↓
Task.WhenAll()
↓
等待全部完成
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[]
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() |
例如:
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
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。
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 自動取消
這就帶出下一個問題:
如果工作還沒完成,但我們已經不需要它了呢?
先不要急著看程式碼。
先想一個情境。
使用者搜尋:
「台北餐廳」
程式開始呼叫 API:
送出 Request
↓
等待台北餐廳資料
但資料還沒回來,使用者已經改搜尋:
「桃園餐廳」
這時:
原本「台北餐廳」的工作
→ 還在進行
但是
→ 結果已經不需要
我們希望可以告訴原本的工作:
這個工作不用繼續了,可以取消。
這就是:
Cancellation
要解決的問題。
Cancellation 主要會看到兩個東西:
CancellationTokenSource
CancellationToken
可以先這樣理解:
CancellationTokenSource
→ 負責提出取消要求
CancellationToken
→ 讓工作知道有人要求取消
整體概念:
CancellationTokenSource
↓
提供 Token
↓
Token 傳給工作
↓
工作開始
之後如果不需要
↓
source.Cancel()
↓
提出取消要求
↓
工作配合取消
先有這個整體概念,再來看完整程式。
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
接下來再一段一段拆解。
CancellationTokenSource第一段:
using CancellationTokenSource source =
new CancellationTokenSource();
這裡建立:
CancellationTokenSource
可以先理解成:
取消功能的控制端。
它最重要的工作有兩個:
提供 Token
+
提出取消要求
之後會用:
source.Token
取得 Token。
以及:
source.Cancel();
提出取消要求。
所以:
source.Token
→ 給工作使用
source.Cancel()
→ 要求取消
接著:
Task task =
WorkAsync(
source.Token
);
這裡做兩件事:
呼叫 WorkAsync()
+
把 source.Token 傳進去
WorkAsync():
async Task WorkAsync(
CancellationToken cancellationToken
)
會收到:
CancellationToken
可以先把 Token 理解成:
讓工作知道「是否有人要求取消」的管道。
現在關係建立了:
source
↓
source.Token
↓
WorkAsync()
所以外面的 Source 和裡面的工作才有辦法透過 Cancellation 機制連起來。
在 WorkAsync() 裡:
await Task.Delay(
5000,
cancellationToken
);
原本:
await Task.Delay(5000);
只是:
等待 5 秒
現在多傳:
cancellationToken
意思是:
等待 5 秒,但如果期間收到取消要求,可以提早取消。
所以:
WorkAsync()
↓
CancellationToken
↓
Task.Delay()
Token 最後要傳到真正執行工作的地方。
這點很重要:
只有外層 Method 收到 Token 還不夠,通常還要把 Token 繼續傳給真正支援 Cancellation 的工作。
外面先等待:
await Task.Delay(1000);
所以:
WorkAsync
→ 原本要等 5 秒
外面
→ 先等 1 秒
1 秒後執行:
source.Cancel();
這時很容易誤會:
Cancel()
→ 強制殺掉 Task
不是。
Cancel() 的意思是:
提出「請取消」的要求。
流程:
source.Cancel()
↓
取消已被要求
↓
Token 可以觀察到
↓
Task.Delay 發現取消要求
↓
配合取消
所以:
Cancel()
≠
強制停止 Thread
Cancel()
≠
直接殺掉 Task
看另一個版本:
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
→ 協作式取消
意思就是:
呼叫端提出取消要求,工作端配合停止。
OperationCanceledException 是什麼?回到:
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine(
"工作已取消"
);
}
當:
Task.Delay
↓
因為 Cancellation 而停止
task 不會變成:
成功完成
而是:
被取消
Task 可以先理解成有三種結果:
Task
├─ 成功
├─ 失敗
└─ 取消
當:
await task;
遇到被取消的 Task 時,會透過:
OperationCanceledException
告訴呼叫端:
這個工作是因為 Cancellation 而結束。
所以:
Exception
→ 工作發生錯誤
OperationCanceledException
→ 工作因取消而結束
取消不一定表示程式發生 Bug。
很多時候只是:
使用者已經不需要這個工作
前面的:
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 很實際的使用情境。
假設程式有兩層 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 的地方。
現在把剛才的程式重新整理一次:
① 建立 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()提出取消要求,讓工作配合停止。
有
→ Sequential await
沒有
→ 啟動多個非同步操作
→ Task.WhenAll()
Exception
↓
await
↓
try / catch
建立 CancellationTokenSource
↓
把 Token 傳給工作
↓
Cancel()
↓
工作配合取消
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?
看:
using CancellationTokenSource source =
new CancellationTokenSource();
Task task =
WorkAsync(
source.Token
);
source.Cancel();
回答:
CancellationTokenSource
→ 負責什麼?
source.Token
→ 為什麼要傳給 WorkAsync?
source.Cancel()
→ 是強制終止工作嗎?
今天最重要的是:
工作有依賴
→ Sequential await
工作彼此獨立
→ Task.WhenAll()
工作失敗
→ Exception Handling
工作不需要
→ Cancellation
關於 Task.WhenAll():
它負責等待多個 Task 全部完成,不負責啟動工作。
關於 Cancellation:
CancellationTokenSource
→ 控制取消
CancellationToken
→ 讓工作知道取消要求
Cancel()
→ 提出取消要求
工作
→ 配合取消
最後記住三句話:
工作有依賴,就依序
await。
工作彼此獨立,可以讓等待時間重疊,再使用
Task.WhenAll()等待全部完成。
Cancellation 不是強制終止工作,而是透過
CancellationToken讓工作可以收到取消要求,再由工作配合停止。