通過一個範例形象地理解C# async await非同步

2023-01-31 06:02:00

前言

相信大家看過不少講C# async await的文章,部落格園就能搜到很多,但還是有很多C#程式設計師不明白。
如果搞不明白,其實也不影響使用。但有人就會疑惑,為什麼要用非同步?我感覺它更慢了,跟同步有啥區別?
有的人研究深入,比如去研究狀態機,可能會明白其中的原理。但深入研究的畢竟少數。有的人寫一些範例,有的專業有的不是很專業,但看的人仍有不明白的。
所以我寫了一個範例,儘量簡單,讓大家形象地理解一下C#的非同步。
就是一個範例,沒有什麼理論講解,大家自己測試自己思考。如果大家看理論講解,就能明白,我也就沒必要寫這個範例了。

範例的測試步驟

先直接測試,看結果,下面再放程式碼

  1. 點選VS2022的啟動按鈕,啟動程式,它會先啟動Server工程,再啟動AsyncAwaitDemo2工程
  2. 快速連續點選5次button1(呼叫的是同步方法)
  3. 快速連續點選5次button2(呼叫的是非同步方法)
  4. 觀察思考輸出結果,體會非同步的好處

測試截圖

測試截圖說明

  1. 5次點選button1,介面是卡住的,但不影響輸出結果,5次點選button2介面不會卡住(截圖看不出來,測試過程中有體現)
  2. 5次點選button1,共5次同步請求,每個請求耗時2秒,一共耗時10秒,嫌慢,想要並行請求?那你可能要使用Task.Run,線上程中呼叫同步方法,這又涉及到執行緒佔用問題
  3. 5次點選button2,共5次非同步請求,每個請求耗時2秒,但一共耗時只有2秒,注意,程式碼中請求web api介面使用Thread類了嗎?沒有!使用Task.Run了嗎?沒有!使用了async await語法糖,是不是比通過new Thread或Task.Run要簡單多了?就像寫同步程式碼那樣,卻實現了非同步並行的效果。

一個問題

其它部落格也寫過範例,是控制檯範例,輸出的主執行緒ID,是有可能會變化的。這裡是Winform範例,輸出的主執行緒ID始終是UI執行緒的ID,即是1;
我沒有演示主執行緒ID發生變化的情況,這樣會使範例變得複雜一點,不容易看明白。

伺服器端

伺服器端和使用者端是兩個獨立的工程,測試時在一起跑,但其實可以分開部署,部署到不同的機器上
伺服器端是一個web api介面,用.NET 6、VS2022開發,程式碼如下:

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    [HttpGet]
    [Route("[action]")]
    public async Task<string> ForPreheat()
    {
        return await Task.FromResult("預熱返回結果");
    }

    [HttpGet]
    [Route("[action]")]
    public async Task<string> Get()
    {
        await Task.Delay(2000); //模擬耗時操作
        return "測試返回結果";
    }
}

使用者端

大家看使用者端程式碼時,不需要關心伺服器端怎麼寫
使用者端是一個Winform工程,用.NET 6、VS2022開發,程式碼如下:

public partial class Form1 : Form
{
    private string _urlPreheat = "http://localhost:5028/Test/ForPreheat";
    private string _url = "http://localhost:5028/Test/Get";
    private int _index1 = 1; //測試時請快速連續點選5次button1
    private int _index2 = 1; //測試時請快速連續點選5次button2
    private Stopwatch _sw1;
    private Stopwatch _sw2;

    public Form1()
    {
        InitializeComponent();
    }

    private async void Form1_Load(object sender, EventArgs e)
    {
        Request(_urlPreheat); //預熱
        await RequestAsync(_urlPreheat); //預熱
        Log("==== 預熱請求結束 ====================================");
    }

    #region Log
    private void Log(string msg)
    {
        msg = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}  {msg}\r\n";

        if (this.InvokeRequired)
        {
            this.BeginInvoke(new Action(() =>
            {
                txtLog.AppendText(msg);
            }));
        }
        else
        {
            txtLog.AppendText(msg);
        }
    }
    #endregion

    private async void button1_Click(object sender, EventArgs e)
    {
        await Task.Delay(1000); //為了能1秒內快速點選幾次,為了能寫這句程式碼button1_Click前面加了async,但後面的程式碼仍是同步呼叫;如果不加這句,第一次點選介面就卡死了,無法連續點選5次。

        Log("==== 同步請求開始 ====================================");
        if (_index1 == 1) _sw1 = Stopwatch.StartNew();

        Request(_url);

        if (_index1 == 5)
        {
            _sw1.Stop();
            Log($"5次請求耗時:{_sw1.Elapsed.TotalSeconds:0.000}秒");
        }
        _index1++;
    }

    private async void button2_Click(object sender, EventArgs e)
    {
        await Task.Delay(1000); //為了和button1保持一致

        Log("==== 非同步請求開始 ====================================");
        if (_index2 == 1) _sw2 = Stopwatch.StartNew();

        await RequestAsync(_url);

        if (_index2 == 5)
        {
            _sw2.Stop();
            Log($"5次請求耗時:{_sw2.Elapsed.TotalSeconds:0.000}秒");
        }
        _index2++;
    }

    private async Task RequestAsync(string url)
    {
        Stopwatch sw = Stopwatch.StartNew();
        HttpClient httpClient = HttpClientFactory.GetClient();
        var result = await (await httpClient.GetAsync(url)).Content.ReadAsStringAsync();
        sw.Stop();
        Log($"執行緒ID={Thread.CurrentThread.ManagedThreadId},請求耗時:{sw.Elapsed.TotalSeconds:0.000}秒,返回內容長度:{result.Length}");
    }

    private void Request(string url)
    {
        Stopwatch sw = Stopwatch.StartNew();
        var result = HttpUtil.HttpGet(url);
        sw.Stop();
        Log($"執行緒ID={Thread.CurrentThread.ManagedThreadId},請求耗時:{sw.Elapsed.TotalSeconds:0.000}秒,返回內容長度:{result.Length}");
    }
}

思考

把button2_Click方法中的await RequestAsync(_url);修改為兩行:

await RequestAsync(_url);
await RequestAsync(_url);

那麼5次點選,每次點選請求2次,共5×2=10次請求,請問這10次請求的總耗時大約是幾秒?
答案是4秒,為什麼?
因為每次點選的2次請求是順序的,耗時是2+2=4秒,而5次點選是並行的,沒有增加耗時時長

Demo完整程式碼

https://gitee.com/s0611163/AsyncAwaitDemo2

流程圖

最後,我畫了兩個流程圖,但這兩個流程圖可能畫的很不專業,所以僅供參考
button1點選5次的流程圖:

button2點選5次的流程圖:

我之前說過,非同步的本質是回撥,這句話是否正確我也不知道,主要是為了大家理解非同步。