C# 不是已經有 async 和 await 語法可實現非同步呼叫嗎?為什麼還需要非同步串流(async streams)呢?
👉 在 GitHub 上面閱讀本文 (GitHub 上面的程式碼沒有顯示行號,較不方便)
在需要處理大量資料的場合,我們可以在程式中透過「非同步」(asynchronous)的方式來呼叫其他 API(例如 Web API、資料查詢 API 等等)以獲取資料,好讓用戶端在我們的程式等待其他 API 回傳資料的過程中還能繼續做其他事情,不至於完全卡住使用者介面。這是非同步呼叫的好處之一。
剛才說的那些 API 所回傳的資料類型通常是 IEnumerable<T>,然而,IEnumerable<T> 的運作方式是同步的(synchronous)。意思是,在拉取資料的場合,用戶端必須等到資料提供端回傳所有需要之資料,才能對那些資料進行後續處理(例如顯示在網頁上)。也就是說,雖然非同步呼叫可以讓使用者繼續動動滑鼠、敲敲鍵盤,但沒辦法讓她想看到的資料盡快出現。
想像一下,你正在開發一個線上購物網站,當使用者在瀏覽商品清單頁面時,拚命地快速往下滑(快速跳下一頁),此時你的網站應用程式會收到許多抓取資料的請求。假設使用者每次往下跳一頁需要抓取 50 筆資料,那麼當使用者連續快速跳下一頁,雖然她的鍵盤與滑鼠都還可以繼續操作,但終究還是得等到所有該抓的資料全部取得之後,才有辦法看到她想看的東西。換言之,無論我們的網站後端程式在抓取資料時有沒有使用非同步呼叫,使用者需要等待的時間幾乎是一樣的,因為她此時的目的只有一個:要盡快看到她想看的資料,而不是在查詢資料時還去點其他按鈕或連結。
那麼,我們是否能在拉取資料時,讓使用者先看到一部份結果?也就是說,一邊抓取資料的同時就一邊回傳當下已經獲取的部分資料——就算只先出現 1 筆資料也好,而不要等到整頁(例如 50 筆)資料全都拉回來之後才一次全部顯示。這就是非同步串流(async streams)能夠派上用場的地方。
IEnumerable<T> 與 yield return
欲了解 C# 非同步串流的運作機制與寫法,至少必須提到兩個 .NET 基礎類別:IEnumerble<T> 和 IAsyncEnumerable<T>,同時也得了解 C# 的 yield return 用法。首先讓我們來看一下 IEnumerble<T> 與 yield return 的範例:
IEnumerable<int> numbers = GetNumbers(); | |
foreach (int n in numbers) | |
{ | |
Console.WriteLine($"取得 {n}"); | |
} | |
static IEnumerable<int> GetNumbers() | |
{ | |
var numbers = new int[] { 1, 2, 3 }; | |
foreach (int n in numbers) | |
{ | |
yield return n; | |
Console.WriteLine($"回傳 {n} 之後"); | |
} | |
} |
此範例的功能很單純,只是呼叫 GetNumbers 函式來取得一串整數,然後用一個迴圈把數字輸出至螢幕。執行結果如下:
取得 1 回傳 1 之後 取得 2 回傳 2 之後 取得 3 回傳 3 之後
👉試試看:https://dotnetfiddle.net/Kj1SSA
觀察重點是程式執行的流程:由於使用了 yield return,使得 GetNumbers 函式的執行流程被切成三段(因為回傳的串列元素總數為 3)。
底下說明程式執行的過程(讀起來可能有點燒腦):
- 第 1 行呼叫 GetNumebers 方法,看似已經立刻取得一個完整的整數串列,但實際上並非如此。若以單步追蹤的方式來逐步執行,第 1 行程式碼跑完後,螢幕上並沒有輸出任何文字。
- 進入第 2 行的 foreach 迴圈,從 numbers 串列中取出第一個元素時,此時才會真正進入 GetNumbers 方法,並且依序執行第 9~12 行程式碼。第 12 行程式碼的 yield return 會立即返回當前的元素(整數 1),然後程式流程立刻回到呼叫端(即第 4 行)繼續執行,於是在螢幕上輸出字串:「取得 1」。
- 接下來,第 2 行的 foreach 進入第二圈,欲取出 numbers 串列的第二個元素,此時程式流程會跳回上一次 yield return 敘述的下面接著執行,於是印出字串:「回傳 1 之後」。接著會跑 GetNumbers 函式中的第二圈,再次碰到 yield return,於是回傳當前的元素(整數 2),程式流程立刻回到呼叫端(即第 4 行)繼續執行,於是在螢幕上輸出字串:「取得 2」。
- 跟上一個步驟所描述的流程相同,這次回傳的元素是整數 3。
像這樣跳來跳去的執行流程,是不是有點像 async 呼叫?(但不是)
值得一提的是,GetNumbers 函式雖然反覆進入多次,但第 9 行用來初始化整數陣列的程式碼只會執行一次。換言之,GetNumbers 函式宛如一個狀態機(state machine),能夠記住自身的狀態。這是 yield return 產生的效果。
如果你對這個範例的執行過程還有些疑問,不妨動手修改一下程式碼來實驗看看。比如說,yield return 不是一定要寫在迴圈裡面,你也可以把 GetNumbers 方法改寫成這樣:
IEnumerable<int> GetNumbers() | |
{ | |
yield return 1; | |
Console.WriteLine("回傳 1 之後"); | |
yield return 2; | |
Console.WriteLine("回傳 2 之後"); | |
yield return 3; | |
Console.WriteLine("回傳 3 之後"); | |
} |
執行結果跟先前的寫法一樣。
另一方面,呼叫端的 foreach 迴圈,骨子裡其實是用列舉器 IEnumerator<T> 的 MoveNext 方法來取得下一個元素,所以先前範例的 1~5 行也可以改寫成:
IEnumerator<int> e = GetNumbers().GetEnumerator(); | |
while (e.MoveNext()) | |
{ | |
Console.WriteLine($"取得 {e.Current}"); | |
} | |
// 底下是先前的寫法,一併列出來,方便對照。 | |
IEnumerable<int> numbers = GetNumbers(); | |
foreach (int n in numbers) | |
{ | |
Console.WriteLine($"取得 {n}"); | |
} |
IAsyncEnumerable<T> 與 await foreach
IEnumerable<int> GetNumbers() | |
{ | |
var numbers = await SomeApi.GetNumbersAsync(); | |
foreach (int n in numbers) | |
{ | |
yield return n; | |
Console.WriteLine($"回傳 {n} 之後"); | |
} | |
} |
async IEnumerable<int> GetNumbers() | |
{ | |
var numbers = await SomeApi.GetNumbersAsync(); | |
... (略) | |
} |
async IAsyncIEnumerable<int> GetNumbers() | |
{ | |
var numbers = await SomeApi.GetNumbersAsync(); | |
foreach (int n in numbers) | |
{ | |
yield return n; | |
Console.WriteLine($"回傳 {n} 之後"); | |
} | |
} |
IAsyncEnumerable<int> numbers = GetNumbers(); | |
await foreach (int n in numbers) | |
{ | |
Console.WriteLine($"取得 {n}"); | |
} | |
async IAsyncEnumerable<int> GetNumbers() | |
{ | |
await Task.Delay(1000); | |
yield return 1; | |
await Task.Delay(1000); | |
yield return 2; | |
await Task.Delay(1000); | |
yield return 3; | |
} |
IAsyncEnumerator<int> e = GetNumbers().GetAsyncEnumerator(); | |
while (await e.MoveNextAsync()) | |
{ | |
Console.WriteLine($"取得 {e.Current}"); | |
} |
重點整理
- 以往使用 IEnumerable<T> 來傳遞資料時,是以「同步」的方式運行。
- 支援非同步串流的函式,其回傳型別是 IAsyncEnumerable<T>,而不是 IEnumerable<T>。
- 可讓我們在同一個方法當中使用 await 來取得非同步工作的結果,並使用 yield return 來回傳當下已獲取之資料;即抓到一點就回傳一點,邊抓邊傳的模式。
- 用戶端程式在使用 await foreach 來逐一取得非同步串流的內容(元素)時,背後真正觸發其「抓取資料」動作的是 IAsyncEnumerator<T> 的 MoveNextAsync 方法。用白話來說就是:當用戶端需要下一筆資料,才立刻去抓那一筆資料。
沒有留言: