
清晨,陽光尚未透過窗簾,我們站在廚房前,嘗試用程式煮一頓早餐。
那是一個沒有 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(_ => { BoilWaterAsync(() => { Console.WriteLine("開水已經燒好,開始其他準備工作");
int completedTasks = 0; Action checkCompletion = () => { if (Interlocked.Increment(ref completedTasks) == 3) { Console.WriteLine("早餐準備完成!"); 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(); 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("開始準備早餐...");
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("無法煎蛋"); }); }
|
自動包裝 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) { Console.WriteLine($"Caught specific exception: {ex.Message}"); } }
public async Task PrepareBreakfastAsync() { 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 ,我們不再是任務的監工,而是節奏的設計師。我們寫下了「先煮水,再煎蛋,然後同時烤麵包與沖咖啡」,而程式,就會記得這些段落,替我們安排得井然有序。