進程和線程的區(qū)別
在學(xué)習(xí)編程時,通常初學(xué)者會分不清楚進程和線程,其實弄清楚者兩個概念并不是很難,下面就跟YJBYS小編一起來看看吧。
簡而言之,一個程序至少有一個進程,一個進程至少有一個線程.
線程的劃分尺度小于進程,使得多線程程序的并發(fā)性高。
另外,進程在執(zhí)行過程中擁有獨立的內(nèi)存單元,而多個線程共享內(nèi)存,從而極大地提高了程序的運行效率。
線程在執(zhí)行過程中與進程還是有區(qū)別的。每個獨立的線程有一個程序運行的入口、順序執(zhí)行序列和程序的出口。但是線程不能夠獨立執(zhí)行,必須依存在應(yīng)用程序中,由應(yīng)用程序提供多個線程執(zhí)行控制。
從邏輯角度來看,多線程的意義在于一個應(yīng)用程序中,有多個執(zhí)行部分可以同時執(zhí)行。但操作系統(tǒng)并沒有將多個線程看做多個獨立的應(yīng)用,來實現(xiàn)進程的調(diào)度和管理以及資源分配。這就是進程和線程的重要區(qū)別。
進程是具有一定獨立功能的程序關(guān)于某個數(shù)據(jù)集合上的一次運行活動,進程是系統(tǒng)進行資源分配和調(diào)度的一個獨立單位.
線程是進程的一個實體,是CPU調(diào)度和分派的基本單位,它是比進程更小的能獨立運行的基本單位.線程自己基本上不擁有系統(tǒng)資源,只擁有一點在運行中必不可少的資源(如程序計數(shù)器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源.
一個線程可以創(chuàng)建和撤銷另一個線程;同一個進程中的多個線程之間可以并發(fā)執(zhí)行.
進程和線程的主要差別在于它們是不同的操作系統(tǒng)資源管理方式。進程有獨立的地址空間,一個進程崩潰后,在保護模式下不會對其它進程產(chǎn)生影響,而線程只是一個進程中的不同執(zhí)行路徑。線程有自己的堆棧和局部變量,但線程之間沒有單獨的地址空間,一個線程死掉就等于整個進程死掉,所以多進程的程序要比多線程的程序健壯,但在進程切換時,耗費資源較大,效率要差一些。但對于一些要求同時進行并且又要共享某些變量的并發(fā)操作,只能用線程,不能用進程。如果有興趣深入的話,我建議你們看看《現(xiàn)代操作系統(tǒng)》或者《操作系統(tǒng)的設(shè)計與實現(xiàn)》。對就個問題說得比較清楚。
5.1 簡介
進程(process)是一塊包含了某些資源的內(nèi)存區(qū)域。操作系統(tǒng)利用進程把它的工作劃分為一些功能單元。
進程中所包含的一個或多個執(zhí)行單元稱為線程(thread)。進程還擁有一個私有的虛擬地址空間,該空間僅能被它所包含的線程訪問。
當(dāng)運行.NET程序時,進程還會把被稱為CLR的軟件層包含到它的內(nèi)存空間中。上一章曾經(jīng)對CLR做了詳細描述。該軟件層是在進程創(chuàng)建期間由運行時宿主載入的(參見4.2.3節(jié))。
線程只能歸屬于一個進程并且它只能訪問該進程所擁有的資源。當(dāng)操作系統(tǒng)創(chuàng)建一個進程后,該進程會自動申請一個名為主線程或首要線程的線程。主線程將執(zhí)行運行時宿主, 而運行時宿主會負責(zé)載入CLR。
應(yīng)用程序(application)是由一個或多個相互協(xié)作的進程組成的。例如,Visual Studio開發(fā)環(huán)境就是利用一個進程編輯源文件,并利用另一個進程完成編譯工作的應(yīng)用程序。
在Windows NT/2000/XP操作系統(tǒng)下,我們可以通過任務(wù)管理器在任意時間查看所有的應(yīng)用程序和進程。盡管只打開了幾個應(yīng)用程序,但是通常情況下將有大約30個進程同時運行。 事實上,為了管理當(dāng)前的會話和任務(wù)欄以及其他一些任務(wù),系統(tǒng)執(zhí)行了大量的進程。
5.2 進程
5.2.1 簡介
在運行于32位處理器上的32位Windows操作系統(tǒng)中,可將一個進程視為一段大小為4GB(232字節(jié))的線性內(nèi)存空間,它起始于0x00000000結(jié)束于0xFFFFFFFF。這段內(nèi)存空間不能被其他進程所訪問,所以稱為該進程的私有空間。這段空間被平分為兩塊,2GB被系統(tǒng)所有,剩下2GB被用戶所有。
如果有N個進程運行在同一臺機器上,那么將需要N×4GB的海量RAM,還好事實并非如此。
Windows是按需為每個進程分配內(nèi)存的,4GB是32位系統(tǒng)中一個進程所占空間的上限。
將進程所需的內(nèi)存劃分為4KB大小的內(nèi)存頁,并根據(jù)使用情況將這些內(nèi)存頁存儲在硬盤上或加載到RAM中,通過系統(tǒng)的這種虛擬內(nèi)存機制,我們可以有效地減少對實際內(nèi)存的需求量。當(dāng)然這些對用戶和開發(fā)者來說都是透明的。
5.2.2 System.Diagnostics.Process類
System.Diagnostics.Process類的實例可以引用一個進程,被引用的進程包含以下幾種。
該實例的當(dāng)前進程。
本機上除了當(dāng)前進程的其他進程。
遠程機器上的某個進程。
通過該類所包含的方法和字段,可以創(chuàng)建或銷毀一個進程,并且可以獲得一個進程的相關(guān)信息。下面將討論一些使用該類實現(xiàn)的常見任務(wù)。
5.2.3 創(chuàng)建和銷毀子進程
下面的程序創(chuàng)建了一個稱為子進程的新進程。在這種情況下,初始的進程稱為父進程。子進程啟動了一個記事本應(yīng)用程序。父進程的線程在等待1秒后銷毀該子進程。該程序的執(zhí)行效果就是打開并關(guān)閉記事本。
例5-1
靜態(tài)方法Start()可以使用已存在的Windows文件擴展名關(guān)聯(lián)機制。例如,我們可以利用下面的代碼執(zhí)行同樣的操作。
默認情況下,子進程將繼承其父進程的安全上下文。但還可以使用Process.Start()方法的一個重載版本在任意用戶的安全上下文中啟動該子進程,當(dāng)然需要通過一個System.Diagnostics. ProcessStartInfo類的實例來提供該用戶的用戶名和密碼。
5.2.4 避免在一臺機器上同時運行同一應(yīng)用程序的多個實例
有些應(yīng)用程序需要這種功能。實際上,通常來說在同一臺機器上同時運行一個應(yīng)用程序的多個實例并沒有意義。
直到現(xiàn)在,為了在Windows下滿足上述約束,開發(fā)者最常用的方法仍然是使用有名互斥體(named mutex)技術(shù)(參見5.7.2節(jié))。然而采用這種技術(shù)來滿足上述約束存在以下缺點:
該技術(shù)具有使互斥體的名字被其他應(yīng)用程序所使用的較小的、潛在的風(fēng)險。在這種情況下該技術(shù)將不再有效并且會造成很難檢測到的bug。
該技術(shù)不能解決我們僅允許一個應(yīng)用程序產(chǎn)生N個實例這種一般的問題。
幸而在System.Diagnostics.Process類中擁有GetCurrentProcess()(返回當(dāng)前進程)和GetPro- cesses()(返回機器上所有的進程)這樣的靜態(tài)方法。在下面的程序中我們?yōu)樯鲜鰡栴}找到了一個優(yōu)雅且簡單的解決方案。
例5-2
通過方法參數(shù)指定了遠程機器的名字后,GetProcesses()方法也可以返回遠程機器上所有的進程。
5.2.5 終止當(dāng)前進程
可以調(diào)用System.Environment類中的靜態(tài)方法Exit(int exitCode)或FailFast(stringmessage)終止當(dāng)前進程。Exit()方法是最好的選擇,它將徹底終止進程并向操作系統(tǒng)返回指定的退出代碼值。之所以稱為徹底終止是因為當(dāng)前對象的所有清理工作以及finally塊的執(zhí)行都將由不同的線程完成。當(dāng)然,終止進程將花費一定的時間。
顧名思義,F(xiàn)ailFast()方法可以迅速終止進程。Exit()方法所做的預(yù)防措施將被它忽略。只有一個包含了指定信息的嚴(yán)重錯誤會操作系統(tǒng)記錄到日志中。你可能想要在探查問題的時候使用該方法,因為可以將該程序的徹底終止視為數(shù)據(jù)惡化的起因。
5.3 線程
5.3.1 簡介
一個線程包含以下內(nèi)容。
一個指向當(dāng)前被執(zhí)行指令的指令指針;
一個棧;
一個寄存器值的集合,定義了一部分描述正在執(zhí)行線程的處理器狀態(tài)的.值;
一個私有的數(shù)據(jù)區(qū)。
所有這些元素都歸于線程執(zhí)行上下文的名下。處在同一個進程中的所有線程都可以訪問該進程所包含的地址空間,當(dāng)然也包含存儲在該空間中的所有資源。
我們不準(zhǔn)備討論線程在內(nèi)核模式或者用戶模式執(zhí)行的問題。盡管.NET以前的Windows一直使用這兩種模式,并且依然存在,但是對.NET Framework來說它們是不可見的。
并行使用一些線程通常是我們在實現(xiàn)算法時的自然反應(yīng)。實際上,一個算法往往由一系列可以并發(fā)執(zhí)行的任務(wù)組成。但是需要引起注意的是,使用大量的線程將引起過多的上下文切換,最終反而影響了性能。
同樣,幾年前我們就注意到,預(yù)測每18個月處理器運算速度增加一倍的摩爾定律已不再成立。處理器的頻率停滯在3GHz~4GHz上下。這是由于物理上的限制,需要一段時間才能取得突破。同時,為了在性能競爭中不會落敗,較大的處理器制造商如AMD和Intel目前都將目標(biāo)轉(zhuǎn)向多核芯片。因此我們可以預(yù)計在接下去的幾年中這種類型的架構(gòu)將廣泛被采用。在這種情況下,改進應(yīng)用性能的唯一方案就是合理地利用多線程技術(shù)。
5.3.2 受托管的線程與 Windows線程
必須要了解,執(zhí)行.NET應(yīng)用的線程實際上仍然是Windows線程。但是,當(dāng)某個線程被CLR所知時,我們將它稱為受托管的線程。具體來說,由受托管的代碼創(chuàng)建出來的線程就是受托管的線程。如果一個線程由非托管的代碼所創(chuàng)建,那么它就是非托管的線程。不過,一旦該線程執(zhí)行了受托管的代碼它就變成了受托管的線程。
一個受托管的線程和非托管的線程的區(qū)別在于,CLR將創(chuàng)建一個System.Threading.Thread類的實例來代表并操作前者。在內(nèi)部實現(xiàn)中,CLR將一個包含了所有受托管線程的列表保存在一個叫做ThreadStore地方。
CLR確保每一個受托管的線程在任意時刻都在一個AppDomain中執(zhí)行,但是這并不代表一個線程將永遠處在一個AppDomain中,它可以隨著時間的推移轉(zhuǎn)到其他的AppDomain中。關(guān)于AppDomain的概念參見4.1。
從安全的角度來看,一個受托管的線程的主用戶與底層的非托管線程中的Windows主用戶是無關(guān)的。
5.3.3 搶占式多任務(wù)處理
我們可以問自己下面這個問題: 我的計算機只有一個處理器,然而在任務(wù)管理器中我們卻可以看到數(shù)以百計的線程正同時運行在機器上!這怎么可能呢?
多虧了搶占式多任務(wù)處理,通過它對線程的調(diào)度,使得上述問題成為可能。調(diào)度器作為Windows內(nèi)核的一部分,將時間切片,分成一段段的時間片。這些時間間隔以毫秒為精度且長度并不固定。針對每個處理器,每個時間片僅服務(wù)于單獨一個線程。線程的迅速執(zhí)行給我們造成了它們在同時運行的假象。我們在兩個時間片的間隔中進行上下文切換。該方法的優(yōu)點在于,那些正在等待某些Windows資源的線程將不會浪費時間片,直到資源有效為止。
之所以用搶占式這個形容詞來修飾這種多任務(wù)管理方式,是因為在此種方式下線程將被系統(tǒng)強制性中斷。那些對此比較好奇的人應(yīng)該了解到,在上下文切換的過程中,操作系統(tǒng)會在下一個線程將要執(zhí)行的代碼中插入一條跳轉(zhuǎn)到下一個上下文切換的指令。該指令是一個軟中斷,如果線程在遇到這條指令前就終止了(例如,它正在等待某個資源),那么該指定將被刪除而上下文切換也將提前發(fā)生。
搶占式多任務(wù)處理的主要缺點在于,必須使用一種同步機制來保護資源以避免它們被無序訪問。除此之外,還有另一種多任務(wù)管理模型,被稱為協(xié)調(diào)式多任務(wù)管理,其中線程間的切換將由線程自己負責(zé)完成。該模型普遍認為太過危險,原因在于線程間的切換不發(fā)生的風(fēng)險太大。如我們在4.2.8節(jié)中所解釋的那樣,該機制會在內(nèi)部使用以提升某些服務(wù)器的性能,例如SQL Server2005。但Windows操作系統(tǒng)僅僅實現(xiàn)了搶占式多任務(wù)處理。
5.3.4 進程與線程的優(yōu)先級
某些任務(wù)擁有比其他任務(wù)更高的優(yōu)先級,它們需要操作系統(tǒng)為它們申請更多的處理時間。例如,某些由主處理器負責(zé)的外圍驅(qū)動器必須不能被中斷。另一類高優(yōu)先級的任務(wù)就是圖形用戶界面。事實上,用戶不喜歡等待用戶界面被重繪。
那些從Win32世界來的用戶知道在CLR的底層,也就是Windows操作系統(tǒng)中,可以為每個線程賦予一個0~31的優(yōu)先級。但你無法在.NET的世界中也使用這些數(shù)值,因為:
它們無法描述自身的含義。
隨著時間的流逝這些值是非常容易變化的。
1. 進程的優(yōu)先級
可以使用Process類中的類型為ProcessPriorityClass的PriorityClass{get;set;}屬性為進程賦予一個優(yōu)先級。System.Diagnostics.ProcessPriorityClass枚舉包含以下值:
如果某個進程中屬于Process類的PriorityBoostEnabled屬性的值為true(默認值為true),那么當(dāng)該進程占據(jù)前臺窗口的時候,它的優(yōu)先級將增加一個單位。只有當(dāng)Process類的實例引用的是本機進程時,才能夠訪問該屬性。
可以通過以下操作利用任務(wù)管理器來改變一個進程的優(yōu)先級:在所選的進程上點擊右鍵>設(shè)置優(yōu)先級>從提供的6個值(和上圖所述一致)中做出選擇。
Windows操作系統(tǒng)有一個優(yōu)先級為0的空閑進程。該進程不能被其他任何進程使用。根據(jù)定義,進程的活躍度用時間的百分比表示為:100%減去在空閑進程中所耗費時間的比率。
2. 線程的優(yōu)先級
每個線程可以結(jié)合它所屬進程的優(yōu)先級,并使用System.Threading.Thread類中類型為ThreadPriority的Priority{get;set;}屬性定義各自的優(yōu)先級。System.Threading.Thread- Priority包含以下枚舉值:
在大多數(shù)應(yīng)用程序中,不需要修改進程和線程的優(yōu)先級,它們的默認值為Normal。
5.3.5 System.Threading.Thread類
CLR會自動將一個System.Threading.Thread類的實例與各個受托管的線程關(guān)聯(lián)起來?梢允褂迷搶ο髲木程自身或從其他線程來操縱線程。還可以通過System.Threading.Thread類的靜態(tài)屬性CurrentThread來獲得當(dāng)前線程的對象。
Thread類有一個功能使我們能夠很方便的調(diào)試多線程應(yīng)用程序,該功能允許我們使用一個字符串為線程命名:
5.3.6 創(chuàng)建與Join一個線程
只需通過創(chuàng)建一個Thread類的實例,就可以在當(dāng)前的進程中創(chuàng)建一個新的線程。該類擁有多個構(gòu)造函數(shù),它們將接受一個類型為System.Threading.ThreadStart或System.Threading.Parame-trizedThreadStart的委托對象作為參數(shù),線程被創(chuàng)建出來后首先執(zhí)行該委托對象所引用的方法。使用ParametrizedThreadStart類型的委托對象允許用戶為新線程將要執(zhí)行的方法傳入一個對象作為參數(shù)。Thread類的一些構(gòu)造函數(shù)還接受一個整型參數(shù)用于設(shè)置線程要使用的最大棧的大小,該值至少為128KB(即131072字節(jié))。創(chuàng)建了Thread類型的實例后,必須調(diào)用Thread.Start()方法以真正啟動這個線程。
例5-3
該程序輸出:
在這個例子中,我們使用Join()方法掛起當(dāng)前線程,直到調(diào)用Join()方法的線程執(zhí)行完畢。該方法還存在包含參數(shù)的重載版本,其中的參數(shù)用于指定等待線程結(jié)束的最長時間(即超時)所花費的毫秒數(shù)。如果線程中的工作在規(guī)定的超時時段內(nèi)結(jié)束,該版本的Join()方法將返回一個布爾量True。
5.3.7 掛起一個線程
可以使用Thread類的Sleep()方法將一個正在執(zhí)行的線程掛起一段特定的時間,還可以通過一個以毫秒為單位的整型值或者一個System.TimeSpan結(jié)構(gòu)的實例設(shè)定這段掛起的時間。該結(jié)構(gòu)的一個實例可以設(shè)定一個精度為1/10 ms(100ns)的時間段,但是Sleep()方法的最高精度只有1ms。
我們也可以從將要掛起的線程自身或者另一個線程中使用Thread類的Suspend()方法將一個線程的活動掛起。在這兩種情況中,線程都將被阻塞直到另一個線程調(diào)用了Resume()方法。相對于Sleep()方法,Suspend()方法不會立即將線程掛起,而是在線程到達下一個安全點之后,CLR才會將該線程掛起。安全點的概念參見4.7.11節(jié)。
5.3.8 終止一個線程
一個線程可以在以下場景中將自己終止。
從自己開始執(zhí)行的方法(主線程中的Main()方法,其他線程中ThreadStart委托對象所引用的方法)中退出。
被自己終止。
被另一個線程終止。
第一種情況不太重要,我們將主要關(guān)注另兩種情況。在這兩種情況中,都可以使用Abort()方法(通過當(dāng)前線程或從當(dāng)前線程之外的一個線程)。使用該方法將在線程中引發(fā)一個類型為ThreadAbortException的異常。由于線程正處于一種被稱為AbortRequested的特殊狀態(tài),該異常具有一個特殊之處:當(dāng)它被異常處理所捕獲后,將自動被重新拋出。只有在異常處理中調(diào)用Thread.ResetAbort()這個靜態(tài)方法(如果我們有足夠的權(quán)限)才能阻止它的傳播。
例5-4 主線程的自殺
當(dāng)線程A對線程B調(diào)用了Abort()方法,建議調(diào)用B的Join()方法,讓A一直等待直到B終止。Interrupt()方法也可以將一個處于阻塞狀態(tài)的線程(即由于調(diào)用了Wait()、Sleep()或者Join()其中一個方法而阻塞)終止。該方法會根據(jù)要被終止的線程是否處于阻塞狀態(tài)而表現(xiàn)出不同的行為。
如果該方法被另一個線程調(diào)用時,要被終止的線程處于阻塞狀態(tài),那么會產(chǎn)生ThreadInterruptedException異常。
如果該方法被另一個線程調(diào)用時,要被終止的線程不處于阻塞狀態(tài),那么一旦該線程進入阻塞狀態(tài),就會引發(fā)異常。這種行為與線程對自己調(diào)用Interrupt()方法是一樣的。
5.3.9 前臺線程與后臺線程
Thread類提供了IsBackground{get;set}的布爾屬性。當(dāng)前臺線程還在運行時,它會阻止進程被終止。另一方面,一旦所指的進程中不再有前臺線程,后臺線程就會被CLR自動終止(調(diào)用Abort()方法)。IsBackground的默認值為false,這意味著所有的線程默認情況處于前臺狀態(tài)。
5.3.10 受托管線程
Thread類擁有一個System.Threading.ThreadState枚舉類型的字段ThreadState,它包含以下枚舉值:
有關(guān)每個狀態(tài)的具體描述可以在MSDN上一篇名為“ThreadStateEnumeration”的文章中找到。該枚舉類型是一個二進制位域,這表示一個該類型的實例可以同時表示多個枚舉值。例如,一個線程可以同時處于Running、AbortRequested和Background這三種狀態(tài)。二進制位域的概念參見10.11.3節(jié)。
根據(jù)我們在前面的章節(jié)中所了解的知識,我們定義了如圖5-1所示的簡化的狀態(tài)圖。
圖5-1 簡化的托管線程
5.4 訪問資源同步簡介
在多線程應(yīng)用(一個或多個處理器)的計算中會使用到同步這個詞。實際上,這些應(yīng)用程序的特點就是它們擁有多個執(zhí)行單元,而這些單元在訪問資源的時候可能會發(fā)生沖突。線程間會共享同步對象,而同步對象的目的在于能夠阻塞一個或多個線程,直到另一個線程使得某個特定條件得到滿足。
我們將看到,存在多種同步類與同步機制,每種制針對一個或一些特定的需求。如果要利用同步構(gòu)建一個復(fù)雜的多線程應(yīng)用程序,那么很有必要先掌握本章的內(nèi)容。我們將在下面的內(nèi)容中盡力區(qū)分他們,尤其要指出那些在各個機制間最微妙的區(qū)別。
合理地同步一個程序是最精細的軟件開發(fā)任務(wù)之一,單這一個主題就足以寫幾本書。在深入到細節(jié)之前,應(yīng)該首先確認使用同步是否不可避免。通常,使用一些簡單的規(guī)則可以讓我們遠離同步問題。在這些規(guī)則中有線程與資源的親緣性規(guī)則,我們將在稍后介紹。
應(yīng)該意識到,對程序中資源的訪問進行同步時,其難點來自于是使用細粒度鎖還是粗粒度鎖這個兩難的選擇。如果在訪問資源時采用粗粒度的同步方式,雖然可以簡化代碼但是也會把自己暴露在爭用瓶頸的問題上。如果粒度過細,代碼又會變的很復(fù)雜,以至于維護工作令人生厭。然后又會遇上死鎖和競態(tài)條件這些在下面章節(jié)將要介紹的問題。
因此在我們開始談?wù)撚嘘P(guān)同步機制之前,有必要先了解一下有關(guān)競態(tài)條件和死鎖的概念。
5.4.1 競態(tài)條件
競態(tài)條件指的是一種特殊的情況,在這種情況下各個執(zhí)行單元以一種沒有邏輯的順序執(zhí)行動作,從而導(dǎo)致意想不到的結(jié)果。
舉一個例子,線程T修改資源R后,釋放了它對R的寫訪問權(quán),之后又重新奪回R的讀訪問權(quán)再使用它,并以為它的狀態(tài)仍然保持在它釋放它之后的狀態(tài)。但是在寫訪問權(quán)釋放后到重新奪回讀訪問權(quán)的這段時間間隔中,可能另一個線程已經(jīng)修改了R的狀態(tài)。
另一個經(jīng)典的競態(tài)條件的例子就是生產(chǎn)者/消費者模型。生產(chǎn)者通常使用同一個物理內(nèi)存空間保存被生產(chǎn)的信息。一般說來,我們不會忘記在生產(chǎn)者與消費者的并發(fā)訪問之間保護這個空間。容易被我們忘記的是生產(chǎn)者必須確保在生產(chǎn)新信息前,舊的信息已被消費者所讀取。如果我們沒有采取相應(yīng)的預(yù)防措施,我們將面臨生產(chǎn)的信息從未被消費的危險。
如果靜態(tài)條件沒有被妥善的管理,將導(dǎo)致安全系統(tǒng)的漏洞。同一個應(yīng)用程序的另一個實例很可能會引發(fā)一系列開發(fā)者所預(yù)計不到的事件。一般來說,必須對那種用于確認身份鑒別結(jié)果的布爾量的寫訪問做最完善的保護。如果沒有這么做,那么在它的狀態(tài)被身份鑒別機制設(shè)置后,到它被讀取以保護對資源的訪問的這段時間內(nèi),很有可能已經(jīng)被修改了。已知的安全漏洞很多都歸咎于對靜態(tài)條件不恰當(dāng)?shù)墓芾。其中之一甚至影響了Unix操作系統(tǒng)的內(nèi)核。
5.4.2 死鎖
死鎖指的是由于兩個或多個執(zhí)行單元之間相互等待對方結(jié)束而引起阻塞的情況。例如:
一個線程T1獲得了對資源R1的訪問權(quán)。
一個線程T2獲得了對資源R2的訪問權(quán)。
T1請求對R2的訪問權(quán)但是由于此權(quán)力被T2所占而不得不等待。
T2請求對R1的訪問權(quán)但是由于此權(quán)力被T1所占而不得不等待。
T1和T2將永遠維持等待狀態(tài),此時我們陷入了死鎖的處境!這種問題比你所遇到的大多數(shù)的bug都要隱秘,針對此問題主要有三種解決方案:
在同一時刻不允許一個線程訪問多個資源。
為資源訪問權(quán)的獲取定義一個關(guān)系順序。換句話說,當(dāng)一個線程已經(jīng)獲得了R1的訪問權(quán)后,將無法獲得R2的訪問權(quán)。當(dāng)然,訪問權(quán)的釋放必須遵循相反的順序。
為所有訪問資源的請求系統(tǒng)地定義一個最大等待時間(超時時間),并妥善處理請求失敗的情況。幾乎所有的.NET的同步機制都提供了這個功能。
前兩種技術(shù)效率更高但是也更加難于實現(xiàn)。事實上,它們都需要很強的約束,而這點隨著應(yīng)用程序的演變將越來越難以維護。盡管如此,使用這些技術(shù)不會存在失敗的情況。
大的項目通常使用第三種方法。事實上,如果項目很大,一般來說它會使用大量的資源。在這種情況下,資源之間發(fā)生沖突的概率很低,也就意味著失敗的情況會比較罕見。我們認為這是一種樂觀的方法。秉著同樣的精神,我們在19.5節(jié)描述了一種樂觀的數(shù)據(jù)庫訪問模型。
5.5 使用volatile字段與Interlocked類實現(xiàn)同步
5.5.1 volatile字段
volatile字段可以被多個線程訪問。我們假設(shè)這些訪問沒有做任何同步。在這種情況下,CLR中一些用于管理代碼和內(nèi)存的內(nèi)部機制將負責(zé)同步工作,但是此時不能確保對該字段讀訪問總能讀取到最新的值,而聲明為volatile的字段則能提供這樣的保證。在C#中,如果一個字段在它的聲明前使用了volatile關(guān)鍵字,則該字段被聲明為volatile。
不是所有的字段都可以成為volatile,成為這種類型的字段有一個條件。如果一個字段要成為volatile,它的類型必須是以下所列的類型中的一種:
引用類型(這里只有訪問該類型的引用是同步的,訪問其成員并不同步)。
一個指針(在不安全的代碼塊中)。
sbyte、byte、short、ushort、int、uint、char、float、bool(工作在64位處理器上時為double、long與ulong)。
一個使用以下底層類型的枚舉類型:byte、sbyte、short、ushort、int、uint(工作在64位的處理器上時為double、long與ulong)。
你可能已經(jīng)注意到了,只有值或者引用的位數(shù)不超過本機整型值的位數(shù)(4或8由底層處理器決定)的類型才能成為volatile。這意味著對更大的值類型進行并發(fā)訪問必須進行同步,下面我們將會對此進行討論。
5.5.2 System.Threading.Interlocked類
經(jīng)驗顯示,那些需要在多線程情況下被保護的資源通常是整型值,而這些被共享的整型值最常見的操作就是增加/減少以及相加。.NETFramework利用System.Threading.Interlocked類提供了一個專門的機制用于完成這些特定的操作。這個類提供了Increment()、Decrement()與Add()三個靜態(tài)方法,分別用于對int或者long類型變量的遞增、遞減與相加操作,這些變量以引用方式作為參數(shù)傳入。我們認為使用Interlocked類讓這些操作具有了原子性。
下面的程序顯示了兩個線程如何并發(fā)訪問一個名為counter的整型變量。一個線程將其遞增5次,另一個將其遞減5次。
例5-5
該程序輸出(以非確定方式輸出,意味著每執(zhí)行一次顯示的結(jié)果都是不同的):
如果我們不讓這些線程在每次修改變量后休眠10毫秒,那么它們將有足夠的時間在一個時間片中完成它們的任務(wù),那樣也就不會出現(xiàn)交叉操作,更不用說并發(fā)訪問了。
5.5.3 Interlocked類提供的其他功能
Interlocked類還允許使用Exchange()靜態(tài)方法,以原子操作的形式交換某些變量的狀態(tài)。還可以使用CompareExchange()靜態(tài)方法在滿足一個特定條件的基礎(chǔ)上以原子操作的形式交換兩個值。
5.6 使用System.Threading.Monitor類與C#的lock關(guān)鍵字實現(xiàn)同步
以原子操作的方式完成簡單的操作無疑是很重要的,但是這還遠不能涵蓋所有需要用到同步的事例。System.Threading.Monitor類幾乎允許將任意一段代碼設(shè)置為在某個時間僅能被一個線程執(zhí)行。我們將這段代碼稱之為臨界區(qū)。
5.6.1 Enter()方法和Exit()方法
Monitor類提供了Enter(object)與Exit(object)這兩個靜態(tài)方法。這兩個方法以一個對象作為參數(shù),該對象提供了一個簡單的方式用于唯一標(biāo)識那個將以同步方式訪問的資源。當(dāng)一個線程調(diào)用了Enter()方法,它將等待以獲得訪問該引用對象的獨占權(quán)(僅當(dāng)另一個線程擁有該權(quán)力的時候它才會等待)。一旦該權(quán)力被獲得并使用,線程可以對同一個對象調(diào)用Exit()方法以釋放該權(quán)力。
一個線程可以對同一個對象多次調(diào)用Enter(),只要對同一對象調(diào)用相同次數(shù)的Exit()來釋放獨占訪問權(quán)。
一個線程也可以在同一時間擁有多個對象的獨占權(quán),但是這樣會產(chǎn)生死鎖的情況。
絕不能對一個值類型的實例調(diào)用Enter()與Exit()方法。
不管發(fā)生了什么,必須在finally子句中調(diào)用Exit()以釋放所有的獨占訪問權(quán)。
如果在例5-5中,一個線程非要將counter做一次平方而另一個線程非要將counter乘2,我們就不得不用Monitor類去替換對Interlocked類的使用。f1()與f2()的代碼將變成下面這樣:
例5-6[1]
人們很容易想到用counter來代替typeof(Program),但是counter是一個值類型的靜態(tài)成員。需要注意平方和倍增操作是不滿足交換律的,所以counter的最終結(jié)果是非確定性的。
5.6.2 C#的lock關(guān)鍵字
C#語言通過lock關(guān)鍵字提供了一種比使用Enter()和Exit()方法更加簡潔的選擇。我們的程序可以改寫為下面這個樣子:
例5-7
和for以及if關(guān)鍵字一樣,如果被lock關(guān)鍵字定義的塊僅包含一條指令,就不再需要花括號。我們可以再次改寫為:
使用lock關(guān)鍵字將引導(dǎo)C#編譯器創(chuàng)建出相應(yīng)的try/finally塊,這樣仍舊可以預(yù)期到任何可能引發(fā)的異常。可以使用Reflector或者ildasm.exe工具驗證這一點。
5.6.3 SyncRoot模式
和前面的例子一樣,我們通常在一個靜態(tài)方法中使用Monitor類配合一個Type類的實例。同樣,我們往往會在一個非靜態(tài)方法中使用this關(guān)鍵字來實現(xiàn)同步。在兩種情況下,我們都是通過一個在類外部可見的對象對自身進行同步。如果其他部分的代碼也利用這些對象來實現(xiàn)自身的同步,就會出現(xiàn)問題。為了避免這種潛在的問題,我們推薦使用一個類型為object的名為SyncRoot的私有成員,至于該成員是靜態(tài)的還是非靜態(tài)的則由需要而定。
例5-8
System.Collections.ICollection接口提供了object類型的SyncRoot{get;}屬性。大多數(shù)的集合類(泛型或非泛型)都實現(xiàn)了該接口。同樣地,可以使用該屬性同步對集合中元素的訪問。不過在這里SyncRoot模式并沒有被真正的應(yīng)用,因為我們對訪問進行同步所使用對象不是私有的。
例5-9
5.6.4 線程安全類
若一個類的每個實例在同一時間不能被一個以上的線程所訪問,則該類稱之為一個線程安全的類。為了創(chuàng)建一個線程安全的類,只需將我們見過的SyncRoot模式應(yīng)用于它所包含的方法。如果一個類想變成線程安全的,而又不想為類中代碼增加過多負擔(dān),那么有一個好方法就是像下面這樣為其提供一個經(jīng)過線程安全包裝的繼承類。
例5-10
另一種方法就是使用System.Runtime.Remoting.Contexts.SynchronizationAttribute,這點我們將在本章稍后討論。
5.6.5 Monitor.TryEnter()方法
該方法與Enter()相似,只不過它是非阻塞的。如果資源的獨占訪問權(quán)已經(jīng)被另一個線程占據(jù),該方法將立即返回一個false返回值。我們也可以調(diào)用TryEnter()方法,讓它以毫秒為單位阻塞一段有限的時間。因為該方法的返回結(jié)果并不確定,并且當(dāng)獲得獨占訪問權(quán)后必須在finally子句中釋放該權(quán)力,所以建議當(dāng)TryEnter()失敗時立即退出正在調(diào)用的函數(shù):
例5-11[2]
5.6.6 Monitor類的Wait()方法, Pulse()方法以及PulseAll()方法
Wait()、Pulse()與PulseAll()方法必須在一起使用并且需要結(jié)合一個小場景才能被正確理解。我們的想法是這樣的:一個線程獲得了某個對象的獨占訪問權(quán),而它決定等待(通過調(diào)用Wait())直到該對象的狀態(tài)發(fā)生變化。為此,該線程必須暫時失去對象獨占訪問權(quán),以便讓另一個線程修改對象的狀態(tài)。修改對象狀態(tài)的線程必須使用Pulse()方法通知那個等待線程修改完成。下面有一個小場景具體說明了這一情況。
擁有OBJ對象獨占訪問權(quán)的T1線程,調(diào)用Wait(OBJ)方法將它自己注冊到OBJ對象的被動等待列表中。
由于以上的調(diào)用,T1失去了對OBJ的獨占訪問權(quán)。因此,另一個線程T2通過調(diào)用Enter(OBJ)獲得OBJ的獨占訪問權(quán)。
T2最終修改了OBJ的狀態(tài)并調(diào)用Pulse(OBJ)通知了這次修改。該調(diào)用將導(dǎo)致OBJ被動等待列表中的第一個線程(在這里是T1)被移到OBJ的主動等待列表的首位。而一旦OBJ的獨占訪問權(quán)被釋放,OBJ主動等待列表中的第一個線程將被確保獲得該權(quán)力。然后它就從Wait(OBJ)方法中退出等待狀態(tài)。
在我們的場景中,T2調(diào)用Exit(OBJ)以釋放對OBJ的獨占訪問權(quán),接著T1恢復(fù)訪問權(quán)并從Wait(OBJ)方法中退出。
PulseAll()將使得被動等待列表中的線程全部轉(zhuǎn)移到主動等待列表中。注意這些線程將按照它們調(diào)用Wait()的順序到達非阻塞態(tài)。
如果Wait(OBJ)被一個調(diào)用了多次Enter(OBJ)的線程所調(diào)用,那么該線程將需要調(diào)用相同次數(shù)的Exit(OBJ)以釋放對OBJ的訪問權(quán)。即使在這種情況下,另一個線程調(diào)用一次Pulse(OBJ)就足以將第一個線程變成非阻塞態(tài)。
下面的程序通過ping與pong兩個線程以交替的方式使用一個ball對象的訪問權(quán)來演示該功能。
例5-12
該程序輸出(以不確定的方式):
pong線程沒有結(jié)束并且仍然阻塞在Wait()方法上。由于pong線程是第二個獲得ball對象的獨占訪問權(quán)的,所以才導(dǎo)致了該結(jié)果。
【進程和線程的區(qū)別】相關(guān)文章:
設(shè)計并發(fā)服務(wù)器,使用多進程與多線程有什么區(qū)別?12-03
關(guān)于linux查看進程ps top區(qū)別09-30
ssat和托福的區(qū)別10-14
咖啡的種類和區(qū)別10-09
速錄和速記的區(qū)別10-01
綠茶和白茶的區(qū)別10-22
紋眉和畫眉的區(qū)別07-19
朗誦和播音的區(qū)別09-04