多线程并发:每个开发人员都应了解的内容
各種類型的 源碼,書籍,工具等 進入 磐實資源站 可以找到.網址 --> www.panshsoft.net ? 如果你有好的文章,源碼提供給本站 可以進入 bbs.panshsoft.com 發貼 或 聯系 QQ: 513182470? 解決軟件開發問題或其它問題 可以 加入群:125101150 也可以點擊 -->? 求助 ?
?
?
?多線程和共享內存線程模型 ?爭用及并發訪問如何能夠打破不變量 ?作為爭用標準解決方案的鎖定 ?何時需要鎖定 ?如何使用鎖定;理解開銷 ?鎖定如何能夠各行其道
?十年前,只有核心系統程序員會擔心在多個執行線程的情況下編寫正確代碼的復雜性。絕大多數程序員編寫的是順序執行程序,可以徹底避免這個問題。但是現在,多處理器計算機正在普及。很快,非多線程程序將處于劣勢,因為它們無法利用可用計算資源中很大的一部分。
不幸的是,編寫正確的多線程程序并不容易。這主要是因為程序員們還沒有習慣“其他線程可能正在改變不屬于它們下面的內存”這種思維方式。更糟糕的是,出現錯誤時,程序在絕大多數時候會繼續運行下去。只有在有壓力(正式運行)條件下,Bug 才會顯示出來;發生故障時,極少有足夠的信息可供有效地調試應用程序。圖 1 匯總了順序執行程序和多線程程序之間的主要不同之處。如圖所示,要讓多線程程序一次成功需要很大的精力。 ?
?
多線程并發:每個開發人員都應了解的內容 圖 1
?
本文有三個目標。首先,我將說明多線程程序并不是那么神秘。無論程序是順序運行的還是多線程的,編寫正確程序的基本要求是一樣的:程序中的所有代碼都必須保護程序其他部分需要的任何不變量。其次,我將證明雖然這條原則非常簡單,但在多線程情形中,保護程序不變量要困難得多。通過示例將能看出,順序運行環境中的小細節在多線程環境中具有驚人的復雜性。最后,我將告訴您如何對付可能潛伏在多線程程序中的復雜問題。本指南總結了非常系統地保護程序不變量的策略。如圖 2 中的表格所示,這在多線程情形中更加復雜。造成這種在使用多線程時更加復雜的原因有很多,我將在以下各節中進行解釋。 ?
多線程并發:每個開發人員都應了解的內容 圖 2
?
線程和內存
就核心部分而言,多線程編程似乎非常簡單。在這種模式下,不是僅有一個處理單元按順序進行工作,而是有兩個或多個處理單元同時執行。因為多個處理器可能是真實的硬件,也可能是通過對單個處理器進行時序多路復用來實現的,所以我們使用術語“線程”來代替處理器。多線程編程的棘手之處在于線程之間的通訊方式。
多線程并發:每個開發人員都應了解的內容 ?圖 3 共享內存線程模型
?
最常部署的多線程通訊模型稱為共享內存模型。在此模型中,所有線程都可以訪問同一個共享內存池,如圖 3 所示。此模型的優勢在于,編寫多線程程序的方式與順序執行程序幾乎相同。但這種優勢同時也是它最大的問題。該模型不區分正在嚴格地供線程局部使用的內存(例如局部聲明的變量)與正在用于和其他線程通訊的內存(例如某些全局變量及堆內存)。因為與分配給線程的局部內存相比,可能會被共享的內存需要更仔細地處理,所以非常容易犯錯誤。
?
爭用 ?設想一個處理請求的程序,它擁有全局計數器 totalRequests,每完成一次請求就計數一次。正如您看到的,對于順序執行程序來說,進行此操作的代碼非常簡單: totalRequests = totalRequests + 1
但如果程序采用多線程來處理請求和更新 totalRequests,就會出現問題。編譯器可能會把遞增運算編譯成以下機器碼: MOV EAX, [totalRequests]? // load memory for totalRequests into register
INC EAX?????????????????? // update register
MOV [totalRequests], EAX? // store updated value back to memory
考慮一下,如果兩個線程同時運行此代碼,會產生什么結果?如圖 4 所示,兩個線程會加載同一個 totalRequests 值,均進行遞增運算,然后均存回 totalRequests。最終結果是,兩個線程都處理了請求,但 totalRequests 中的值只增加了一。這顯然不是我們想要的結果。類似這種因為線程之間計時錯誤而造成的 Bug 稱為爭用。
?
多線程并發:每個開發人員都應了解的內容 圖 4 爭用圖解
?
????? 雖然這個示例看上去比較簡單,但對于更復雜的實際爭用,問題的基本結構是一樣的。要形成爭用,需要具備四個條件。
第一個條件是,存在可以從多個線程中進行訪問的內存位置。這類位置通常是全局/靜態變量(例如 totalRequests 的情形)或可從全局/靜態變量中進行訪問的堆內存。
第二個條件是,存在程序正常運行必需的與這些共享內存位置關聯的屬性。在此示例中,該屬性即為 totalRequests 準確地表示任何線程已執行的遞增語句的任何一部分的總次數。通常,屬性需要在更新發生前包含 true 值(即 totalRequests 必須包含準確的計數),以保證更新是正確的。
第三個條件是,在實際更新的某一段過程中,屬性不包含值。在此特定情形中,從捕獲 totalRequests 到存儲它,totalRequests 不包含不變量。
發生爭用的第四個,也是最后一個必備條件是,當打破不變量時有其他線程訪問內存,從而造成錯誤行為。
?
鎖定
??? 防止爭用的最常見方法是使用鎖定,在打破不變量時阻止其他線程訪問與該不變量關聯的內存。這可以避免上述爭用成因中的第四個條件。
最通用的一類鎖定有多個不同的名稱,例如監視程序、關鍵部分、互斥、二進制信號量等。但無論叫什么,提供的基本功能是相同的。鎖定可提供 Enter 和 Exit 方法,一個進程調用 Enter 后,其他進程調用 Enter 的所有嘗試都會導致自己受阻(等待),直到該進程調用 Exit。調用 Enter 的線程是鎖定的擁有者,如果非鎖定擁有者調用了 Exit,將被視為編程錯誤。鎖定提供了確保在任何給定的時間,只有一個進程能夠執行特定代碼區域的機制。
在 Microsoft? .NET Framework 中,鎖定是由 System.Threading.Monitor 類來實現的。Monitor 類略微與眾不同,因為它不定義實例。這是由于鎖定功能是由 System.Object 有效地實現的,這樣能夠鎖定任何對象。以下是如何使用鎖定來解決與 totalRequests 相關的爭用:
?
static object totalRequestsLock = new Object();? // executed at program
// init
...
System.Threading.Monitor.Enter(totalRequestsLock);
totalRequests = totalRequests + 1;
System.Threading.Monitor.Exit(totalRequestsLock);
?
雖然這段代碼確實解決了爭用,但它會帶來另一個問題。如果在鎖定過程中發生了異常,那么將不會調用 Exit。這將導致嘗試運行此代碼的所有其他線程永遠被阻。在許多程序中,任何異常都會被視作程序的致命錯誤,因此在那些情形中發生的事情并不有趣。 但對于希望能夠從異常中恢復的程序來說,在 finally 子句中放入對 Exit 的調用可以增加解決方案的健壯性: System.Threading.Monitor.Enter(totalRequestsLock);
try {
totalRequests = totalRequests + 1;
} finally {
System.Threading.Monitor.Exit(totalRequestsLock);
}
此模式很常用,C# 和 Visual Basic? .NET 都有支持它的特殊語句結構。以下 C# 代碼和前例中的 try/finally 語句等效: lock(totalRequestsLock) {
totalRequests = totalRequests + 1;
}
就個人而言,我對 lock 語句的心情是自相矛盾的。一方面,它是一個方便的捷徑。但另一方面,它讓程序員們對自己正在編寫的代碼是否健壯心存疑慮。請記住,引入鎖定區域是因為重要的程序不變量沒有使用該區域。如果在該區域中引發異常,那么很可能在引發異常的同時打破了不變量。允許程序在不嘗試修復不變量的情況下繼續下去是個不恰當的做法。
在 totalRequests 示例中,沒有有用的清理可做,所以 lock 語句是適當的。此外,如果 lock 語句主體所做的一切都是只讀的,那么 lock 語句也是適當的。但總體而言,如果發生異常,那么需要做更多的清理工作。在此情形中,lock 語句不會帶來太多價值,因為無論如何都將需要明確的 try/finally 語句。
?
正確使用鎖定
????? 大多數程序員都遇到過爭用,也明白如何使用鎖定來防止爭用的簡單示例。但如果沒有詳細解釋,示例自身并不能使人了解在實際程序中有效使用鎖定的重要原則。
我們最重要的觀察結果是,鎖定為代碼區域提供互斥,但總體上,程序員想要保護內存區域。在 totalRequests 示例中,目標是確定不變量包含 totalRequests 上的 true 值(內存位置)。但為了做到這一點,我們實際在代碼區域附近放置了鎖定(totalRequests 的增量)。這提供了圍繞 totalRequests 的互斥,因為它是唯一引用 totalRequests 的代碼。如果有不進入鎖定便更新 totalRequests 的其他代碼,那么將失去內存互斥,繼而代碼將具備爭用條件。
這引申出以下原則。對于為內存區域提供互斥的鎖定,不進入同一鎖定便不得寫入該內存。在正確設計的程序中,與每個鎖定相關聯的是該鎖定提供互斥的內存區域。不幸的是,至今還沒有一種可行的編碼方式能夠讓這種關聯變得清晰,而這個信息對于要推論程序的多線程行為的任何人來說,無疑都是至關重要的。
以此推斷,每個鎖定都應有一個與之關聯的說明,其中記載了該鎖定為之提供互斥的準確內存區域(數據結構集)。在 totalRequests 的示例中,totalRequestsLock 保護 totalRequests 變量,僅此而已。在實際程序中,鎖定嘗試保護更大的區域,例如整個數據結構、若干相關數據結構或者從數據結構中可以訪問的所有內存。有時鎖定僅保護數據結構的一部分(例如哈希表的哈希存儲桶鏈),但不論保護的區域到底為何,重要的是程序員要記下它。有了寫下的說明,才有可能通過系統的方法來驗證在更新關聯內存之前,是否已進入相關鎖定。絕大多數爭用是由于未能保證在訪問相關內存之前,一定要進入正確鎖定而引起的,所以在這個審核步驟上花時間是值得的。
每個鎖定都具備對自己要保護的內存的準確說明之后,請檢查是否有受不同鎖定保護的任何區域發生重疊現象。雖然重疊并不一定是錯誤的,但也應避免,因為與兩個不同鎖定相關聯的內存發生重疊是毫無用處的。考慮一下,兩個鎖定共用的內存需要更新時會發生什么?又該使用哪個鎖定呢?可能的做法包括:
任意進入其中一個鎖定 這種做法是不可取的,因為它不再提供互斥。如果采取這種做法,兩個不同的更新站點可以選擇不同的鎖定,然后同時更新同一個內存位置。
始終進入兩個鎖定 這將提供互斥,但需要兩倍開銷,且與僅讓該位置擁有一個鎖定相比,沒有提供任何優勢。
始終進入其中一個鎖定 這與僅有一個鎖定保護該特定位置是等效的。
?
?
需要多少個鎖定?
為了說明下一個要點,示例略微更加復雜。這次我們不是只有一個 totalRequests 計數器,而是有兩個不同的計數器,分別用于高/低優先級的請求。totalRequests 不是直接存儲,而是按以下方法計算: totalRequests = highPriRequests + lowPriRequests;
程序需要的不變量是:兩個全局變量之和等于任何線程已處理請求的次數。與上一個示例不同,此不變量涉及兩個內存位置。這立即帶來是需要一個鎖定還是兩個鎖定的問題。對這個問題的回答取決于設計目的。
擁有兩個鎖定(一個用于 highPriRequests,另一個用于 lowPriRequests)的主要優勢在于它允許更多的并發。如果一個線程嘗試更新 highPriRequests,另一個線程嘗試更新 lowPriRequests,但只有一個鎖定,那么一個線程必須等待另一個線程。如果有兩個鎖定,那么每個線程都可以繼續運行,而不會發生爭用。在示例中,這對并發的改善是微乎其微的,因為對單個鎖定的采用相對較少,且占用的時間不會太長。但是想像一下,如果鎖定正在保護處理請求期間頻繁使用的表,情況又會怎樣。在此情形中,最好只鎖定表的某些部分(例如哈希存儲桶條目),以便若干線程能夠同時訪問表。
具備兩個鎖定的主要劣勢是因此帶來的復雜性。很顯然,程序關聯的部分多了,程序員犯錯的機會也就多了。這種復雜性隨著系統中鎖定數量的增多而快速提高,所以最好是使用較少的鎖定來保護較大的內存區域,且僅當鎖定爭用即將成為性能瓶頸時才分割這些內存區域。
最極端的情況是,程序可以只有一個鎖定,它保護可從多個線程中訪問的所有內存。對于請求-處理示例,如果線程無需訪問共享數據便可處理請求,這種設計會工作得很好。如果處理請求需要線程對共享內存進行多次更新,那么單個鎖定將成為瓶頸。在此情形中,需要將由一個鎖定保護的單一較大內存區域分割成若干不重疊的子集,每個子集都由自己的鎖定來保護。
?
?
對讀取采用鎖定
到目前為止,我已經展示了寫入內存位置之前,應始終進入鎖定的做法,但尚未討論讀取內存時應采取的做法。讀取的情況略微更加復雜,因為它取決于程序員的期望值。讓我們回到上例,假設您決定讀取 highPriRequests 和 lowPriRequests 來計算 totalRequests: totalRequests = highPriRequests + lowPriRequests;
在此情形中,我們期望這兩個內存位置中的值相加,得到準確的總請求數。這只有在進行計算時,兩個值都沒有發生變化的情況下才能實現。如果每個計數器都有自己的鎖定,那么在能夠求和之前,需要進入兩個鎖定。
相比之下,遞增 highPriRequests 的代碼僅需要采用一個鎖定。這是因為,更新代碼使用的唯一不變量是 highPriRequests 為一個準確的計數器;lowPriRequests 決不會被涉及。一般來說,代碼需要程序不變量時,必須采用與涉及不變量的任何內存相關聯的所有鎖定。
圖 5 顯示了有助于解釋這一點的一個類比示例。將計算機內存想像成具有數千個窗口的吃角子***,每個窗口對應一個內存位置。啟動程序時,就像拉動吃角子***的手柄。隨著其他線程對內存值的更改,內存位置開始旋轉。一個線程進入鎖定時,與鎖定相關聯的位置將停止旋轉,因為代碼始終遵循在嘗試進行更新前必須獲得鎖定的約定。該線程可以重復此過程,獲得更多的鎖定,導致更多的內存凍結,直到該線程需要的所有內存位置都穩定下來。現在,該線程能夠執行操作,不會受到其他線程的干擾。
?
多線程并發:每個開發人員都應了解的內容 圖 5 交換受鎖定保護的值的五個步驟
?
這個類比示例可以幫助程序員改變觀念,從過去認為在確實發生變化之前什么都沒變,轉變為相信一切都在變化,除非使用鎖定進行保護。構造多線程應用程序時,采用這種新觀念是最重要的一條忠告。
?
什么內存需要鎖定保護?
我們已經探討了如何使用鎖定來保護程序不變量,但我還沒有確切說明什么內存需要這種保護。一個簡單且正確的回答是,所有內存都需要由鎖定來保護,但這對絕大多數應用程序來說未免有些過頭了。
以下多種途徑中的任何一種都能保證內存在多線程使用中的安全。首先,僅由一個線程訪問的內存是安全的,因為其他線程不會受其影響。這包括絕大多數局部變量和發布之前的所有堆分配內存(發布后其他線程可以訪問)。但內存發布后,便不在此列,必須使用其他一些技術。
其次,發布后處于只讀狀態的內存不需要鎖定,因為與之關聯的任何不變量都必須為程序剩余部分保留(由于值不變)。
然后,主動從多個線程中更新的內存通常使用鎖定來確保在打破程序不變量時只有一個線程具有訪問權限。
最后,在某些程序不變量相對較弱的特殊情形中,可以執行無需鎖定便能完成的更新。此時通常會運用專門的比較并交換指令。最好將這些技術視作鎖定的輕型特殊實現。
程序中使用的所有內存都應屬于上述四種情形之一。此外,最后一種情形顯然更加脆弱,更易出錯,因此僅當對性能的要求遠超出相關風險時,才應格外小心地使用。我將有專文討論這種情形。暫時排除這種情形之后,通用規則變為所有程序內存都應屬于以下三種情形之一:線程獨占、只讀或受鎖定保護。
系統化的鎖定
在實踐中,絕大多數重要的多線程程序都帶有不少爭用漏洞。問題的主要癥結在于,程序員們完全不清楚何時需要鎖定,這正是我下面要澄清的。但僅僅了解這一點還不夠。只要漏掉一個鎖定就會引入爭用,所以仍然非常容易犯錯。我們需要用強大的系統方法來幫助避免簡單但易犯的錯誤。然而,即便是當前最好的技術,也需要分外小心才能很好地應用。
對于系統化的鎖定而言,最簡單、最有用的技術之一是監視程序的概念。這一概念的基本思想是附加在面向對象的設計中已存在的數據抽象之上。想像一個哈希表的示例。在精心設計的類中,已假設客戶端僅通過調用類的實例方法來訪問類的內部狀態。如果對任何實例方法的入口采用鎖定,在退出時釋放,那么就可以使用系統的方法確保僅當已獲得鎖定時才會發生對內部數據(實例字段)的所有訪問,如圖 6 所示。遵循此協議的類稱為監視程序。
?
多線程并發:每個開發人員都應了解的內容 圖 6 使用 Monitor 類
????? 在 .NET Framework 中,鎖定的名稱是 System.Threading.Monitor,無一例外。此類型是為了支持監視程序概念而專門設計的。.NET 鎖定對 System.Object 進行操作的原因是為了使監視程序的創建更加輕松。為提高效率,每個對象都有一個內置鎖定,可用于保護自己的實例數據。通過在 lock(this) 語句中嵌入每個實例方法的主體,就可以形成監視程序。此外,還有一個特殊屬性 [MethodImpl(MethodImplAttributes.Synchronized)],它可以放置在實例方法上,以便自動插入 lock(this) 語句。另外,.NET 鎖定是可重入的,這表示已進入鎖定的線程無需阻止便可再次進入該線程。這允許方法調用同一個類上的其他方法,而不會導致通常會發生的死鎖。
雖然監視程序非常有用,且使用 .NET 很容易編寫,但它決不是解決鎖定問題的萬能方法。如果不加區別地使用,會得到要么過小要么過大的鎖定。考慮一個應用程序,它使用圖 6 中所示的哈希表來實現更高級別的運算(稱為 Debit),將貨幣從一個帳戶轉入另一個帳戶。Debit 方法對哈希表使用 Find 方法來檢索兩個帳戶,使用 Update 方法來實際執行轉帳操作。因為哈希表是一個監視程序,所以對 Find 和 Update 的調用可以保證是原子式進行的。不幸的是,Debit 方法需要的遠不止此原子性保證。如果在 Debit 對 Update 進行的兩次調用之間,有另一個線程更新了其中一個帳戶,那么 Debit 將會出錯。監視程序在單一調用中對哈希表保護得很好,但是若干調用過程中需要的不變量丟失了,因為進行的鎖定過小。
用監視程序修復 Debit 方法中的爭用問題會導致過大的鎖定。我們需要的是能夠保護 Debit 方法使用的所有內存的鎖定,且在方法持續期間能夠保持鎖定。如果使用監視程序來實現此目的,那它應如圖 7 中所示的 Accounts 類。每個高級別操作(例如 Debit 或 Credit)都將在執行自己的操作前,獲得對 Accounts 的鎖定,因而提供所需的互斥。創建 Accounts 監視程序可以修復爭用問題,但接下來又出現了哈希表到底需要多少個鎖定的問題。如果對 Accounts 的所有訪問(繼而對哈希表的所有訪問)都獲得 Accounts 鎖定,那么訪問哈希表(這是 Accounts 的一部分)的互斥已經得到了保證。如果確實如此,那么擁有哈希表鎖定的開銷便是沒有必要的。進行的鎖定過多。
多線程并發:每個開發人員都應了解的內容 ?圖 7 只有頂層需要監視程序 ?
?????? 監視程序概念的另一個重要弱點是,如果類將可更新指針分發給其數據,它就不提供保護。例如,類似哈希表上的 Find 這樣的方法經常會返回一個調用方可以更新的對象。因為這些更新可以在對哈希表的任何調用之外發生,所以它們不受鎖定的保護,這就破壞了希望監視程序提供的保護。最終,當需要采用多個鎖定時,監視程序完全沒有辦法應對這種更加復雜的情況。
監視程序是個有用的概念,但它們只是一個工具,用于實現精心設計出的鎖定。有時,鎖定保護的內存與數據抽象自然一致。此時,監視程序是實現鎖定設計的最佳機制。但在有些時候,一個鎖定將保護多個數據結構,或是僅保護數據結構的一部分。在這些情形中,監視程序便不太適合。目前還不可避免地要進行精確定義系統需要哪些鎖定以及每個鎖定保護哪些內存之類的艱辛工作。現在,讓我們試著總結出可以幫助進行此設計的若干指導原則。
總體而言,絕大多數可重復使用的代碼(例如容器類)都不應內建鎖定,因為這種代碼只能保護自己,而且無論代碼使用什么鎖定,它似乎都會需要一個更強大的鎖定。但是,如果必須使代碼在出現高級別程序錯誤時也能正常工作,這條規則就不再適用。全局內存堆和對安全性非常敏感的代碼都是例外情形的示例。
采用保護大量內存的少數鎖定不易出錯,且效率更高。如果允許所需并發數的話,保護多個數據結構的單個鎖定是個不錯的設計。如果每個線程的工作不要求對共享內存進行多次更新,那么我會將這個原則運用到極致,采用保護所有共享內存的一個鎖定。這會使程序的簡單性幾乎可與順序執行程序相媲美。對于工作線程之間沒有太多交互的應用程序,它會工作得很好。
在線程頻繁讀取共享結構,但只是偶爾寫入的情形中,可以使用類似 System.Threading.ReaderWriterLock 這樣的讀寫鎖將系統中的鎖定數量保持在較少的水平。這類鎖定有一個進入讀取操作的方法和一個進入寫入操作的方法。鎖定將允許多個讀取操作同時進入,但寫入操作獲得的訪問是獨占式的。這樣,因為讀取操作不會阻止其他操作,所以系統可以做得更簡單(包含更少的鎖定),且仍可獲得所需的并發性。
不幸的是,即使有了這些指導原則的幫助,設計高并發的系統仍然基本上比編寫順序執行系統要困難得多。鎖定的使用經常會與面向對象的普通程序抽象發生沖突,因為鎖定確實是程序的另一個獨立維度,有著自己的設計標準(其他類似維度包括生命周期管理、事務行為、實時限制和異常處理行為)。 有時對鎖定的需要和對數據抽象的需要是一致的,例如當兩者都用于控制對實例數據的訪問時。但有時它們也會發生沖突(監視程序的嵌套沒有太大用處,指針會使監視程序發生“泄漏”)。
對于這種沖突沒有太好的解決方法。最終使得多線程程序更加復雜。我們有辦法控制復雜性。您已經看到了一種策略:嘗試在數據抽象層次結構的頂層使用少量鎖定。然而此策略也會與模塊性發成沖突,因為很多數據結構很可能是由一個鎖定來保護。這意味著,沒有一個明顯的數據結構可供從它上面掛起鎖定。通常來說,鎖定需要的是全局變量(對于讀取/寫入數據而言,這永遠不是一個真正的好想法),或是所涉及的最大全局數據結構的一部分。在后一種情形中,必須能夠從需要鎖定的任何其他結構中訪問該結構。有時這是一項困難的工作,因為可能需要為某些方法添加額外的參數,設計可能變得有些混亂,但這比其他解決方法好得多。
設計中開始顯示出類似這樣的復雜性時,正確的反應是讓它變得清晰明確,而不是忽略它。如果某些方法自身沒有獲得鎖定,但期望自己的調用方提供該互斥,那么這種要求是調用該方法的前提條件,應包含在它的接口約定中。另一方面,如果數據抽象可以獲得鎖定或在保持鎖定的同時調用客戶端(虛擬)方法,那么這也需要是接口約定的一部分。只有讓接口之間的這些細節變得清晰明確之后,才能為局部代碼做出正確的決定。在好的設計中,這些約定中的絕大多數都是很簡單的。被調用方期望調用方已提供對整個相關數據結構的獨占,所以指定這一點并不是太困難。
在理想環境下,并發設計的復雜性可以隱藏在類庫中。不幸的是,類庫設計人員幾乎沒有辦法可以讓庫對多線程更加友好。如哈希表示例所示,鎖定只是一個用途極少的數據結構。僅當設計可以添加鎖定的程序線程結構時,鎖定的作用才能表現出來。通常這使得發揮鎖定的作用成為應用程序開發人員的責任。只有類似 ASP.NET 這樣,定義了可以插入最終用戶代碼的線程結構的整體框架才能幫助它們的客戶端減輕認真設計和分析鎖定的壓力。
死鎖
?????? 避免系統中具有多個鎖定的另一個原因是死鎖。一旦程序中有多個鎖定,便有可能發生死鎖。例如,如果一個線程嘗試依次進入鎖定 A 和鎖定 B,而與此同時,另一個線程嘗試依次進入鎖定 B 和鎖定 A,那么在每個線程進入另一個線程在嘗試進入第二個鎖定之前擁有的那個鎖定時,它們之間有可能會發生死鎖。
從編程角度來看,通常有兩種方法可以防止死鎖。防止死鎖的第一種方法(也是最好的一種)是,對于永遠不需要一次獲得多個鎖定的系統,不要讓系統中有足夠多的鎖定。如果這可行的話,可以通過約定獲得鎖定的順序來防止死鎖。僅當存在符合以下條件的線程循環鏈時,才能形成死鎖:鏈中的每個線程都在等待已被隊列中下一個線程獲得的鎖定。為防止這樣,系統中的每個鎖定都分配有“級別”,且對程序進行了相關設計,使得線程始終都只嚴格地按照級別遞減順序獲得鎖定。此協議使周期包含鎖定,因而不會形成死鎖。如果此策略無效(找不到一組級別),那么很可能是程序的鎖定獲得行為取決于輸入,此時最重要的是保證在每種情形中都不會發生死鎖。通常情況下,這類代碼會借助超時或某些死鎖檢測方案來解決問題。
死鎖是將系統中的鎖定數量保持在較少狀態的另一個重要原因。如果無法做到這一點,那么必須進行分析,決定為什么必須同時獲得多個鎖定。請記住,僅當代碼需要獨占訪問受不同鎖定保護的內存時,才需要獲得多個鎖定。這種分析通常要么生成可避免死鎖的簡單鎖定排序,要么表明徹底避免死鎖是不可能的。
鎖定的開銷
避免系統中有多個鎖定的另一個原因是進入和離開鎖定的開銷。最輕型的鎖定使用特殊的比較/交換指令來檢查是否已獲得鎖定,如果沒有,它們將以單一原子操作進入鎖定。不幸的是,這種特殊指令的開銷相對較大,耗時通常是普通指令的十倍至數百倍。造成這種大開銷的主要原因有兩個,對于真實的多處理器系統發生的問題,它們都是必須的操作。
第一個原因是,比較/交換指令必須保證沒有其他處理器也在嘗試進行同樣的操作。從根本上說,這要求一個處理器與系統中的所有其他處理器進行協調。這是一個緩慢的操作,形成了鎖定開銷的下限(數十個周期)。造成大開銷的另一個原因是,內存系統的內部處理通訊效果。獲得鎖定后,程序很有可能要訪問剛被另一個線程修改過的內存。如果此線程是在另一個處理器上運行的,那么有必要保證所有其他處理器上的所有掛起的寫入都已刷新,以便當前線程看到更新。執行此操作的開銷在很大程度上取決于內存系統的工作方式以及有多少寫入需要刷新。在最糟糕的情形中,這一開銷會很大,可能多達數百個周期或更多。
因此,鎖定的開銷有著重大意義。如果一個被頻繁調用的方法需要獲得鎖定,且僅執行一百條左右的指令,內存開銷很可能會成為一個問題。此時通常需要重新設計程序,以便能為更大的工作單元占用鎖定。
除了進入和離開鎖定的原始開銷之外,隨著系統中處理器數量的增加,開銷會成為有效使用所有處理器的主要障礙。如果程序中的鎖定過少,可能會使所有處理器都處于繁忙狀態,因為它們要等待被另一個處理器鎖定的內存。另一方面,如果程序中的鎖定過多,那么很容易出現一個被多個處理器頻繁進入和退出的“熱”鎖定。這會導致極高的內存刷新開銷,且吞吐量也不與處理器的數量成正比。實現吞吐量良性增減的唯一設計是其中的工作線程無需和共享數據交互,便能完成大多數工作。
無疑,性能問題可能使我們希望徹底避免鎖定。在特定的約束環境中,這是可以做到的,但正確實現這一點涉及到的細節遠比使用鎖定讓互斥正確發揮作用多得多。只有絕對必要時,才可在充分了解相關問題之后使用這種方法。我將有另文專門討論這個問題。
?
同步概述
雖然鎖定提供了讓線程各行其道的方法,但沒有提供讓線程合作(同步)的機制。我將簡單介紹同步線程,但不引入爭用的原則。您將看到,它們并不比適當鎖定的原則困難多少。
在 .NET Framework 中,同步功能是在 ManualResetEvent 和 AutoResetEvent 類中實現的。這些類提供 Set、Reset 和 WaitOne 方法。WaitOne 方法會在事件處于重置狀態期間使線程受阻。當另一個線程調用 Set 方法時,AutoResetEvents 將允許調用 WaitOne 的那個線程取消阻止,而 ManualResetEvents 將允許所有等待的線程取消阻止。
通常使用事件來表明存在一個更復雜的程序屬性。例如,程序可能具有線程的工作隊列,并使用事件向線程報告隊列不為空。請注意,這引入了一個程序不變量,即當且僅當隊列不為空時,才應設置事件。適當鎖定的規則要求,如果代碼需要不變量,那么必須存在為與該不變量相關聯的所有內存提供獨占訪問的鎖定。在隊列中應用此原則,得到的建議是:所有對事件和隊列的訪問都僅應在進入通用鎖定之后進行。
不幸的是,這種設計會導致死鎖。讓我們看看下面這個示例。線程 A 進入鎖定,需要等待隊列被填充(同時擁有隊列的鎖定)。線程 B 要將線程 A 需要的條目添加到隊列,它在修改隊列之前,將嘗試進入隊列的鎖定,因而受阻于線程 A。形成死鎖!
一般來說,在等待事件的同時占用鎖定不是什么好事。畢竟,為什么在一個線程等待其他事情的時候,要將所有其他線程都鎖于數據結構之外呢?您也許會提到死鎖。常見的做法是釋放鎖定,然后等待事件。但是現在,事件和隊列可能沒有同步。我們已經打破了“事件是隊列何時不為空的精確指示器”這個不變量。典型的解決方案是,將此情形中的不變量弱化為“如果事件被重置,則隊列為空”。此新不變量足夠強大,等待事件仍然是安全的,不會有永遠等下去的風險。這個寬松的不變量意味著,當線程從 WaitOne 中返回時,它不能假設隊列中已存在條目。蘇醒的線程必須進入隊列的鎖定,驗證該隊列中存在條目。如果沒有(例如一些其他線程移除了條目),它必須再次等待。如果線程之間的公平性非常重要,那么此解決方案有問題,但對于絕大多數用途,它工作得很好。
總結
編寫優秀的順序執行程序和多線程程序的基本原則并沒有太大不同。在這兩種情形中,整個基本代碼都必須保護程序中其他地方需要的不變量。如圖 2 所示,不同之處在于,在多線程情形中保護程序不變量更加復雜。結果是,構建正確的多線程程序需要等級高得多的準則。此準則的一部分是確保通過諸如監視程序這樣的工具,所有線程共享的讀-寫數據都得到鎖定的保護。另一部分是精心設計哪些內存由哪個鎖定來保護,并控制鎖定必然為程序帶來的額外復雜性。在順序執行程序中,好的設計通常就是最簡單的設計。而對于多線程程序,這意味著擁有能實現所需并發性的最少數量的鎖定。如果保持鎖定設計的簡潔,并系統地跟蹤鎖定設計,您就一定能編寫出沒有爭用的多線程程序。??
轉載于:https://www.cnblogs.com/qqxiongmao/archive/2013/04/23/3039167.html
總結
以上是生活随笔為你收集整理的多线程并发:每个开发人员都应了解的内容的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python网络编程2:创建套接字和套接
- 下一篇: 问题九十一:汉诺塔