.net 溫故知新:【5】非同步程式設計 async await

XSpringSun發表於2021-08-26

1、非同步程式設計

非同步程式設計是一項關鍵技術,可以直接處理多個核心上的阻塞 I/O 和併發操作。 通過 C#、Visual Basic 和 F# 中易於使用的語言級非同步程式設計模型,.NET 可為應用和服務提供使其變得可響應且富有彈性。

上面是關於非同步程式設計的解釋,我們日常程式設計過程或多或少的會使用到非同步程式設計,為什麼要試用非同步程式設計?因為用程式處理過程中使用檔案和網路 I/O,比如處理檔案的讀取寫入磁碟,網路請求介面API,預設情況下 I/O API 一般會阻塞。
這樣的結果是導致我們的使用者介面卡住體驗差,有些伺服器的硬體利用率低,服務處理能力請求響應慢等問題。基於任務的非同步 API 和語言級非同步程式設計模型改變了這種模型,只需瞭解幾個新概念就可預設進行非同步執行。

現在普遍使用的非同步程式設計模式是TAP模式,也就是C# 提供的 async 和 await 關鍵詞,實際上我們還有另外兩種非同步模式:基於事件的非同步模式 (EAP),以及非同步程式設計模型 (APM)

APM 是基於 IAsyncResult 介面提供的非同步程式設計,例如像FileStream類的BeginRead,EndRead就是APM實現方式,提供一對開始結束方法用來啟動和接受非同步結果。使用委託的BeginInvoke和EndInvoke的方式來實現非同步程式設計。
EAP 是在 .NET Framework 2.0 中引入的,比較多的體現在WinForm程式設計中,WinForm程式設計中很多控制元件處理事件都是基於事件模型,經常用到跨執行緒更新介面的時候就會使用到BeginInvoke和Invoke。事件模式算是對APM的一種補充,定義了一系列事件包括完成、進度、取消的事件讓我們在非同步呼叫的時候能註冊響應的事件進行操作。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine(DateTime.Now + " start");
        IAsyncResult result = BeginAPM();
        //EndAPM(result);
        Console.WriteLine(DateTime.Now + " end");

        Console.ReadKey();
    }


    delegate void DelegateAPM();
    static DelegateAPM delegateAPM = new DelegateAPM(DelegateAPMFun);

    public static IAsyncResult BeginAPM()
    {
        return delegateAPM.BeginInvoke(null, null);
    }

    public static void EndAPM(IAsyncResult result)
    {
        delegateAPM.EndInvoke(result);
    }
    public static void DelegateAPMFun()
    {
        Console.WriteLine("DelegateAPMFun...start");
        Thread.Sleep(5000);
        Console.WriteLine("DelegateAPMFun...end");

    }
}

如上程式碼我使用委託實現非同步呼叫,BeginAPM 方法使用 BeginInvoke 開始非同步呼叫,然後 DelegateAPMFun 非同步方法裡面停5秒。看下下面的列印結果,是 main 方法裡面的列印在前,非同步方法裡面的列印在後,說明該操作是非同步的。

其中一行程式碼EndAPM(result)被註釋了,呼叫了委託 EndInvoke 方法,該方法會阻塞程式直到非同步呼叫完成,所以我們可以放到適當的位置用來獲取執行結果,這類似於TAP模式的await 關鍵字,放開改行程式碼執行下。

以上兩種方式已不推薦使用,編寫理解起來比較晦澀,感興趣的可以自行了解下,而且這種方式在.net 5裡面已經不支援委託的非同步呼叫了,所以如果要執行需要在.net framework框架下。
TAP 是在 .NET Framework 4 中引入的,是目前推薦的非同步設計模式,也是我們本文討論的重點方向,但是TAP並不一定是執行緒,他是一種任務,理解為工作的非同步抽象,而非線上程之上的抽象。

2、async await

使用 async await 關鍵字可以很輕鬆的實現非同步程式設計,我們子需要將方法加上 async 關鍵字,方法內的非同步操作使用 await 等待非同步操作完成後再執行後續操作。

class Program
{

    static void Main(string[] args)
    {
        Console.WriteLine(DateTime.Now + " start");
        AsyncAwaitTest();
        Console.WriteLine(DateTime.Now + " end");
        Console.ReadKey();
    }

    public static async void AsyncAwaitTest()
    {
        Console.WriteLine("test start");
        await Task.Delay(5000);
        Console.WriteLine("test end");
    }
}

AsyncAwaitTest 方法使用 async 關鍵字,使用await關鍵字等待5秒後列印"test end"。在 Main 方法裡面呼叫 AsyncAwaitTest 方法。

使用 await 在任務完成前將控制讓步於其呼叫方,可讓應用程式和服務執行有用工作。 任務完成後程式碼無需依靠回撥或事件便可繼續執行。 語言和任務 API 整合會為你完成此操作。
使用await 的方法必須使用 async 關鍵字,如果我們 Main 方法裡面想等待 AsyncAwaitTest 則 Main 方法需要加上 async 並返回 Task。

3、async await 原理

將上面 Main 方法不使用 await 呼叫的方式編譯後使用ILSpy反編譯dll,使用C# 4.0才能看到編譯器為我們做了什麼。因為4.0不支援 async await 所以會反編譯到具體程式碼,4.0 以後的反編譯後會直接顯示 async await 語法。

通過反編譯後可以看到在非同步方法裡面重新生成了一個泛型類 d__1 實現介面IAsyncStateMachine,然後呼叫Start方法,Start中進行了一些執行緒處理後呼叫 stateMachine.MoveNext() 即呼叫d__1例項化物件的MoveNext方法。

public static void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
	if (stateMachine == null)
	{
		ThrowHelper.ThrowArgumentNullException(ExceptionArgument.stateMachine);
	}
	Thread currentThread = Thread.CurrentThread;
	Thread thread = currentThread;
	ExecutionContext executionContext = currentThread._executionContext;
	ExecutionContext executionContext2 = executionContext;
	SynchronizationContext synchronizationContext = currentThread._synchronizationContext;
	try
	{
		stateMachine.MoveNext();
	}
	finally
	{
		SynchronizationContext synchronizationContext2 = synchronizationContext;
		Thread thread2 = thread;
		if (synchronizationContext2 != thread2._synchronizationContext)
		{
			thread2._synchronizationContext = synchronizationContext2;
		}
		ExecutionContext executionContext3 = executionContext2;
		ExecutionContext executionContext4 = thread2._executionContext;
		if (executionContext3 != executionContext4)
		{
			ExecutionContext.RestoreChangedContextToThread(thread2, executionContext3, executionContext4);
		}
	}
}

我們再看編譯器為生成的類 <AsyncAwaitTest>d__1

MoveNext方法將 AsyncAwaitTest 邏輯程式碼包含進去了,我們的原始碼因為只有一個 await 操作,如果有多個 await 操作,那麼MoveNext裡面應該還會有多個分段邏輯,將不同段的MoveNext放入不同的狀態分段塊。
在該類中也有一個if判斷,按照 1__state 狀態引數,最開始呼叫的時候是-1,執行進來 num != 0 則執行我們的業務程式碼if裡面的,這個時候會順序執行業務程式碼,直到碰到 await 則執行如下程式碼

awaiter = Task.Delay(5000).GetAwaiter();
if (!awaiter.IsCompleted)
{
    num = (<> 1__state = 0);

    <> u__1 = awaiter;

    < AsyncAwaitTest > d__1 stateMachine = this;

    <> t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
    return;
}

在該過程中 <> t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine) 將 await 句和狀態機進行傳遞呼叫 AwaitUnsafeOnCompleted方法,該方法一直跟下去會找到執行緒池的操作。

// System.Threading.ThreadPool
internal static void UnsafeQueueUserWorkItemInternal(object callBack, bool preferLocal)
{
    s_workQueue.Enqueue(callBack, !preferLocal);
}

程式將封裝的任務放入執行緒池進行呼叫,這個時候非同步方法就切換到了另一個執行緒,或者在原執行緒上執行(如果非同步方法執行時間比較短可能就不會進行執行緒切換,這個主要看排程程式)。
執行完成 await 後狀態 1__state 已經更改了為 0,程式會再次呼叫 MoveNext 進入 else 之後沒有return和其它邏輯,則繼續執行到結束。
可以看到這是一個狀態控制的執行邏輯,是一種“狀態機模式”的設計模式,對於 Main 方法呼叫 AsyncAwaitTest 邏輯此刻進入if,碰到await則進入執行緒排程執行,如果非同步方法切換到其它執行緒呼叫,則方法 Main 繼續執行,當狀態機執行切換到另外一個狀態後再次 MoveNext 直到執行完非同步方法。

4、async 與 執行緒

有了上面的基礎我們知道 async 與 await 通常是成對配合使用的,當我們的方法標記為非同步的時候,裡面的耗時操作就需要 await 進行標記等待完成後執行後續邏輯,呼叫該非同步方法的呼叫者可以決定是否等待,如果不用 await 則呼叫者非同步執行或者就在原執行緒上執行非同步方法。

如果 async 關鍵字修改的方法不包含 await 表示式或語句,則該方法將同步執行,可選擇性通過 Task.Run API 顯式請求任務在獨立執行緒上執行。
可以將 AsyncAwaitTest 方法改為顯示執行緒執行:

public static async Task AsyncAwaitTest()
{
    Console.WriteLine("test start");
    await Task.Run(() =>
    {
        Thread.Sleep(5000);
    });
    Console.WriteLine("test end");
}

5、取消任務 CancellationToken

如果不想等待非同步方法完成,可以通過 CancellationToken 取消該任務,CancellationToken 是一個struct,通常使用 CancellationTokenSource 來建立 CancellationToken,因為CancellationTokenSource 有一些列的[方法]用於我們取消任務而不用去操作CancellationToken 結構體。

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

然我改造下方法,將 CancellationToken 傳遞到非同步方法,cts.CancelAfter(3000) 3秒鐘後取消任務,我們監聽CancellationToken 如果 IsCancellationRequested==true 則直接返回 。

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken ct = cts.Token;
    cts.CancelAfter(3000);

    Console.WriteLine(DateTime.Now + " start");
    AsyncAwaitTest(ct);
    Console.WriteLine(DateTime.Now + " end");
    Console.ReadKey();
}

public static async Task AsyncAwaitTest(CancellationToken ct)
{
    Console.WriteLine("test start");
    await Task.Delay(5000);
    Console.WriteLine(DateTime.Now + " cancel");
    if (ct.IsCancellationRequested) {
        return;
    }
    //ct.ThrowIfCancellationRequested();
    Console.WriteLine("test end");
}

因為我們是手動通過程式碼判斷狀態結束非同步,所以即使在3秒後就已經結束了任務,但是await Task.Delay(5000) 任然會等待5秒執行完。還有一種方式就是我們不判斷是否取消,直接呼叫ct.ThrowIfCancellationRequested() 給我們判斷,這個方法如果,但是任然不能及時結束。這個時候我們還有另外一種處理方式,就是將CancellationToken 傳遞到 await 的非同步API方法裡,可能會立即結束,也可能不會,這個要取決非同步實現。

public static async Task AsyncAwaitTest(CancellationToken ct)
{
    Console.WriteLine("test start");
    //傳遞CancellationToken 取消
    await Task.Delay(5000,ct);
    Console.WriteLine(DateTime.Now + " cancel");
    
    //手動處理取消
    //if (ct.IsCancellationRequested) {
    //    return;
    //}

    //呼叫方法處理取消
    //ct.ThrowIfCancellationRequested();
    Console.WriteLine("test end");
}

6、注意項

在非同步方法裡面不要使用 Thread.Sleep 方法,有兩種可能:
1、Sleep在 await 之前,則會直接阻塞呼叫方執行緒等待Sleep。
2、Sleep在 await 之後,但是 await 執行在呼叫方的執行緒上也會阻塞呼叫方執行緒。
所以我們應該使用 Task.Delay 用於等待操作。那為什麼我上面的 Task.Run 裡面使用了 Thread.Sleep呢,因為 Task.Run 是顯示請求在獨立執行緒上執行,所以我知道這裡寫不會阻塞呼叫方,上面我只是為了演示,所以不建議用。

相關文章