2019年7月5日 星期五

為什麼需要使用非同步程式設計,真的可以提升整體應用程式的執行效能嗎?

為什麼需要使用非同步程式設計,真的可以提升整體應用程式的執行效能嗎?

大家對於為什麼需要使用非同步程式設計都存在著許多問題,難道真的使用非同步程式設計真的可以提升整體系統效能嗎?這個可以說是的,不過,這需要對於用戶呼叫端與提供服務的被呼叫端,最好都能夠全面的實作出具有使用非同步的設計,否則,將會有可能因為執行緒集區內因為預設的執行緒被長時間占用,有新的需求展生的時候,執行緒集區需要產生出新的背景執行緒,此時,將會造成這個系統會變成不好的執行效能,因為系統突然間會產生出大量額外的執行緒,這些執行的建立將會耗用額外的系統資源,每個新執行緒在建立的時候,需要額外的 1MB 的記憶體,而且建立這些記憶體所需要的各個執行緒需要用到的內容,也需要花費 CPU 的時間,另外,這些大量的執行緒也會造成作業系統存在著內容交換 Content Switch 的效能問題。
在這篇文章將會需要撰寫出使用同步方法的來呼叫遠端 Web API,這個 API 是使用 ASP.NET Core 所撰寫的,而且將會有兩個 Action,一個是使用同步方式執行的 Action,另外一個是採用非同步方式執行的 Action;而且在測試端將會使用 .NET Core 的專案建立起一個 Console 應用專案,執行一個 20 次的迴圈,呼叫同一個 API,該 API 將會需要 1.2 秒的時間才能夠得到執行結果。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

後端的 Web API : 同步 Action

首先來看看在後端 ASP.NET Core 專案中的兩個 Web API (可以在這個 LargeWebConnectionAPIServer 專案中看到這裡描述的程式碼) Action ( AddSync ),在底下的程式碼中,將會使用同步等待的方式, Thread.Sleep 的方式,造成該 Http 請求可以將會造成停留指定 delay 的 ms 時間,而且在這裡也會看到這個 Action 的方法使用 public string AddSync(int value1, int value2, int delay) 這樣方式的宣告。
C Sharp / C#
[HttpGet("AddSync/{value1}/{value2}/{delay}")]
public string AddSync(int value1, int value2, int delay)
{
    DateTime Begin = DateTime.Now;
    int workerThreadsAvailable;
    int completionPortThreadsAvailable;
    int workerThreadsMax;
    int completionPortThreadsMax;
    int workerThreadsMin;
    int completionPortThreadsMin;
    ThreadPool.GetAvailableThreads(out workerThreadsAvailable, out completionPortThreadsAvailable);
    ThreadPool.GetMaxThreads(out workerThreadsMax, out completionPortThreadsMax);
    ThreadPool.GetMinThreads(out workerThreadsMin, out completionPortThreadsMin);
    Thread.Sleep(delay);
    var fooUrl = Url.Action("ResponAndAwait2");
    DateTime Complete = DateTime.Now;
    return $"AW:{workerThreadsAvailable} AC:{completionPortThreadsAvailable}" +
        $" MaxW:{workerThreadsMax} MaxC:{completionPortThreadsMax}" +
        $" MinW:{workerThreadsMin} MinC:{completionPortThreadsMin} ({Begin.TimeOfDay} - {Complete.TimeOfDay})";
}

後端的 Web API : 非同步 Action

接著,在這個後端 ASP.NET Core 專案內,還有另外一個 Action (AddAsync ),如同底下的程式碼中,將會採用非同步等待的方式, await Task.Delay 的方式,使用非同步的方式來造成該 Http 請求可以將會造成停留指定 delay 的 ms 時間,所以,當執行 await 敘述之後,將會立即將該執行緒歸還給執行緒集區,若等待的執行到了,將會從執行緒集區內取得一個新執行緒,繼續後續的工作;而且在這裡也會看到這個 Action 的方法使用 public async Task<string> AddAsync(int value1, int value2, int delay) 這樣方式的宣告。這個 AddAsync 與 AddSync 的運作方式將會表現出不同的執行效果。
等下,將會在用戶端(也就是在 .NET Core 專案內),就算使用的是非同步方式來執行遠端 Web API 的呼叫,若後端的 Action 沒有使用非同步方式的設計,對於有大量的需求發生的時候,例如,若有 100 或者 1000 個 HTTP Request 請求發生的話,將會造成效能表現出不好。
C Sharp / C#
[HttpGet("AddAsync/{value1}/{value2}/{delay}")]
public async Task<string> AddAsync(int value1, int value2, int delay)
{
    DateTime Begin = DateTime.Now;
    int workerThreadsAvailable;
    int completionPortThreadsAvailable;
    int workerThreadsMax;
    int completionPortThreadsMax;
    int workerThreadsMin;
    int completionPortThreadsMin;
    ThreadPool.GetAvailableThreads(out workerThreadsAvailable, out completionPortThreadsAvailable);
    ThreadPool.GetMaxThreads(out workerThreadsMax, out completionPortThreadsMax);
    ThreadPool.GetMinThreads(out workerThreadsMin, out completionPortThreadsMin);
    await Task.Delay(delay);
    var fooUrl = Url.Action("ResponAndAwait2");
    DateTime Complete = DateTime.Now;
    return $"AW:{workerThreadsAvailable} AC:{completionPortThreadsAvailable}" +
        $" MaxW:{workerThreadsMax} MaxC:{completionPortThreadsMax}" +
        $" MinW:{workerThreadsMin} MinC:{completionPortThreadsMin} ({Begin.TimeOfDay} - {Complete.TimeOfDay})";
}
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

使用同步方式來進行大量的遠端 Web API 呼叫

在用戶端專案 (WhyNeedAsynchronous) 內,將會有 ConnectWebAPI() 這個測試方法,其程式碼如下所示,在這裡可以看到將會執行 MaxTasks 次的迴圈,每次迴圈執行的時候,將會產生一個 HttpClient 物件,並且使用 client.GetStringAsync(APIEndPoint).Result; 敘述進行遠端 Web API 呼叫,這個時候的 URL (APIEndPoint) 將會是 https://localhost:5001/api/values/AddSync/8/9/1200 ,這裡將會呼叫遠端 Web API 的同步 Action。
C Sharp / C#
public static void ConnectWebAPI()
{
    APIEndPoint = "https://localhost:5001/api/values/AddSync/8/9/1200";
    for (int i = 1; i <= MaxTasks; i++)
    {
        int idx = i;
        DateTime begin = DateTime.Now;
        Console.WriteLine($"Task{idx} Begin");
        HttpClient client = new HttpClient();
        string result = client.GetStringAsync(
            APIEndPoint).Result;
        DateTime complete = DateTime.Now;
        TimeSpan total = complete - begin;
        Console.WriteLine($"Task{idx} Completed ({total.TotalMilliseconds} ms)");
    }
}
這裡將會是執行結果,這裡將會執行 10 次的呼叫,也就是 MaxTasks 為 10
從底下的執行結果可以看出,由於用戶端這裡每呼叫一次 Web API,將需要等候這 Web API 執行結束之後,才能夠繼續執行下去;另外,在後端這個 Action 中,將會暫停 1.2 秒鐘的時間,並且這個測試 API 伺服器是在本地端,所以,可以看出除了第一個 API 呼叫,其他的呼叫幾乎 1.3 秒的時間,就會完成 API 的呼叫。
這10次的 API 呼叫,總共花費了 14.2 秒的時間
Task1 Begin
Task1 Completed (2796.4031 ms)
Task2 Begin
Task2 Completed (1267.8894 ms)
Task3 Begin
Task3 Completed (1275.1012 ms)
Task4 Begin
Task4 Completed (1283.1228 ms)
Task5 Begin
Task5 Completed (1259.7341 ms)
Task6 Begin
Task6 Completed (1264.8557 ms)
Task7 Begin
Task7 Completed (1257.8372 ms)
Task8 Begin
Task8 Completed (1257.6333 ms)
Task9 Begin
Task9 Completed (1267.7973 ms)
Task10 Begin
Task10 Completed (1271.3545 ms)
花費時間: 14208 ms

使用非同步方式來進行大量的遠端同步 Web API 呼叫

現在將原先使用同步方式呼叫遠端同步 Web API 的方法,改寫成為 public static async Task ConnectWebAPIAsync() ,如底下程式碼所示,在這裡將會使用 Task.Run 方法一次產生出 8 個 (請修正 static int MaxTasks = 8; 靜態變數為 8) 非同步工作,最後使用 await Task.WhenAll(tasks); 敘述來等候這些 8 個工作都完全結束執行,並且看看執行結果。
C Sharp / C#
public static async Task ConnectWebAPIAsync()
{
    APIEndPoint = "https://localhost:5001/api/values/AddSync/8/9/1200";
    List<Task> tasks = new List<Task>();

    for (int i = 1; i <= MaxTasks; i++)
    {
        int idx = i;
        tasks.Add(Task.Run(async () =>
        {
            DateTime begin = DateTime.Now;
            Console.WriteLine($"Task{idx} Begin");
            HttpClient client = new HttpClient();
            string result = await client.GetStringAsync(
                APIEndPoint);
            DateTime complete = DateTime.Now;
            TimeSpan total = complete - begin;
            Console.WriteLine($"Task{idx} Completed ({total.TotalMilliseconds} ms)");
        }));
    }

    await Task.WhenAll(tasks);
}
底下將會是同時啟動 8 個 Web API 的呼叫的執行結果,可以看出每個呼叫 Web API 將會花費掉 3.0 秒左右的時間,可是,因為採用非同步並行方式的執行,所以,全部8過程都執行完畢之後,將會需要 3.2 秒的時間,這就是非同步並行的好處。
Task1 Begin
Task4 Begin
Task7 Begin
Task3 Begin
Task8 Begin
Task5 Begin
Task2 Begin
Task6 Begin
Task8 Completed (2985.2111 ms)
Task2 Completed (2985.2325 ms)
Task1 Completed (2985.2408 ms)
Task3 Completed (2985.2731 ms)
Task7 Completed (3015.5964 ms)
Task5 Completed (3036.1804 ms)
Task4 Completed (3057.7806 ms)
Task6 Completed (3082.6267 ms)
花費時間: 3163 ms
你也可以自行測試看看,若同時啟動 4個 Web API 呼叫,整個過程的完成大約是 3.0 秒的時間。
Task1 Begin
Task2 Begin
Task4 Begin
Task3 Begin
Task3 Completed (2899.5366 ms)
Task4 Completed (2899.5434 ms)
Task2 Completed (2899.5468 ms)
Task1 Completed (2899.5584 ms)
花費時間: 2979 ms
現在,來做個測試,若同時啟動大量的非同步工作,在這裡將會啟動 20 個工作,那麼,你認為整個工作的完成大約需要多久的時間呢?底下是執行結果:
結果是整體大約需要 5.8 秒的時間才能夠完成,比起前面的測試,多花了許多時間,而且單一非同步工作耗用最多的時間約 5644.7863 ms,每個非同步工作的執行完成時間都不盡相同,似乎執行品質差太多了。
Task7 Begin
Task3 Begin
Task4 Begin
Task5 Begin
Task2 Begin
Task6 Begin
Task1 Begin
Task8 Begin
Task10 Begin
Task11 Begin
Task9 Begin
Task16 Begin
Task13 Begin
Task15 Begin
Task12 Begin
Task14 Begin
Task17 Begin
Task18 Begin
Task19 Begin
Task20 Begin
Task19 Completed (3048.035 ms)
Task7 Completed (4536.7249 ms)
Task4 Completed (4536.7274 ms)
Task5 Completed (4536.7361 ms)
Task9 Completed (3128.5916 ms)
Task11 Completed (3128.6298 ms)
Task14 Completed (3128.3731 ms)
Task6 Completed (4536.6977 ms)
Task1 Completed (4536.6914 ms)
Task12 Completed (3152.7945 ms)
Task8 Completed (4578.8109 ms)
Task2 Completed (4612.1355 ms)
Task10 Completed (3240.7134 ms)
Task16 Completed (3241.6604 ms)
Task17 Completed (3161.3379 ms)
Task13 Completed (3241.8153 ms)
Task18 Completed (3820.7351 ms)
Task20 Completed (4091.6754 ms)
Task3 Completed (5644.7863 ms)
Task15 Completed (4270.5756 ms)
花費時間: 5778 ms
若是設定為 100 個工作,這樣的實行結果約 18168 ms,底下是片段執行結果,從底下的執行結果可以看到幾乎每次呼叫 Web API 都需要約 15 秒以上的時間才能夠執行完成,而且,要等到 100 個非同步工作全部都完成,竟然需要 18 秒以上的時間。可是,在後端的 Web API ,不是每次都僅會停留 1.2 秒的時間,就會完成這次 HTTP Request 請求的呼叫,這裡竟然卻需要超過 15 秒以上的時間。
相信大家都有遇到這樣的情境,平常運作好好的後端 Web API 服務,突然間湧入了大量的 HTTP 請求,就會造成整體執行效能都全面的降低;可是,在用戶端這裡,不是有使用 Task.Run 來產生出大量的非同步工作,進行非同步的 Web API 呼叫,但是,似乎還是會造成效能嚴重延遲的缺陷。
原則上,這樣的問題將會發生在當要進行非同步呼叫的時候,必須用戶端與伺服器端都需要全面的實作出非同步作業,而且在非同步方法內呼叫的各類別庫 API 或者自己寫的 API,也都需要全面採用非同步方式的呼叫,這樣才會有效果。
.
.
.
Task88 Completed (15671.8551 ms)
Task81 Completed (15694.0615 ms)
Task80 Completed (15689.4539 ms)
Task82 Completed (15677.0617 ms)
Task78 Completed (15734.1956 ms)
Task84 Completed (15744.3717 ms)
Task96 Completed (15520.643 ms)
Task99 Completed (15540.1246 ms)
Task97 Completed (15691.7009 ms)
Task10 Completed (16636.2705 ms)
Task95 Completed (15691.5936 ms)
Task85 Completed (15827.9553 ms)
Task93 Completed (15731.722 ms)
Task45 Completed (16217.36 ms)
Task66 Completed (16009.9087 ms)
花費時間: 18168 ms
若此時使用同樣的用戶端呼叫程式碼,遠端的 Web API URL 改成佈署在 Azure 上的 Web 伺服器,此時,此時,同樣會產生 100 個非同步呼叫遠端 Web API,不過會呼叫遠端的同步 Web API,會得到這樣的結果:
在這個時候將會得到總共花費了 22 秒的時間才完成 100 個 API 呼叫
.
.
.
Task27 Completed (20247.6625 ms)
Task41 Completed (20240.1331 ms)
Task68 Completed (19881.8897 ms)
Task51 Completed (19995.4965 ms)
Task63 Completed (19999.0567 ms)
Task40 Completed (20270.6105 ms)
Task80 Completed (19810.503 ms)
Task39 Completed (20201.4586 ms)
Task44 Completed (20241.756 ms)
Task49 Completed (20182.8585 ms)
Task86 Completed (19773.1069 ms)
Task66 Completed (20001.9611 ms)
Task8 Completed (21899.3644 ms)
Task14 Completed (20485.2145 ms)
Task98 Completed (19729.4884 ms)
Task72 Completed (19983.916 ms)
Task73 Completed (20003.6746 ms)
Task92 Completed (19807.5306 ms)
花費時間: 22206 ms

使用非同步方式來進行大量的遠端非同步 Web API 呼叫

現在將用戶端要呼叫的 Web API,從 https://localhost:5001/api/values/AddSync/8/9/1200 改寫成為 https://localhost:5001/api/values/AddASync/8/9/1200 ,因此將建立起另外一個方法 public static async Task ConnectAsyncWebAPIAsync(),如底下程式碼所示,在這裡切換成為呼叫遠端非同步的 API,其他的程式碼將維持不變,如同上一個方法相同。
C Sharp / C#
public static async Task ConnectAsyncWebAPIAsync()
{
    APIEndPoint = "https://localhost:5001/api/values/AddASync/8/9/1200";
    List<Task> tasks = new List<Task>();

    for (int i = 1; i <= MaxTasks; i++)
    {
        int idx = i;
        tasks.Add(Task.Run(async () =>
        {
            DateTime begin = DateTime.Now;
            Console.WriteLine($"Task{idx} Begin");
            HttpClient client = new HttpClient();
            string result = await client.GetStringAsync(
                APIEndPoint);
            DateTime complete = DateTime.Now;
            TimeSpan total = complete - begin;
            Console.WriteLine($"Task{idx} Completed ({total.TotalMilliseconds} ms)");
        }));
    }

    await Task.WhenAll(tasks);
}
現在已經切換使用了後端的非同步 Action,所以,當啟動 100 非同步工作同時呼叫遠端的非同步API,全部 API 呼叫完成僅需 7780 ms 的時間,比起前一個測試,雖然也同時產生 100 非同步工作,但是呼叫的卻是遠端的同步 API,而全部完成 100 API 呼叫,將需要 18168 ms;所以前端與後端都全部使用非同步的 API,整體執行上快了 10 秒鐘的時間。
.
.
.
Task50 Completed (5148.6828 ms)
Task38 Completed (5360.4139 ms)
Task25 Completed (5587.0058 ms)
Task37 Completed (5434.9276 ms)
Task63 Completed (5197.6284 ms)
Task65 Completed (5211.5087 ms)
Task64 Completed (5308.2167 ms)
Task62 Completed (5322.1625 ms)
Task52 Completed (5470.9549 ms)
Task61 Completed (5403.1796 ms)
Task59 Completed (5465.9516 ms)
Task82 Completed (5298.9463 ms)
Task55 Completed (5599.2855 ms)
Task71 Completed (5514.2504 ms)
Task72 Completed (5543.5817 ms)
Task78 Completed (5544.1085 ms)
Task67 Completed (5633.3116 ms)
花費時間: 7780 ms
現在,同樣的使用遠端非同步 API 來呼叫,一樣的改為 Web API URL 改成佈署在 Azure 上的 Web 伺服器,此時,此時,同樣會產生 100 個非同步呼叫遠端非同步 Web API,會得到這樣的結果:
在這個時候將會得到總共花費了 5.2 秒的時間才完成 100 個 API 呼叫,可以看出雖然有大量的 HTTP 請求產生了,但是,卻不會造成要花費 22 秒才能夠完成的窘境。
.
.
.
Task57 Completed (3013.9469 ms)
Task1 Completed (4921.3941 ms)
Task38 Completed (3297.1535 ms)
Task19 Completed (3424.446 ms)
Task91 Completed (2711.7554 ms)
Task30 Completed (3401.6785 ms)
Task16 Completed (3613.8872 ms)
Task53 Completed (3172.1048 ms)
Task86 Completed (2924.069 ms)
Task15 Completed (3700.262 ms)
Task84 Completed (2964.108 ms)
Task76 Completed (3021.8329 ms)
Task59 Completed (3200.5844 ms)
Task6 Completed (5106.9847 ms)
花費時間: 5205 ms

其他參考

對於當使用了 HttpClient 工廠方法 Factory 來取得一個 HttpClient 執行個體 或者 單一靜態 HttpClient 物件 又或者 每次都會產生出一個新的 HttpClient 執行個體 做多次遠端 同步或者非同步 Web API 存取之效能比較,可以參考 分別使用 HttpClient Factory / 靜態 HttpClient / HttpClient 執行個體 做多次遠端 同步或者非同步 Web API 存取之效能比較 這篇文章






1 則留言: