Java併發編程(Java Concurrency)(4) - 併發模型

原文鏈接:http://tutorials.jenkov.com/java-concurrency/concurrency-models.html

  摘要:這是翻譯自一個大概30個小節的關於Java併發編程的入門級教程,原作者Jakob Jenkov,譯者Zhenning Lang,轉載請註明出處,thanks and have a good time here~~~(希望自己不要留坑)

併發模型

併發系統可以基於不同的併發模型。併發模型描述了系統中的多個線程是如何協同完成指定的任務的。不同的併發模型以不同的方式分割任務,並且線程間也以不同的方式進行通訊和協作。本教程將分析目前(2015年)最流行的併發模型。

1 併發模型與分佈式系統的相似性

本文中描述的併發模型和分佈式系統中應用的不同架構具有相似性。在一個併發系統中,不同的線程間互相通信。在一個分佈式系統中,不同的進程(可能在不同的計算機上)間也會相互通信。這兩者本質上是相當類似的,這也是爲什麼不同的併發模型和分佈式系統中應用的不同架構如此相似。

當然分佈式系統有其獨特的額外難點,例如網絡中斷,或者結點失效等。但一個大服務器上運行的併發系統也可能遇到類似的問題,例如CPU故障,網卡故障,磁盤故障等。儘管上述故障發生的可能性很低,但理論上是存在這些情況的。

由於併發模型和分佈式系統架構的相似性,二者經常相互借鑑。例如,不同線程間的任務分配模型類似於分佈式系統的負載均衡模型。二者的異常處理技術也是類似的,例如日誌(logging)、故障切換(fail-over)和等冪性任務(idempotency of jobs)等。

2 並行工作者模型(Parallel workers model)

並行工作者模型是我要介紹的第一個併發模型。到來的任務被分配給不同的工作者來執行,如下圖所示:

並行工作者併發模型示意

在並行工作者併發模型中一個“委託者”(delegator)將到來的任務分配給不同的工作者。每個工作者完成自己被分配的全部任務。不同的工作者是在不同的線程中(也可能是不同的CPU中)以並行的方式運行。

舉個生活中的例子,如果一個汽車生產廠才用了並行工作者模型,那麼每輛汽車將由一個工人負責完成製造。這需要每個工人都有汽車的生產說明書,並且從頭至尾的完成每個生產細節。

並行工作者併發模型是Java應用中最常用的一種併發模型(儘管這個情況在改變)。在java.util.concurrent包中的很多併發工具類的目的是爲了應用這個模型。在Java企業版的服務器應用中,也可以看到這個模型的蹤跡。

2.1 並行工作者模型的優點

並行工作者模型的優點是其原理易於理解,如果想增加並行化規模只需要增加工作者的個數即可。

例如,假如你正在實現一個網絡爬蟲,你可以用n 個工作者(線程),每個工作者用來獲取一定數目的網頁。然後通過調整n 來看究竟用幾個線程可以獲得最短的總運行時間(最高的運行性能)。由於網絡爬蟲程序是一個I/O密集型的任務,所以最終結果很可能是一個CPU中可以運行多個爬蟲線程。這種情況下,如果一個CPU只有一個爬蟲線程將會浪費CPU資源,因爲下載數據通常會產生大量的CPU等待時間的。

2.2 並行工作者模型的缺點

並行工作者模型在其簡單的外表下隱藏了數個缺點,但這裏我僅接受其中最明顯的幾個。

2.2.1 共享狀態將使複雜性增加

現實中的並行工作者併發模型要比上面的例子複雜得多。共享型的幾個工作者需要具有一些共享資源的訪問權限,這種共享既可能是內存級也可能是共享型的數據庫。下圖展示了這種情況下是如何使並行工作者併發模型變得複雜的:

具有共享狀態的並行工作者併發模型示例

有些時候,這些共享的狀態變量(數據)可能是通訊中的任務隊列。然而其他時候,這些共享的狀態可能是商業數據,數據緩存,數據庫的連接池等等。

一旦並行工作者模型隱含了共享狀態,問題將變得複雜。當線程獲取共享數據時,必須通過某種方式使共享數據的變化對於其他線程是可見的(將其推送至主內存,而不僅僅是保存在執行這個線程的CPU的緩存中)。線程間需要避免競爭死鎖和許多其他的共享狀態併發問題。

此外,當線程間相互等待獲取共享數據的時候,程序的並行性也被削弱了。許多的併發式數據結構都是“阻塞1式的,這意味着在指定的時間內,只有一個或者有限的線程可以訪問這些數據。這將導致對於這些共享型數據結構的競爭狀態,而從本質上說高度的競爭將導致獲取共享數據的代碼在一定程度上的串行化

現代的非阻塞式的併發算法可以減緩這種競爭,從而使性能提升,然而非阻塞式的併發算法通常是難以實現的。

持久化數據結構”是另一種選擇。一個持久化的數據總是保存着其本身被更改前的值/狀態/版本。因此,如果多個線程同時操作同一個持久化的數據並且其中一個線程修改了這個數據,那麼這個做出修改動作的線程將得到新數據的引用。而其他的線程得到的是未經修改的舊數據的引用,持久化的含義就是這種不變性。Scala語言具有幾種持久化的數據結構。

雖然持久化數據結構是共享數據併發讀寫中遇到的問題的一個看似“優雅的”解決方案,但其性能並不那麼好。

舉個例子,一個持久化列表(persistent list)將所有新元素(修改後的數據)添加到表頭,並且返回最新添加元素的引用。其他的線程仍然使用列表中次新的元素的引用,對於這些線程來說,這個列表表現得就像沒有任何改變一樣,即新加入的元素對於他們來說是不可見的。

這樣的一個持久化列表可以用鏈表來實現(linked list)。然而不幸的是,現在的硬件並不能很好的支持鏈表。列表中的每個元素是一個個分離的對象,這些對象的位置可能遍佈計算機的內存的任何地址。目前的CPU獲取連續內存數據的速度更快,這導致了利用數組(array )實現持久化列表的性能會更優。數組被用來存儲在內存中連續的數據。CPU緩存(cache)可以一次性讀入一個大體量的數組,從而達到緩存一次數組,CPU就可以持續地直接從緩存中讀取數據的目的。對於數據元素分散在內存各處的鏈表來說是無法達到這種效果的。

2.2.2 無狀態的工作者

共享的狀態可以被系統中的其他線程進行修改,因此工作者必須在每次需要共享數據的時候重讀這些數據,來保證他所獲得的數據是最新的。這對於無論共享狀態是保存在內存中還是保存在外部數據庫中都是適用的。如果一個工作者不在其內部保存共享狀態(而是每次都重新讀取最新的數據),那麼我們稱其爲無狀態的

每次都重新讀取數據將使得程序變慢,尤其是從外部數據庫中讀取的情況。

2.2.3 任務順序的非確定性

並行工作者模型的另一個缺點是其各個任務的執行順序是非確定性的,沒有辦法保證那個任務先被執行那個任務後被執行。任務A可能比任務B先被分配給工作者,然而任務B卻可能要先於任務A被執行。

並行工作者模型的不確定性導致了很難在固定的時間點推理出系統的狀態,更不用說想保證一個任務在另一個任務之前被率先執行(如果這可以實現的話,可以說是難上加難)。

3. 流水線模型(Assembly line model)

流水線併發模型是我要介紹的第二個併發模型,在不同的平臺/圈子中,這個模型也具有其他的名字,如反應式系統(reactive system)或事件驅動系統(event driven system)。下圖是流水線併發模型的一個圖示:

流水線併發模型

工作者被組織成沿着流水線進行工作,每個工作者僅完成全部任務的一小部分。當一個工作者完成了自己的部分,其下一個工作者將繼續完成下一個部分的工作。2

每個工作者在其自己的線程中運行,和其他的工作者沒有狀態上的共享。所以流水線模型有時也被稱爲無共享併發模型

具有流水線併發模型的系統經常被設計爲使用“非阻塞”的I/O,其含義是當一個工作者開始了一個I/O操作(例如讀取文件或讀取網絡數據)該工作者並不等待I/O操作結束。由於I/O操作太過緩慢,所以等待I/O操作實際是在浪費CPU資源。在I/O操作的同時CPU可以被用來做一些其他的事情。當I/O操作結束後,其結果(例如讀取到的文件數據或者寫數據的狀態返回)將被傳遞給另一個工作者。

如果使用了非阻塞的I/O,那麼I/O操作決定了兩個工作者間的界限。一個工作者儘可能的完成任務,直到他不得不開始一個I/O操作。隨後他放棄對任務的控制權。當I/O操作完成後,流水線中的下一個工作者繼續完成任務,知道他也不得不開始I/O操作。3

具有非阻塞I/O的流水線併發模型中,I/O操作成爲了兩個工作者職責的分界線

在實際情況下,任務不僅僅沿着單一的流水線被處理。由於大多數系統可以執行多於一條任務,許多的任務根據其完成情況沿着流水線逐個地被工作者們處理。現實中可能同時存在多條不同的虛擬流水線。下圖是實際情況下流水線系統的示意:

具有多條流水線的流水線併發模型

在流水線併發模型中,任務可能被向前傳遞給不止一個工作者。例如,一個任務可能同時被傳遞給一個任務執行者和一個任務日誌記錄者。下圖展示了三條流水線是如何以將任務傳遞給一個工作者來結束的(中間流水線的最後一個工作者):

流水線併發模型中任務被分配給不同的工作者

流水線模型可能會比上述情況複雜得多。

3.1 反應式系統(Reactive system)和事件驅動系統(Event driven system)

使用流水線併發模型的系統有時也被稱爲反應式系統或事件驅動系統。系統中的工作者對系統中發生的事件進行反應,這些事件既可能是系統接收到來自外界的消息,也可能是來自其他工作者的消息。到來的HTTP請求或者結束將文件讀入內存都是這裏所說的“事件”的例子。

截止到寫這個教程,已經有一些有趣的反應式/事件驅動系統平臺,並且未來還會有更多。其中一些比較有名的平臺如下:

  • Vert.x
  • Akka
  • Node.JS (JavaScript)

就我個人而言,Vert.x是相當讓我感興趣的(特別是對於我這種沉迷於Java/JVM的人)

3.2 行動者(Actors) vs. 通道(Channels)

“行動者”和“通道”是流水線模型的兩個類似的例子。

在一個行動者模型中,每個工作者被稱爲一個行動者。行動者之間可以直接相互傳遞信息。這些信息被異步地傳遞和處理。行動者模型可以被用來實現單任務或多任務流水線模型。下圖是行動者模型的示意圖:

利用行動者模型實現的流水線併發模型

在通道模型中,工作者們之間並不直接進行相互通信,取而代之的是工作者將他們的消息(事件)發佈在不同的通道上。其他的工作者可以監聽這些通道中的消息,這一過程中,發佈消息的工作者不需要知道有誰在監聽自己的消息。下圖是通道模型的一個展示:

利用行動者模型實現的流水線併發模型

目前爲止對於我來說,通道模型似乎更加的靈活。一個工作者不需要知道流水線上哪些工作者是其後續(繼續處理他處理過的任務)。工作者只需要知道將任務(消息)推送到哪些通道中。通道的監聽者可以在不影響發佈者的情況下訂閱和取消訂閱通道。這在某種程度上是對工作者之間的解耦。

3.3 流水線模型的優點

與並行工作者模型相比,流水線模型具有一些優勢,這裏我只列舉了最大的幾個有點。

3.3.1 無共享狀態

工作者們不共享任何狀態的事實意味着在實現流水線模型時無須考慮共享狀態引起的許多併發問題(競爭、死鎖等)。這使得流水線模型中的任務者實現起來更加簡單。在實現一個工作者時,這個工作者彷彿是處理整個任務的唯一線程 - 實際上變成了單線程編程。

3.3.2 工作者是有狀態

由於工作者知道沒有其他的線程會改變其數據,這樣一來工作者可以被設計爲有狀態(Stateful)的。這裏的“有狀態”指的是工作者可以將其需要操作的數據存在內存中,並將最終的處理後的結果寫回到外部存儲系統中。一個有狀態的工作者通常比無狀態的工作者運行速度更快。

3.3.3 更符合硬件特性

單線程編程具有運行更符合底層硬件運行特性的優勢。首先,當你能假設你的代碼以單線程模式實現時,通常你可以設計出更加優化的數據結構和算法。

其次,單線程、有狀態的工作者可以緩存數據。當數據被緩存後,其後續具有較大的概率還會被再次緩存,這導致了獲取緩存數據將更快。4

我所說的“符合硬件特性”(hardware conformity)指的是:編寫代碼的方式可以自然地從底層硬件運行方式中受益。有一些開發者將其稱爲“器械協同”(mechanical sympathy)。而我更喜歡稱其爲符合硬件特性的原因是現代的計算機幾乎沒有機械部分,並且“協同”(sympathy)指的是合拍,而“符合”(conformity)在這裏更加恰當。不過這都是一些文字遊戲,你可以用你喜歡的稱呼。

3.3.4 可以獲知任務的執行順序

在實現流水線併發模型時,我們可以以某種方式保證任務執行的先後順序。保證執行順序可以使在某個時間點獲知系統狀態變得更加簡單。更進一步,所有到來的任務可以寫進日誌。這個日誌可以被隨後用來重建系統的狀態,以防系統中任何部分的錯誤。

想要實現保證任務順序並不一定簡單,但通常是可以實現的。如果能做到,這將大大簡化備份、數據存儲等工作,因爲這都可以通過日誌文件來實現。

3.4 流水線模型的缺點

流水線併發模型的主要缺點是執行一個任務時通常需要涉及多個工作者,而這將導致工程中的過多的類的個數。進而,對於給定的任務,想弄清楚究竟哪段代碼在執行他將變得更加困難。

同時,編碼工作也可能很困難。工作者的代碼很多時候被寫成回調句柄(callback handler)的形式。擁有太多的回調句柄的代碼將會成爲所謂的“回調地獄”(callback hell),即很難從這些回調中還原並跟蹤頂層函數的真實含義,並且也很難確定是否每個回調函數都有其所需數據的訪問權限。

對於並行工作者模型來說,這一點是不足爲慮的。你只需要打開一個工作者的實現代碼並且從頭至尾閱讀一邊。當然,並行工作者也可能被展開成多個不同的類,但是其執行過程是很容易從代碼中讀懂的。

4 函數並行化模型

函數並行化模型是第三個併發模型,並且在當下(2015)被廣泛討論。

函數並行化模型的基本思想是:通過函數調用來實現你的代碼。函數可以被看作是“代理”(agents)或者“行動者”(actors),並且相互之間發送消息,就好像上面流水線模型中所敘述的一樣。當一個函數調用另一個函數,這很類似與發送一個消息。

所有被傳遞給函數的參數都被複製成相應的副本再傳入函數中,所以接收函數外部的所有實體都無法再對數據進行操作。這種複製是爲了在本質上避免共享數據的競爭問題。這使得函數的執行類似於一個原子操作(atomic operation)。每個函數調用相對於其他函數都被獨立地執行。

當一個一系列並行化函數可以被單獨調用執行時(即他們之間在執行的過程中不存在數據交互等耦合),每個函數都可以在多個獨立的CPU中執行。這意味着利用併發函數式模型實現的算法可以在不同的CPU中並行的運行。

隨着Java 7的發佈,我們可以使用java.util.concurrent包中的ForkAndJoinPool來實現類似於函數並行化模型的代碼。隨着Java 8的發佈,我們有了並行流(streams),使得對於大型的集合(collection),其迭代器可以實現並行化。值得注意的是,有一些開發者對ForkAndJoinPool持批判態度(在我的ForkAndJoinPool教程中你可以找到具體的鏈接)。

函數並行化的難點在於對並行化的函數的深入瞭解。幾個跨CPU協作的函數通常需要額外的在管理時間上的損耗,一個函數所完成的問題的規模需要足夠大才能抵消這種損耗。如果調用函數的任務規模過小,並行化反而會比單線程更慢。

就我個人的理解(可能並不正確),你可以自己利用反應式/事件驅動模型來實現一個模型,並且將一個完整的任務進行拆分,其效果和函數並行化模型很相似。然而,前者卻可以更加精準的控制並行化的部分和並行化的程度(個人觀點)。

此外,如果想讓將一個任務拆分和由多個CPU協作所產生的額外時耗變得合理和有意義,其條件是僅當該任務是程序的唯一執行的工作。然而,如果程序還同時併發的執行許多其他的任務(例如網絡服務、數據庫服務和許多其他事要做),試着並行化其中單一的任務並無任何意義。計算機的其他CPU總是忙着做其他工作,所以用一個更慢的函數並行化任務來拖慢這些CPU是沒有意義的。這時如果用流水線併發模型可能會更好,因爲流水線模型具有更少的額外損耗並且和底層硬件契合的更佳。

5 孰優孰略?

那麼這些併發模型孰優孰略呢?

通常的情況是這依賴於你的系統想要完成什麼樣的任務。如果你的任務本身就具有很好的並行性,不同的任務間相互獨立並且也不需要共享狀態,那麼這個系統更適合於用並行工作者模型來實現。

然而更多時候任務間並非具有天然的並行性,相互之間缺乏獨立行。對於這一類系統,我認爲流水線併發模型是利大於弊的,並且也優於並行工作者模型。

你甚至不需要自己編寫流水線模型的基本框架。現代的諸如Vert.x的一些平臺已經爲你完成了大部分工作。就我個人而言我的下一個項目將使用諸如Vert.x的平臺來實現。同時我個人認爲,JavaEE並沒有任何的侷限性。


  1. 阻塞式I/O指操作一個I/O過程中,如果還沒有完成操作,線程將停住並一直等待I/O操作結束,無法繼續完成I/O操作後續的代碼功能。
  2. 這裏的理解可以考慮這樣的情況,一個任務分成三個步驟完成,假設現在一個任務的第一個步驟已經完成了,那麼它將到達第二個步驟;如果此時又來了一個新的任務,那麼第一個任務的第二個步驟和第二個任務的第一個步驟將併發的執行 - 也就是說,只要任務是多個,就可能產生併發。但如果任務只有一個,或者第二個任務到達時第一個任務已經全部被執行完,此時就不會有併發。
  3. 這裏的意思是說,在流水線模型中任務的劃分儘量以I/O作爲分界,因爲這樣就不用再一個任務被執行的時候調用I/O了(I/O通常都是比較慢的)
  4. 這是因爲計算機大多數(80%)的時間在處理少量(20%)的數據,所以纔會有緩存可以加速程序運行的說法。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章