Async


咖啡廳裡,播放著熟悉的爵士樂,氣味是熱牛奶與咖啡豆交融後的溫暖。呼叫器還沒響,但我確定店員剛剛有聽見我點了那杯厚拿鐵

我不確定她現在是不是正在打奶泡,或是還在處理上一張訂單;但我不需起身確認,我選擇坐著,等待震動響起地那一刻


🎵 段落一:傳訊以後的沉默


這是一段將備份資料上傳到 S3 的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
private async Task UpdateS3DataAsync(string s3RecordData, string s3Path)
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(s3RecordData));
var request = new PutObjectRequest { BucketName = this._bucketName, InputStream = stream, Key = s3Path };

var response = await this._amazonS3.PutObjectAsync(request);
if (response.HttpStatusCode != HttpStatusCode.OK)
{
throw new ApplicationException($"處理備份時發生錯誤 - 上傳至S3錯誤:{response.ResponseMetadata} StatusCode : {response.HttpStatusCode},備份失敗不可往下執行");
}

this._logger.LogInformation($"商品頁資料備份至S3新增完畢, 位置:{s3Path}");
}

測試了幾次,未能在 S3 Bucket 見到資料的身影,程式也沒有拋出任何錯誤,程式碼像沒有執行過一樣

然而問題不在程式碼本身,而在呼叫端的態度,沒有 await、或 GetAwaiter(),甚至連 Task.Result 都沒取,就這樣任由任務派發出去而不管結果如何

1
2
// 呼叫端完全沒有等待結果, 也沒有取得任務執行的狀態
_testService.UploadToS3Async(shopProductBadgePair.Key, productBadge.ProductBadge_Id, salepageIds);

因為呼叫端忽略了下達 await,導致非同步邏輯變成了放生的任務,它在背景默默執行,但主流程不關心它是否完成、是否失敗,就像你傳訊息出去但不管對方是否收到一樣


而程式碼修正的方式可分成兩種…

方式一:明確等待,當下確保得到任務執行結果才往下執行

1
2
3

//// 以同步的方式等待方法執行完成
await _testService.UploadToS3Async(shopProductBadgePair.Key, productBadge.ProductBadge_Id, salepageIds);

方式二:先執行、後等待,彈性處理任務進度

1
2
3
4
5
6
7
8
9
10
11

//// 先交出目前的狀態,並且呼叫端可以先繼續往下執行
Task task = _testService.UploadToS3Async(shopProductBadgePair.Key, productBadge.ProductBadge_Id, salepageIds);

//// ...做其他事情, 上廁所、聊天、運動...

//// 事情都最完了,等待最後非同步任務的結果
await task;
$"{task.IsCompleted}".Dump();


這種寫法帶給我們更多自由,你可以先發出請求、做點別的事情,等所有任務都在執行的路上,再一起收尾。適合需要進行多工處理或並行任務的場景

🎵 段落二 : 將方法標記為 async,呼叫端 await 的意義

當我們在定義的方法加上 async 修飾詞,就會啟用下面兩項非常關鍵的功能

功能一 : 標記為 async 的方法內可以使用 await。而這個 await 就像是故事的中場休息,它告訴編譯器:「這裡先不要急著往下走,等我拿到結果再繼續。」,與此同時,控制權會被交還給呼叫端,讓主流程可以決定繼續跑別的事情

功能二 : 被 async 標記的方法,會自動回傳一個 Task(或 Task)給呼叫端,也就是說呼叫端可以拿到任務執行完成與否


整個非同步的生命週期如下
Image


當程式執行到 await ,它會先「記住目前的位置」(也就是等下回來繼續的地方),然後把控制權交還給呼叫端,讓其他任務可以先繼續跑。此時,非同步任務會在背景悄悄啟動(例如發出 API、寫檔、上傳 S3 等等)。等到背景任務真的完成時,程式會「回到剛剛記住的地方」,繼續往下執行剩下的程式碼。

  • async:讓方法能「中途暫停」、「回傳 Task」
  • await:告訴方法「請等它完成我才要往下做」
  • Task:是一張「未完成的回應紙條」,你可以隨時等它完成,或是暫時放著做別的事

🎵 段落三 : 關於 Task 的實驗

  • 我們紀錄起始時間,再呼叫 LogAsync(),但不立刻等它完成,而是先繼續往下執行並記錄時間。
  • 最後才透過 await task,等待這個工作真正結束印出結果
    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
    async void Main()
    {
    var startTime = DateTime.Now.ToString("HH:mm:ss");

    Task task = LogAsync();

    var endTime = DateTime.Now.ToString("HH:mm:ss");
    $"{startTime} -- {endTime}".Dump();

    await task;
    $"完成沒 : {task.IsCompleted}".Dump();
    }

    public async Task LogAsync()
    {
    await Task.Delay(5000);
    string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "log.txt");

    using (var writer = new StreamWriter(path, true))
    {
    await writer.WriteLineAsync(DateTime.Now.ToString("HH:mm:ss"));
    }
    }


    // 22:22:31 -- 22:22:31
    // 完成沒 : True

主線程不會被卡住,它會說:「我先記下這個任務,等你回來我再處理你回報的結果。」

🎵 段落四 : 用洗內褲的故事來說明非同步…

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
void Main()
{
"準備來洗內褲囉!".Dump();
Thread.Sleep(TimeSpan.FromSeconds(1));

//// 建立洗內褲的任務 (Code Task)
var task = new Task<int>(() =>
{
int time = new Random().Next(1,5);
Thread.Sleep(TimeSpan.FromSeconds(time));
return time;
});

Thread.Sleep(TimeSpan.FromSeconds(1));
"洗衣機開始洗內褲...".Dump();

//// 開始洗吧 暫時不管
task.Start();
Thread.Sleep(TimeSpan.FromSeconds(1));
"先去看一下進擊的巨人,好在意結局阿".Dump();
Thread.Sleep(TimeSpan.FromSeconds(3));

"嗚嗚好感動,我的內褲呢?".Dump();
Thread.Sleep(TimeSpan.FromSeconds(1));
$"洗衣機 : {task.IsCompleted}".Dump();

//// 確認洗完了嗎
if(task.IsCompleted)
{
$"洗內褲洗了 : {task.Result} 秒".Dump();
}
else
{
"等等,快洗完了".Dump();
task.Wait();
$"洗內褲洗了 : {task.Result} 秒鐘".Dump();
}

"洗完了,曬一曬,吃麥當勞".Dump();
}

//// 準備來洗內褲囉!
//// 洗衣機開始洗內褲...
//// 先去看一下進擊的巨人,好在意結局阿
//// 嗚嗚好感動,阿內褲洗完了沒?
//// 洗衣機 : True
//// 洗內褲洗了 : 4 秒
//// 洗完了,曬一曬,來去吃麥當勞

想像你是執行緒本尊,一條誠懇又努力的 Main Thread。你每天負責處理生活中各種重要邏輯,但偶爾也要負責洗內褲(尺寸 XXXXXXXL、滿是泥巴、極度資源密集),這種事如果你親自處理,會讓主要的生活邏輯原地卡死。但如果你能派出一個任務非同步的去處理這件事( new Task(() => …)),就能在等待的這段時間裡,去追劇、去 dump log、去感動、去活著
Image

🎵 樂曲回顧 : 關於 Throughput

學非同步時可能會想問:「這樣程式會跑得比較快嗎?」

而事實上,非同步的核心價值不是讓單一任務變快,而是讓 App 在同一段時間內完成更多任務,這是所謂的產能(Throughput)的提升,而不是效能(Performance)的進步

我們用工廠來想像一下:

假設一位工廠員工在製作產品 A。製作途中有個「焊接」步驟需要請外包來完成。這時,他把產品 A 交給外包,然後立刻開始製作產品 B,而不是站在原地等 A 回來。等外包完成後,把產品 A 送回工廠,這時再由另一位員工將它包裝完成。這樣的流程中,產品 A、B 各自都花了一樣的製作時間,但因為我們不浪費等待的空檔,整體工廠在同一段時間內完成了更多產品,就是非同步帶來的「Throughput 增加」


而非同步最適合處理「I/O Bound 工作」,也就是那些大部分時間都在等別人回覆的事情

  • 從硬碟讀寫檔案
  • 查資料庫
  • 呼叫外部 API
  • 與外部設備、服務通訊

這些操作都會讓 Thread 處於「等人回信」的狀態。如果你用同步方式處理,就會浪費一個 Thread 傻傻等著。但如果操作非同步,就能在等待期間釋放該 Thread 去做別的事,等到回信來了再接著處理剩下的工作

而今天若是 CPU Bound 的工作就不同了,比如要同時運算一堆複雜公式、解壓縮大量圖片或跑深度學習模型,這些都是「全程都在燒腦」的任務。這時 Thread 根本沒有閒著,也就沒有什麼好「釋放」的空間了,非同步的優勢自然就會變得不明顯



🎵 結語:那杯咖啡,終究會響起提示音

你知道咖啡師正在忙、訂單正在準備,你也知道,等到一切都準備好,那杯屬於你的咖啡,終究會震動提醒你「可以來取了」

非同步程式設計教會我們的,是一種更成熟的節奏感:不要卡在原地,不要什麼事都全程參與,需要學會把任務交出去,讓資源被更有效地運用,並且在正確的時候回頭、收尾