C#非同步程式設計由淺入深(三)細說Awaiter

白煙染黑墨發表於2022-03-01

  上一篇末尾提到了Awaiter這個型別,上一篇說了,能await的物件,必須包含GetAwaiter()方法,不清楚的朋友可以看上篇文章。那麼,Awaiter到底有什麼特別之處呢?
  首先,從上篇文章我們知道,一個Awaiter必須實現INotifyCompletion介面,這個介面定義如下:

namespace System.Runtime.CompilerServices
{
    /// <summary>
    /// Represents an operation that will schedule continuations when the operation completes.
    /// </summary>
    public interface INotifyCompletion
    {
        /// <summary>Schedules the continuation action to be invoked when the instance completes.</summary>
        /// <param name="continuation">The action to invoke when the operation completes.</param>
        /// <exception cref="System.ArgumentNullException">The <paramref name="continuation"/> argument is null (Nothing in Visual Basic).</exception>
        void OnCompleted(Action continuation);
    }
}

  除此之外還必須包含IsCompleted屬性和包含GetResult()方法。
  注意OnCompleted的引數是一個Action委託,並且不出意外的話,委託裡面總會有一個地方呼叫一個MoveNext()方法,它推動狀態機到達下一個狀態,然後執行下一個狀態需要執行的程式碼。
  那麼,知道這個有什麼用呢?第一,它是你充分了解async/await這套機制的基礎,包括與之相關的同步上下文、執行上下文、死鎖問題等,第二,它可以實現一些特殊的功能。
  從上一篇我們知道,OnCompleted中的contination的主要目的是推動狀態機的執行,也就是推動非同步方法中await後面部分的程式碼執行。從這裡看出,continuation的執行是受我們控制的,因此我們可以直接執行它,或是等待某個條件成熟然後執行它,我們可以把它放到執行緒池執行,也可以單獨起一個執行緒執行。譬如,我們可以讓await後面部分的程式碼直接線上程池上執行。

public static async Task AwaiterTest()
{
    Console.WriteLine($"是否是執行緒池執行緒?{Thread.CurrentThread.IsThreadPoolThread}");
    await default (SkipToThreadPoolAwaiter);
    Console.WriteLine($"是否是執行緒池執行緒?{Thread.CurrentThread.IsThreadPoolThread}");
}

static void Main(string[] args)
{
    _ = AwaiterTest();
    Console.ReadLine();
}

public struct SkipToThreadPoolAwaiter : INotifyCompletion
{
    public bool IsCompleted => false;
    public void GetResult() 
    {
        Console.WriteLine("呼叫GetResult以獲取結果");
    }
    public void OnCompleted(Action continuation)
    {
        Console.WriteLine("呼叫OnCompleted,把Await後面部分要執行的程式碼傳遞過來(傳遞MoveNext,以推動狀態機流轉)");
        ThreadPool.QueueUserWorkItem(state =>
        {
            Console.WriteLine("開始執行Await後面部分的程式碼");
            continuation();
            Console.WriteLine("後面部分的程式碼執行完畢");
        });
        Console.WriteLine("返回撥用執行緒");
    }

    public SkipToThreadPoolAwaiter GetAwaiter()
    {
        Console.WriteLine("獲得Awaiter");
        return this;
    }
}

  這是一個控制檯程式,輸出結果如下。

是否是執行緒池執行緒?False
獲得Awaiter
呼叫OnCompleted,把Await後面部分要執行的程式碼傳遞過來(傳遞MoveNext,以推動狀態機流轉)
返回撥用執行緒
開始執行Await後面部分的程式碼
呼叫GetResult以獲取結果
是否是執行緒池執行緒?True
後面部分的程式碼執行完畢

  特別注意一下,第五步說明可能有點疑惑,怎麼第六步不是列印是否是執行緒池執行緒?原因是部分awaiter是有返回值的,在執行await後面部分的程式碼時,會首先呼叫GetResult()以獲取結果。這對編譯器改造非同步方法來說是一個固定的模式(上篇文章沒有體現這一步)。
  把Awaiter改成有返回值嘗試。

public static async Task AwaiterTest()
{
    Console.WriteLine($"是否是執行緒池執行緒?{Thread.CurrentThread.IsThreadPoolThread}");
    var res = await default (SkipToThreadPoolAwaiter);
    Console.WriteLine($"結果是{res}");
    Console.WriteLine($"是否是執行緒池執行緒?{Thread.CurrentThread.IsThreadPoolThread}");
}

static void Main(string[] args)
{
    _ = AwaiterTest();
    Console.ReadLine();
}

public struct SkipToThreadPoolAwaiter : INotifyCompletion
{
    public bool IsCompleted => false;
    public int GetResult() 
    {
        Console.WriteLine("呼叫GetResult以獲取結果");
        return 1;
    }
    public void OnCompleted(Action continuation)
    {
        Console.WriteLine("呼叫OnCompleted,把Await後面部分要執行的程式碼傳遞過來(傳遞MoveNext,以推動狀態機流轉)");
        ThreadPool.QueueUserWorkItem(state =>
        {
            Console.WriteLine("開始執行Await後面部分的程式碼");
            continuation();
            Console.WriteLine("後面部分的程式碼執行完畢");
        });
        Console.WriteLine("返回撥用執行緒");
    }

    public SkipToThreadPoolAwaiter GetAwaiter()
    {
        Console.WriteLine("獲得Awaiter");
        return this;
    }
}

  輸出如下

是否是執行緒池執行緒?False
獲得Awaiter
呼叫OnCompleted,把Await後面部分要執行的程式碼傳遞過來(傳遞MoveNext,以推動狀態機流轉)
返回撥用執行緒
開始執行Await後面部分的程式碼
呼叫GetResult以獲取結果
結果是1
是否是執行緒池執行緒?True
後面部分的程式碼執行完畢

  對照前面的文章來看,相信你應該有所得,能解決你部分的疑惑。前面說到,我們可以控制continuation的執行,那如果當前執行緒有同步上下文(SychronizationContext),我們是不是可以放到同步上下文中執行?TaskAwaiter是會這麼做的,如果你不想它使用同步上下文,你可以在Task例項上呼叫ConfigureAwait(false),它表面後面部分的程式碼將不會使用同步上下文執行。
  另外說一下Task.Yield()這個Awaiter,他的行為是捕捉同步上下文,如果有,則會放到同步上下文中執行,如果沒有,則會放到執行緒池中執行。在窗體程式中,有時候你開啟一個模態對話方塊,會導致主窗體部分的動畫沒有反應,在模態對話方塊關閉之後,才會反應。原因是模態對話方塊阻塞了主窗體的訊息迴圈,也就是阻塞了主執行緒,如果想讓動畫先完成,然後再開啟模態對話方塊,則可以在開啟模態對話方塊之前,Await Task.Yield(),這也對應了它的意思,讓渡之意。
  後面文章還會說明同步上下文具體是什麼、非同步程式碼中使用同步程式碼會導致死鎖的本質原因、如何實現類似Task的類,並且怎麼與Async/await這套機制搭配使用等知識。
  覺得有收穫的不妨點個贊,有支援才有動力寫出更好的文章。(.Net深入學習交流群(617374043),歡迎加入!)

相關文章