1. 程式人生 > >Java併發:效能與可伸縮性

Java併發:效能與可伸縮性

概述

編寫正確的程式很難,而編寫正確的併發程式則難上加難。與序列程式相比,在併發程式中存在更多容易出錯的地方。那麼,為什麼還要編寫併發程式?執行緒是Java語言中不可或缺的重要功能,它們能使複雜的非同步程式碼變得更簡單,從而極大地簡化了複雜系統的開發。此外,要想充分發揮多處理器系統的強大計算能力,最簡單的方式就是使用執行緒。隨著處理器數量的持續增長,如何高效地使用併發正變得越來越重要。

執行緒的最主要目的是提高程式的執行效能。執行緒可以使程式更加充分地發揮系統的可用處理能力,從而提高系統的資源利用率。此外,執行緒還可以使程式在執行現有任務的情況下立即開始處理新的任務,從而提高系統的響應性。

然而,許多提升效能的技術同樣會增加複雜性,因此也就增加了在安全性和活躍性上發生失敗的風險。更糟糕的是,雖然某些技術的初衷是提升效能,但事實上卻與最初的目標背道而馳,或者又帶來了其他新的效能問題。雖然我們希望獲得更好的效能——提升效能總會令人滿意,但始終要把安全性放在第一位。首先要保證程式能正確執行,然後僅當程式的效能需求和測試結果要求程式執行得更快時,才應該設法提高它的執行速度。在設計併發的應用程式時,最重要的考慮因素通常並不是將程式的效能提升至極限。

1.對效能的思考

提升效能意味著用更少的資源做更多的事情。“資源”的含義很廣。對於一個給定的操作,通常會缺乏某種特定的資源,例如CPU時鐘週期、記憶體、網路頻寬、I/O頻寬、資料庫請求、磁碟空間以及其他資源。當操作效能由於某種特定的資源而受到限制時,我們通常將該操作稱為資源密集型的操作,例如,CPU密集型、資料庫密集型等。

儘管使用多個執行緒的目標是提升整體效能,但與單執行緒的方法相比,使用多個執行緒總會引人一些額外的效能開銷。造成這些開銷的操作包括:執行緒之間的協調(例如加鎖、觸發訊號以及記憶體同步等),增加的上下文切換,執行緒的建立和銷燬,以及執行緒的排程等。如果過度地使用執行緒,那麼這些開銷甚至會超過由於提高吞吐量、響應性或者計算能力所帶來的效能提升。另一方面,一個併發設計很糟糕的應用程式,其效能甚至比實現相同功能的序列程式的效能還要差。

要想通過併發來獲得更好的效能,需要努力做好兩件事情:更有效地利用現有處理資源,以及在出現新的處理資源時使程式儘可能地利用這些新資源。從效能監視的視角來看,CPU需要儘可能保持忙碌狀態。(當然,這並不意味著將CPU時鐘週期浪費在一些無用的計算上,而是執行一些有用的工作。)如果程式是計算密集型的,那麼可以通過增加處理器來提高效能。因為如果程式無法使現有的處理器保持忙碌狀態,那麼增加再多的處理器也無濟於事。通過將應用程式分解到多個執行緒上執行,使得每個處理器都執行一些工作,從而使所有CPU都保持忙碌態。

1.1效能與可伸縮性

應用程式的效能可以採用多個指標來衡量,例如服務時間、延遲時間、吞吐率、效率、可伸縮性以及容量等。其中一些指標(服務時間、等待時間)用於衡量程式的

“執行速度”,即某個指定的任務單元需要“多快"才能處理完成。另一些指標(生產量、吞吐量)用於程式的“處理能力”,即在計算資源一定的情況下,能完成“多少”工作。

可伸縮性指的是:當增加計算機資源時(例如CPU、記憶體、儲存容量或I/O頻寬),程式的吞吐量或者處理能力能相應地增加。

在併發應用程式中針對可伸縮性進行設計和調整時所採用的方法與傳統的效能調優方法截然不同。當進行效能調優時,其目的通常是用更小的代價完成相同的工作,例如通過快取來重用之前計算的結果,或者採用時間複雜度為O(n2)演算法來代替複雜度為O(nlogn)的演算法。在進行可伸縮性調優時,其目的是設法將問題的計算並行化,從而能利用更多的計算資源來完成更多的工作。

效能的這兩個方面 “多快”和“多少”,是完全獨立的,有時候甚至是相互矛盾的。要實現更高的可伸縮性或硬體利用率,通常會增加各個任務所要處理的工作量,例如把任務分解為多個“流水線”子任務時。具諷刺意味的是,大多數提高單執行緒程式效能的技術,往往都會破壞可伸縮性。

我們熟悉的三層程式模型,即在模型中的表現層、業務邏輯層和持久化層是彼此獨立的,並且可能由不同的系統來處理,這很好地說明了提高可伸縮性通常會造成效能損失的原因。如果把表現層、業務邏輯層和持久化層都融合到單個應用程式中,那麼在處理第一個工作單元時,其效能肯定要高於將應用程式分為多層並將不同層次分佈到多個系統時的效能。這種單一的應用程式避免了在不同層次之間傳遞任務時存在的網路延遲,同時也不需要將計算過程分解到不同的抽象層次,因此能減少許多開銷(例如在任務排隊、執行緒協調以及資料複製時存在的開銷)。

然而,當這種單一的系統到達自身處理能力的極限時,會遇到一個嚴重的問題:要進一步提升它的處理能力將非常困難。因此,我們通常會接受每個工作單元執行更長的時間或消耗更多的計算資源,以換取應用程式在增加更多資源的情況下處理更高的負載。

對於伺服器應用程式來說,“多少”這個方面一一可伸縮性、吞吐量和生產量,往往比“多快”這個方面更受重視。(在互動式應用程式中,延遲或許更加重要,這樣使用者就不用等待進度條的指定,並奇怪程式究竟在執行哪些操作。)

2.執行緒引入的開銷

單執行緒程式既不存線上程排程,也不存在同步開銷,而且不需要使用鎖來保證資料結構的一致性。在多個執行緒的排程和協調過程中都需要一定的效能開銷:對於為了提升效能而引人的執行緒來說,並行帶來的效能提升必須超過併發導致的開銷。

2.1上下文切換

如果主執行緒是唯一的執行緒,那麼它基本上不會被排程出去。另一方面,如果可執行的執行緒數大於CPU的數量,那麼作業系統最終會將某個正在執行的執行緒排程出來,從而使其他執行緒能夠使用CPU。這將導致一次上下文切換,在這個過程中將儲存當前執行執行緒的執行上下文,並將新排程進來的執行緒的執行上下文設定為當前上下文。

切換上下文需要一定的開銷,而線上程排程過程中需要訪問由作業系統和JVM共享的資料結構。應用程式、作業系統以及JVM都使用一組相同的CPU。在JVM和作業系統的程式碼中消耗越多的CPU時鐘週期,應用程式的可用CPU時鐘週期就越少。但上下文切換的開銷並不只是包含JVM和作業系統的開銷。當一個新的執行緒被切換進來時,它所需要的資料可能不在當前處理器的本地快取中,因此上下文切換將導致一些快取缺失,因而執行緒在首次排程執行時會更加緩慢。這就是為什麼排程器會為每個可執行的執行緒分配一個最小執行時間,即使有許多其他的執行緒正在等待執行:它將上下文切換的開銷分攤到更多不會中斷的執行時間上,從而提高整體的吞吐量(以損失響應性為代價)。

當執行緒由於等待某個發生競爭的鎖而被阻塞時,JVM通常會將這個執行緒掛起,並允許它被交換出去。如果執行緒頻繁地發生阻塞,那麼它們將無法使用完整的排程時間片。在程式中發生越多的阻塞(包括阻塞I/O,等待獲取發生競爭的鎖,或者在條件變數上等待),與CPU密集型的程式就會發生越多的上下文切換,從而增加排程開銷,並因此而降低吞吐量。(無阻塞演算法同樣有助於減小上下文切換。參見之前的文章非阻塞同步

上下文切換的實際開銷會隨著平臺的不同而變化,然而按照經驗來看:在大多數通用的處理器中,上下文切換的開銷相當於5000、10000個時鐘週期,也就是幾微秒。

UNIX系統的vmstat命令和Windows系統的perfmon工具都能報告上下文切換次數以及在核心中執行時間所佔比例等資訊。如果核心佔用率較高(超過10%),那麼通常表示排程活動發生得很頻繁,這很可能是由I/O或競爭鎖導致的阻塞引起的。

2.2記憶體同步

同步操作的效能開銷包括多個方面。在synchronized和volatile提供的可見性保證中可能會使用一些特殊指令,即記憶體柵欄〔Memo Barrier)o記憶體柵欄可以重新整理快取,使快取無效,重新整理硬體的寫緩衝,以及停止執行管道。記憶體柵欄可能同樣會對效能帶來間接的影響,因為它們將抑制一些編譯器優化操作。在記憶體柵欄中,大多數操作都是不能被重排序的。

在評估同步操作帶來的效能影響時,區分有競爭的同步和無競爭的同步非常重要。 synchronized機制針對無競爭的同步進行了優化(volatile通常是非競爭的),而在編寫本書時,一個“快速通道(Fast-Path) ”的非競爭同步將消耗20、250個時鐘週期。雖然無競爭同步的開銷不為零,但它對應用程式整體效能的影響微乎其微,而另一種方法不僅會破壞安全性,而且還會使你(或者後續開發人員)經歷非常痛苦的除錯過程。

現代的JVM能通過優化來去掉一些不會發生競爭的鎖,從而減少不必要的同步開銷。如果一個鎖物件只能由當前執行緒訪問,那麼JVM就可以通過優化來去掉這個鎖獲取操作,因為另一個執行緒無法與當前執行緒在這個鎖上發生同步。

一些更完備的JVM能通過逸出分析(EscapeAnalysis)來找出不會發布到堆的本地物件引用(因此這個引用是執行緒本地的)。在程式碼getStoogeNames方法中,對List的唯一引用就是區域性變數stooges,並且所有封閉在棧中的變數都會自動成為執行緒本地變數。在 getstoogeNames的執行過程中,至少會將vector上的鎖獲取/釋放4次,每次呼叫add或 toString時都會執行1次。然而,一個智慧的執行時編譯器通常會分析這些呼叫,從而使 stooges及其內部狀態不會逸出,因此可以去掉這4次對鎖獲取操作。

public String getStoogeNames() {
    List<String> stooges = new Vector<String>();
    stooges.add("");
    stooges.add("");
    stooges.add("");
    return stooges.toString();
}

即使不進行逸出分析,編譯器也可以執行鎖粒度粗化(Lock Coarsening)操作,即將鄰近的同步程式碼塊用同一個鎖合併起來。在getstoogeNmnes中,如杲JVM進行鎖粒度粗化,那麼可能會把3個add與1個toString呼叫合併為單個鎖獲取/釋放操作,並採用啟發式方法來評估同步程式碼塊中採用同步操作以及指令之間的相對開銷。這不僅減少了同步的開銷,同時還能使優化器處理更大的程式碼塊,從而可能實現進一步的優化。

不要過度擔心非競爭同步帶來的開銷。這個基本的機制已經非常快了,並且JVM還能進行額外的優化以進一步降低或消除開銷。因此,我們應該將優化重點放在那些發生鎖競爭的地方。

某個執行緒中的同步可能會影響其他執行緒的效能。同步會增加共享記憶體總線上的通訊量,匯流排的頻寬是有限的,並且所有的處理器都將共享這條匯流排。如果有多個執行緒競爭同步頻寬,那麼所有使用了同步的執行緒都會受到影響。

2.3阻塞

非競爭的同步可以完全在JVM中進行處理(Bacon等,1998),而競爭的同步可能需要作業系統的介人,從而增加開銷。當在鎖上發生競爭時,競爭失敗的執行緒肯定會阻塞。JVM在實現阻塞行為時,可以採用自旋等待(Spin-waiting,指通過迴圈不斷地嘗試獲取鎖,直到成功)或者通過作業系統掛起被阻塞的執行緒。這兩種方式的效率高低,要取決於上下文切換的開銷以及在成功獲取鎖之前需要等待的時間。如果等待時間較短,則適合採用自旋等待方式,而如果等待時間較長,則適合採用執行緒掛起方式。有些JVM將根據對歷史等待時間的分析資料在這兩者之間進行選擇,但是大多數JVM在等待鎖時都只是將執行緒掛起。

當執行緒無法獲取某個鎖或者由於在某個條件等待或在I/O操作上阻塞時,需要被掛起,在這個過程中將包含兩次額外的上下文切換,以及所有必要的作業系統操作和快取操作:被阻塞的執行緒在其執行時間片還未用完之前就被交換出去,而在隨後當要獲取的鎖或者其他資源可用時,又再次被切換回來。(由於鎖競爭而導致阻塞時,執行緒在持有鎖時將存在一定的開銷:當它釋放鎖時,必須告訴作業系統恢復執行阻塞的執行緒。)

3.減少鎖的競爭

我們已經看到,序列操作會降低可伸縮性,並且上下文切換也會降低效能。在鎖上發生競爭時將同時導致這兩種問題,因此減少鎖的競爭能夠提高效能和可伸縮性。

在對由某個獨佔鎖保護的資源進行訪問時,將採用序列方式一一每次只有一個執行緒能訪問它。當然,我們有很好的理由來使用鎖,例如避免資料被破壞,但獲得這種安全性是需要付出代價的。如果在鎖上持續發生競爭,那麼將限制程式碼的可伸縮性。

在併發程式中,對可伸縮性的最主要威脅就是獨佔方式的鎖資源。

有兩個因素將影響在鎖上發生競爭的可能性:鎖的請求頻率,以及每次持有該鎖的時間。如果二者的乘積很小,那麼大多數獲取鎖的操作都不會發生競爭,因此在該鎖上的競爭不會對可伸縮性造成嚴重影響。然而,如果在鎖上的請求量很高,那麼需要獲取該鎖的執行緒將被阻塞並等待。在極端情況下,即使仍有大量工作等待完成,處理器也會被閒置。

有3種方式可以降低鎖的競爭程度:

  1. 減少鎖的持有時間。
  2. 降低鎖的請求頻率。
  3. 使用帶有協調機制的獨佔鎖,這些機制允許更高的併發性。

3.1縮小鎖的範圍(“快進快出”)

降低發生競爭可能性的一種有效方式就是儘可能縮短鎖的持有時間。例如,可以將一些與鎖無關的程式碼移出同步程式碼塊,尤其是那些開銷較大的操作,以及可能被阻塞的操作,例如l/O操作。

我們都知道,如果將一個“高度競爭"的鎖持有過長的時間,那麼會限制可伸縮性。如果某個操作持有鎖的時間超過2亳秒並且所有操作都需要這個鎖,那麼無論擁有多少個空閒處理器,吞吐量也不會超過每秒500個操作。如果將這個鎖的持有時間降為1毫秒,那麼能夠將這個鎖對應的吞吐量提高到每秒1000 個操作。

下面給出了一個示例,其中鎖被持有過長的時間。userLocationMatches方法在一個Map物件中查詢使用者的位置,並使用正則表示式進行匹配以判斷結果值是否匹配所提供的模式。整個userLocationMatches方法都使用了synchronized來修飾,但只有Map.get這個方法才真正需要鎖。

在下面的BetterAttributeStore中重新編寫了AttributeStore,從而大大減少了鎖的持有時間。第一個步驟是構建Map中與使用者位置相關聯的鍵值,這是一個字串,形式為users.name.locationo。這個步驟包括例項化一個StringBuilder物件,向其新增幾個字串,並將結果例項化為一個string型別物件。在獲得了位置後,就可以將正則表示式與位置字串進行匹配。由於在構建鍵值字串以及處理正則表示式等過程中都不需要訪問共享狀態,因此在執行時不需要持有鎖。通過在BetterAtfributeStore中將這些步驟提取出來並放到同步程式碼塊之外,從而減少了鎖被持有的時間。


通過縮小userLocationMatches方法中鎖的作用範圍,能極大地減少在持有鎖時需要執行的指令數量。根據Amdahl定律,這樣消除了限制可伸縮性的一個因素,因為序列程式碼的總量減少了。

由於在AttributeStore中只有一個狀態變數attributes,因此可以通過將執行緒安全性委託給其他的類來進一步提升它的效能。通過用執行緒安全的Map(Hashtable、 synchronizedMap或ConcurrentHashMap)來代替attributes,AttributeStore可以將確保執行緒安全性的任務委託給頂層的執行緒安全容器來實現。這樣就無須在AttributeStore中採用顯式的同步,縮小在訪問Map期間鎖的範圍,並降低了將來的程式碼維護者無意破壞執行緒安全性的風險(例如在訪問attributes之前忘記獲得相應的鎖)。

儘管縮小同步程式碼塊能提高可伸縮性,但同步程式碼塊也不能過小 一些需要採用原子方式執行的操作(例如對某個不變性條件中的多個變數進行更新)必須包含在一個同步塊中。此外,同步需要一定的開銷,當把一個同步程式碼塊分解為多個同步程式碼塊時(在確保正確性的情況下),反而會對效能提升產生負面影響。在分解同步程式碼塊時,理想的平衡點將與平臺相關,但在實際情況中,僅當可以將一些“大量”的計算或阻塞操作從同步程式碼塊中移出時,才應該考慮同步程式碼塊的大小。

3.2減小鎖的粒度

另一種減小鎖的持有時間的方式是降低執行緒請求鎖的頻率(從而減小發生競爭的可能性)。這可以通過鎖分解和鎖分段等技術來實現,在這些技術中將採用多個相互獨立的鎖來保護獨立的狀態變數,從而改變這些變數在之前由單個鎖來保護的情況。這些技術能減小鎖操作的粒度,並能實現更高的可伸縮性,然而,使用的鎖越多,那麼發生死鎖的風險也就越高。

設想一下,如果在整個應用程式中只有一個鎖,而不是為每個物件分配一個獨立的鎖,那麼,所有同步程式碼塊的執行就會變成序列化執行,而不考慮各個同步塊中的鎖。由於很多執行緒將競爭同一個全域性鎖,因此兩個執行緒同時請求這個鎖的概率將劇增,從而導致更嚴重的競爭。所以如果將這些鎖請求分佈到更多的鎖上,那麼能有效地降低競爭程度。由於等待鎖而被阻塞的執行緒將更少,因此可伸縮性將提高。

如果一個鎖需要保護多個相互獨立的狀態變數,那麼可以將這個鎖分解為多個鎖,並且每個鎖只保護一個變數,從而提高可伸縮性,並最終降低每個鎖被請求的頻率。

在程式ServerStatus中給出了某個資料庫伺服器的部分監視介面,該資料庫維護了當前已登入的使用者以及正在執行的請求。當一個使用者登入、登出、開始查詢或結束查詢時,都會呼叫相應的add和remove等方法來更新ServerStatus物件。這兩種型別的資訊是完全獨立的,ServerStatus甚至可以被分解為兩個類,同時確保不會丟失功能。


在程式碼中不是用ServerStatus鎖來保護使用者狀態和查詢狀態,而是每個狀態都通過一個鎖來保護,如下圖程式所示。在對鎖進行分解後,每個新的細粒度鎖上的訪問量將比最初的訪問量少。(通過將使用者狀態和查詢狀態委託給一個執行緒安全的Set,而不是使用顯式的同步,能隱含地對鎖進行分解,因為每個Set都會使用一個不同的鎖來保護其狀態。)


如果在鎖上存在適中而不是激烈的競爭時,通過將一個鎖分解為兩個鎖,能最大限度地提升效能。如果對競爭並不激烈的鎖進行分解,那麼在效能和吞吐量等方面帶來的提升將非常有限,但是也會提高效能隨著競爭提高而下降的拐點值。對競爭適中的鎖進行分解時,實際上是把這些鎖轉變為非競爭的鎖,從而有效地提高效能和可伸縮性。

3.3鎖分段

把一個競爭激烈的鎖分解為兩個鎖時,這兩個鎖可能都存在激烈的競爭。雖然採用兩個執行緒併發執行能提高一部分可伸縮性,但在一個擁有多個處理器的系統中,仍然無法給可伸縮性帶來極大的提高。

在某些情況下,可以將鎖分解技術進一步擴充套件為對一組獨立物件上的鎖進行分解,這種情況被稱為鎖分段。例如,在ConcurrentHashMap的實現中使用了一個包含16個鎖的陣列,每個鎖保護所有雜湊桶的1/16,其中第個雜湊桶由第(N mod 16)個鎖來保護。假設雜湊函式具有合理的分佈性,並且關鍵字能夠實現均勻分佈,那麼這大約能把對於鎖的請求減少到原來的1/16。正是這項技術使得ConcurrentHashMap能夠支援多達16個併發的寫入器。(要使得擁有大量處理器的系統在高訪問量的情況下實現更高的併發性,還可以進一步增加鎖的數量,但僅當你能證明併發寫人執行緒的競爭足夠激烈並需要突破這個限制時,才能將鎖分段的數量超過預設的16個。)

鎖分段的一個劣勢在於:與採用單個鎖來實現獨佔訪問相比,要獲取多個鎖來實現獨佔訪問將更加困難並且開銷更高。通常,在執行一個操作時最多隻需獲取一個鎖,但在某些情況下需要加鎖整個容器,例如當ConcurrentHashMap需要擴充套件對映範圍,以及重新計算鍵值的雜湊值要分佈到更大的桶集合中時,就需要獲取分段所集合中所有的鎖。

下面的StripedMap中給出了基於雜湊的Map實現,其中使用了鎖分段技術。它擁有N-LOCKS個鎖,並且每個鎖保護雜湊桶的一個子集。大多數方法,例如get,都只需要獲得一個鎖,而有些方法則需要獲得所有的鎖,但並不要求同時獲得,例如clear方法的實現。


3.4避免熱點域

鎖分解和鎖分段技術都能提高可伸縮性,因為它們都能使不同的執行緒在不同的資料(或者同一個資料的不同部分)上操作,而不會相互干擾。如果程式採用鎖分段技術,那麼一定要表現出在鎖上的競爭頻率高於在鎖保護的資料上發生競爭的頻率。如果一個鎖保護兩個獨立變數X和Y,並且執行緒A想要訪問X,而執行緒B想要訪問Y(這類似於在ServerStatus中,一個執行緒呼叫addUser,而另一個執行緒呼叫addQuery),那麼這兩個執行緒不會在任何資料上發生競爭,即使它們會在同一個鎖上發生競爭。

當每個操作都請求多個變數時,鎖的粒度將很難降低。這是在效能與可伸縮性之間相互制  衡的另一個方面,一些常見的優化措施,例如將一些反覆計算的結果快取起來,都會引人一些 “熱點域(Hot Field) ”,而這些熱點域往往會限制可伸縮性。

當實現HashMap時,你需要考慮如何在size方法中計算Map中的元素數量。最簡單的方法就是,在每次呼叫時都統計一次元素的數量。一種常見的優化措施是,在插人和移除元素時更新一個計數器,雖然這在put和remove等方法中略微增加了一些開銷,以確保計數器是最新的值,但這將把size方法的開銷從O(n)降低到O(1)。

在單執行緒或者採用完全同步的實現中,使用一個獨立的計數能很好地提高類似size和 isEmpty這些方法的執行速度,但卻導致更難以提升實現的可伸縮性,因為每個修改map的操作都需要更新這個共享的計數器。即使使用鎖分段技術來實現雜湊鏈,那麼在對計數器的訪問進行同步時,也會重新導致在使用獨佔鎖時存在的可伸縮性問題。一個看似效能優化的措施——快取size操作的結果,已經變成了一個可伸縮性問題。在這種情況下,計數器也被稱為熱點域,因為每個導致元素數量發生變化的操作都需要訪問它。

為了避免這個問題,ConcurrentHashMap中的size將對每個分段進行列舉並將每個分段中的元素數量相加,而不是維護一個全域性計數。為了避免列舉每個元素,ConcurrentHashMap為每個分段都維護了一個獨立的計數,並通過每個分段的鎖來維護這個值。

3.5一些替換獨佔鎖的方法

第三種降低競爭鎖的影響的技術就是放棄使用獨佔鎖,從而有助於使用一種友好併發的方式來管理共享狀態。例如,使用併發容器、讀·寫鎖、不可變物件以及原子變數。

ReadWriteLock實現了一種在多個讀取操作以及單個寫人操作情況下的加鎖規則:如果多個讀取操作都不會修改共享資源,那麼這些讀取操作可以同時訪問該共享資源,但在執行寫人操作時必須以獨佔方式來獲取鎖。對於讀取操作佔多數的資料結構, ReadWriteLock能提供比獨佔鎖更高的併發性。而對於只讀的資料結構,其中包含的不變性可以完全不需要加鎖操作。

原子變數提供了一種方式來降低更新“熱點域"時的開銷,例如靜態計數器、序列發生器、或者對連結串列資料結構中頭節點的引用。(AtomicLong )原子變數類提供了在整數或者物件引用上的細粒度原子操作(因此可伸縮性更高),並使用了現代處理器中提供的底層併發原語(例如比較並交換[compare-and-swap])。如果在類中只包含少量的熱點域,並且這些域不會與其他變數參與到不變性條件中,那麼用原子變數來替代它們能提高可伸縮性。(通過減少演算法中的熱點域,可以提高可伸縮性——雖然原子變數能降低熱點域的更新開銷,但並不能完全消除。)

4.減少上下文切換的開銷

在許多工中都包含一些可能被阻塞的操作。當任務在執行和阻塞這兩個狀態之間轉換時,就相當於一次上下文切換。在伺服器應用程式中,發生阻塞的原因之一就是在處理請求時產生各種日誌訊息。為了說明如何通過減少上下文切換的次數來提高吞吐量,我們將對兩種日誌方法的排程行為進行分析。

在大多數日誌框架中都是簡單地對println進行包裝,當需要記錄某個訊息時,只需將其寫人日誌檔案中。有另一種方法:記錄日誌的工作由一個專門的後臺執行緒完成,而不是由發出請求的執行緒完成。從開發人員的角度來看,這兩種方法基本上是相同的。但二者在效能上可能存在一些差異,這取決於日誌操作的工作量,即有多少執行緒正在記錄日誌,以及其他一些因素,例如上下文切換的開銷等。

日誌操作的服務時間包括與I/O流類相關的計算時間,如果I/O操作被阻塞,那麼還會包括執行緒被阻塞的時間。作業系統將這個被阻塞的執行緒從排程佇列中移走並直到I/O操作結束,這將比實際阻塞的時間更長。當I/O操作結束時,可能有其他執行緒正在執行它們的排程時間片,並且在排程佇列中有些執行緒位於被阻塞執行緒之前,從而進一步增加服務時間。如果有多個執行緒在同時記錄日誌,那麼還可能在輸出流的鎖上發生競爭,這種情況的結果與阻塞I/O的情況一樣——執行緒被阻塞並等待鎖,然後被執行緒排程器交換出去。在這種日誌操作中包含了I/O操作和加鎖操作,從而導致上下文切換次數的增多,以及服務時間的增加。

請求服務的時間不應該過長,主要有以下原因。首先,服務時間將影響服務質量:服務時間越長,就意味著有程式在獲得結果時需要等待更長的時間。但更重要的是,服務時間越長,也就意味著存在越多的鎖競爭。上文3.1節中的“快進快出"原則告訴我們,鎖被持有的時間應該儘可能地短,因為鎖的持有時間越長,那麼在這個鎖上發生競爭的可能性就越大。如果一個執行緒由於等待I/O操作完成而被阻塞,同時它還持有一個鎖,那麼在這期間很可能會有另一個執行緒想要獲得這個鎖。如果在大多數的鎖獲取操作上不存在競爭,那麼併發系統就能執行得更好,因為在鎖獲取操作上發生競爭時將導致更多的上下文切換。在程式碼中造成的上下文切換次數越多,吞吐量就越低。

通過將I/O操作從處理請求的執行緒中分離出來,可以縮短處理請求的平均服務時間。呼叫 log方法的執行緒將不會再因為等待輸出流的鎖或者I/O完成而被阻塞,它們只需將訊息放人佇列,然後就返回到各自的任務中。另一方面,雖然在訊息佇列上可能會發生競爭,但put操作相對於記錄日誌的I/O操作(可能需要執行系統呼叫)是一種更為輕量級的操作,因此在實際使用中發生阻塞的概更小(只要佇列沒有填滿)。由於發出日誌請求的執行緒現在被阻塞的概率降低,因此該執行緒在處理請求時被交換出去的概率也會降低。我們所做的工作就是把一條包含I/O操作和鎖競爭的複雜且不確定的程式碼路徑變成一條簡單的程式碼路徑。

從某種意義上講,我們只是將工作分散開來,並將I/O操作移到了另一個使用者感知不到開銷的執行緒上(這本身已經獲得了成功)。通過把所有記錄日誌的I/O轉移到一個執行緒,還消除了輸出流上的競爭,因此又去掉了一個競爭來源。這將提升整體的吞吐量,因為在排程中消耗的資源更少,上下文切換次數更少,並且鎖的管理也更簡單。

通過把I/O操作從處理請求的執行緒轉移到一個專門的執行緒,類似於兩種不同救火方案之間的差異:第一種方案是所有人排成一隊,通過傳遞水桶來救火;第二種方案是每個人都拿著一個水桶去救火。在第二種方案中,每個人都可能在水源和著火點上存在更大的競爭(結果導致了只能將更少的水傳遞到著火點),此外救火的效率也更低,因為每個人都在不停的切換模式(裝水、跑步、倒水、跑步... ...)。在第一種解決方案中,水不斷地從水源傳遞到燃燒的建築物,人們付出更少的體力卻傳遞了更多的水,並且每個人從頭至尾只需做一項工作。正如中斷會干擾人們的工作並降低效率,阻塞和上下文切換同樣會於擾執行緒的正常執行。

5.總結

由於使用執行緒常常是為了充分利用多個處理器的計算能力,因此在併發程式效能的討論中,通常更多地將側重點放在吞吐量和可伸縮性上,而不是服務時間。Amdahl定律告訴我們,程式的可伸縮性取決於在所有程式碼中必須被序列執行的程式碼比例。因為Java程式中序列操作的主要來源是獨佔方式的資源鎖,因此通常可以通過以下方式來提升可伸縮性:減少鎖的持有時間,降低鎖的粒度,以及採用非獨佔的鎖或非阻塞鎖來代替獨佔鎖。

參考

《Java ConCurrentcy in Practice》

—————END—————