.Net異步編程知多少

1. 引言

最近在學習Abp框架,發現Abp框架的不少Api都提供了同步異步兩種寫法。異步編程提及來,你們可能都會說異步編程性能好。但好在哪裏,引入了什麼問題,以及如何使用,想必也未必能答的上來。
本身對異步編程也不是很瞭解,今天就以學習的目的,來梳理下同步異步編程的基礎知識,而後再來介紹下如何使用async/await進行異步編程。下圖是一張大綱,具體可查看腦圖分享連接編程

腦圖摘要

2. 同步異步編程

同步編程是對於單線程來講的,就像咱們編寫的控制檯程序,以main方法爲入口,順序執行咱們編寫的代碼。
異步編程是對於多線程來講的,經過建立不一樣線程來實現多個任務的並行執行。數組

3. 線程

.Net 1.0就發佈了System.Threading,其中提供了許多類型(好比Thread、ThreadStart等)能夠顯示的建立線程。
說到Thread,咱們須要瞭解如下幾個概念:多線程

3.1. 什麼是主線程

每個Windows進程都剛好包含一個用做程序入口點的主線程。進程的入口點建立的第一個線程被稱爲主線程。.Net執行程序(控制檯、Windows Form、Wpf等)使用Main()方法做爲程序入口點。當調用該方法時,主線程被建立。併發

3.2. 什麼是工做者線程

由主線程建立的線程,能夠稱爲工做者線程,用來去執行某項具體的任務。框架

3.3. 什麼是前臺線程

默認狀況下,使用Thread.Start()方法建立的線程都是前臺線程。前臺線程能阻止應用程序的終結,只有全部的前臺線程執行完畢,CLR才能關閉應用程序(即卸載承載的應用程序域)。前臺線程也屬於工做者線程。異步

3.4. 什麼是後臺線程

後臺線程不會影響應用程序的終結,當全部前臺線程執行完畢後,後臺線程不管是否執行完畢,都會被終結。通常後臺線程用來作些可有可無的任務(好比郵箱每隔一段時間就去檢查下郵件,天氣應用每隔一段時間去更新天氣)。後臺線程也屬於工做者線程。async

說了這麼多概念不如來段代碼:異步編程

//主線程入口
 static void Main(string[] args)
 {
     Console.WriteLine("主線程開始!");

     //建立前臺工做線程
     Thread t1 = new Thread(Task1);
     t1.Start();

     //建立後臺工做線程
     Thread t2= new Thread(new ParameterizedThreadStart(Task2));
     t2.IsBackground = true;//設置爲後臺線程
     t2.Start("傳參");
 }

 private static void Task1()
 {
     Thread.Sleep(1000);//模擬耗時操做,睡眠1s
     Console.WriteLine("前臺線程被調用!");
 }

 private static void Task2(object data)
 {
     Thread.Sleep(2000);//模擬耗時操做,睡眠2s
     Console.WriteLine("後臺線程被調用!" + data);
 }

執行發現,【後臺線程被調用】將不會顯示。由於當全部的前臺線程執行完畢後,應用程序就關閉了,不會等待全部的後臺線程執行完畢,因此不會顯示。oop

4. ThreadPool(線程池)

線程池是爲忽然大量爆發的線程設計的,經過有限的幾個固定線程爲大量的操做服務,減小了建立和銷燬線程所需的時間,從而提升效率,這也是線程池的主要好處。
ThreadPool適用於併發運行若干個任務且運行時間不長且互不干擾的場景。
還有一點須要注意,經過線程池建立的任務是後臺任務。性能

舉個例子:

//主線程入口
static void Main(string[] args)
{
    Console.WriteLine("主線程開始!");
    //建立要執行的任務
    WaitCallback workItem = state => Console.WriteLine("當前線程Id爲:" + Thread.CurrentThread.ManagedThreadId);

    //重複調用10次
    for (int i = 0; i < 10; i++)
    {
        ThreadPool.QueueUserWorkItem(workItem);
    }
    Console.ReadLine();
}

執行結果
從圖中能夠看出,程序並無每次執行任務都建立新的線程,而是循環利用線程池中維護的線程。
若是去掉最後一句Consoler.ReadLine(),會發現程序僅輸出【主線程開始!】就直接退出,從而肯定ThreadPool建立的線程都是後臺線程。

5. System.Threading.Tasks

.Net 4.0引入了System.Threading.Tasks,簡化了咱們進行異步編程的方式,而不用直接與線程和線程池打交道。
System.Threading.Tasks中的類型被稱爲任務並行庫(TPL)。TPL使用CLR線程池(說明使用TPL建立的線程都是後臺線程)自動將應用程序的工做動態分配到可用的CPU中。

5.1. Parallel(數據並行)

數據並行是指使用Parallel.For()或Parallel.ForEach()方法以並行方式對數組或集合中的數據進行迭代。
看怎麼用:

ParallelLoopResult result = Parallel.For(0, 10000, i => {
    Console.WriteLine("{0}, task: {1} , thread: {2}", i, Task.CurrentId, Thread.CurrentThread.ManagedThreadId);
});

5.2. PLINQ(並行LINQ查詢)

爲並行運行而設計的LINQ查詢爲PLINQ。System.Linq命名空間的ParallelEnumerable中包含了一些擴展方法來支持PINQ查詢。
使用舉例:

int[] modThreeIsZero = (from num in source.AsParallel()
                        where num % 3 == 0
                        orderby num descending
                        select num).ToArray();

5.3. Task

Task,字面義,任務。使用Task類能夠輕鬆地在次線程中調用方法。

static void Main(string[] args)
{
    Console.WriteLine("主線程ID:" + Thread.CurrentThread.ManagedThreadId);
    Task.Factory.StartNew(() => Console.WriteLine("Task對應線程ID:" + Thread.CurrentThread.ManagedThreadId));
    Console.ReadLine();
}


能夠看見,使用Task咱們沒必要理會具體線程的建立。
咱們也可使用.NET 4.5引入的Task.Run靜態方法來啓動一個線程。

static void Main(string[] args)
{
    Console.WriteLine("主線程ID:" + Thread.CurrentThread.ManagedThreadId);
    Task.Run(() => Console.WriteLine("Task對應線程ID:" + Thread.CurrentThread.ManagedThreadId));
    Console.ReadLine();
}

Task類提供了Wait()方法,用來等待線程task執行完畢。

5.4. 泛型Task

Task 是Task的泛型版本,能夠接收一個返回值。

static void Main(string[] args)
{
    Console.WriteLine("主線程ID:" + Thread.CurrentThread.ManagedThreadId);
    Task<string> task = Task.Run(() =>
    {
        return Thread.CurrentThread.ManagedThreadId.ToString();
    });
    Console.WriteLine("建立Task對應的線程ID:" + task.Result);

    Console.ReadLine();
}

Task提供了不少方法,幫助咱們進行異步任務。瞭解更多,可參考MSDN

5.5. async/await 特性

C# async關鍵字用來指定某個方法、Lambda表達式或匿名方法自動以異步的方式來調用。

我們先來看一個具體的示例吧。

private static void Main(string[] args)
{
    Console.WriteLine("主線程啓動,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);
    var task = GetLengthAsync();

    Console.WriteLine("回到主線程,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);

    Console.WriteLine("線程[" + Thread.CurrentThread.ManagedThreadId + "]睡眠5s:");
    Thread.Sleep(5000); //將主線程睡眠5s

    var timer = new Stopwatch();
    timer.Start(); //開始計算時間

    Console.WriteLine("task的返回值是" + task.Result);

    timer.Stop(); //結束點,另外stopwatch還有Reset方法,能夠重置。
    Console.WriteLine("等待了:" + timer.Elapsed.TotalSeconds + "秒"); //顯示時間

    Console.WriteLine("主線程結束,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);
}

private static async Task<int> GetLengthAsync()
{
    Console.WriteLine("GetLengthAsync()開始執行,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);

    var str = await GetStringAsync();

    Console.WriteLine("GetLengthAsync()執行完畢,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);

    return str.Length;
}

private static Task<string> GetStringAsync()
{
    Console.WriteLine("GetStringAsync()開始執行,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);
    return Task.Run(() =>
    {
        Console.WriteLine("異步任務開始執行,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);

        Console.WriteLine("線程[" + Thread.CurrentThread.ManagedThreadId + "]睡眠10s:");
        Thread.Sleep(10000); //將異步任務線程睡眠10s

        Console.WriteLine("GetStringAsync()執行完畢,當前線程爲:" + Thread.CurrentThread.ManagedThreadId);
        return "GetStringAsync()執行完畢";
    });
}

執行結果

是否是對執行結果感到驚訝?驚訝是對的,且聽咱們下面娓娓道來。

  1. 被async標記的方法,意味着能夠在方法內部使用await,這樣該方法將會在一個await point(等待點)處被掛起,而且在等待的實例完成後該方法被異步喚醒。【注意:await point(等待點)處被掛起,並非說在代碼中使用await SomeMethodAsync()處就掛起,而是在進入SomeMethodAsync()真正執行異步任務時被掛起,切記,切記!!!】
  2. async標記的方法,返回值類型爲voidTaskTask<T>
  3. 被async標記的方法,方法的執行結果或者任何異常都將直接反映在返回類型中。
  4. 不是被async標記的方法,就會被異步執行,剛開始都是同步開始執行。換句話說,方法被async標記不會影響方法是同步仍是異步的方式完成運行。事實上,async使得方法能被分解成幾個部分,一部分同步運行,一些部分能夠異步的運行(而這些部分正是使用await顯示編碼的部分),從而使得該方法能夠異步的完成。
  5. await關鍵字告訴編譯器在async標記的方法中插入一個可能的掛起/喚醒點。 邏輯上,這意味着當你寫await someMethod();時,編譯器將生成代碼來檢查someMethod()表明的操做是否已經完成。若是已經完成,則從await標記的喚醒點處繼續開始同步執行;若是沒有完成,將爲等待的someMethod()生成一個continue委託,當someMethod()表明的操做完成的時候調用continue委託。這個continue委託將控制權從新返回到async方法對應的await喚醒點處。
    返回到await喚醒點處後,無論等待的someMethod()是否已經經完成,任何結果均可從Task中提取,或者若是someMethod()操做失敗,發生的任何異常隨Task一塊兒返回或返回給SynchronizationContext

從第4點能夠解釋爲何上面的demo當調用GetLengthAsync();方法時,輸出GetLengthAsync()開始執行,當前線程爲:1
從第1點能夠解釋調用await GetStringAsync();後,爲何程序會繼續同步執行輸出GetStringAsync()開始執行,當前線程爲:1
當執行到Task.Run的時候,就回到了主線程,從而輸出回到主線程,當前線程爲:1,這說明Task.Run就是咱們所說的await point(等待點)。緊接着代碼將主線程睡眠5s,這時異步任務可不會歇啊,因此會輸出異步任務開始執行,當前線程爲:3
緊接着爲了模擬異步任務耗時,咱們在異步任務中調用Thread.Sleep(10000)將異步任務睡眠10s。
一樣異步任務睡眠的時候,不會影響到咱們的同步任務,主線程睡眠5s後,要去輸出task.Result,這時異步任務尚未執行完畢,因此主線程會等待,直到結果返回,當異步任務完成後會輸出GetStringAsync()執行完畢,當前線程爲:3
從第5點能夠解釋,await等待異步任務完成後,GetLengthAsync()方法被異步喚醒,從而異步執行後續代碼而輸出GetLengthAsync()執行完畢,當前線程爲:3
代碼中咱們用StopWatch來計算大體等待了多久,從結果看等待了5.0004334秒,符合預期(異步線程睡眠了10s,主線程睡眠了5s,兩個線程是並行運行的,因此大體耗時應該爲10s - 5s = 5s)。

那爲何執行到task.Result時,主線程會等待呢,你可能會說異步任務沒有完成。
那異步任務沒有完成不該該影響主線程的繼續執行啊,那主線程到底是被誰掛起進行等待的呢?
首先Task和Task 是awaitable的,這裏就要理解下awaitable這個概念,詳參 await anything,這裏就再也不贅述(講清楚估計得另開一篇)。
這裏就暫且把awaitable理解爲可等待的,就是說若是這個task沒執行完畢,在去取結果的時候它就會等待。

咱們直接來看一下看下源碼吧:

從代碼中咱們能夠清楚看見,在去取task的返回值時,程序回去判斷對應的任務是否執行完畢(IsCompleted),若沒有則繼續等待,也就是在InternalWait方法中執行等待,而InternalWait方法中指定等待的方式爲TaskWaitBehavior.Synchronous也就是同步等待,因此就會掛起主線程。
其實task.Wait()也是相似的邏輯,會同步阻塞主線程去等待異步線程執行完畢。
那咱們就能夠這樣理解task.Result,task.Result至關於執行task.Wait();後再去取值task.Result;

6. 總結

本文主要梳理了如下幾點:

  1. 默認建立的Thread是前臺線程,建立的Task爲後臺線程。
  2. ThreadPool建立的線程都是後臺線程。
  3. 任務並行庫(TPL)使用的是線程池技術。
  4. 調用async標記的方法,剛開始是同步執行的,只有當執行到await標記的方法中的異步任務時,纔會掛起。

異步編程的水很深,標題起大了,有不少知識點沒有講全講到。
文章中所寫是我的理解,不免有紕漏之處,請你們以懷疑的精神閱讀此文,也懇請你們多多指教!!!

參考自:
Async/Await FAQ
await anything
Async/Await異步編程中的最佳作法

相關文章
相關標籤/搜索