透過一個示例形象地理解C# async await 非並行非同步、並行非同步、並行非同步的併發量控制

0611163發表於2023-02-04

前言

接上一篇 透過一個示例形象地理解C# async await非同步
我在 .NET與大資料 中吐槽前同事在雙層迴圈體中(肯定是單執行緒了)頻繁請求es,導致介面的總耗時很長。這不能怪前同事,確實難寫,會使程式碼複雜度增加。
評論區有人說他的理解是使用非同步增加了系統吞吐能力,這個理解是正確的,但對於單個介面的單次請求而言,它是單執行緒的,耗時反而可能比同步還慢。如何縮短單個介面的單次請求的時間呢(要求:儘量不增加程式碼複雜度)?請看下文。

示例的測試步驟

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

  1. 點選VS2022的啟動按鈕,啟動程式,它會先啟動Server工程,再啟動AsyncAwaitDemo2工程
  2. 分別點選三個button
  3. 觀察思考輸出結果

測試截圖

非並行非同步(順序執行的非同步)


截圖說明:單次請求耗時約0.5秒,共10次請求,耗時約 0.5秒×10=5秒

並行非同步


截圖說明:單次請求耗時約0.5秒,共10次請求,耗時約 0.5秒

並行非同步(控制併發數量)


截圖說明:單次請求耗時約0.5秒,共10次請求,併發數是5,耗時約 0.5秒×10÷5=1秒

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

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    [HttpGet]
    [Route("[action]")]
    public async Task<Dictionary<int, int>> Get(int i)
    {
        var result = new Dictionary<int, int>();

        await Task.Delay(500); //模擬耗時操作

        if (i == 0)
        {
            result.Add(0, 5);
            result.Add(1, 4);
            result.Add(2, 3);
            result.Add(3, 2);
            result.Add(4, 1);
        }
        else if (i == 1)
        {
            result.Add(0, 10);
            result.Add(1, 9);
            result.Add(2, 8);
            result.Add(3, 7);
            result.Add(4, 6);
        }

        return result;
    }
}

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

public partial class Form1 : Form
{
    private readonly string _url = "http://localhost:5028/Test/Get";

    public Form1()
    {
        InitializeComponent();
    }

    private async void Form1_Load(object sender, EventArgs e)
    {
        //預熱
        HttpClient httpClient = HttpClientFactory.GetClient();
        await (await httpClient.GetAsync(_url)).Content.ReadAsStringAsync();
    }

    //非並行非同步(順序執行的非同步)
    private async void button3_Click(object sender, EventArgs e)
    {
        await Task.Run(async () =>
        {
            Log($"==== 非並行非同步 開始,執行緒ID={Thread.CurrentThread.ManagedThreadId} ========================");
            Stopwatch sw = Stopwatch.StartNew();
            HttpClient httpClient = HttpClientFactory.GetClient();
            var tasks = new Dictionary<string, Task<string>>();
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < 2; i++)
            {
                int sum = 0;
                for (int j = 0; j < 5; j++)
                {
                    Dictionary<int, int> dict = await RequestAsync(_url, i);
                    if (dict.ContainsKey(j))
                    {
                        int num = dict[j];
                        sum += num;
                        sb.Append($"{num}, ");
                    }
                }
                Log($"輸出:sum={sum}");
            }
            Log($"輸出:{sb}");
            sw.Stop();
            Log($"==== 結束,執行緒ID={Thread.CurrentThread.ManagedThreadId},耗時:{sw.Elapsed.TotalSeconds:0.000}秒 ========================");
        });
    }

    // 並行非同步
    private async void button4_Click(object sender, EventArgs e)
    {
        await Task.Run(async () =>
        {
            Log($"==== 並行非同步 開始,執行緒ID={Thread.CurrentThread.ManagedThreadId} ========================");
            Stopwatch sw = Stopwatch.StartNew();
            HttpClient httpClient = HttpClientFactory.GetClient();
            var tasks = new Dictionary<string, Task<Dictionary<int, int>>>();
            StringBuilder sb = new StringBuilder();
            //雙層迴圈寫第一遍
            for (int i = 0; i < 2; i++)
            {
                for (int j = 0; j < 5; j++)
                {
                    var task = RequestAsync(_url, i);
                    tasks.Add($"{i}_{j}", task);
                }
            }
            //雙層迴圈寫第二遍
            for (int i = 0; i < 2; i++)
            {
                int sum = 0;
                for (int j = 0; j < 5; j++)
                {
                    Dictionary<int, int> dict = await tasks[$"{i}_{j}"];
                    if (dict.ContainsKey(j))
                    {
                        int num = dict[j];
                        sum += num;
                        sb.Append($"{num}, ");
                    }
                }
                Log($"輸出:sum={sum}");
            }
            Log($"輸出:{sb}");
            sw.Stop();
            Log($"==== 結束,執行緒ID={Thread.CurrentThread.ManagedThreadId},耗時:{sw.Elapsed.TotalSeconds:0.000}秒 ========================");
        });
    }

    // 並行非同步(控制併發數量)
    private async void button5_Click(object sender, EventArgs e)
    {
        await Task.Run(async () =>
        {
            Log($"==== 並行非同步(控制併發數量) 開始,執行緒ID={Thread.CurrentThread.ManagedThreadId} ===================");
            Stopwatch sw = Stopwatch.StartNew();
            HttpClient httpClient = HttpClientFactory.GetClient();
            var tasks = new Dictionary<string, Task<Dictionary<int, int>>>();
            Semaphore sem = new Semaphore(5, 5);
            StringBuilder sb = new StringBuilder();
            //雙層迴圈寫第一遍
            for (int i = 0; i < 2; i++)
            {
                for (int j = 0; j < 5; j++)
                {
                    var task = RequestAsync(_url, i, sem);
                    tasks.Add($"{i}_{j}", task);
                }
            }
            //雙層迴圈寫第二遍
            for (int i = 0; i < 2; i++)
            {
                int sum = 0;
                for (int j = 0; j < 5; j++)
                {
                    Dictionary<int, int> dict = await tasks[$"{i}_{j}"];
                    if (dict.ContainsKey(j))
                    {
                        int num = dict[j];
                        sum += num;
                        sb.Append($"{num}, ");
                    }
                }
                Log($"輸出:sum={sum}");
            }
            sem.Dispose(); //別忘了釋放
            Log($"輸出:{sb}");
            sw.Stop();
            Log($"==== 結束,執行緒ID={Thread.CurrentThread.ManagedThreadId},耗時:{sw.Elapsed.TotalSeconds:0.000}秒 ========================");
        });
    }

    private async Task<Dictionary<int, int>> RequestAsync(string url, int i)
    {
        Stopwatch sw = Stopwatch.StartNew();
        HttpClient httpClient = HttpClientFactory.GetClient();
        var result = await (await httpClient.GetAsync($"{url}?i={i}")).Content.ReadAsStringAsync();
        sw.Stop();
        Log($"執行緒ID={Thread.CurrentThread.ManagedThreadId},請求耗時:{sw.Elapsed.TotalSeconds:0.000}秒");
        return JsonSerializer.Deserialize<Dictionary<int, int>>(result);
    }

    private async Task<Dictionary<int, int>> RequestAsync(string url, int i, Semaphore semaphore)
    {
        semaphore.WaitOne();
        try
        {
            Stopwatch sw = Stopwatch.StartNew();
            HttpClient httpClient = HttpClientFactory.GetClient();
            var result = await (await httpClient.GetAsync($"{url}?i={i}")).Content.ReadAsStringAsync();
            sw.Stop();
            Log($"執行緒ID={Thread.CurrentThread.ManagedThreadId},請求耗時:{sw.Elapsed.TotalSeconds:0.000}秒");
            return JsonSerializer.Deserialize<Dictionary<int, int>>(result);
        }
        catch (Exception ex)
        {
            Log($"錯誤:{ex}");
            throw;
        }
        finally
        {
            semaphore.Release();
        }
    }

    #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 void button6_Click(object sender, EventArgs e)
    {
        txtLog.Text = string.Empty;
    }
}

思考

1. 使用Semaphore的注意事項

  1. 如果是Winform程式,可以在button事件方法中定義它的區域性變數。如果是WebAPI介面服務,請在介面方法中定義Semaphore的區域性變數。注意,別定義成全域性的,或者定義成靜態的,或者定義成Controller的成員變數,那樣會嚴重限制使用它的介面的吞吐能力!
  2. 用完呼叫Dispose釋放

2. 儘量不增加程式碼複雜度

請思考程式碼中的註釋"雙層迴圈寫第一遍""雙層迴圈寫第二遍",這個寫法儘量不增加程式碼複雜度,試想一下,如果你用Task.Run,且不說佔用執行緒,就問你怎麼寫能簡單?
有人說,這題我會,這樣寫不就行了:

Dictionary<int, int>[] result = await Task.WhenAll(tasks.Values);

那請問,你接下來怎麼寫?我相信你肯定會寫,但問題是,程式碼的邏輯結構變了,程式碼複雜度增加了!
所以"雙層迴圈寫第一遍""雙層迴圈寫第二遍"是什麼意思?你即能方便合併,又能方便拆分,程式碼邏輯結構沒變,只是複製了一份。

3. RequestAsync的複雜度可控

RequestAsync的複雜度並沒有因為Semaphore的引入變得更復雜,增加的程式碼可以接受。

我寫這篇部落格不只是寫個Demo,我確實有實際專案中的問題需要解決,程式碼如下:

WebAPI的Controller層:

[HttpPost]
[Route("[action]")]
public async Task<List<NightActivitiesResultItem>> Get([FromBody] NightActivitiesPostData data)
{
    return await ServiceFactory.Get<NightActivitiesService>().Get(data.startDate, data.endDate, data.startTime, data.endTime, data.threshold, data.peopleClusters);
}

WebAPI的Service層:

public async Task<List<NightActivitiesResultItem>> Get(string strStartDate, string strEndDate, string strStartTime, string strEndTime, decimal threshold, List<PeopleCluster> peopleClusterList)
{
    List<NightActivitiesResultItem> result = new List<NightActivitiesResultItem>();

    DateTime startDate = DateTime.ParseExact(strStartDate, "yyyyMMdd", CultureInfo.InvariantCulture);
    DateTime endDate = DateTime.ParseExact(strEndDate, "yyyyMMdd", CultureInfo.InvariantCulture);
    string[][] strTimes;
    if (string.Compare(strStartTime, strEndTime) > 0)
    {
        strTimes = new string[2][] { new string[2], new string[2] };
        strTimes[0][0] = strStartTime;
        strTimes[0][1] = "235959";
        strTimes[1][0] = "000000";
        strTimes[1][1] = strEndTime;
    }
    else
    {
        strTimes = new string[1][] { new string[2] };
        strTimes[0][0] = strStartTime;
        strTimes[0][1] = strEndTime;
    }

    foreach (PeopleCluster peopleCluster in peopleClusterList)
    {
        for (DateTime day = startDate; day <= endDate; day = day.AddDays(1))
        {
            string strDate = day.ToString("yyyyMMdd");
            int sum = 0;
            foreach (string[] timeArr in strTimes)
            {
                List<PeopleFeatureAgg> list = await ServiceFactory.Get<PeopleFeatureQueryService>().QueryAgg(strDate + timeArr[0], strDate + timeArr[1], peopleCluster.ClusterIds);
                Dictionary<string, int> agg = list.ToLookup(a => a.ClusterId).ToDictionary(a => a.Key, a => a.First().Count);

                foreach (string clusterId in peopleCluster.ClusterIds)
                {
                    if (agg.TryGetValue(clusterId, out int count))
                    {
                        sum += count;
                    }
                }
            }
            if (sum >= threshold) //大於或等於閾值
            {
                NightActivitiesResultItem item = new NightActivitiesResultItem();
                item.peopleCluster = peopleCluster;
                item.date = strDate;
                item.count = sum;
                foreach (string[] timeArr in strTimes)
                {
                    PeopleFeatureQueryResult featureList = await ServiceFactory.Get<PeopleFeatureQueryService>().Query(strDate + timeArr[0], strDate + timeArr[1], peopleCluster.ClusterIds, 10000);
                    item.list.AddRange(featureList.list);
                }
                item.dataType = "xxx";
                result.Add(item);
            }
        }
    }

    var clusters = result.ConvertAll<PeopleCluster>(a => a.peopleCluster);
    await ServiceFactory.Get<PersonScoreService>().Set(OpeType.Xxx, peopleClusterList, clusters, startDate.ToString("yyyyMMddHHmmss"), endDate.ToString("yyyyMMddHHmmss"));

    return result;
}

思考

上述介面程式碼,它有三層迴圈,在第三層迴圈體中await,第一層迴圈的數量會達到1000甚至10000,第二層迴圈的數量會達到30(一個月30天),甚至90(三個月),第三層迴圈的數量很少。
那麼總請求次數會達到3萬甚至90萬,如果不使用並行非同步請求,那耗時將會很長。

請問:在儘量不增加程式碼複雜度的前提下,怎麼最佳化,能縮短該服務介面的執行時間?

我知道肯定有人要說我了,你傻啊,請求3萬次?你可以改寫一下,只請求一次,或者按天來,每天的資料只請求一次,那最多也才90次。然後在記憶體中計算,這不就快了?
確實是這樣的,確實不應該請求3萬次。但問題沒這麼簡單:

  1. 且不說程式碼的複雜度,你寫的不是一個介面,可能會有幾十個這樣的介面要寫,複雜度增加一點這麼多介面都要寫死人。
  2. 這3萬請求,可都是精確查詢,es強大的快取機制,肯定會命中快取,也就是這些請求實際上基本是直接從記憶體中拿資料,連遍歷集合都不需要,直接命中索引。只是網路往返次數太多。
  3. 這1次請求,或30次請求,對es來說,變成了範圍查詢,es要遍歷,要給你查詢並組織資料,返回集合給你。當然es叢集的運算速度肯定很快。
  4. 這1次請求,或30次請求,結果返回後,你就要在記憶體中計算了,有的介面我就是這樣寫的,但要多寫程式碼,比如在記憶體中計算,為了提高效率,先建立字典,相當於建索引。
  5. 只是邏輯複雜了嗎?你還要多定義一些臨時的變數啊!還可能要多定義一些實體類,哪怕是匿名物件。
  6. 程式碼寫著寫著就變懶了,對於每個介面,先組織好資料,再進行1次請求,然後在記憶體中再遍歷再計算,心智負擔好重
  7. 我在網上看到es叢集預設最多支援10000個併發查詢,需要請求es的業務程式肯定不止一個,對一個業務程式而言,確實要控制併發量
  8. 根據我的觀察,一個WebAPI程式,執行緒數一般也就幾十,多的時候上百,在沒有非同步的時候,併發請求數量實際上受限於物理執行緒。
  9. 使用非同步之後,併發請求數量實際上受限於虛擬執行緒。確實會增加請求es的併發數量,壓力大的時候,這個併發數量可能會很大。

怎麼檢視併發請求數

windows的cmd命令:
netstat -ano | findstr 5028

還有兩個問題,部落格中沒有體現

1. 客戶端程式執行請求時,客戶端執行緒數量

透過工作管理員檢視,非並行非同步,執行緒數很少,請求開始後只增加了一兩個執行緒。並行非同步執行緒數較多。並行非同步控制併發數量,執行緒數少很多。

2. Semaphore會阻塞當前執行緒

semaphore.WaitOne()阻塞執行緒一直阻塞到semaphore.Release(),使用了Semaphore的介面,被請求一次,阻塞一個執行緒,不過問題不是很大。

思考

.NET只有一個CLR執行緒池和一個非同步執行緒池(完成埠執行緒池),當執行緒池中執行緒數量不夠用時,.NET每秒才增加1到2個執行緒,執行緒增加的速度非常緩慢。結合非同步,考慮一下這是為什麼?
我認為(不一定對):

  1. 非同步不需要大量物理執行緒,少量即可
  2. 如果執行緒增加速度很快,以非同步的吞吐量,怕不是要把es請求掛!因為併發請求數太多了。

總結

  1. 並行非同步,會有併發量太大,導致諸如資料庫或者es叢集抗不住的問題,謹慎使用。
  2. 並行非同步(控制併發數量),這個目前是最佳實踐。

完整測試原始碼

注意是AsyncParallel分支
https://gitee.com/s0611163/AsyncAwaitDemo2/tree/AsyncParallel/

最後

上述我寫的實際介面,可最佳化也可不最佳化,耗時長沒有問題,還有很多服務介面,它們透過定時任務在凌晨錯開時間跑,結果儲存在資料庫中供前端查詢。這是離線分析。
前同事寫的介面是實時的,所以他覺得es慢了,如果只請求一次呢,可能es的查詢語句也不好寫,所以用ClickHouse,利用SQL靈活性,只查詢一次,然後在記憶體中計算。

後續

又寫了個測試程式,測試大量請求,並限制請求併發量。
注意是AsyncParallel2分支
https://gitee.com/s0611163/AsyncAwaitDemo2/tree/AsyncParallel2/

怎麼測試?

  1. 啟動服務端後,再啟動客戶端
  2. 點選第一個按鈕,觀察輸出,開啟Windows的資源管理器,檢視Server.exe程式和AsyncAwaitDemo2.exe程式的執行緒數量,然後客戶端可以關了,因為跑完至少要半小時。
  3. 點選第二個按鈕,觀察輸出,開啟Windows的資源管理器同上,觀察工作執行緒數和非同步執行緒數佔用,能看到資料明顯變化,大概幾秒後就可以跑完。
  4. 點選第三個按鈕,觀察輸出,開啟Windows的資源管理器同上,觀察工作執行緒數和非同步執行緒數佔用,能看到資料明顯變化,大概20秒能跑完。0.065秒/每次請求×3萬次請求÷100併發量≈20秒。

注意觀察服務端執行緒數量

  1. 並行非同步請求
    並行非同步請求時,請求3萬次只需要幾秒,第二次點選需要的時間更短,僅需大約2.5秒。注意觀察服務端執行緒數量,50不到!
    我把服務端修改成同步介面,客戶端程式碼不動,試了一下,3萬個請求,客戶端報異常:由於目標計算機積極拒絕,無法連線。
    我把服務端的執行緒池改大一些,ThreadPool.SetMinThreads(200, 200),客戶還是報異常:遠端主機強迫關閉了一個現有的連線。

  2. 並行非同步請求(控制併發數量)
    3萬次請求,耗時大約60秒,很顯然服務端的吞吐量較低。
    把服務端的執行緒池改大一些,ThreadPool.SetMinThreads(200, 200),可以達到非同步介面同樣的吞吐量。

相關文章