.net 溫故知新:【5】異步編程 async await

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 是顯示請求在獨立線程上運行,所以我知道這裏寫不會阻塞調用方,上面我只是爲了演示,所以不建議用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章