Async

清晨,陽光尚未透過窗簾,我們站在廚房前,嘗試用程式煮一頓早餐。
那是一個沒有 await、沒有 Task 的時代,所有的流程只能靠一層一層的 callback 串接。


🎵 callback hell


乍聽之下,好像還不難 煮水、烤麵包、沖咖啡、煎蛋,各做各的

但實際上,設計上的重點是 : 程式必須非常確切地知道「什麼時候可以開始下一步」
我們不能一開始就同時烤麵包、煎蛋,因為你得等水煮好才能確定一切準備開始。

所以寫了第一個 callback:水煮好後要做三件事。那三件事每一個也不是即時完成,它們也要非同步地執行完之後,才能說「早餐好了」。於是你只好在第一個 callback 裡再寫三個 callback,讓它們「各自完成時回報進度」。

而這些回報又必須集中到某個地方統一判斷:「三件事都完成了嗎?」才能進一步呼叫最後的完成通知。
就這樣,每一個步驟都像是娃娃裡的另一個娃娃:你打開一個 callback,裡面又包了一個 callback,再包一個。每一層都綁著條件與時序,錯一個就會讓整體邏輯崩塌。

每一口麵包、每一杯咖啡的背後,其實藏著的是:「時機控制的難度、流程依賴的交織,以及錯誤處理無從分散」的開發痛點。
這就是所謂的 callback hell。當我們以為自己只是寫個簡單的早餐流程,卻無意間搭起了一張難以維護的邏輯網。

讓我們來示範…


🔁 第一層 callback:等水煮好再做事

你先寫了一個「燒水」的非同步方法,並附上一段 callback,意思是「水煮好後,請幫我做下面三件事:烤麵包、沖咖啡、煎蛋」。這裡 callback 只是個函式,等燒水這件事做完後,它就會被呼叫起來。


🔁🔁 第二層 callback:三件事也不是同步完成的

接著你會發現:烤麵包、沖咖啡、煎蛋這三件事本身也需要時間,它們也不能立即完成。
所以每個動作也得再各自加上 callback,讓它們「完成後回報一下進度」。

為了判斷「什麼時候早餐全部準備好」,設計一個計數器,追蹤每件事是否都完成。
只有當三件事都完成後,才能觸發最終的 onComplete,表示早餐大功告成。


實作

🔧 主流程(PrepareBreakfastAsync)

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

public static void PrepareBreakfastAsync(Action onComplete)
{
Console.WriteLine("開始準備早餐...");

//// 建立一個燒開水工作
ThreadPool.QueueUserWorkItem(_ =>
{
//// 燒開水工作完成會開始呼叫 ToastBreadAsync、BrewCoffeeAsync、FryEggsAsync
BoilWaterAsync(() =>
{
Console.WriteLine("開水已經燒好,開始其他準備工作");

int completedTasks = 0;
Action checkCompletion = () =>
{
//// 每次呼叫一個以下的工作項目,completedTasks增加1並檢查是否達到3
if (Interlocked.Increment(ref completedTasks) == 3)
{
Console.WriteLine("早餐準備完成!");
//// 所有工作完成後,最後調用最外層的 onComplete
onComplete?.Invoke();
}
};

ToastBreadAsync(checkCompletion);
BrewCoffeeAsync(checkCompletion);
FryEggsAsync(checkCompletion);
});
});
}


🔧 各個步驟(也都需要 callback)
每個任務都使用 ThreadPool.QueueUserWorkItem 模擬非同步操作,並在完成後呼叫自己的 callback。

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
34
35
36
37
38
39
40
41
42
43
44
45

private static void BoilWaterAsync(Action callback)
{
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("開始燒開水...");
Thread.Sleep(3000);
Console.WriteLine("開水燒好囉");
callback?.Invoke();
});
}

private static void ToastBreadAsync(Action callback)
{
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("開始烤麵包...");
Thread.Sleep(1000);
Console.WriteLine("麵包烤好了");
callback?.Invoke();
});
}

private static void BrewCoffeeAsync(Action callback)
{
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("開始沖咖啡...");
Thread.Sleep(1500);
Console.WriteLine("咖啡沖好了");
callback?.Invoke();
});
}

private static void FryEggsAsync(Action callback)
{
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("開始煎蛋...");
Thread.Sleep(1500);
Console.WriteLine("蛋煎好了");
callback?.Invoke();
});
}


執行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

PrepareBreakfastAsync(() => {
Console.WriteLine("所有早餐準備工作完成,可以開始享用了!");
});


// 開始準備早餐...
// 開始燒開水...
// 開水燒好囉
// 開水已經燒好,開始其他準備工作
// 開始煮咖啡...
// 開始烤麵包...
// 開始煎蛋...
// 麵包好囉
// 蛋好囉
// 咖啡好囉
// 早餐準備完成!
// 所有早餐準備工作完成,可以開始享用了!


😵 這樣寫有什麼問題?

這樣的寫法,就是最原始的非同步土法煉鋼,問題不少:

  • 邏輯複雜、程式難讀:callback 層層嵌套,誰先誰後一不小心就錯。
  • 錯誤難追蹤:一旦其中某個步驟出錯,很難知道錯在哪一層。
  • 擴充性差:如果以後早餐要加「切水果」「倒牛奶」怎麼辦?再加 callback 嗎?

🌱 所以,我們需要更好的方法

callback 的確可以完成非同步,但會讓我們的程式碼陷入混亂深淵。
那麼,有沒有更好、可讀性更高、維護性更強的方式來處理這種複雜的非同步邏輯呢?
接下來,我們將進入「任務」(Task)與 async/await 的世界 —— 開發者從 callback hell 中逃脫的解方!



任務 ( Task )

走出 callback hell,我們開始尋找一種更聰明的方式來管理非同步流程。這時,「任務(Task)」登場了。

Task 就像是你派出去的一位可靠助手。你不用一直站在旁邊看著他工作,他會自己告訴你:「我好了」、「我失敗了」,甚至可以主動回報結果給你。你只需要在適當時機收割成果,或者處理錯誤就好。

比起 callback 一層層的巢狀設計,Task 把非同步包裝成一個「可以觀察、可以等待」的物件,你可以選擇:

  • 等它完成再繼續(用 await)
  • 讓它自己跑、你去做別的事(讓它 fire and forget)
  • 讓多個任務一起跑(像是煮水、烤麵包、煎蛋同時開始)

更棒的是,你可以使用 Task 的 API 組合這些任務,比方說 Task.WhenAll(…) 可以等多個任務完成再統一處理,這就像你在門口放了一個三合一警報器,等三件事都完成再「叮!」一聲通知你。

用 callback 寫早餐,就像你在廚房裝了三個計時器,每一個都要你手動檢查;用 Task 寫早餐,則像是你安裝了一台自動早餐機,幫你同步煮水、煎蛋、烤吐司,完成後還會自己發簡訊通知你。



🎵 Task + ContinueWith:非同步的建構積木

既然 Task 這麼萬能,我們當然可以靠它來打造一個乾淨俐落的早餐流程。接下來,我們用 Task 的 .ContinueWith(…) 來接力執行一系列任務,這就像是排一個「任務接龍」:一旦上一個任務完成,就自動觸發下一個任務,完全不需要我們手動監看。

而且,我們還能指定觸發條件,例如:

  • OnlyOnRanToCompletion:只有當任務「成功執行完」才繼續
  • NotOnFaulted:跳過有錯的流程
  • OnlyOnFaulted:某任務失敗時專門走錯誤處理路線

這些都讓 Task 不再只是「非同步執行」而已,它開始具備了邏輯控制的能力 —— 有點像是「流程的智慧中樞」。
所以這一段我們的做法是:

  • 先執行 BoilWater(),並回傳一個 Task 物件。
  • 當這個 Task 成功完成後,用 .ContinueWith(…) 去排程三件事:ToastBread()、BrewCoffee()、FryEggs()。
  • 接著我們再用 Task.WhenAll(…) 等待這三件事都完成後,再印出「早餐準備完成」的訊息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

public static Task PrepareBreakfast()
{
Console.WriteLine("開始準備早餐...");
var boilWaterTask = BoilWater();

//// boilWaterTask 完成後可以開始 toastBreadTask、brewCoffeeTask、fryEggsTask
var toastBreadTask = boilWaterTask
.ContinueWith(_ => ToastBread(), TaskContinuationOptions.OnlyOnRanToCompletion);
var brewCoffeeTask = boilWaterTask
.ContinueWith(_ => BrewCoffee(), TaskContinuationOptions.OnlyOnRanToCompletion);
var fryEggsTask = boilWaterTask
.ContinueWith(_ => FryEggs(), TaskContinuationOptions.OnlyOnRanToCompletion);

//// 所有事情都完成後印出 早餐準備完成!
return Task.WhenAll(toastBreadTask,brewCoffeeTask,fryEggsTask)
.ContinueWith(_ =>
{
Console.WriteLine("早餐準備完成!");
}, TaskContinuationOptions.OnlyOnRanToCompletion);
}

模擬各自實現

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
34
35
36
37
38

private static Task BoilWater()
{

Console.WriteLine("開始燒開水...");
Thread.Sleep(3000);
Console.WriteLine("開水燒好囉");
return Task.CompletedTask;
}

private static void ToastBread()
{

Console.WriteLine("開始烤麵包...");
Thread.Sleep(1000);
Console.WriteLine("麵包烤好了");

}

private static void BrewCoffee()
{

Console.WriteLine("開始沖咖啡...");
Thread.Sleep(1500);
Console.WriteLine("咖啡沖好了");

}

private static void FryEggs()
{

Console.WriteLine("開始煎蛋...");
Thread.Sleep(1500);
Console.WriteLine("蛋煎好了");

}


執行結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Console.WriteLine("開始準備早餐流程");
PrepareBreakfast().ContinueWith(_ =>
{
Console.WriteLine("所有早餐準備工作完成,可以開始享用了!");
},TaskContinuationOptions.NotOnRanToCompletion);


// 開始準備早餐流程
// 開始準備早餐...
// 開始燒開水...
// 開水燒好囉
// 開始烤麵包...
// 開始沖咖啡...
// 開始煎蛋...
// 麵包烤好了
// 蛋煎好了
// 咖啡沖好了
// 早餐準備完成!

這種寫法讓我們清楚掌控哪些步驟是「前置作業」、哪些可以「同步並行」、哪些要「最後收尾」。
雖然 .ContinueWith(…) 的寫法比 callback 已經好很多,但語法上還是稍顯繁瑣,有點像在排「任務陣列」,一不注意還是會眼花。不用擔心,接下來我們將會迎來 async/await —— 讓這一切變得更自然的語法糖 🍬!



🎵 async await

你不再需要一直 .ContinueWith(…) 去接任務,而是可以像寫同步程式一樣去描述非同步邏輯 —— 程式結構更直觀,語意也更順暢。

換句話說,async/await 就像是幫你寫了一套「任務筆記本」:你只要寫下「接下來要做什麼」,系統會幫你記得「做一半時停在哪」、「要等什麼人回來」、「接著做哪一步」。

所以我們可以把早餐流程改寫成 async/await 的版本,你會發現幾件事變得清楚又舒服:

  • 用 await BoilWaterAsync() 就表示:「先等水煮好,再繼續」
  • 用 await Task.WhenAll(…) 表示:「這幾件事可以一起做,等都做完再說」

每個方法都變成獨立、清爽、單一責任的小單元!

1
2
3
4
5
6
7
8
9
10
11
12

public async Task PrepareBreakfastAsync()
{
Console.WriteLine("開始準備早餐...");

//// 開始燒水,但開水要先燒完才往下做,這時候的 Thread 被釋放去做其他事情,等開水燒完,某條 Thread 再回來繼續主流程
await BoilWaterAsync();

//// 全部做完才可以往下走
await Task.WhenAll(BrewCoffeeAsync(),FryEggsAsync(),ToastBreadAsync());
Console.WriteLine("早餐準備完成!");
}

模擬做早餐任務,每個任務都有一個 await 斷點,遇到這個斷點就會回到主程序並告訴主程序現在的狀態是甚麼,等到 await 的內容完成,就會再次從 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
public async Task BoilWaterAsync()
{
Console.WriteLine("開始燒水...");
await Task.Delay(2000); // 模擬燒水時間
Console.WriteLine("水燒好了");
}

public async Task ToastBreadAsync()
{
Console.WriteLine("開始烤麵包...");
await FailMakingBreadAsync();
Console.WriteLine("麵包烤好了");
}

public async Task BrewCoffeeAsync()
{
Console.WriteLine("開始沖咖啡...");
await Task.Delay(1500); // 模擬沖咖啡時間
Console.WriteLine("咖啡沖好了");
}

public async Task FryEggsAsync()
{
Console.WriteLine("開始煎蛋...");
await Task.Delay(1500); // 模擬煎蛋時間
Console.WriteLine("蛋煎好了");
}

執行結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

void Main()
{
Console.WriteLine("開始準備早餐流程");
PrepareBreakfastAsync();
}


// 開始準備早餐流程
// 開始準備早餐...
// 開始燒水...
// 水燒好了
// 開始沖咖啡...
// 開始煎蛋...
// 開始烤麵包...
// 麵包烤好了
// 蛋煎好了
// 咖啡沖好了
// 早餐準備完成!

從這一段改變我們可以看到邏輯流程更清晰,減少了 Nested 和 callback 造成的複雜性,也不用一直 ContinueWith,直接讓這個方法建立一個幫我記住狀態的狀態機!



🎵 async / await 其實做的更多

到目前為止,我們已經用 async/await 把早餐流程寫得乾淨又直覺,沒有多餘的 callback、也不需要繞來繞去的 ContinueWith,看起來就像同步流程一樣自然。

但 async/await 的魅力還不只如此。

它最貼心的一點是:不只是把流程簡化了,連「錯誤」與「回傳值」的處理都幫你偷偷做好了。

就像你早上走進早餐店,老闆不但幫你煮好、包好、裝袋,甚至還幫你把找零放進錢包裡、收據塞好袋口 —— async/await 做的就是這種貼心的「語法服務」。


自動錯誤拆包,不再面對可怕的 AggregateException

平常我們在處理多個 Task 的時候,如果出現錯誤,很常會被 AggregateException 砸滿頭,還要手動 .InnerExceptions 把每個錯誤逐個拆出來處理。但 await 幫你做了這些事 —— 它會自動把第一層包裝解開,讓你用熟悉的 try-catch 就能捕捉最裡層的例外。
你不用再管什麼是 .Wait()、什麼是 .Result,也不用手動檢查哪一個任務丟出例外。只要 await 它,錯誤就會直接丟給你 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

async void Main()
{
try
{
await PrepareBreakfastAsync();
}
catch (AggregateException ex)
{
Console.WriteLine($"Caught AggregateException: {ex.Message}");
foreach (var innerException in ex.InnerExceptions)
{
Console.WriteLine($"Inner Exception: {innerException.GetType().Name} - {innerException.Message}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Caught other Exception: {ex.GetType().Name} - {ex.Message}");
}
}

public async Task PrepareBreakfastAsync()
{
await Task.WhenAll(BoilWaterAsync(),ToastBreadAsync(),BrewCoffeeAsync(),FryEggsAsync());
}

private async Task BoilWaterAsync()
{
await Task.Run(() => { throw new InvalidOperationException("無法燒開水"); });
}

private async Task ToastBreadAsync()
{
await Task.Run(() => { throw new InvalidOperationException("無法烤麵包"); });
}

private async Task BrewCoffeeAsync()
{
await Task.Run(() => { throw new InvalidOperationException("無法沖咖啡"); });
}

private async Task FryEggsAsync()
{
await Task.Run(() => { throw new InvalidOperationException("無法煎蛋"); });
}


//// Caught other Exception: InvalidOperationException - 無法燒開水

自動包裝 return,不必自己 new Task.Result

在 async 方法裡,當你用 return 回傳一個結果(像是字串),編譯器會自動幫你包裝成 Task 的形式,你不需要自己 return Task.FromResult(…) 這麼麻煩。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

async void Main()
{
try
{
await PrepareBreakfastAsync();
}
catch (InvalidOperationException ex)
{
// 直接捕獲 InvalidOperationException,而不是 AggregateException
Console.WriteLine($"Caught specific exception: {ex.Message}");
}

}

public async Task PrepareBreakfastAsync()
{
// 不需要自己包裝成 Task
Console.WriteLine("早餐直接做好!");
}

// 早餐直接做好!

或者是

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

async void Main()
{
try
{
var result = await PrepareBreakfastAsync();
Console.WriteLine(result);
}
catch (InvalidOperationException ex)
{
\ Console.WriteLine($"Caught specific exception: {ex.Message}");
}

}

public async Task<string> PrepareBreakfastAsync()
{
Console.WriteLine("早餐直接做好!");
return "一人份早餐誰要吃";
}


// 早餐直接做好!
// 一人份早餐誰要吃

在 async 方法中,可以直接返回預期的類型(在這個例子中是 string),而不需要把他包裝在 Task



🎵 結語

那個還沒拉開窗簾的清晨,我們依然站在廚房,嘗試煮一頓早餐。

但我想起來了,我們知道怎麼優雅的處理非同步

曾經,我們手忙腳亂地排著 callback,每個步驟都像是掛在一根線上的期待,只要中間一節斷了,整頓早餐就會掉落地上。

曾經,我們學會用 Task 分工合作,把流程切成有秩序的任務,彼此約定完成的時機,再聚在一起完成一場微型的同步樂章。

而現在有 async/await ,我們不再是任務的監工,而是節奏的設計師。我們寫下了「先煮水,再煎蛋,然後同時烤麵包與沖咖啡」,而程式,就會記得這些段落,替我們安排得井然有序。