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. 什么是后臺線程
后臺線程不會影響應用程序的終結,當所有前臺線程執行完畢后,后臺線程無論是否執行完畢,都會被終結。一般后臺線程用來做些無關緊要的任務(比如郵箱每隔一段時間就去檢查下郵件,天氣應用每隔一段時間去更新天氣)。后臺線程也屬于工作者線程。
說了這么多概念不如來段代碼:
//主線程入口
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);
}
執行發現,【后臺線程被調用】將不會顯示。因為當所有的前臺線程執行完畢后,應用程序就關閉了,不會等待所有的后臺線程執行完畢,所以不會顯示。
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<TResult>
Task<TResult>是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()執行完畢";
});
}
是不是對執行結果感到驚訝?驚訝是對的,且聽我們下面娓娓道來。
- 被async標記的方法,意味著可以在方法內部使用await,這樣該方法將會在一個await point(等待點)處被掛起,并且在等待的實例完成后該方法被異步喚醒?!咀⒁猓篴wait point(等待點)處被掛起,并不是說在代碼中使用
await SomeMethodAsync()
處就掛起,而是在進入SomeMethodAsync()
真正執行異步任務時被掛起,切記,切記?。?!】 - async標記的方法,返回值類型為
void
、Task
、Task<T>
。 - 被async標記的方法,方法的執行結果或者任何異常都將直接反映在返回類型中。
- 不是被async標記的方法,就會被異步執行,剛開始都是同步開始執行。換句話說,方法被async標記不會影響方法是同步還是異步的方式完成運行。事實上,async使得方法能被分解成幾個部分,一部分同步運行,一些部分可以異步的運行(而這些部分正是使用await顯示編碼的部分),從而使得該方法可以異步的完成。
- 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<T>是awaitable的,這里就要理解下awaitable這個概念,詳參await anything,這里就不再贅述(講清楚估計得另開一篇)。
這里就暫且把awaitable理解為可等待的,就是說如果這個task沒執行完畢,在去取結果的時候它就會等待。
我們直接來看一下看下源碼吧:
從代碼中我們可以清楚看見,在去取task的返回值時,程序回去判斷對應的任務是否執行完畢(IsCompleted),若沒有則繼續等待,也就是在InternalWait
方法中執行等待,而InternalWait
方法中指定等待的方式為TaskWaitBehavior.Synchronous
也就是同步等待,所以就會掛起主線程。
其實task.Wait()也是類似的邏輯,會同步阻塞主線程去等待異步線程執行完畢。
那我們就可以這樣理解task.Result,task.Result相當于執行task.Wait();
后再去取值task.Result;
。
6. 總結
本文主要梳理了以下幾點:
- 默認創建的Thread是前臺線程,創建的Task為后臺線程。
- ThreadPool創建的線程都是后臺線程。
- 任務并行庫(TPL)使用的是線程池技術。
- 調用async標記的方法,剛開始是同步執行的,只有當執行到await標記的方法中的異步任務時,才會掛起。
異步編程的水很深,標題起大了,有很多知識點沒有講全講到。
文章中所寫是個人理解,難免有紕漏之處,請大家以懷疑的精神閱讀此文,也懇請大家多多指教?。?!