C#非同步程式設計是怎麼回事(番外)

ggtc發表於2024-06-08

在上一篇通訊協議碰到了多執行緒,阻塞、非阻塞、鎖、訊號量...,會碰到很多問題。因此我感覺很有必要研究多執行緒非同步程式設計

首先以一個例子開始

image

我說明一下這個例子。
這是一個演示非同步程式設計的例子。

  • 輸入job [name],在一個同步的Main方法中,以一發即忘的方式呼叫非同步方法StartJob()
  • 輸入time,呼叫同步方法PrintCurrentTime()輸出時間。
  • 輸出都帶上執行緒ID,便於觀察。
    可以看到,主執行緒不會阻塞。主執行緒在同步方法中使用一發即忘的方式呼叫非同步方法時,在非同步方法中碰到阻塞時,主執行緒返回同步方法中繼續執行。而非同步方法在另一個執行緒中繼續執行。
    程式如下
internal class Program
{
    static void Main(string[] args)
    {
        while (true)
        {
            Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Enter 'time' to get current time or 'job [name]' to start a job:");
            string input = Console.ReadLine();

            if (input.StartsWith("time"))
            {
                // 輸出當前時間
                PrintCurrentTime();
            }
            else if (input.StartsWith("job"))
            {
                // 啟動一個非同步任務,執行指定的工作
                string[] parts = input.Split(new char[] { ' ' }, 2);
                string jobName = parts.Length > 1 ? parts[1] : string.Empty;
                StartJob(jobName);
            }
            else
            {
                Console.WriteLine("Invalid input. Please try again.");
            }
        }
    }

    static void PrintCurrentTime()
    {
        Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Current time: {DateTime.Now}");
    }

    static async void StartJob(string jobName)
    {
        // 獲取主執行緒的執行緒 ID
        int mainThreadId = Thread.CurrentThread.ManagedThreadId;

        // 檢查是否在主執行緒上
        bool onMainThread = Thread.CurrentThread.ManagedThreadId == mainThreadId;

        Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Starting job '{jobName}'. This will take 10 seconds...");

        // 輸出主執行緒上下文移動情況
        Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Main thread context moved to new thread: {(!onMainThread)}");

        await Task.Delay(10000); // 模擬任務需要10秒鐘完成

        // 輸出任務完成資訊及上下文移動情況
        Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Job '{jobName}' completed. Main thread context moved to new thread: {(!onMainThread)}");
    }

}

上下文流轉

一個方法從一個執行緒程式碼棧被切換,或者說被剪下到另一個執行緒程式碼棧上去,可以稱為上下文流轉
這對於理解非同步程式設計是一個重要的點。
但由於上面的程式缺少必要變數,我需要在不同位置加幾個變數,來展示上下文確實被移動了。

static async void StartJob(string jobName)
{
	int mainThreadId = Thread.CurrentThread.ManagedThreadId;
	// 檢查是否在主執行緒上
	bool onMainThread = Thread.CurrentThread.ManagedThreadId == mainThreadId;
	...
}

image
可以看到onMainThread一直為False,這個變數從執行緒1移動到執行緒5
而且bool是值型別,在棧上面,這說明StartJob這段程式碼確實移動到執行緒5的棧上面去了。(每個執行緒都有一個呼叫棧)

使用VS除錯視窗監視執行緒

想要再進一步,更清晰的話說明上下文流轉的話,那就得監視這兩個執行緒棧的內容了。萬幸的是 vs提供了這個功能,除錯 > 視窗 > 並行堆疊

  • 命中斷點時,StartJob方法在主執行緒24876上
    image

  • 10秒後再次命中,StartJob方法跑到了任務執行緒上。而主執行緒現在在Main函式的Console.ReadLine()那裡阻塞
    image

  • 程式碼阻塞與執行緒阻塞
    在上面的例子中我們引出兩種現象,程式碼阻塞執行緒阻塞
    程式碼阻塞時,執行緒不一定阻塞,原執行緒沒有阻塞,去執行別的程式碼了,而由新執行緒接手當前上下文和呼叫棧阻塞在這裡,比如這裡的await Task.Delay(10000)
    程式碼阻塞時執行緒也可能阻塞,比如lock(lockObj)Console.ReadLine()
    為了方便,我們姑且這樣命名吧

    • 程式碼阻塞時,執行緒不阻塞稱之為等待await
    • 程式碼阻塞時,執行緒也阻塞稱之為阻塞block
  • 為什麼有兩個箭頭
    這裡為什麼有執行緒24666和27548兩個NET TP Worker(.NET Thread Pool (TP) Worker)?據chatGPT解釋,Delay語句線上程池中找了一個執行緒去執行,一旦延遲時間到達,StartJob會在其中一個執行緒池執行緒上恢復執行。計時是一個執行緒,恢復上下文是另一個執行緒。Delay就代表了我們的那個耗時執行緒(不是非同步方法所線上程)。
    既然有兩個執行緒的聯動,其中就出現了一些熟悉的東西。訊號量Semaphore,一次性訊號量的消耗TrySetResult,但詳細過程我還不清楚。
    MSDN上的例子也是這樣
    image

以同步的方式進行非同步程式設計

原來把非同步方法的上下文移動到新執行緒N,保證主執行緒不阻塞(脫離主執行緒U)。然後N用第三個執行緒C執行耗時任務,最後把C結果給位於N中的上下文。
站在程式碼編寫者的角度,不特意去看執行緒的話,就不會注意到非同步方法的上下文從一個執行緒跑到另一個執行緒上去了。這就是所謂的以同步的方式進行非同步程式設計。
那麼執行緒N的執行就明晰了。先儲存上下文,然後啟用新執行緒C進行耗時任務,並阻塞。等C使用訊號量或其他什麼通知N時,N再根據C的結果繼續執行。
可以這樣總結

  • asyncawait是一個語法糖。
  • 以同步的方式進行非同步程式設計的方式是使用語法糖,以同步的方式書寫程式碼,然後編譯成適當的非同步的實現。

我列出幾種可能的非同步的實現

1. 非同步狀態機

  • 非同步狀態機是C#編譯async語法糖的實現方式
  • 非同步方法StartJob將會被編譯成一個同步方法StartJobAsync和一個狀態機StartJobAsyncMachine
  • 狀態機流轉上下文的方式是將新執行緒用到的變數提升為欄位,儲存於可被執行緒共享的程序堆中
  • MoveNext方法可以被不同執行緒執行,這是關鍵
點選檢視程式碼
internal class Program
{
    ...

    internal static void StartJobAsync(string jobName)
    {
        StartJobAsyncMachine stateMachine = new StartJobAsyncMachine();
        stateMachine.builder = AsyncVoidMethodBuilder.Create();
        stateMachine.jobName = jobName;
        stateMachine.state = -1;
        stateMachine.builder.Start(ref stateMachine);
    }

    public sealed class StartJobAsyncMachine : IAsyncStateMachine
    {
        public int state;

        public AsyncVoidMethodBuilder builder;

        private TaskAwaiter taskAwaiter;

        //形參會編譯成public欄位
        public string jobName;
        //被新執行緒使用的區域性變數會編譯成private欄位
        private bool onMainThread;

        private void MoveNext()
        {
            int num = state;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)
                {
                    // 獲取主執行緒的執行緒 ID
                    int mainThreadId = Thread.CurrentThread.ManagedThreadId;

                    // 檢查是否在主執行緒上
                    onMainThread = Thread.CurrentThread.ManagedThreadId == mainThreadId;

                    Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Starting job '{jobName}'. This will take 10 seconds...");

                    // 輸出主執行緒上下文移動情況
                    Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Main thread context moved to new thread: {(!onMainThread)}");
                    awaiter = Task.Delay(10000).GetAwaiter();

                    if (!awaiter.IsCompleted)
                    {
                        num = (state = 0);
                        taskAwaiter = awaiter;
                        StartJobAsyncMachine stateMachine = this;
                        builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                        return;
                    }
                }
                else
                {
                    awaiter = taskAwaiter;
                    taskAwaiter = default(TaskAwaiter);
                    num = (state = -1);
                }
                awaiter.GetResult();
                // 輸出任務完成資訊及上下文移動情況
                Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Job '{jobName}' completed. Main thread context moved to new thread: {(!onMainThread)}");
            }
            catch (Exception exception)
            {
                state = -2;
                builder.SetException(exception);
                return;
            }
            state = -2;
            builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            this.MoveNext();
        }

        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            this.SetStateMachine(stateMachine);
        }

    }
}

StartJobAsync的呼叫和原方法等效。我在Main中在加一種指令jobMachine呼叫StartJobAsync。原來的改為job空格

else if (input.StartsWith("jobMachine "))
{
    // 啟動一個非同步任務,執行指定的工作
    string[] parts = input.Split(new char[] { ' ' }, 2);
    string jobName = parts.Length > 1 ? parts[1] : string.Empty;
    StartJobAsync(jobName);
}

image

2. 協程

這種方法到底叫協程還是非同步迭代器,我不太分得清,但目的是能夠達到的,我暫且就叫做協程好了。
雖然這種做法就像脫褲子放屁,因為協程最後也會編譯成狀態機。這個例子主要是為了演示直觀。
理論上,C#中的非同步/等待(async/await)語法並不是直接編譯成協程的,而是由編譯器生成狀態機(state machine)來管理非同步操作。但是,我們可以透過理解協程的工作原理以及C#非同步/等待模型的特性,來描繪一種可能的編譯結果。
這裡我寫了一個基於協程的非同步的實現。效果和原來的等同。

  • 原理
    和狀態機實現基本一樣。對於每個async方法生成一個協程。而且在非同步方法巢狀時,那麼async方法內部的async方法在編譯時就不需要開一個新執行緒了。要不然得多少執行緒。
internal class Program
{
    static void Main(string[] args)
    {
        while (true)
        {
            ...
            else if (input.StartsWith("jobCorotine "))
            {
                // 啟動一個非同步任務,執行指定的工作
                string[] parts = input.Split(new char[] { ' ' }, 2);
                string jobName = parts.Length > 1 ? parts[1] : string.Empty;
                StartJobAsync_2(jobName);
            }
            ...
        }
    }

    #region 非同步協程
    static void StartJobAsync_2(string jobName)
    {
        StartJobAsyncCorotine startJobCorotine = new StartJobAsyncCorotine();
        startJobCorotine.jobName = jobName;
        var enumerator = startJobCorotine.DelayedOperations();
        var iterator = enumerator.GetEnumerator();
        bool next = false;
        while (true)
        {
            next = iterator.MoveNext();
            if (!iterator.Current.IsCompleted)
            {
                //非同步方法中存在耗時任務,切換到新執行緒
                break;
            }
            next = false;
        }
        if (next == false)
        {
            return;
        }
        //非同步方法存在耗時任務,切換上下文到新執行緒
        Task.Run(() =>
        {
            do
            {
                if (!iterator.Current.IsCompleted)
                {
                    //建立耗時任務執行緒進行耗時任務
                    Task.Run(() =>
                    {
                        iterator.Current.GetResult();
                    }).Wait();
                }
            }
            while (iterator.MoveNext());
        });
    }

    public sealed class StartJobAsyncCorotine
    {
        //形參因為需要執行時賦值,只能寫成欄位的形式
        public string jobName;

        public int Count = 1;

        public IEnumerable<TaskAwaiter> DelayedOperations()
        {
            TaskAwaiter awaiter1;

            // 獲取主執行緒的執行緒 ID
            int mainThreadId = Thread.CurrentThread.ManagedThreadId;

            // 檢查是否在主執行緒上
            bool onMainThread = Thread.CurrentThread.ManagedThreadId == mainThreadId;

            Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Starting job '{jobName}'. This will take 10 seconds...");

            // 輸出主執行緒上下文移動情況
            Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Main thread context moved to new thread: {(!onMainThread)}");

            awaiter1 = Task.Delay(10000).GetAwaiter(); // 模擬任務需要10秒鐘完成
            //出去判斷這是否是耗時任務以切換執行緒
            yield return awaiter1;

            // 輸出任務完成資訊及上下文移動情況
            Console.WriteLine($"(Thread ID: {Thread.CurrentThread.ManagedThreadId}) Job '{jobName}' completed. Main thread context moved to new thread: {(!onMainThread)}");
        }
    }
    #endregion
}
  • 效果確實和原來一樣

image

3. 閉包

這真不需要多說,透過閉包進行捕獲上下文真的是太常見了,Ajax中用到吐🤮

帶返回值的上下文流轉

StartJob是沒有返回值的,假如我們需要一個返回值呢,比如一個bool,用於判斷接下來的執行流程。
呼叫非同步方法StartJob的同步方法Main之間存在著絕對的分界線——兩個執行緒。同步方法不會被交給非同步方法中的那個新執行緒,沒法在同步方法中以同步的方式進行非同步程式設計
唯一的一點看頭是,至少Task還給我們留下了一個回撥ContinueWith可用。但條件允許的話,何不把回撥的內容寫在非同步方法內部呢?

相關文章