1. 程式人生 > 實用技巧 >.NET 非同步詳解

.NET 非同步詳解

前言

部落格園中有很多關於 .NET async/await 的介紹,但是很遺憾,很少有正確的,甚至說大多都是“從現象編原理”都不過分。

最典型的比如通過前後執行緒 ID 來推斷其工作方式、在 async 方法中用 Thread.Sleep 來解釋 Task 機制而匯出多執行緒模型的結論、在 Task.Run 中包含 IO bound 任務來推出這是開了一個多執行緒在執行任務的結論等等。

看上去似乎可以解釋的通,可是很遺憾,無論是從原理還是結論上看都是錯誤的。

要了解 .NET 中的 async/await 機制,首先需要有作業系統原理的基礎,否則的話是很難理解清楚的,如果沒有這些基礎而試圖向他人解釋,大多也只是基於現象得到的錯誤猜想。

初看非同步

說到非同步大家應該都很熟悉了,2012 年 C# 5 引入了新的非同步機制:Task,並且還有兩個新的關鍵字 awaitasync,這已經不是什麼新鮮事了,而且如今這個非同步機制已經被各大語言借鑑,如 JavaScript、TypeScript、Rust、C++ 等等。

下面給出一個簡單的對照:

語言 排程單位 關鍵字/方法
C# Task<>ValueTask<> asyncawait
C++ std::future<> co_await
Rust std::future::Future<> .await
JavaScript、TypeScript Promise<> asyncawait

當然,這裡這並不是本文的重點,只是提一下,方便大家在有其他語言經驗的情況下(如果有),可以認識到 C# 中 Taskasync/await 究竟是一個和什麼可以相提並論的東西。

多執行緒程式設計

在該非同步程式設計模型誕生之前,多執行緒程式設計模型是很多人所熟知的。一般來說,開發者會使用 Threadstd::thread 之類的東西作為執行緒的排程單位來進行多執行緒開發,每一個這樣的結構表示一個對等執行緒,執行緒之間採用互斥或者訊號量等方式進行同步。

多執行緒對於科學計算速度提升等方面效果顯著,但是對於 IO 負荷的任務,例如從讀取檔案或者 TCP 流,大多數方案只是分配一個執行緒進行讀取,讀取過程中阻塞該執行緒:

void Main()
{
    while (true)
    {
        var client = socket.Accept();
        new Thread(() => ClientThread(client)).Start();
    }
}

void ClientThread(Socket client)
{
    var buffer = new byte[1024];
    while (...)
    {
        // read and block
        client.Read(buffer, 0, 1024); 
    }
}

上述程式碼中,Main 函式在接收客戶端之後即分配了一個新的使用者執行緒用於處理該客戶端,從客戶端接收資料。client.Read() 執行後,該執行緒即被阻塞,即使阻塞期間該執行緒沒有任何的操作,該使用者執行緒也不會被釋放,並被作業系統不斷輪轉排程,這顯然浪費了資源。

另外,如果執行緒數量多起來,頻繁在不同執行緒之間輪轉切換上下文,執行緒的上下文也不小,會浪費掉大量的效能。

非同步程式設計

因此我們在 Linux 上有了 epoll/io_uring 技術,在 Windows 上有了 IOCP 技術用以實現非同步 IO 操作。

(這裡插句題外話,吐槽一句,Linux 終於知道從 Windows 抄作業了。先前的 epoll 對比 IOCP 簡直不能打,被 IOCP 全面打壓,io_uring 出來了才好不容易能追上 IOCP,不過 IOCP 從 Windows Vista 時代開始每一代都有很大的優化,io_uring 能不能追得上還有待商榷)

這類 API 有一個共同的特性就是,在操作 IO 的時候,呼叫方控制權被讓出,等待 IO 操作完成之後恢復先前的上下文,重新被排程繼續執行。

所以表現就是這樣的:

假設我現在需要從某裝置中讀取 1024 個位元組長度的資料,於是我們將緩衝區的地址和內容長度等資訊封裝好傳遞給作業系統之後我們就不管了,讀取什麼的讓作業系統去做就好了。

作業系統在核心態下利用 DMA 等方式將資料讀取了 1024 個位元組並寫入到我們先前的 buffer 地址下,然後切換到使用者態將從我們先前讓出控制權的位置,對其進行排程使其繼續執行。

你可以發現這麼一來,在讀取資料期間就沒有任何的執行緒被阻塞,也不存在被頻繁排程和切換上下文的情況,只有當 IO 操作完成之後才會被重新排程並恢復先前讓出控制權時的上下文,使得後面的程式碼繼續執行。

Task (ValueTask)

說了這麼久還是沒有解釋 Task 到底是個什麼東西,從上面的分析就可以得出,Task 其實就是一個所謂的排程單位,每個非同步任務被封裝為一個 Task 在 CLR 中被排程,而 Task 本身會執行在 CLR 中的預先分配好的執行緒池中。

總有很多人因為 Task 藉助執行緒池執行而把 Task 歸結為多執行緒模型,這是完全錯誤的。

這個時候有人跳出來了,說:你看下面這個程式碼

static async Task Main()
{
    while (true)
    {
        Console.WriteLine(Environment.CurrentManagedThreadId);
        await Task.Delay(1000);
    }
}

輸出的執行緒 ID 不一樣欸,你騙人,這明明就是多執行緒!對於這種言論,我也只能說這些人從原理上理解的就是錯誤的。

當代碼執行到 await 的時候,此時當前的控制權就已經被讓出了,當前執行緒並沒有在阻塞地等待延時結束;待 Task.Delay() 完畢後,CLR 從執行緒池當中挑起了一個先前分配好的已有的但是空閒的執行緒,將讓出控制權前的上下文資訊(暫存器值)恢復,使得該執行緒恰好可以從先前讓出的位置繼續執行下去。這個時候,可能挑到了先前讓出前所在的那個執行緒,導致前後執行緒 ID 一致;也有可能挑到了另外一個和之前不一樣的執行緒執行下面的程式碼,使得前後的執行緒 ID 不一致。在此過程中並沒有任何的新執行緒被分配了出去。

但是上面和經典的多執行緒程式設計的那一套有任何的關係嗎?完全沒有。

至於 ValueTask 是個什麼玩意,官方發現,Task 由於本身是一個 class,在執行時如果頻繁反覆的分配和回收會給 GC 造成不小的壓力,因此出了一個 ValueTask,這個東西是 struct,分配在棧上,這樣的話就不會給 GC 造成壓力了,減輕了開銷。不過也正因為 ValueTask 是會在棧上分配的值型別結構,因此提供的功能也不如 Task 全面。

Task.Run

由於 .NET 是允許有多個執行緒的,因此也提供了 Task.Run 這個方法,允許我們將 CPU bound 的任務放在上述的執行緒池之中的某個執行緒上執行,並且允許我們將該負載作為一個 Task 進行管理,僅在這一點才和多執行緒的採用執行緒池的程式設計比較像。

對於瀏覽器環境(v8),這個時候是完全沒有多執行緒這一說的,因此你開的新的 Promise 其實是後面利用事件迴圈機制,將該微任務以非同步的方式執行。

想一想在 JavaScript 中,Promise 是怎麼用的:

let p = new Promise((resolve, reject) => {
    // do something
    let success = true;
    let result = 123456;

    if (success) {
        resolve(result);
    }
    else {
        reject("failed");
    }
})

然後呼叫:

let r = await p;
console.log(r); // 輸出 123456

你只需要把這一套背後的驅動器:事件迴圈佇列,替換成 CLR 的執行緒池,就差不多是 .NET 的 Task 相對 JavaScript 的 Promise 的工作方式了。

如果你把 CLR 執行緒池執行緒數量設定為 1,那就和 JavaScript 這套幾乎差不多了(雖然實現上還是有差異)。

自己封裝非同步邏輯

瞭解了上面的東西之後,相信對 .NET 中的非同步機制應該理解得差不多了,可以看出來這一套是名副其實的 coroutine,並且在實現上是 stackless 的。至於有的人說的什麼狀態機什麼的,只是實現過程中利用的手段而已,並不是什麼重要的東西。

那我們要怎麼樣使用 Task 來編寫我們自己的非同步程式碼呢?

事件驅動其實也可以算是一種非同步模型,例如以下情景:

A 函式呼叫 B 函式,呼叫後就立馬讓出控制權(例如:BeginInvoke),B 函式執行完成後觸發事件執行 C 函式。

private event Action CompletedEvent;

void A()
{
    CompletedEvent += C;
    Console.WriteLine("begin");
    ((Action)B).BeginInvoke();
}

void B()
{
    Console.WriteLine("running");
    CompletedEvent?.Invoke();
}

void C()
{
    Console.WriteLine("end");
}

那麼我們現在想要做一件事,就是把上面的事件驅動改造為利用 async/await 的非同步程式設計模型,改造後的程式碼就是簡單的:

async Task A()
{
    Console.WriteLine("begin");
    await B();
    Console.WriteLine("end");
}

Task B()
{
    Console.WriteLine("running");
    return Task.CompletedTask;
}

你可以看到,原本 C 函式的內容被放到了 A 呼叫 B 的下面,為什麼呢?其實很簡單,因為這裡 await B(); 這一行以後的內容,本身就可以立即為 B 函式的回調了,只不過在內部實現上,不是直接從 B 進行呼叫的回撥,而是 A 先讓出控制權,B 執行完成後,CLR 切換上下文,將 A 排程回來繼續執行剩下的程式碼。

如果事件相關的程式碼已經確定不可改動(即不能改動 B 函式),我們想將其封裝為非同步呼叫的模式,那隻需要利用 TaskCompletionSource 即可:

private event Action CompletedEvent;

async Task A()
{
    // 因為 TaskCompletionSource 要求必須有一個泛型引數
    // 因此就隨便指定了一個 bool
    // 本例中其實是不需要這樣的一個結果的
    // 需要注意的是從 .NET 5 開始
    // TaskCompletionSource 不再強制需要泛型引數
    var tsc = new TaskCompletionSource<bool>();
    // 隨便寫一個結果作為 Task 的結果
    CompletedEvent += () => tsc.SetResult(false);

    Console.WriteLine("begin");
    await tsc.Task;
    Console.WriteLine("end");
}

void B()
{
    Console.WriteLine("running");
    CompletedEvent?.Invoke();
}

順便提一句,這個 TaskCompletionSource<T> 其實和 JavaScript 中的 Promise<T> 更像。SetResult() 方法對應 resove()SetException() 方法對應 reject()。.NET 比 JavaScript 還多了一個取消狀態,因此還可以 SetCancel() 表示任務被取消了。

同步方式呼叫非同步程式碼

說句真的,一般能有這個需求,都說明你的程式碼寫的有問題,但是如果你無論如何都想以阻塞的方式去等待一個非同步任務完成的話:

Task t = ...
t.GetAwaiter().GetResult();

祝你好運,這相當於,t 中的非同步任務開始執行後,你將當前執行緒阻塞,然後等到 t 完成之後再喚醒,可以說是:毫無意義,而且很有可能因為程式碼編寫不當而導致死鎖的發生。

void async 是什麼?

最後有人會問了,函式可以寫 async Task Foo(),還可以寫 async void Bar(),這有什麼區別呢?

對於上述程式碼,我們一般呼叫的時候,分別這麼寫:

await Foo();
Bar();

可以發現,誒這個 Bar 函式不需要 await 誒。為什麼呢?

其實這和用以下方式呼叫 Foo 是一樣的:

_ = Foo();

換句話說就是呼叫後瞬間就直接拋掉不管了,不過這樣你也就沒法知道這個非同步任務的狀態和結果了。

await 必須配合 Task/ValueTask 才能用嗎?

當然不是。

在 C# 中只要你的類中包含 GetAwaiter() 方法和 bool IsCompleted 屬性,並且 GetAwaiter() 返回的東西包含一個 GetResult() 方法、一個 bool IsCompleted 屬性和實現了 INotifyCompletion,那麼這個類的物件就是可以 await 的。

public class MyTask<T>
{
    public MyAwaiter<T> GetAwaiter()
    {
        return new MyAwaiter<T>();
    }
}

public class MyAwaiter<T> : INotifyCompletion
{
    public bool IsCompleted { get; private set; }
    public T GetResult()
    {
        throw new NotImplementedException();
    }
    public void OnCompleted(Action continuation)
    {
        throw new NotImplementedException();
    }
}

public class Program
{
    static async Task Main(string[] args)
    {
        var obj = new MyTask<int>();
        await obj;
    }
}

結語

本文至此就結束了,感興趣的小夥伴可以多多學習一下作業系統原理,對 CLR 感興趣也可以去研究其原始碼:https://github.com/dotnet/runtime

從現象猜測本質是大忌,可能解釋的通但是終究只是偶然現象,而且從原理上看也是完全錯誤的,甚至官方的實現程式碼稍微變一下可能立馬就無法解釋的通了。

總之,通過本文希望大家能對非同步和 .NET 中的非同步有一個更清晰的理解。

感謝閱讀。