C#中關鍵字 async 和 await 的使用
1. 背景知識點
(1)同步和異步
同步:相同的步速或步調。
在多線程編程中,異步就是:在當前線程之外,另開一個線程,以執行一個相對獨立的任務;當前線程不管新開線程是否執行完畢,繼續執行自身任務或結束自身。相反地,同步就是:當前線程等待新開線程執行完畢,再繼續執行自身任務【一個等待另一個的結束,在它結束之后,繼續自身】。
通俗地講,同步--調用方等待任務完成;異步--調用方不等待任務完成。
(2)三種異步的詳細介紹及實現
- .NET 三種異步的詳細介紹及實現,查看微軟官方相關文檔 :
-
異步編程模型 (APM,Asynchronous Programming Model) :
通過在類中提供 public IAsyncResult BeginOperation()、public int EndOperation(IAsyncResult asyncResult) 兩個方法,來實現異步編程:在 BeginOperation 方法中新開線程【可以通過委托的BeginInvoke方法來開線程】,並返回 IAsyncResult 結果;在 EndOperation 方法中阻塞當前線程而等待新開線程結束,並返回新開線程的結果[例如,int]。
-
基於事件的異步模式 (EAP,Event-based Asynchronous Pattern);
先聲明一個委托,或不聲明委托而使用 .net 提供的委托;然后再定義一個 EventArg 類,或使用 .net 提供的EventArg 類;最后在類中聲明一個事件,並實現一個以 Async 為后綴的方法,在方法中適當的地方響應事件回調【例如,OnXXXCompleted()】。
-
基於任務的異步模式 (TAP,Task-based Asynchronous Pattern) 。
異步方法應返回 System.Threading.Tasks.Task 或 System.Threading.Tasks.Task
對象。
使用手動方式實現 TAP 異步模式:需要創建一個 TaskCompletionSource對象、執行異步操作,並在操作完成時,調用 SetResult、SetException、SetCanceled 方法。
使用編譯器以實現 TAP 異步模式:使用 async 關鍵字,將方法交給編譯器進行執行必要的轉換,從而實現 TAP 異步模式。異步方法應返回 System.Threading.Tasks.Task<TResult> 對象時,函數的主體應返回 TResult。
TAP 取代了 APM 和 EAP,遷移時請參考: TAP 與 APM、EAP 等的互相轉化。
與 TAP 類似的 TPL,更側重於並行處理,而 TAP 更側重於異步處理。
-
2. async 和 await 關鍵字的誕生
async,單詞原意:異步。
C# 中的 async 和 await 關鍵字,屬於基於任務的異步模式,是 .NET 對 TAP 在語言級別上的支持。通過這兩個關鍵字,可以輕松創建異步方法(我們可以像寫同步代碼一樣去寫異步代碼,幾乎與創建同步方法一樣輕松)。
- async 和 await 是基於 Task 的;
- Task 是對 ThreadPool 的封裝改進,主要是為了更有效的控制線程池中的線程(ThreadPool 中的線程,我們很難通過代碼控制其執行順序,任務延續和取消等等);
- ThreadPool 基於 Thread 的,主要目的是減少 Thread 創建數量和管理 Thread 的成本。
net4.0 在 ThreadPool 的基礎上推出了 Task 類,微軟極力推薦使用 Task 來執行異步任務,現在 C# 類庫中的異步方法基本都用到了 Task;net5.0 推出了 async/await,讓異步編程更為方便。我們在開發中可以嘗試使用 Task 來替代 Thread/ThreadPool,處理本地 IO 和網絡 IO 任務時,盡量使用 async/await 來提高任務執行效率。觀點來源
誕生的目的和意義:通過 async 和 await 關鍵字,可以輕松創建異步方法,從而實現異步編程,比 APM、EAP 模式來得輕松、易讀和易維護,和 TAP 的其他方式 相比,也有同樣的優點。
3. 用法
關鍵字 async 用於標記函數,使編譯器知道該函數是一個異步方法,從而執行必要的轉換,以實現 TAP 異步模式。
關鍵字 await 用於獲取“被關鍵字 async 標記的函數”的調用結果。獲取結果時,會暫停自身所在函數的執行【因此,自身所在的函數也應該用關鍵字 async 標記】。
函數被 async 標記 | 函數未被 async 標記 | |
---|---|---|
函數體中含有 await | 基於任務的異步模式 | 報錯,await 只能用於被 async 標記的函數中 |
函數體中沒有 await | 警告,函數將以同步方式運行 | 普通函數 |
async 和 await 關鍵字的使用限制:
- 被 async 標記函數,返回類型只能是:void、Task、Task<T>、IAsyncEnumerable<T>、IAsyncEnumerator<T> 以及類似任務的類型;
其中,IAsyncEnumerable<T> 或 IAsyncEnumerator<T> 是在.NET Core 3.0(.NET Standard 2.1)中引入的; - 程序入口 main 函數的返回類型:如果 main 函數中使用了 await 運算符,則它的返回類型只能是 Task 或 Task<int>,否則報錯:程序不包含適合於入口點的靜態 "Main" 方法。
- 不能對返回類型是 void 的異步方法使用 await 運算符。
類似任務(Task-like)的類型:實現了 public GetAwaiter() 的類型,例如:System.Threading.Tasks.ValueTask<TResult> 類型,參考:《C#中的 Awaiter》。
4. 應用場景
根據“被 async 標記的函數”的返回類型,將應用場景分為以下五種:
調用方目的(應用場景) | 異步方法的返回值類型 | 異步方法的返回語句 |
---|---|---|
僅僅只是調用一下異步方法,不和異步方法做其他交互 | Void | 不需要 return 語句 |
不想通過異步方法獲取一個值,僅僅想追蹤異步方法的執行狀態 | Task | 不需要 return 語句 |
想通過調用異步方法獲取一個 T 類型的返回值 | Task<T> | return T 類型值 |
想通過調用異步方法獲取一個返回值;該返回值為枚舉,用於遍歷其中元素;該枚舉包含多個 T 類型元素 | IAsyncEnumerable<T> | (yield) return T 類型值 |
同上 | IAsyncEnumerator<T> | 同上 |
通俗的理解:
- 返回類型 void:調用后不管任務的執行情況;
- 返回類型 Task:調用后可以知道任務是否結束;
- 返回類型 Task<T>:調用后可以拿到任務的結果;
- 返回類型 IAsyncEnumerable<T>、IAsyncEnumerator<T>:調用后可以以枚舉的方式拿到任務的結果。
- 返回類型 類似任務的類型:略。
5. 原理
在 C# 方面,編譯器將代碼轉換為狀態機,它將跟蹤類似以下內容:到達 await 時暫停執行以及后台作業完成時繼續執行。從理論上講,這是異步的承諾模型的實現。
6. 控制流流轉
(1)返回類型是 void 的異步方法
返回類型是 void 的異步方法,不能對它使用 await 運算符,須直接調用;這樣的調用,不阻塞調用方所在線程。
- 異步方法中,第一個 await 之前的代碼,由調用方線程執行;
- 如果調用方在“異步調用”之后還有代碼,由調用方所在線程立即執行(不阻塞)。
- 異步方法中,各 await 語句,由實際的 Task 另開線程執行【此處“另開線程”是相對於調用方所在線程:無論物理上,還是邏輯上,都不是調用方所在線程】;
而各 await 語句所開的線程,在邏輯上,是不同線程,在物理上,可能是同一個線程【前一個 await 語句所對應的線程已經執行完畢,后一個 await 語句將不再創建新的物理線程,而是復用前一個線程,以降低創建線程的開銷】; - 異步方法中,最后一個 await 之后如果有代碼,則由“最后完成任務”的線程來執行【未必是最后一個 await 語句所對應的線程】。當然,如果異步方法中只有一個 await 語句,則它就是最后一個 await 語句,必然是由其來執行之后的代碼;
- 異步方法中,如果有多個 await,並且它們中間夾有其他代碼,這些代碼的執行情況是:
從“第一個 await 所開線程”開始,由其執行“第一個和第二個await中間的代碼”,- A、執行完畢時檢查“第二個 await 所開線程”是否已經執行完畢:
-> 如果已經執行完畢,則由“第一個 await 所開線程”繼續執行“第二個和第三個 await 中間的代碼”;
-> 如果尚未執行完畢,則“第一個 await 所開線程”結束並歸還給線程池,然后等待“第二個await所開線程”執行完畢,並且之后,由“第二個await所開線程”來執行“第二個和第三個await中間的代碼”; - B、如此執行,每遇到一個 await 語句,都檢查其所對應線程是否已經執行完畢,如果沒有執行完畢,則線程結束並歸還給線程池,否則繼續執行。
- A、執行完畢時檢查“第二個 await 所開線程”是否已經執行完畢:
示例一
internal partial class Program
{
static void Main(string[] args)
{
//另開線程,用作“調用方線程”
Task.Run(() =>
{
ToTest();
});
//主線程駐留內存
Console.ReadKey();
}
}
public partial class Test
{
public static void PrintThreadInfo(string callTag)
{
//輸出當前線程Id,以及調用時給的標記
Console.WriteLine($"Thread Id:{Environment.CurrentManagedThreadId} - {callTag}");
}
}
新開線程:await 語句中,由 Task 另開的線程。
(2)返回類型是 Task 或 Task<T> 的異步方法
調用方對異步方法的調用方式,面臨 2 個選擇:
- 一是直接調用:收到警告,但不報錯,同時控制流流轉方式與“返回類型是 void 的異步方法”的相同。
- 二是在異步方法前加 await 關鍵字,如下圖示例二所示:
- 調用方線程僅執行到異步方法中 第一個 await 語句;
- 調用方 await 語句之后還有代碼,視為異步方法最后一個await語句之后的代碼,將由新開線程執行;
- 其他的控制流流轉方式,與“返回類型是 void 的”控制流流轉方式一致。
示例二
7. 總結
使用關鍵字 async 和 await ,使開發者能更好地、簡便地進異步編程。
控制流流轉方式簡便記憶:
- await 前,同線程(與調用方);
- await 后,新線程(包括調用方調用后代碼);
- 返回 void,不阻塞;
- 多個 await,比誰活得久。
8. 結束語
如有錯誤,歡迎指正。