先看一個例子程序員
private void buttonGetPage_Click(object sender,EventArgs e) { Thread t=new Thread(()=> { var request=HttpWebRequest.Create("http://www.cnblogs.com/luminji"); var response=request.GetResponse(); var stream=response.GetResponseStream(); using(StreamReader reader=new StreamReader(stream)) { var content=reader.ReadLine(); textBoxPage.Text=content; } }); t.Start(); }
能夠預見,若是該網頁的內容不少,或者當前的網絡情況不太好,獲取網頁的過程會持續較長時間。因而,咱們可能會想到用新起工做線程的方法來完成這項工做,這樣在等待網頁內容返回的過程當中Winform界面就不會被阻滯了。算法
是的,上面的程序解決了界面阻滯的問題,可是,它高效嗎?答案是:不。編程
要理解這一點,須要從「IO操做的DMA(DirectMemory Access)模式」開始講起。DMA即直接內存訪問,是一種不通過CPU而直接進行內存數據存儲的數據交換模式。經過DMA的數據交換幾乎能夠不損耗CPU的資源。在硬件中,硬盤、網卡、聲卡、顯卡等都有DMA功能。CLR所提供的異步編程模型就是讓咱們充分利用硬件的DMA功能來釋放CPU的壓力。
使用異步模式去實現,代碼以下所示:數組
private void buttonGetPage_Click(object sender,EventArgs e) { var request=HttpWebRequest.Create("http://www.sina.com.cn"); request.BeginGetResponse(this.AsyncCallbackImpl,request); } public void AsyncCallbackImpl(IAsyncResult ar) { WebRequest request=ar.AsyncState as WebRequest; var response=request.EndGetResponse(ar); var stream=response.GetResponseStream(); using(StreamReader reader=new StreamReader(stream)) { var content=reader.ReadLine(); textBoxPage.Text=content; } }
通過修改的示例採用了異步模式,它使用線程池進行管理。新起異步操做後,CLR會將工做丟給線程池中的某個工做線程來完成。當開始I/O操做的時候,異步會將工做線程還給線程池,這時候就至關於獲取網頁的這個工做不會再佔用任何CPU資源了。直到異步完成,即獲取網頁完畢,異步纔會經過回調的方式通知線程池,讓CLR響應異步完畢。可見,異步模式藉助於線程池,極大地節約了CPU的資源。安全
明白了異步和多線程的區別後,咱們來肯定二者的應用場景:服務器
所謂線程同步,就是多個線程在某個對象上執行等待(也可理解爲鎖定該對象),直到該對象被解除鎖定。C#中對象的類型分爲引用類型和值類型。CLR在這兩種類型上的等待是不同的。咱們能夠簡單地理解爲在CLR中,值類型是不能被鎖定的,即不能在一個值類型對象上執行等待。而在引用類型上的等待機制,又分爲兩類:鎖定和信號同步。網絡
鎖定使用關鍵字lock和類型Monitor。二者沒有實質區別,前者實際上是後者的語法糖。這是最經常使用的同步技術。
多線程
信號同步機制中涉及的類型都繼承自抽象類WaitHandle,因此它們底層的原理是一致的,維護的都是一個系統內核句柄。架構
EventWaitHandle :EventWaitHandle維護一個由內核產生的布爾類型對象(稱爲「阻滯狀態」),若是其值爲false,那麼在它上面等待的線程就阻塞。能夠調用類型的Set方法將其值設置爲true,解除阻塞。併發
Semaphore :Semaphore維護一個由內核產生的整型變量,若是其值爲0,則在它上面等待的線程就會阻塞;若是其值大於0,則解除阻塞,同時,每解除一個線程阻塞,其值就減1。
Mutex :EventWaitHandle和Semaphore提供的都是單應用程序域內的線程同步功能,Mutex則不一樣,它爲咱們提供了跨應用程序域阻塞和解除阻塞線程的能力。
C#中,讓線程同步的另外一種編碼方式就是使用線程鎖。線程鎖的原理,就是鎖住一個資源,使得應用程序在此刻只有一個線程訪問該資源。通俗地講,就是讓多線程變成單線程。在C#中,能夠將被鎖定的資源理解成new出來的普通CLR對象。
什麼樣的對象可以成爲一個鎖對象(也叫同步對象)。
1)同步對象在須要同步的多個線程中是可見的同一個對象。即若是不是鎖定的同一個對象,則線程鎖失效。 一樣,lock(this),咱們一樣不建議在代碼中編寫這樣的代碼。若是兩個對象的實例分別執行了鎖定的代碼,實際鎖定的也就會是兩個對象,徹底不能達到同步的目的。
2)在非靜態方法中,靜態變量不該做爲同步對象。。在編寫多線程代碼時,要遵循這樣的一個原則:類型的靜態方法應當保證線程安全,非靜態方法不需實現線程安全。 ,FCL中的絕大部分類都遵循了這個原則,像上一個示例中,若是將syncObject變成static,就至關於讓非靜態方法具有了線程安全性,這帶來的一個問題是,若是應用程序中該類型存在多個實例,在遇到這個鎖的時候,它們都會產生同步
3)值類型對象不能做爲同步對象。值類型對象不能做爲同步對象。值類型在傳遞到另外一個線程的時候,會建立一個副本,這至關於每一個線程鎖定的也是兩個對象。所以,值類型對象不能做爲同步對象。
4)避免將字符串做爲同步對象。:鎖定字符串是徹底沒有必要的,並且至關危險。這整個過程看上去和值類型正好相反。字符串在CLR中會被暫存到內存裏,若是有兩個變量被分配了相同內容的字符串,那麼這兩個引用會被指向同一塊內存。因此,若是有兩個地方同時使用了lock(「abc」),那麼它們實際鎖定的是同一個對象,這會致使整個應用程序被阻滯。
5)下降同步對象的可見性。可見範圍最廣的一種同步對象是typeof(SampleClass)。typeof方法所返回的結果(也就是類型的type)是SampleClass的全部實例所共有的,即:全部實例的type都指向typeof方法的結果。這樣一來,若是咱們lock(typeof(SampleClass)),當前應用程序中全部SampleClass的實例線程將會所有被同步。這樣編碼徹底沒有必要,並且這樣的同步對象太開放了。
例外:一些經常使用的集合類型(如ArrayList)提供了公共屬性SyncRoot ,ArrayList操做的大部分應用場景不涉及多線程同步,因此它的方法更多的是單線程應用場景。若ArrayList的全部非靜態方法都要考慮線程安全,那麼ArrayList徹底能夠將這個SyncRoot變成靜態私有的。如今它將SyncRoot變爲公開的,是讓調用者本身去決定操做是否須要線程安全,咱們在編寫代碼時,除非有這樣的要求,不然就應該始終考慮下降同步對象的可見性,將同步對象藏起來,只開放給本身或本身的子類就夠了(實際狀況並很少)。
在CLR中,線程分爲前臺線程和後臺線程,即每一個線程都有一個IsBackground屬性。二者在表現形式上的惟一區別是:若是前臺線程不退出,應用程序的進程就會一直存在,必須全部的前臺線程所有退出,應用程序纔算退出。然後臺進程則沒有這方面的限制,若是應用程序退出,後臺線程也會一併退出。
用Thread建立的線程默認是前臺線程,也就是IsBackground屬性默認是false。以上代碼需等到工做結束(敲入一個按鍵)應用程序纔會結束,而若是設置IsBackground爲true,應用程序則會馬上結束。但咱們要注意線程池中的線程默認都是後臺線程。
基於先後臺線程的區別,在實際編碼中應該更多地使用後臺線程。只有在很是關鍵的工做中,如線程正在執行事務或佔有的某些非託管資源須要釋放時,才使用前臺線程。
線程的調度是一個複雜的過程,對於C#開發者來講,須要理解的就是:線程之間的調度佔有必定的時間和空間開銷,而且,它不實時。下面是一個測試的例子,本意是將0到9分別傳給10個不一樣的線程,結果卻事與願違:
static int_id=0;static void Main() { for(int i=0;i<10;i++,_id++) { Thread t=new Thread(()=> { Console.WriteLine(string.Format("{0}:{1}",Thread.CurrentThread.Name,_id)); }); t.Name=string.Format("Thread{0}",i); t.IsBackground=true; t.Start(); } Console.ReadLine(); }
這段代碼的輸出從兩個方面印證了線程不是當即啓動的。
要讓需求獲得正確的編碼,須要把上面的for循環修改爲爲一段同步代碼:
static int_id=0; static void Main() { for(int i=0;i<10;i++,_id++) { NewMethod1(i,_id); } Console.ReadLine(); } private static void NewMethod1(int i,int realTimeID) { Thread t=new Thread(()=>{ Console.WriteLine(string.Format("{0}:{1}", Thread.CurrentThread.Name,realTimeID)); }); t.Name=string.Format("Thread{0}",i); t.IsBackground=true; t.Start(); } }
能夠看到,線程雖然保持了不會當即啓動的特色,可是傳入線程的ID值,因爲在for循環內部變成了同步代碼,因此可以正確傳入。
線程在C#中有5個優先級:Highest、AboveNormal、Normal、BelowNormal和Lowest。講到線程的優先級,就會涉及線程的調度。Windows系統是一個基於優先級的搶佔式調度系統。在系統中,若是有一個線程的優先級較高,而且它正好處在就緒狀態,系統老是會優先運行該線程。換句話說,高優先級的線程老是在系統調度算法中獲取更多的CPU執行時間。
在C#中,使用Thread和ThreadPool新起的線程,默認優先級都是Normal。雖然能夠修改線程的優先級,可是通常不建議這樣作。固然,若是是一些很是關鍵的線程,咱們仍是能夠提高線程的優先級的。這些關鍵線程應當具備運行時間短、能即刻進入等待狀態等特徵。
開發者總嘗試對本身的代碼有更多的控制。例如,「讓那個還在工做的線程立刻中止下來」。然而,並不是咱們想怎樣就能夠怎樣的,這至少涉及兩個問題。
第一個問題 正如線程不能當即啓動同樣,線程也並非說停就停的。不管採用何種方式通知工做線程須要中止,工做線程都會忙完手頭最緊要的活,而後在它以爲合適的時候退出。以最傳統的Thread.Abort方法爲例,若是線程當前正在執行的是一段非託管代碼,那麼CLR就不會拋出ThreadAbortException,只有當代碼繼續回到CLR中時,纔會引起ThreadAbortException。固然,即使是在CLR環境中,ThreadAbortException也不會當即引起。
第二個問題 要正確中止線程,不在於調用者採起了什麼行爲(如最開始的Thread.Abort()方法),而更多依賴於工做線程是否能主動響應調用者的中止請求。大致機制是,若是線程須要被中止,那麼線程自身就應該負責給調用者開放這樣的接口:Cancled。線程在工做的同時,還要以某種頻率檢測Cancled標識,若檢測到Cancled,線程本身才會負責退出。
FCL如今爲咱們提供了標準的取消模式:協做式取消(Cooperative Cancellation)。協做式取消的機制就是上文第二個問題中所提到的機制。下面是一個最基礎的協做式取消的示例:
CancellationTokenSource cts=new CancellationTokenSource(); Thread t=new Thread(()=>{ while(true) { if(cts.Token.IsCancellationRequested) { Console.WriteLine("線程被終止!"); break; } Console.WriteLine(DateTime.Now.ToString()); Thread.Sleep(1000); } }); t.Start(); Console.ReadLine(); cts.Cancel();
調用者使用CancellationTokenSource的Cancel方法通知工做線程退出。工做線程則以大約1000ms的頻率一邊工做,一邊檢查是否有外界傳入的Cancel信號,如有這樣的信號,則退出。能夠看到,在正確中止線程的機制中,真正起到主要做用的是線程自己。示例中的工做代碼比較簡單,但足以說明問題。更復雜的計算式工做,也應該以這樣的一種方式,妥善而正確地處理退出。
協做式取消中的關鍵類型是CancellationTokenSource。它有一個關鍵屬性Token, Token是一個名爲CancellationToken的值類型。CancellationToken繼而進一步提供了布爾值的屬性IsCancellationRequested做爲須要取消工做的標識。CancellationToken還有一個方法尤爲值得注意,那就是Register方法。它負責傳遞一個Action委託,在線程中止的時候被回調,使用方法以下:
cts.Token.Register(()=> { Console.WriteLine("工做線程被終止了。"); });
本例使用Thread進行了演示,若是使用ThreadPool也是同樣的模式。還有任務Task,它依賴於CancellationTokenSource和CancellationToken完成了全部的取消控制。
在多數狀況下,建立過多的線程意味着應用程序的架構設計可能存在着缺陷。常常有人會問,一個應用程序中到底含有多少線程纔是合理的。如今咱們找一臺PC機,打開Windows的任務管理器,看看操做系統中正在運行的程序有多少個線程。
如圖所示,大多數應用程序的線程數不會太多。
錯誤地建立過多線程的一個典型的例子是:爲每個Socket鏈接創建一個線程去管理。每一個鏈接一個線程,意味着在32位系統的服務器不能同時管理超過1000臺的客戶機。CLR爲每一個進程分配的內存會超過1MB。約1000個進程,加上.NET進程啓動自己所佔用的一些內存,即刻就耗盡了系統能分配給進程的最大可用地址空間2GB。即使應用程序在設計之初的需求設計書中說明,生產環境中客戶端數目不會超過500臺,在管理這500臺客戶端時進行線程上下文切換,也會損耗至關多的CPU時間。這類I/O密集型場合應該使用異步去完成。
過多的線程還會帶來另外的問題:新起的線程可能須要等待至關長的時間纔會真正運行。這是一個至關無奈的結果,在多數狀況下,咱們都不能忍受等待這麼長時間。如下的這段測試代碼,在雙核系統中,通過了大概5分鐘的時間,才運行到了線程T201:
static void Main(string[]args) { for(int i=0;i<200;i++) { Thread t=new Thread(()=> { int j=1; while(true) { j++; } }); t.IsBackground=true; t.Start(); } Thread.Sleep(5000); Thread t201=new Thread(()=> { while(true) { Console.WriteLine("T201正在執行"); } }); t201.Start(); Console.ReadKey(); }
除了啓動問題外,線程之間的切換也存在一樣的問題,T201的下一次執行,還會等待至關長的時間。
因此,不要濫用線程,尤爲不要濫用過多的線程。當新起線程的時候,須要仔細思考這項工做是否真的須要新起線程去完成。即便真的須要線程也應該考慮使用線程池技術。例如所提到的Socket鏈接這樣的I/O密集型場合,應當始終考慮使用異步來完成。異步會在後臺使用線程池進行管理。1000臺客戶端在使用了異步技術後,實際只要幾個線程就能完成全部的管理工做(具體取決於「心跳頻率」)。
使用線程能極大地提高用戶體驗度,可是做爲開發者應該注意到,線程的開銷是很大的。
線程的空間開銷來自:
線程的時間開銷來自:
因爲要進行如此多的工做,因此建立和銷燬一個線程就意味着代價「昂貴」。爲了不程序員無節制地使用線程,微軟開發了「線程池」技術。簡單來講,線程池就是替開發人員管理工做線程。當一項工做完畢時,CLR不會銷燬這個線程,而是會保留這個線程一段時間,看是否有別的工做須要這個線程。至於什麼時候銷燬或新起線程,由CLR根據自身的算法來作這個決定。因此,若是咱們要多線程編碼,不該想到:
Thread t=new Thread(()=>{ //工做代碼});t.Start();
應該首先想到依賴線程池:
ThreadPool.QueueUserWorkItem((objState)=>{ //工做代碼},null);
線程池技術能讓咱們重點關注業務的實現,而不是線程的性能測試。
還提到了一個類型BackgroundWorker。BackgroundWorker是在內部使用了線程池的技術;同時,在Winform或WPF編碼中,它還給工做線程和UI線程提供了交互的能力。若是咱們稍加註意,就會發現:Thread和ThreadPool默認都沒有提供這種交互能力,而BackgroundWorker卻經過事件提供了這種能力。這種能力包括:報告進度、支持完成回調、取消任務、暫停任務等。
ThreadPool相對於Thread來講具備不少優點,可是ThreadPool在使用上卻存在必定的不方便。好比:
以往,若是開發者要實現上述功能,須要完成不少額外的工做。如今,FCL中提供了一個功能更強大的概念:Task。Task在線程池的基礎上進行了優化,並提供了更多的API。在FCL 4.0中,若是咱們要編寫多線程程序,Task顯然已經優於傳統的方式了。
任務Task具有如下屬性,可讓咱們查詢任務完成時的狀態:
須要注意的是,任務並無提供回調事件來通知完成(像BackgroundWorker同樣),它是經過啓用一個新任務的方式來完成相似的功能。ContinueWith方法能夠在一個任務完成的時候發起一個新任務,這種方式自然就支持了任務的完成通知:咱們能夠在新任務中獲取原任務的結果值。
Task還支持任務工廠的概念。任務工廠支持多個任務之間共享相同的狀態,如取消類型CancellationTokenSource就是能夠被共享的。經過使用任務工廠,能夠同時取消一組任務。
因此在FCL 4.0和之後的時代,若是要使用多線程,咱們理應更多地使用Task。
在命名空間System.Threading.Tasks中,有一個靜態類Parallel簡化了在同步狀態下的Task的操做。Parallel主要提供3個有用的方法:For、ForEach、Invoke。
static void Main(string[]args) { int[]nums=new int[]{1,2,3,4}; Parallel.For(0,nums.Length,(i)=> { Console.WriteLine("針對數組索引{0}對應的那個元素{1}的一些工做代碼……",i, nums[i]); }); Console.ReadKey(); }
實際狀況,工做代碼並未按照數組的索引次序進行遍歷。這是由於咱們的遍歷是並行的,不是順序的。因此,這裏也能夠引出一個小建議:若是咱們的輸出必須是同步的或者說必須是順序輸出的,則不該使用Parallel的方式。
static void Main(string[]args) { List<int>nums=new List<int>{1,2,3,4}; Parallel.ForEach(nums,(item)=> { Console.WriteLine("針對集合元素{0}的一些工做代碼……",item); }); Console.ReadKey(); }
Parallel的Invoke方法爲咱們簡化了啓動一組並行操做,它隱式啓動的就是Task。該方法接受Params Action[]參數,以下所示:
static void Main(string[]args){ Parallel.Invoke(()=> { Console.WriteLine("任務1……"); }, ()=> { Console.WriteLine("任務2……"); }, ()=> { Console.WriteLine("任務3……"); }); Console.ReadKey(); }
一樣,因爲全部的任務都是並行的,因此它不保證前後次序。
Parallel的使用方法,不知道你們是否注意到文中使用的字眼:在同步狀態下簡化了Task的使用。也就是說,在運行Parallel中的For、ForEach方法時,調用者線程(在示例中就是主線程)是被阻滯的。Parallel雖然將任務交給Task去處理,即交給CLR線程池去處理,不過調用者會一直等到線程池中的相關工做所有完成。表示並行的靜態類Parallel甚至只提供了Invoke方法,而沒有同時提供一個BeginInvoke方法,這也從必定程度上說明了這個問題。
在使用Task時,咱們最常使用的是Start方法(Task也提供了RunSynchronously),它不會阻滯調用者線程。以下所示:
static void Main(){ Task t=new Task(()=> { while(true) { } }); t.Start(); Console.WriteLine("主線程即將結束"); Console.ReadKey(); }
輸出爲:主線程即將結束
使用Parallel執行相近的功能,主線程被阻滯:
static void Main(){ //在這裏也可使用Invoke方法 Parallel.For(0,1,(i)=> { while(true) { } }); Console.WriteLine("主線程即將結束"); Console.ReadKey();}
若是執行這段代碼,永遠不會有輸出。
並行編程,意味着運行時在後臺將任務分配到儘可能多的CPU上,雖然它在後臺使用Task進行管理,但這並不意味着它等同於異步。
每次返回結果不一致的狀況
Parallel的For和ForEach方法還支持一些相對複雜的應用。在這些應用中,它容許咱們在每一個任務啓動時執行一些初始化操做,在每一個任務結束後,又執行一些後續工做,同時,還容許咱們監視任務的狀態。可是,請記住上面這句話「容許咱們監視任務的狀態」是錯誤的:應該把其中的「任務」改爲「線程」。這,就是陷阱所在。
咱們須要深入理解這些具體的操做和應用,否則,極有可能陷入這個陷阱中去。下面體會這段代碼的輸出是什麼,以下所示:
static void Main(string[]args) { int[]nums=new int[]{1,2,3,4}; int total=0; Parallel.For<int>(0,nums.Length,()=> { return 1; }, (i,loopState,subtotal)=> { subtotal+=nums[i]; return subtotal; }, (x)=>Interlocked.Add(ref total,x) ); Console.WriteLine("total={0}",total); Console.ReadKey();
這段代碼有可能輸出11,較少的狀況下輸出12,雖然理論上有可能輸出13和14,可是咱們應該不多有機會觀察到。要明白爲何會有這樣的輸出,首先必須詳細瞭解For方法的各個參數。上面這個For方法的聲明以下:
public static ParallelLoopResult For
前面兩個參數相對容易理解,分別是起始索引和結束索引。
localInit和localFinally就比較難理解了,而且陷阱也在這裏。要理解這兩個參數,必須先理解Parallel.For方法的運做模式。For方法採用併發的方式來啓動循環體中的每一個任務,這意味着,任務是交給線程池去管理的。在上面的代碼中,循環次數共計4次,實際運行時調度啓動的後臺線程也就只有一個或兩個。這就是併發的優點,也是線程池的優點,Parallel經過內部的調度算法,最大化地節約了線程的消耗。localInit的做用是若是
Parallel爲咱們新起了一個線程,它就會執行一些初始化的任務在上面的例子中:
()=>{ return 1;}
它會將任務體中的subtotal這個值初始化爲1。
localFinally的做用是,在每一個線程結束的時候,它執行一些收尾工做:
(x)=>Interlocked.Add(ref total,x)
這行代碼所表明的收尾工做實際就是:
total=total+subtotal;
其中的x,其實表明的就是任務體中的返回值,具體在這個例子中就是subtotal在返回時的值。使用Interlocked是對total使用原子操做,以免併發所帶來的問題。如今,咱們應該很好理解爲何上面這段代碼的輸出會不肯定了。Parallel一共啓動了4個任務,可是咱們不能肯定Parallel到底爲咱們啓動了多少個線程,那是運行時根據本身的調度算法決定的。若是全部的併發任務只用了一個線程,則輸出爲11;若是用了兩個線程,那麼根據程序的邏輯來看,輸出就是12了。
在這段代碼中,若是讓localInit返回的值爲0,也許你就永遠不會注意到這個陷阱:
()=>{ return 0;}
如今,爲了更清晰地體會這個陷阱,咱們使用下面這段更好理解的代碼:
static void Main(string[]args) { string[]stringArr=new string[]{"aa","bb","cc","dd","ee","ff", "gg","hh"}; string result=string.Empty; Parallel.For<string>(0,stringArr.Length,()=>"-",(i,loopState, subResult)=> { return subResult+=stringArr[i]; }, (threadEndString)=> { result+=threadEndString; Console.WriteLine("Inner:"+threadEndString); }); Console.WriteLine(result); Console.ReadKey(); }
這段代碼的一個可能的輸出爲:
Inner: -aabbccddffgghh
Inner: -ee
-aabbccddffgghh-ee
LINQ最基本的功能就是對集合進行遍歷查詢,並在此基礎上對元素進行操做。仔細推敲會發現,並行編程簡直就是專門爲這一類應用準備的。所以,微軟專門爲LINQ拓展了一個類ParallelEnumerable(該類型也在命名空間System.Linq中),它所提供的擴展方法會讓LINQ支持並行計算,這就是所謂的PLINQ。
傳統的LINQ計算是單線程的,PLINQ則是併發的、多線程的,咱們經過下面這個示例就能夠看出這個區別:
static void Main(string[]args){ List<int>intList=new List<int>(){0,1,2,3,4,5,6,7,8,9}; var query=from p in intList select p; Console.WriteLine("如下是LINQ順序輸出:"); foreach(int item in query) { Console.WriteLine(item.ToString()); } Console.WriteLine("如下是PLINQ並行輸出:"); var queryParallel=from p in intList.AsParallel()select p; foreach(int item in queryParallel) { Console.WriteLine(item.ToString()); } }
LINQ的輸出會按照intList中的索引順序打印出來。而PLINQ的輸出是雜亂無章的。
並行輸出還有另一種方式能夠處理,那就是對queryParallel求ForAll:
queryParallel.ForAll((item)=>{ Console.WriteLine(item.ToString());});
可是這種方法會帶來一個問題,若是要將並行輸出後的結果進行排序,ForAll會忽略掉查詢的AsOrdered請求。以下所示:
var queryParallel=from p in intList.AsParallel().AsOrdered() select p; queryParallel.ForAll((item)=> { Console.WriteLine(item.ToString()); });
AsOrdered方法能夠對並行計算後的隊列進行從新組合,以便保持順序。但是在ForAll方法中,它所完成的輸出還是無序的。若是要保持AsOrdered方法的需求,咱們應當始終使用第一種並行方式,即:
var queryParallel=from p in intList.AsParallel().AsOrdered() select p; foreach(int item in queryParallel) { Console.WriteLine(item.ToString()); }
在並行查詢後再進行排序,會犧牲掉必定的性能。一些擴展方法默認會對元素進行排序,這些方法包括:OrderBy、OrderByDescending、ThenBy和ThenByDescending。在實際的使用中,必定要注意到各類方式之間的差異,以便程序按照咱們的設想運行。還有一些其餘的查詢方法,好比Take。若是咱們這樣編碼:
foreach(int item in queryParallel.Take(5)) { Console.WriteLine(item.ToString()); }
建議在對集合中的元素項進行操做的時候使用PLINQ代替LINQ。可是要記住,不是全部並行查詢的速度都會比順序查詢快,在對集合執行某些方法時,順序查詢的速度會更快一點,如方法ElementAt等。在開發中,咱們應該仔細辨別這方面的需求,以便找到最佳的解決方案。
在任什麼時候候,異常處理都是很是重要的一個環節。多線程與並行編程中尤爲是這樣。若是不處理這些後臺任務中的異常,應用程序將會莫名其妙的退出。處理那些不是主線程(若是是窗體程序,那就是UI主線程)產生的異常,最終的辦法都是將其包裝到主線程上。
在任務並行庫中,若是對任務運行Wait、WaitAny、WaitAll等方法,或者求Result屬性,都能捕獲到AggregateException異常。能夠將AggregateException異常看作是任務並行庫編程中最上層的異常。在任務中捕獲的異常,最終都應該包裝到AggregateException中。一個任務並行庫異常的簡單處理示例以下:
static void Main(string[]args) { Task t=new Task(()=> { throw new Exception("任務並行編碼中產生的未知異常"); }); t.Start(); try { //如有Result,可求Result t.Wait(); } catch(AggregateException e) { foreach(var item in e.InnerExceptions) { Console.WriteLine("異常類型:{0}{1}來自: {2}{3}異常內容:{4}",item.GetType(), Environment.NewLine, item.Source,Environment.NewLine,item.Message ); } } Console.WriteLine("主線程立刻結束"); Console.ReadKey(); }
上面的代碼輸出:
異常類型:System.Exception 來自:ConsoleApplication3 異常內容:任務並行編碼中產生的未知異常 主線程立刻結束
你們也許已經注意到,雖然運行Wait、WaitAny、WaitAll方法,或者求Result屬性能獲得任務的異常信息,可是這會阻滯當前線程。這每每不是咱們所但願看到的,豈能爲了獲得一個異常就故意等待?這時能夠考慮任務並行庫中Task類型的一個功能:新起一個後續任務,就能夠解決等待的問題:
static void Main(){ Task t=new Task(()=> { throw new Exception("任務並行編碼中產生的未知異常"); }); t.Start(); Task tEnd=t.ContinueWith((task)=> { foreach(Exception item in task.Exception.InnerExceptions) { Console.WriteLine("異常類型:{0}{1}來自: {2}{3}異常內容:{4}",item.GetType(),Environment.NewLine, item.Source,Environment.NewLine,item.Message); } }, TaskContinuationOptions.OnlyOnFaulted ); Console.WriteLine("主線程立刻結束"); Console.ReadKey();}
以上輸出:
主線程立刻結束異常類型:System.Exception 來自:ConsoleApplication3 異常內容:任務並行編碼中產生的未知異常
以上方法解決了主線程等待的問題,可是仔細研究咱們會發現,異常處理沒有回到主線程中,它仍是在線程池中。在某些場合,好比對於業務邏輯上特定異常的處理,須要採起這種方式,並且咱們也鼓勵這種用法。但很明顯,更多時候咱們還須要更進一步將異常處理封裝到主線程。
Task沒有提供將任務中的異常包裝到主線程的接口。一個可行的辦法是,仍舊使用相似Wait的方法來達到此目的。在本建議一開始的代碼中,咱們對於主工做任務採用Wait的方法,這是不可取的。由於主工做任務也許會持續一段較長的時間,那樣會阻塞調用者,並讓調用者以爲不能忍受。而本建議的第二段代碼中,新任務只完成了處理異常,這意味着新任務不會延續較長時間,因此,在這個新任務上維持等待對於調用者來講,是能夠忍受的。但這個不是最好代方法。
對線程調用Wait方法(或者求Result),由於它會阻滯主線程,而且CLR在後臺會新起線程池線程來完成額外的工做。若是要包裝異常到主線程,另一個方法就是使用事件通知的方式:
EventHandler<AggregateExceptionArgs>AggregateExceptionCatched; public class AggregateExceptionArgs:EventArgs { public AggregateException AggregateException{get;set;} } static void Main(string[]args) { AggregateExceptionCatched+=EventHandler<AggregateExceptionArgs> (Program_AggregateExceptionCatched); Task t=new Task(()=> { try { throw new InvalidOperationException("任務並行編碼中產生的未知異常"); } catch(Exception err) { AggregateExceptionArgs errArgs=new AggregateExceptionArgs() { AggregateException=new AggregateException(err)}; AggregateExceptionCatched(null,errArgs); } }); t.Start(); Console.WriteLine("主線程立刻結束"); Console.ReadKey();} static void Program_AggregateExceptionCatched(object sender, AggregateExceptionArgs e) { foreach(var item in e.AggregateException.InnerExceptions) { Console.WriteLine("異常類型:{0}{1}來自:{2}{3}異常內容:{4}", item.GetType(),Environment.NewLine,item.Source,Environment.NewLine,item.Message); } }
在這個例子中,咱們聲明瞭一個委託AggregateExceptionCatchHandler,它接受兩個參數,一個是事件的通知者;另外一個是事件變量AggregateExceptionArgs。AggregateExceptionArgs是爲了包裝異常而新建的一個類型。在主線程中,咱們爲事件AggregateExceptionCatched分配了事件處理方法Program_AggregateExceptionCatched,當任務Task捕獲到異常時,代碼引起事件。
這種方式徹底沒有阻滯主線程。若是是在Winform或WPF窗體程序中,要在事件處理方法中處理UI界面,還能夠將異常信息交給窗體的線程模型去處理。因此,最終建議你們採用事件通知的模型處理Task中的異常。
注意 任務調度器TaskScheduler提供了這樣一個功能,它有一個靜態事件用於處理未捕獲到的異常。通常不建議這樣使用,由於事件回調是在進行垃圾回收的時候才發生的。
因爲Task的Start方法是異步啓動的,因此咱們須要額外的技術來完成異常處理。Parallel相對來講就要簡單不少,由於Parallel的調用者線程會等到全部的任務所有完成後,再繼續本身的工做。簡單來講,它具備同步的特性,因此,用下面的這段代碼就能夠實現將併發異常包裝到主線程中:
static void Main(string[]args) { try { var parallelExceptions=new ConcurrentQueue<Exception>(); Parallel.For(0,1,(i)=> { try { throw new InvalidOperationException("並行任務中出現的異常"); } catch(Exception e) { parallelExceptions.Enqueue(e); } if(parallelExceptions.Count>0) throw new AggregateException(parallelExceptions); }); } catch(AggregateException err) { foreach(Exception item in err.InnerExceptions) { Console.WriteLine("異常類型:{0}{1}來自:{2}{3}異常內容:{4}",item.InnerException.GetType(),Environment.NewLine,item.InnerException.Source, Environment.NewLine,item.InnerException.Message); } } Console.WriteLine("主線程立刻結束"); Console.ReadKey(); }
在Parallel的異常處理中,咱們使用了一個線程安全的泛型集合ConcurrentQueue<T>來處理併發中有可能會遇到的集合線程安全性問題(參考Linq確保集合的線程安全)。
並行所帶來的後臺任務及任務的管理,都會帶來必定的開銷,若是一項工做原本就能很快完成,或者說循環體很小,那麼並行的速度也許會比非並行要慢。
static void DoInFor() { for(int i=0;i<200;i++) { DoSomething(); }} static void DoInParalleFor() { Parallel.For(0,200,(i)=> { DoSomething(); }); } static void DoSomething() { for(int i=0;i<10;i++) { i++; } }
將DoSomething方法中的循環體由10變爲10000000。運行的結果爲:
同步耗時:00:00:01.3059138
並行耗時:00:00:00.6560593
除了上面提到的狀況外,要謹慎使用並行的狀況還包括:某些自己就須要同步運行的場合,或者須要較長時間鎖定共享資源的場合。
在對整型數據進行同步操做時,可使用靜態類Interlocked的Add方法,這就極大地避免了因爲進行原子操做長時間鎖定某個共享資源所帶來的同步性能損耗。
理論上,針對total的加法操做,須要使用一個同步鎖,不然就沒法避免一次torn read(即兩次mov操做所致使的字段內存地址邊界對齊問題)。FCL經過提供Interlocked類型解決了這個問題。FCL用來解決簡單類型的原子性操做還提供了volatile關鍵字。FCL現有的原子性操做爲咱們同步整型數據的時候,帶來了性能上的提升。可是,在其餘一些場合,咱們卻不得不考慮由於同步鎖帶來的損耗。
例如:
static void Main(string[]args) { SampleClass sample=new SampleClass(); Parallel.For(0,10000000,(i)=> { sample.SimpleAdd(); }); Console.WriteLine(sample.SomeCount); } class SampleClass { public long SomeCount{get;private set;} public void SimpleAdd() { SomeCount++; } }
這段代碼的輸出或許是:8322580
顯然,這與咱們的期待輸出10000000有很大的差距。爲了保證輸出正確,必須爲並行中的方法體加鎖(假設SampleClass是外部提供的API,無權進行源碼修改在其內部加鎖):
object syncObj=new object(); Parallel.For(0,10000000,(i)=> { lock(syncObj) { sample.SimpleAdd(); } });
通過以上修改後,代碼輸出就正確了。可是,這段代碼也帶來了另外的問題。因爲鎖的存在,系統的開銷也增長了,同步帶來的線程上下文切換,使咱們犧牲了CPU時間與空間性能。簡單地說,就是這段代碼還不如不用並行。在上面提到過,鎖其實就是讓多線程變成單線程(由於同時只容許有一個線程訪問資源)。因此,咱們須要謹慎地對待並行方法中的同步問題。若是方法體的所有內容都須要同步運行,就徹底不該該使用並行。