1. 程式人生 > 實用技巧 >C#同步方法中呼叫非同步方法

C#同步方法中呼叫非同步方法

一、結果:

關於ThreadPool 中的執行緒呼叫演算法,其實很簡單,每個執行緒都有一個自己的工作佇列local queue,此外執行緒池中還有一個global queue全域性工作佇列,首先一個執行緒被創建出來後,先看看自己的工作佇列有沒有被分配task,如果沒有的話,就去global queue找task,如果還沒有的話,就去別的執行緒的工作佇列找Task。

第二種情況:在同步方法裡呼叫非同步方法,不wait()

如果這個非同步方法進入的是global Task 則線上程飢餓的情況下,也會發生死鎖的情況。至於為什麼,可以看那篇博文裡的解釋,因為global Task的優先順序很高,所有新產生的執行緒都去執行global Task,而global task又需要一個執行緒去執行local task,所以產生了死鎖。

二、過程

我在寫程式碼的時候(.net core)有時候會碰到void方法裡,

呼叫async方法並且Wait,而且我還看到別人這麼寫了。

而且我這麼寫的時候,編譯器沒有提示任何警告。

但是看了文章:一碼阻塞,萬碼等待:ASP.NET Core 同步方法呼叫非同步方法“死鎖”的真相 瞭解。

1.同步方法裡呼叫非同步方法

同步方法裡呼叫非同步方法,一種是wait() 一種是不wait();

private void fun()
{  
    funAsync.Wait();
    funAsync();
}

這兩種場景都沒有編譯錯誤。首先我們來看一下,在 void裡呼叫 async 方法,

並且要等待async的結果出來之後,才能進行後續的操作。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleTool2
{
    private class Program
    {
        private static void Main(string[] args)
        {
            Producer();
        }

        private static void Producer()
        {
            
var result = Process().Result; //或者 //Process().Wait(); } private static async Task<bool> Process() { await Task.Run(() => { Thread.Sleep(1000); }); Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString()); return true; } } }

這個Producer,這是一個void方法,裡面呼叫了非同步方法Process()

其中Process()是一個執行1秒的非同步方法,呼叫的方式是Process().Result 或者Process().Wait(),咱們來執行一遍。

沒有任何問題。看起來,這樣寫完全沒有問題啊,不報錯,執行也是正常的。

接下來,我們修改一下程式碼,讓程式碼更加接近生產環境的狀態。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleTool2
{
    class Program
    {
        private static void Main(string[] args)
        {
            while (true)
            {
                Task.Run(Producer);
                Thread.Sleep(200);
            }
        }

        private static void Producer()
        {
            var result = Process().Result;
        }

        private static async Task<bool> Process()
        {
            await Task.Run(() =>
            {
                Thread.Sleep(1000);
            });

            Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
            return true;
        }
    }
}

在Main函式里加了for迴圈,並且1秒鐘執行5次Producer(),使用Task.Run(),1秒鐘有5個Task產生。相當於生產環境的qps=5。接下來我們再執行下,看看結果:

沒有CPU消耗,但是執行緒數一直增加,直到突破一臺電腦的最大執行緒數,導致伺服器宕機。這明顯出現問題了,執行緒肯定發生了死鎖,而且還在不斷產生新的執行緒。

至於為什麼只執行了兩次Task,我們可以猜測是因為程式中初始的TreadPool 中只有兩個執行緒,所以執行了兩次Task,然後就發生了死鎖。

現在我們定義一個Produce2() 這是一個正常的方法,非同步函式呼叫非同步函式。

private static async Task Producer2()
  {
      await Process();
  }

仔細觀察這個圖,我們發現第一秒執行了一個Task,第二秒執行了三個Task,從第三秒開始,就穩定執行了4-5次Task,這裡的時間統計不是很精確,

但是可以肯定從某個時間開始,程式達到了預期效果,TreadPool中的執行緒每秒中都能穩定的完成任務。而且我們還能觀察到,在最開始,

程式是反應很慢的,那個時候執行緒不夠用,同時應該在申請新的執行緒,直到後來執行緒足夠處理這樣的情況了。咱們再看看這個時候的程序資訊:

執行緒數一直穩定在25個,也就是說25個執行緒就能滿足這個程式的運行了。到此我們可以證明,在同步方法裡呼叫非同步方法確實是不安全的,尤其在併發量很高的情況下。

探究原因

我們再深層次討論下為什麼同步方法裡呼叫非同步方法會卡死,而非同步方法呼叫非同步方法則很安全呢?

咱們回到一開始的程式碼裡,我們加上一個初始化執行緒數量的程式碼,看看這樣是否還是會出現卡死的狀況。由於前面的分析我們知道,這個程式在一秒中並行執行5個Task,每個Task裡面也就是Producer 都會執行一個Processer 非同步方法,所以粗略估計需要10個執行緒。於是我們就初始化執行緒數為10個。

using System;
    using System.Threading;
    using System.Threading.Tasks;

    namespace ConsoleTool2
    {
       private  class Program
        {
            private  static void Main(string\[\] args) {
                ThreadPool.SetMinThreads(10, 10);

                while (true)
                {
                    Task.Run(Producer2);
                    Thread.Sleep(200);
                }
            }

            private static void Producer() {
                var result = Process().Result;
            }

            private static async Task Producer2() {
                await Process();
            }

            private static async Task<bool\> Process() {
                await Task.Run(() =>
                {
                    Thread.Sleep(1000);
                });

                Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
                return true;
            }
        }
    }

執行一下發現,是沒問題的。說明一開始設定多的執行緒是有用的,經過實驗發現,只要初始執行緒小於10個,都會出現死鎖。

而.net core的預設初始執行緒是肯定小於10個的。那麼當初始執行緒小於10個的時候,發生什麼了?發生了大家都聽說過的名詞,執行緒飢餓。

就是執行緒不夠用了,這個時候ThreadPool生產新的執行緒滿足需求。然後我們再關注下,同步方法裡呼叫非同步方法並且.Wait()的情況下會發生什麼。

private void Producer()
    {
        Process().Wait()
    }

首先有一個執行緒A ,開始執行Producer , 它執行到了Process 的時候,新產生了一個的執行緒 B 去執行這個Task。

這個時候 A 會掛起,一直等 B 結束,B被釋放,然後A繼續執行剩下的過程。這樣執行一次Producer 會用到兩個執行緒,

並且A 一直掛起,一直不工作,一直在等B。這個時候執行緒A 就會阻塞。

Task Producer()
    {
       await Process();
    }

這個和上面的區別就是,同時執行緒A,它執行到Producer的時候,產生了一個新的執行緒B執行 Process。

但是 A 並沒有等B,而是被ThreadPool拿來做別的事情,等B結束之後,ThreadPool 再拿一個執行緒出來執行剩下的部分。所以這個過程是沒有執行緒阻塞的。

再結合線程飢餓的情況,也就是ThreadPool 中發生了執行緒阻塞+執行緒飢餓,會發生什麼呢?假設一開始只有8個執行緒,第一秒中會並行執行5個Task Producer,

5個執行緒被拿來執行這5個Task,然後這個5個執行緒(A)都在阻塞,並且ThreadPool 被要求再拿5個執行緒(B)去執行Process,但是執行緒池只剩下3個執行緒,

所以ThreadPool 需要再產生2個執行緒來滿足需求。但是ThreadPool 1秒鐘最多生產2個執行緒,等這2個執行緒被生產出來以後,又過去了1秒,這個時候無情又進來5個Task,又需要10個執行緒了。

別忘了執行第一波Task的一些執行緒應該釋放了,釋放多少個呢?應該是3個Task佔有的執行緒,因為有2個在等TreadPool生產新執行緒嘛。

所以釋放了6個執行緒,5個Task,6個執行緒,計算一下,就可以知道,只有一個Task可以被完全執行,其他4個都因為沒有新的執行緒執行Process而阻塞。

於是ThreadPool 又要去產生4個新的執行緒去滿足4個被阻塞的Task,花了2秒時間,終於生產完了。但是糟糕又來了10個Task,需要20個執行緒,

而之前釋放的執行緒已經不足以讓任何一個Task去執行Process了,因為這些不足的執行緒都被分配到了Producer上,沒有執行緒再可以去執行Process了(經過上面的分析一個Task需要2個執行緒A,B,並且A阻塞,直到B執行Process完成)。

所以隨著時間的流逝,要執行的Task越來越多卻沒有一個能執行結束,而執行緒也在不斷產生,就產生了我們上面所說的情況。

## 我們該怎麼辦?經過上面的分析我們知道,線上程飢餓的情況下,使用同步方法呼叫非同步方法並且wait結果,是會出問題的,那麼我們應該怎麼辦呢?

首先當然是應該避免這種有風險的做法。其次,還有一種方法。經過實驗,我發現,使用專有執行緒

Task.Run(Producer);
    改成
    Task.Factory.StartNew(
              Producer,
              TaskCreationOptions.LongRunning
       );

就是TaskCreationOptions.LongRunning 選項,就是開闢一個專用執行緒,而不是在ThreadPool中拿執行緒,這樣是不會發生死鎖的。

因為ThreadPool 不管理專用執行緒,每一個Task進來,都會有專門的執行緒執行,而Process 則是由ThreadPool 中的執行緒執行,這樣TheadPool中的執行緒其實是不存在阻塞的,因此也不存在死鎖。

結語

關於ThreadPool 中的執行緒呼叫演算法,其實很簡單,每個執行緒都有一個自己的工作佇列local queue,此外執行緒池中還有一個global queue全域性工作佇列,首先一個執行緒被創建出來後,先看看自己的工作佇列有沒有被分配task,如果沒有的話,就去global queue找task,如果還沒有的話,就去別的執行緒的工作佇列找Task。

第二種情況:在同步方法裡呼叫非同步方法,不wait()

如果這個非同步方法進入的是global Task 則線上程飢餓的情況下,也會發生死鎖的情況。至於為什麼,可以看那篇博文裡的解釋,因為global Task的優先順序很高,所有新產生的執行緒都去執行global Task,而global task又需要一個執行緒去執行local task,所以產生了死鎖。