如何避免讓執行緒摸魚,請用非同步技術 async await 拿捏他~

2023-02-09 15:00:56

發現問題

你點了外賣後,會一直不做其它事情,一直等外賣的到來麼?
當然不會拉!

我們來看看程式碼世界的:

public void Query(){
    // 當前執行緒 向 資料庫伺服器 發起查詢命令
    // 在 資料庫伺服器 返回資料之前,當前執行緒 一直等待,不幹活了!!!
    var data = Database.Query(); 
}

假設在一個請求響應中:

  1. 執行緒用 5ms 來驗證使用者的輸入的引數;
  2. 執行緒用 50ms 來等待資料庫返回;
  3. 執行緒用 5ms 序列化資料響應返回給使用者;

可以看到在 60ms 中,執行緒摸魚 50ms。

而很多Web框架,收到一個請求,就會建立一個執行緒來處理,
如果片刻間內有100個使用者請求這個方法,那麼就得安排100個執行緒,
有沒有方法讓第1個執行緒在等待資料返回時,先去接待第N+1個使用者(校驗請求引數什麼的)
這樣就能大大減少執行緒數量~

通過上面的例子,我相信你已有所悟:非同步就是避免讓執行緒摸魚。

概念與理論

接下來為了更有效地溝通和提示逼格,我們還是使用專業的術語。

複習一下執行緒的阻塞睡眠掛起
主要是弄明白阻塞的定義,和什麼時候會發生阻塞

執行緒阻塞

Thread t = new Thread(()=>{
    // 阻塞:執行緒 被動 地等待外部返回,才能繼續執行
    var resp = Http.Get(url); // 需要等待網路傳輸檔案
});

執行緒睡眠

Thread t = new Thread(()=>{
    // 睡眠:執行緒 主動 停止執行片刻,然後繼續執行
    Thread.Sleep(1000);
});

執行緒掛起

// 虛擬碼,C# 的 ThreadPool 沒有這些方法

// 主動叫執行緒去休息
ThreadPool.Recycle(t)

// 等到有工作了,再叫執行緒處理執行
t = ThreadPool.GetThread();
t.Run(fun);

Synchronous(同步)
本人對 同步 給出比較容易理解的定義是:按順序步驟,一個步驟只做一件事情。
本人以前看到 同步 這個詞,錯誤地顧名思義,以為是同一刻時間做幾件事,錯錯錯!!!

// 執行緒會一步一步執行以下程式碼,這個過程叫 同步

// 先發完簡訊
SMS.Send(msg); // 2秒

// 再發郵件
Email.Send(smg); // 1秒

// 總耗時 3秒

Parallel(並行)
指兩個或兩個以上事件(或執行緒)在同一時刻發生。

// 分別建立兩個執行緒並行去執行,誰也不用等待誰~
Thread t1 = new Thread(()=>{
    SMS.Send(msg); // 2秒
});

// t2 執行緒不需要等待 t1 執行緒
Thread t2 = new Thread(()=>{
    Email.Send(smg); // 1秒
});

// 總耗時 2秒

微軟官方檔案-使用 Async 和 Await 的非同步程式設計

微軟用的做早餐的例子:

  1. 倒一杯咖啡。
  2. 加熱平底鍋,然後煎兩個雞蛋。
  3. 煎三片培根。
  4. 烤兩片面包。
  5. 在烤麵包上加黃油和果醬。
  6. 倒一杯橙汁。

同步則是單人(單執行緒)從 1 到 6 一步一步地做 —— 效率低。
並行則是多人(多執行緒),一人倒咖啡;一人煎雞蛋;一個...同時進行 —— 效率高,人力成本高。
非同步則是單人(單執行緒),點火熱平底鍋,平底鍋要等待變熱,那麼先把麵包放進烤麵包機...

Asynchronous(非同步)
指的是,當執行緒遇到阻塞時,讓執行緒先去執行其它工作~

我們應該體驗過,當一個人要在很多事情上來回切換的時候,很容易出錯。

做早餐,我們點火熱平底鍋後就去烤麵包,但平底鍋什麼時候好,我們什麼時候切換回來煎雞蛋,還是去倒橙汁。

要將程式碼的執行過程寫成非同步的,也不是容易的事情。

好在 C# 提供 asyncawait 這兩個關鍵字,
輕鬆建立非同步方法(幾乎與建立同步方法一樣輕鬆) —— 微軟官方檔案原話

理論講解完畢,是時候來實踐了~

async 修飾符

public void Get()
{
    // 這是一個 同步方法
    // 如果這個內部有會發生阻塞的功能程式碼,比如讀取網路資源,
    // 那麼一個執行緒執行這個方法遇到阻塞,這個執行緒就會摸魚~
}

要將一個同步方法宣告為非同步方法,首先需要將用 async 修飾符標記一下,

public async void Get()
{
    // 這是一個 非同步方法
    // 如果這個內部有會發生阻塞的功能程式碼
    // 那麼一個執行緒執行這個方法遇到阻塞時,這個執行緒就會去做其它事情~
}
public async void Get()
{
    HttpClient httpClient = new HttpClient();
    httpClient.GetAsync("https://learn.microsoft.com/zh-cn/docs/");
}

加入一些我們需要觀察的程式碼後,得:

public static void Main()
{
    Console.WriteLine($"Main 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    Get();

    Console.WriteLine($"Main 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");


    Console.ReadKey();
}

// 這程式碼是有問題的,我有意為之,用來和接下來的更完善的程式碼做比較~
public static async void Get()
{
    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    HttpClient httpClient = new HttpClient();
    httpClient.GetAsync("https://learn.microsoft.com/zh-cn/docs/");

    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
}

執行後的控制檯輸出:

Main 開始執行前執行緒 Id:1
Get  開始執行前執行緒 Id:1
Get  執行結束後執行緒 Id:1
Main 執行結束後執行緒 Id:1

注意!!!這個時候方法雖然被宣告為非同步的,但現在執行過程還是同步的!!!!

await 運運算元

微軟官方檔案:async(C# 參考) 中:

非同步方法同步執行,直至到達其第一個 await 表示式,此時會將方法掛起,直到等待的任務完成。

如果 async 關鍵字修改的方法不包含 await 表示式或語句,則該方法將同步執行。 編譯器警告將通知你不包含 await 語句的任何非同步方法,因為該情況可能表示存在錯誤。 請參閱編譯器警告(等級 1)CS4014。

所以完善的程式碼,應該是這樣子的:

public static void Main()
{
    Console.WriteLine($"Main 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    Get(); // Get 方法雖然是宣告為非同步的,但依舊時同步執行

    Console.WriteLine($"Main 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");


    Console.ReadKey();
}

public static async void Get()
{
    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    HttpClient httpClient = new HttpClient();

    // 加上 await 運運算元,才是真正的非同步執行!!!
    await httpClient.GetAsync("https://learn.microsoft.com/zh-cn/docs/");

    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
}

執行後的控制檯輸出:

Main 開始執行前執行緒 Id:1 # 執行緒1,進入 main 函數 
Get  開始執行前執行緒 Id:1 # 執行緒1,執行 Get  函數,遇到阻塞,但執行緒1被要求不能摸魚,
Main 執行結束後執行緒 Id:1 # 於是看看有沒有其它工作做,發現需要列印...
Get  執行結束後執行緒 Id:9 # 阻塞結束後,誰來執行剩下的程式碼呢?
               # 如果執行緒1有空,可以回來執行,如果執行緒1忙,則有其它執行緒接管
               # 由排程分配決定

我們自己定義的非同步方法 Get() 和呼叫非同步方法 httpClient.GetAsync
只有 httpClient.GetAsync 是非同步執行的。
也就是說單單使用 async 還不夠,還得必須同時使用 await

Task 類

通常來說,我們使用 httpClient.GetAsync,都是希望能處理返回的資料。

微軟官方檔案:非同步方法的返回型別

  • Task 表示不返回值且通常非同步執行的單個操作。
  • Task<TResult> 表示返回值且通常非同步執行的單個操作。
  • void 對於除事件處理程式以外的程式碼,通常不鼓勵使用 async void 方法,因為呼叫方不能 await 那些方法,並且必須實現不同的機制來報告成功完成或錯誤條件。
public static async void Get()
{
    const string url = "https://learn.microsoft.com/zh-cn/docs/";

    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    HttpClient httpClient = new HttpClient();
    // 用 Task 來 = 一個非同步操作
    Task<HttpResponseMessage> taskResp = httpClient.GetAsync(url);

    HttpResponseMessage resp = await taskResp;// 等待非同步操作完成返回
    // 可以對 resp 進行一些處理
    
    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
}

上面程式碼可以簡化為:

public static async void Get()
{
    const string url = "https://learn.microsoft.com/zh-cn/docs/";

    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    HttpClient httpClient = new HttpClient();

    HttpResponseMessage resp = await httpClient.GetAsync(url);

    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
}

多個Task 的例子:

public static async void Get()
{
    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    HttpClient httpClient = new HttpClient();

    var t1 = httpClient.GetAsync("https://learn.microsoft.com/");
    var t2 = httpClient.GetAsync("https://cn.bing.com/");
    var t3 = httpClient.GetAsync("https://www.cnblogs.com/");

    Console.WriteLine($"Get await 之前的執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
    
    await Task.WhenAll(t1, t2, t3); // 等待多個非同步任務完成

    //Task.WaitAll(t1, t2, t3);
    //await Task.Yield();
    //await Task.Delay(0);

    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
}

執行後的控制檯輸出:

Main 開始執行前執行緒 Id:1
Get  開始執行前執行緒 Id:1
Get  await 之前的執行緒 Id:1
Main 執行結束後執行緒 Id:1
Get  執行結束後執行緒 Id:14

按微軟官方檔案的建議和規範的最終版本:

public static void Main()
{
    Console.WriteLine($"Main 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    GetAsync().Wait();

    Console.WriteLine($"Main 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");


    Console.ReadKey();
}

// 通常不鼓勵使用 async void 方法
// 非同步方法名約定以 Async 結尾
public static async Task GetAsync()
{
    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    HttpClient httpClient = new HttpClient();

    var t1 = httpClient.GetAsync("https://learn.microsoft.com/");
    var t2 = httpClient.GetAsync("https://cn.bing.com/");
    var t3 = httpClient.GetAsync("https://www.cnblogs.com/");

    Console.WriteLine($"Get await 之前的執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");
    Task.WaitAll(t1, t2, t3); // 等待多個非同步任務完成

    await Task.Yield();
    //await Task.Delay(0);

    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

}

執行後的控制檯輸出:

Main 開始執行前執行緒 Id:1
Get  開始執行前執行緒 Id:1
Get  await 之前的執行緒 Id:1
Get  執行結束後執行緒 Id:5
Main 執行結束後執行緒 Id:1

測試

public static async Task GetAsync()
{
    Console.WriteLine($"Get 開始執行前執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    Stopwatch sw = new Stopwatch();
    sw.Start();

    TestHttp(); // http 網路不穩定,不好觀察時間,可以試試 TestIdle()

    sw.Stop();
    Console.WriteLine($"一共耗時:{sw.ElapsedMilliseconds} 毫秒");

    Console.WriteLine($"Get 執行結束後執行緒 Id:{Thread.CurrentThread.ManagedThreadId}");

    await Task.Yield();
}

public static void TestHttp()
{
    HttpClient httpClient = new HttpClient();

    List<Task<HttpResponseMessage>> tasks = new List<Task<HttpResponseMessage>>();
    for (int i = 0; i < 10; i++)
    {
        var t = httpClient.GetAsync("https://learn.microsoft.com/");
        tasks.Add(t);
    }

    Task.WaitAll(tasks.ToArray());

    foreach (var item in tasks)
    {
        var html = item.Result.Content.ReadAsStringAsync().Result;
    }
}

public static void TestIdle()
{
    List<Task> tasks = new List<Task>();
    for (int i = 0; i < 10; i++)
    {
        var t = Idle();
        tasks.Add(t);
    }

    Task.WaitAll(tasks.ToArray());
}

public static async Task Idle()
{
    // 可以用於模擬阻塞效果
    await Task.Delay(1000);

    // 不能用 Sleep 來模擬阻塞,Sleep 不是阻塞,是睡眠
    // Thread.Sleep(1000);
}
    
Main 開始執行前執行緒 Id:1
Get 開始執行前執行緒 Id:1
一共耗時:604 毫秒 # 1個執行緒幹了10個執行緒的活,時間還差不多,美滋滋~
Get 執行結束後執行緒 Id:1
Main 執行結束後執行緒 Id:1

至此,關於 C# 中非同步程式設計的三個知識點 asyncawaitTask 講解完畢。

在寫例子的過程中,
發現 HttpClient 這個類很多方法都是非同步方法了,
依稀記得以前還有同步方法和非同步方法提供選擇的,
看來微軟是在逼大家進步啊~

如果文章能幫到你,點個贊吧,十分感謝~

參考資料

非同步程式設計:
https://docs.microsoft.com/zh-cn/dotnet/csharp/async

使用 Async 和 Await 的非同步程式設計:
https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/async

非同步程式設計模型:
https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/async/task-asynchronous-programming-model

深入瞭解非同步:
https://docs.microsoft.com/zh-cn/dotnet/standard/async-in-depth

async 關鍵字:
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/async

await 運運算元:
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/await

Async/Await 非同步程式設計中的最佳做法:
https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming

Future 與 promise:
https://zh.wikipedia.org/wiki/Future與promise