原文連結 作者: Jakob Jenkov 譯者: 林威建 [[email protected]]
并發系統可以采用多種并發程式設計模型來實作。并發模型指定了系統中的線程如何通過協作來完成配置設定給它們的作業。不同的并發模型采用不同的方式拆分作業,同時線程間的協作和互動方式也不相同。這篇并發模型教程将會較深入地介紹目前(2015年,本文撰寫時間)比較流行的幾種并發模型。
并發模型與分布式系統之間的相似性
本文所描述的并發模型類似于分布式系統中使用的很多體系結構。在并發系統中線程之間可以互相通信。在分布式系統中程序之間也可以互相通信(程序有可能在不同的機器中)。線程和程序之間具有很多相似的特性。這也就是為什麼很多并發模型通常類似于各種分布式系統架構。
當然,分布式系統在處理網絡失效、遠端主機或程序宕掉等方面也面臨着額外的挑戰。但是運作在巨型伺服器上的并發系統也可能遇到類似的問題,比如一塊CPU失效、一塊網卡失效或一個磁盤損壞等情況。雖然出現失效的機率可能很低,但是在理論上仍然有可能發生。
由于并發模型類似于分布式系統架構,是以它們通常可以互相借鑒思想。例如,為工作者們(線程)配置設定作業的模型一般與分布式系統中的負載均衡系統比較相似。同樣,它們在日志記錄、失效轉移、幂等性等錯誤處理技術上也具有相似性。
【注:幂等性,一個幂等操作的特點是其任意多次執行所産生的影響均與一次執行的影響相同】
并行工作者
第一種并發模型就是我所說的并行工作者模型。傳入的作業會被配置設定到不同的工作者上。下圖展示了并行工作者模型:
在并行工作者模型中,委派者(Delegator)将傳入的作業配置設定給不同的工作者。每個工作者完成整個任務。工作者們并行運作在不同的線程上,甚至可能在不同的CPU上。
如果在某個汽車廠裡實作了并行工作者模型,每台車都會由一個勞工來生産。勞工們将拿到汽車的生産規格,并且從頭到尾負責所有工作。
在Java應用系統中,并行工作者模型是最常見的并發模型(即使正在轉變)。java.util.concurrent包中的許多并發實用工具都是設計用于這個模型的。你也可以在Java企業級(J2EE)應用伺服器的設計中看到這個模型的蹤迹。
并行工作者模型的優點
并行工作者模式的優點是,它很容易了解。你隻需添加更多的工作者來提高系統的并行度。
例如,如果你正在做一個網絡爬蟲,可以試試使用不同數量的工作者抓取到一定數量的頁面,然後看看多少數量的工作者消耗的時間最短(意味着性能最高)。由于網絡爬蟲是一個IO密集型工作,最終結果很有可能是你電腦中的每個CPU或核心配置設定了幾個線程。每個CPU若隻配置設定一個線程可能有點少,因為在等待資料下載下傳的過程中CPU将會空閑大量時間。
并行工作者模型的缺點
并行工作者模型雖然看起來簡單,卻隐藏着一些缺點。接下來的章節中我會分析一些最明顯的弱點。
共享狀态可能會很複雜
在實際應用中,并行工作者模型可能比前面所描述的情況要複雜得多。共享的工作者經常需要通路一些共享資料,無論是記憶體中的或者共享的資料庫中的。下圖展示了并行工作者模型是如何變得複雜的:
有些共享狀态是在像作業隊列這樣的通信機制下。但也有一些共享狀态是業務資料,資料緩存,資料庫連接配接池等。
一旦共享狀态潛入到并行工作者模型中,将會使情況變得複雜起來。線程需要以某種方式存取共享資料,以確定某個線程的修改能夠對其他線程可見(資料修改需要同步到主存中,不僅僅将資料儲存在執行這個線程的CPU的緩存中)。線程需要避免竟态,死鎖以及很多其他共享狀态的并發性問題。
此外,在等待通路共享資料結構時,線程之間的互相等待将會丢失部分并行性。許多并發資料結構是阻塞的,意味着在任何一個時間隻有一個或者很少的線程能夠通路。這樣會導緻在這些共享資料結構上出現競争狀态。在執行需要通路共享資料結構部分的代碼時,高競争基本上會導緻執行時出現一定程度的串行化。
現在的非阻塞并發算法也許可以降低競争并提升性能,但是非阻塞算法的實作比較困難。
可持久化的資料結構是另一種選擇。在修改的時候,可持久化的資料結構總是保護它的前一個版本不受影響。是以,如果多個線程指向同一個可持久化的資料結構,并且其中一個線程進行了修改,進行修改的線程會獲得一個指向新結構的引用。所有其他線程保持對舊結構的引用,舊結構沒有被修改并且是以保證一緻性。Scala程式設計包含幾個持久化資料結構。
【注:這裡的可持久化資料結構不是指持久化存儲,而是一種資料結構,比如Java中的String類,以及CopyOnWriteArrayList類,具體可參考】
雖然可持久化的資料結構在解決共享資料結構的并發修改時顯得很優雅,但是可持久化的資料結構的表現往往不盡人意。
比如說,一個可持久化的連結清單需要在頭部插入一個新的節點,并且傳回指向這個新加入的節點的一個引用(這個節點指向了連結清單的剩餘部分)。所有其他現場仍然保留了這個連結清單之前的第一個節點,對于這些線程來說連結清單仍然是為改變的。它們無法看到新加入的元素。
這種可持久化的清單采用連結清單來實作。不幸的是連結清單在現代硬體上表現的不太好。連結清單中得每個元素都是一個獨立的對象,這些對象可以遍布在整個計算機記憶體中。現代CPU能夠更快的進行順序通路,是以你可以在現代的硬體上用數組實作的清單,以獲得更高的性能。數組可以順序的儲存資料。CPU緩存能夠一次加載數組的一大塊進行緩存,一旦加載完成CPU就可以直接通路緩存中的資料。這對于元素散落在RAM中的連結清單來說,不太可能做得到。
無狀态的工作者
共享狀态能夠被系統中得其他線程修改。是以工作者在每次需要的時候必須重讀狀态,以確定每次都能通路到最新的副本,不管共享狀态是儲存在記憶體中的還是在外部資料庫中。工作者無法在内部儲存這個狀态(但是每次需要的時候可以重讀)稱為無狀态的。
每次都重讀需要的資料,将會導緻速度變慢,特别是狀态儲存在外部資料庫中的時候。
任務順序是不确定的
并行工作者模式的另一個缺點是,作業執行順序是不确定的。無法保證哪個作業最先或者最後被執行。作業A可能在作業B之前就被配置設定工作者了,但是作業B反而有可能在作業A之前執行。
并行工作者模式的這種非确定性的特性,使得很難在任何特定的時間點推斷系統的狀态。這也使得它也更難(如果不是不可能的話)保證一個作業在其他作業之前被執行。
流水線模式
第二種并發模型我們稱之為流水線并發模型。我之是以選用這個名字,隻是為了配合“并行工作者”的隐喻。其他開發者可能會根據平台或社群選擇其他稱呼(比如說反應器系統,或事件驅動系統)。下圖表示一個流水線并發模型:
類似于工廠中生産線上的勞工們那樣組織工作者。每個工作者隻負責作業中的部分工作。當完成了自己的這部分工作時工作者會将作業轉發給下一個工作者。每個工作者在自己的線程中運作,并且不會和其他工作者共享狀态。有時也被成為無共享并行模型。
通常使用非阻塞的IO來設計使用流水線并發模型的系統。非阻塞IO意味着,一旦某個工作者開始一個IO操作的時候(比如讀取檔案或從網絡連接配接中讀取資料),這個工作者不會一直等待IO操作的結束。IO操作速度很慢,是以等待IO操作結束很浪費CPU時間。此時CPU可以做一些其他事情。當IO操作完成的時候,IO操作的結果(比如讀出的資料或者資料寫完的狀态)被傳遞給下一個工作者。
有了非阻塞IO,就可以使用IO操作确定工作者之間的邊界。工作者會盡可能多運作直到遇到并啟動一個IO操作。然後交出作業的控制權。當IO操作完成的時候,在流水線上的下一個工作者繼續進行操作,直到它也遇到并啟動一個IO操作。
在實際應用中,作業有可能不會沿着單一流水線進行。由于大多數系統可以執行多個作業,作業從一個工作者流向另一個工作者取決于作業需要做的工作。在實際中可能會有多個不同的虛拟流水線同時運作。這是現實當中作業在流水線系統中可能的移動情況:
作業甚至也有可能被轉發到超過一個工作者上并發處理。比如說,作業有可能被同時轉發到作業執行器和作業日志器。下圖說明了三條流水線是如何通過将作業轉發給同一個工作者(中間流水線的最後一個工作者)來完成作業:
流水線有時候比這個情況更加複雜。
反應器,事件驅動系統
采用流水線并發模型的系統有時候也稱為反應器系統或事件驅動系統。系統内的工作者對系統内出現的事件做出反應,這些事件也有可能來自于外部世界或者發自其他工作者。事件可以是傳入的HTTP請求,也可以是某個檔案成功加載到記憶體中等。在寫這篇文章的時候,已經有很多有趣的反應器/事件驅動平台可以使用了,并且不久的将來會有更多。比較流行的似乎是這幾個:
- Vert.x
- AKKa
- Node.JS(JavaScript)
我個人覺得Vert.x是相當有趣的(特别是對于我這樣使用Java/JVM的人來說)
Actors 和 Channels
Actors 和 channels 是兩種比較類似的流水線(或反應器/事件驅動)模型。
在Actor模型中每個工作者被稱為actor。Actor之間可以直接異步地發送和處理消息。Actor可以被用來實作一個或多個像前文描述的那樣的作業處理流水線。下圖給出了Actor模型:
而在Channel模型中,工作者之間不直接進行通信。相反,它們在不同的通道中釋出自己的消息(事件)。其他工作者們可以在這些通道上監聽消息,發送者無需知道誰在監聽。下圖給出了Channel模型:
在寫這篇文章的時候,channel模型對于我來說似乎更加靈活。一個工作者無需知道誰在後面的流水線上處理作業。隻需知道作業(或消息等)需要轉發給哪個通道。通道上的監聽者可以随意訂閱或者取消訂閱,并不會影響向這個通道發送消息的工作者。這使得工作者之間具有松散的耦合。
流水線模型的優點
相比并行工作者模型,流水線并發模型具有幾個優點,在接下來的章節中我會介紹幾個最大的優點。
無需共享的狀态
工作者之間無需共享狀态,意味着實作的時候無需考慮所有因并發通路共享對象而産生的并發性問題。這使得在實作工作者的時候變得非常容易。在實作工作者的時候就好像是單個線程在處理工作-基本上是一個單線程的實作。
有狀态的工作者
當工作者知道了沒有其他線程可以修改它們的資料,工作者可以變成有狀态的。對于有狀态,我是指,它們可以在記憶體中儲存它們需要操作的資料,隻需在最後将更改寫回到外部存儲系統。是以,有狀态的工作者通常比無狀态的工作者具有更高的性能。
較好的硬體整合(Hardware Conformity)
單線程代碼在整合底層硬體的時候往往具有更好的優勢。首先,當能确定代碼隻在單線程模式下執行的時候,通常能夠建立更優化的資料結構和算法。
其次,像前文描述的那樣,單線程有狀态的工作者能夠在記憶體中緩存資料。在記憶體中緩存資料的同時,也意味着資料很有可能也緩存在執行這個線程的CPU的緩存中。這使得通路緩存的資料變得更快。
我說的硬體整合是指,以某種方式編寫的代碼,使得能夠自然地受益于底層硬體的工作原理。有些開發者稱之為mechanical sympathy。我更傾向于硬體整合這個術語,因為計算機隻有很少的機械部件,并且能夠隐喻“更好的比對(match better)”,相比“同情(sympathy)”這個詞在上下文中的意思,我覺得“conform”這個詞表達的非常好。當然了,這裡有點吹毛求疵了,用自己喜歡的術語就行。
合理的作業順序
基于流水線并發模型實作的并發系統,在某種程度上是有可能保證作業的順序的。作業的有序性使得它更容易地推出系統在某個特定時間點的狀态。更進一步,你可以将所有到達的作業寫入到日志中去。一旦這個系統的某一部分挂掉了,該日志就可以用來重頭開始重建系統當時的狀态。按照特定的順序将作業寫入日志,并按這個順序作為有保障的作業順序。下圖展示了一種可能的設計:
實作一個有保障的作業順序是不容易的,但往往是可行的。如果可以,它将大大簡化一些任務,例如備份、資料恢複、資料複制等,這些都可以通過日志檔案來完成。
流水線模型的缺點
流水線并發模型最大的缺點是作業的執行往往分布到多個工作者上,并是以分布到項目中的多個類上。這樣導緻在追蹤某個作業到底被什麼代碼執行時變得困難。
同樣,這也加大了代碼編寫的難度。有時會将工作者的代碼寫成回調處理的形式。若在代碼中嵌入過多的回調處理,往往會出現所謂的回調地獄(callback hell)現象。所謂回調地獄,就是意味着在追蹤代碼在回調過程中到底做了什麼,以及確定每個回調隻通路它需要的資料的時候,變得非常困難
使用并行工作者模型可以簡化這個問題。你可以打開工作者的代碼,從頭到尾優美的閱讀被執行的代碼。當然并行工作者模式的代碼也可能同樣分布在不同的類中,但往往也能夠很容易的從代碼中分析執行的順序。
函數式并行(Functional Parallelism)
第三種并發模型是函數式并行模型,這是也最近(2015)讨論的比較多的一種模型。函數式并行的基本思想是采用函數調用實作程式。函數可以看作是”代理人(agents)“或者”actor“,函數之間可以像流水線模型(AKA 反應器或者事件驅動系統)那樣互相發送消息。某個函數調用另一個函數,這個過程類似于消息發送。
函數都是通過拷貝來傳遞參數的,是以除了接收函數外沒有實體可以操作資料。這對于避免共享資料的競态來說是很有必要的。同樣也使得函數的執行類似于原子操作。每個函數調用的執行獨立于任何其他函數的調用。
一旦每個函數調用都可以獨立的執行,它們就可以分散在不同的CPU上執行了。這也就意味着能夠在多處理器上并行的執行使用函數式實作的算法。
Java7中的java.util.concurrent包裡包含的ForkAndJoinPool能夠幫助我們實作類似于函數式并行的一些東西。而Java8中并行streams能夠用來幫助我們并行的疊代大型集合。記住有些開發者對ForkAndJoinPool進行了批判(你可以在我的ForkAndJoinPool教程裡面看到批評的連結)。
函數式并行裡面最難的是确定需要并行的那個函數調用。跨CPU協調函數調用需要一定的開銷。某個函數完成的工作單元需要達到某個大小以彌補這個開銷。如果函數調用作用非常小,将它并行化可能比單線程、單CPU執行還慢。
我個人認為(可能不太正确),你可以使用反應器或者事件驅動模型實作一個算法,像函數式并行那樣的方法實作工作的分解。使用事件驅動模型可以更精确的控制如何實作并行化(我的觀點)。
此外,将任務拆分給多個CPU時協調造成的開銷,僅僅在該任務是程式目前執行的唯一任務時才有意義。但是,如果目前系統正在執行多個其他的任務時(比如web伺服器,資料庫伺服器或者很多其他類似的系統),将單個任務進行并行化是沒有意義的。不管怎樣計算機中的其他CPU們都在忙于處理其他任務,沒有理由用一個慢的、函數式并行的任務去擾亂它們。使用流水線(反應器)并發模型可能會更好一點,因為它開銷更小(在單線程模式下順序執行)同時能更好的與底層硬體整合。
使用那種并發模型最好?
是以,用哪種并發模型更好呢?
通常情況下,這個答案取決于你的系統打算做什麼。如果你的作業本身就是并行的、獨立的并且沒有必要共享狀态,你可能會使用并行工作者模型去實作你的系統。雖然許多作業都不是自然并行和獨立的。對于這種類型的系統,我相信使用流水線并發模型能夠更好的發揮它的優勢,而且比并行工作者模型更有優勢。
你甚至不用親自編寫所有流水線模型的基礎結構。像Vert.x這種現代化的平台已經為你實作了很多。我也會去為探索如何設計我的下一個項目,使它運作在像Vert.x這樣的優秀平台上。我感覺Java EE已經沒有任何優勢了。
------------------越是喧嚣的世界,越需要甯靜的思考------------------
合抱之木,生于毫末;九層之台,起于壘土;千裡之行,始于足下。
積土成山,風雨興焉;積水成淵,蛟龍生焉;積善成德,而神明自得,聖心備焉。故不積跬步,無以至千裡;不積小流,無以成江海。骐骥一躍,不能十步;驽馬十駕,功在不舍。锲而舍之,朽木不折;锲而不舍,金石可镂。蚓無爪牙之利,筋骨之強,上食埃土,下飲黃泉,用心一也。蟹六跪而二螯,非蛇鳝之穴無可寄托者,用心躁也。