可以使用不同的並發模型來實現並發系統。一並發模型指定的系統協作線程如何完成他們給予的任務。不同的並發模型以不同的方式拆分任務,線程可以以不同的方式進行通信和協作。本並發模型教程將更深入地介紹撰寫本文時(2015年至2019年)使用的最受歡迎的並發模型。
並發模型和分布式系統的相似性
本文中描述的並發模型類似於分布式系統中使用的不同體系結構。在並發系統中,不同的線程彼此通信。在分布式系統中,不同的進程相互通信(可能在不同的計算機上)。線程和進程本質上非常相似。這就是為什么不同的並發模型通常看起來與不同的分布式系統體系結構相似的原因。
當然,分布式系統還面臨着額外的挑戰,即網絡可能會失敗,或者遠程計算機或進程關閉等。但是,如果CPU發生故障,網卡發生故障,磁盤發生故障,則在大型服務器上運行的並發系統可能會遇到類似的問題。失敗的可能性可能會更低,但理論上仍會發生。
由於並發模型與分布式系統體系結構相似,因此它們經常可以相互借鑒。例如,用於在工作人員(線程)之間分配工作的模型通常類似於分布式系統中的負載平衡模型。錯誤處理技術(例如日志記錄,故障轉移,任務的冪等)也是如此。
共享狀態與分離狀態
並發模型的一個重要方面是,組件和線程是設計為在線程之間共享狀態,還是具有獨立的狀態,這些狀態永遠不會在線程之間共享。
共享狀態意味着系統中的不同線程將在它們之間共享某些狀態。通過狀態是指一些數據,通常是一個或多個對象或相似。當線程共享狀態時,可能會出現爭用條件 和死鎖等問題。當然,這取決於線程如何使用和訪問共享對象。
分開的狀態意味着系統中的不同線程在它們之間不共享任何狀態。萬一不同的線程需要通信,它們可以通過在它們之間交換不可變對象或通過在它們之間發送對象(或數據)的副本來進行通信。因此,當沒有兩個線程寫入同一對象(數據/狀態)時,可以避免大多數常見的並發問題。
使用單獨的狀態並發設計通常可以使代碼的某些部分更易於實現和推理,因為您知道只有一個線程將寫入給定對象。您不必擔心並發訪問該對象。但是,使用單獨的狀態並發性,您可能需要更全面地考慮應用程序設計。我覺得這是值得的。我個人更喜歡單獨的狀態並發設計。
平行工人
第一個並發模型是我所說的並行工作器模型。傳入的工作分配給不同的工人。這是說明並行工作程序並發模型的圖:
在並行工人並發模型中,委托人將傳入的作業分配給不同的工人。每個工人完成全部工作。這些工作程序並行工作,在不同的線程中運行,並可能在不同的CPU上運行。
如果在汽車制造廠實施並行工人模型,則每輛汽車將由一名工人生產。工人將獲得要制造的汽車的規格,並會從頭到尾制造所有東西。
並行工作程序並發模型是Java應用程序中最常用的並發模型(盡管正在改變)。java.util.concurrent Java包 中的許多並發實用程序都是設計用於此模型的。您還可以在Java Enterprise Edition應用程序服務器的設計中看到此模型的痕跡。
平行工人優勢
並行工作程序並發模型的優點是易於理解。為了增加應用程序的並行化,您只需添加更多工作程序即可。
例如,如果您正在實施Web搜尋器,則可以使用不同數量的工作程序來搜尋一定數量的頁面,並查看哪個數字提供了最短的總搜尋時間(意味着最高的性能)。由於Web爬網是一項IO密集型工作,您最終可能會為計算機中的每個CPU /內核使用幾個線程。每個CPU一個線程太少了,因為它在等待數據下載時會處於許多空閑狀態。
平行工人的劣勢
但是,並行工作程序並發模型具有一些隱藏在簡單表面下的缺點。我將在以下各節中解釋最明顯的缺點。
共享狀態會變得復雜
實際上,並行工作程序並發模型比上面說明的要復雜一些。共享工作者經常需要訪問內存或共享數據庫中的某種共享數據。下圖顯示了如何使並行工作器並發模型復雜化:
這種共享狀態中的某些處於諸如工作隊列之類的通信機制中。但是這種共享狀態中的一些是業務數據,數據緩存,與數據庫的連接池等。
一旦共享狀態潛入並行工作程序並發模型中,它就會開始變得復雜。線程需要以確保一個線程的更改對其他線程可見的方式訪問共享數據(推送到主內存中,而不僅僅是卡在執行該線程的CPU的CPU緩存中)。線程需要避免爭用條件, 死鎖和許多其他共享狀態並發問題。
此外,當線程在訪問共享數據結構時互相等待時,並行化的一部分會丟失。許多並發數據結構正在阻塞,這意味着一個或一組有限的線程可以在任何給定時間訪問它們。這可能導致對這些共享數據結構的爭用。高競爭本質上將導致訪問共享數據結構的部分代碼的執行序列化。
現代的非阻塞並發算法可以減少爭用並提高性能,但是很難實現非阻塞算法。
持久數據結構是另一種選擇。永久數據結構在修改后始終保留其自身的先前版本。因此,如果多個線程指向相同的持久數據結構,並且一個線程對其進行了修改,則修改線程將獲得對新結構的引用。所有其他線程保留對舊結構的引用,該舊結構仍保持不變,因此是一致的。Scala編程包含幾個持久數據結構。
雖然持久性數據結構是對共享數據結構進行並發修改的理想解決方案,但持久性數據結構往往無法很好地執行。
例如,一個持久列表會將所有新元素添加到列表的開頭,並返回對新添加元素的引用(該引用隨后指向列表的其余部分)。所有其他線程仍保留對列表中先前第一個元素的引用,並且對這些線程而言,列表保持不變。他們看不到新添加的元素。
這樣的持久列表被實現為鏈接列表。不幸的是,鏈表在現代硬件上的表現不佳。列表中的每個元素都是一個單獨的對象,這些對象可以分布在整個計算機的內存中。現代CPU順序訪問數據的速度要快得多,因此在現代硬件上,從陣列頂部實現的列表中可以獲得更高的性能。數組順序存儲數據。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
-
阿卡
-
Node.JS(JavaScript)
我個人認為Vert.x非常有趣(特別是對於像我這樣的Java / JVM恐龍)。
演員與頻道
角色和通道是裝配線(或反應/事件驅動)模型的兩個類似示例。
在演員模型中,每個工人都稱為演員。演員可以直接彼此發送消息。消息是異步發送和處理的。如前所述,可以使用Actor來實現一個或多個作業處理裝配線。這是說明參與者模型的圖:
在渠道模型中,工作人員不直接相互通信。相反,他們在不同的渠道上發布消息(事件)。然后,其他工作人員可以在這些頻道上收聽消息,而發件人不知道誰在收聽。這是說明通道模型的圖:
在撰寫本文時,渠道模型對我來說似乎更靈活。工人不需要知道稍后在裝配線中將處理什么工作的工人。它只需要知道將作業轉發到哪個渠道(或將消息發送到等等)。頻道上的偵聽器可以訂閱和取消訂閱,而不會影響工作人員對頻道的寫入。這允許工人之間的聯軸器稍松一些。
流水線優勢
與並行工作程序模型相比,組裝線並發模型具有多個優點。在以下各節中,我將介紹最大的優點。
沒有共享狀態
工作人員與其他工作人員不共享任何狀態的事實意味着無需考慮並發訪問共享狀態可能引起的所有並發問題,就可以實現他們。這使實施工人變得容易得多。您將工作程序實現為好像是執行該工作的唯一線程-本質上是單線程實現。
有狀態的工人
由於工作人員知道沒有其他線程修改其數據,因此工作人員可以是有狀態的。有狀態的意思是他們可以將需要操作的數據保留在內存中,僅將更改寫回最終的外部存儲系統。因此,有狀態工人通常比無狀態工人更快。
更好的硬件整合
單線程代碼的優勢在於,它通常與底層硬件的工作方式更好地相符。首先,當您可以假定代碼以單線程模式執行時,通常可以創建更多優化的數據結構和算法。
其次,如上所述,單線程有狀態工作者可以在內存中緩存數據。當數據緩存在內存中時,也更有可能將此數據也緩存在執行線程的CPU的CPU緩存中。這樣可以更快地訪問緩存的數據。
當以自然受益於底層硬件工作方式的方式編寫代碼時, 我將其稱為硬件一致性。一些開發商稱這種機械同情。我更喜歡“硬件一致性”一詞,因為計算機幾乎沒有機械零件,並且在這種情況下,“同情”一詞被用作“更好地匹配”的隱喻,我相信“符合”一詞可以很好地傳達。無論如何,這是挑剔的。使用您喜歡的任何術語。
可以訂購工作
可以根據組裝線並發模型以保證作業排序的方式實現並發系統。作業排序使在任何給定時間點推斷系統狀態變得更加容易。此外,您可以將所有傳入的作業寫入日志。然后,在系統的任何部分出現故障的情況下,可以使用此日志從頭開始重建系統狀態。作業以特定順序寫入日志,並且該順序成為保證的作業順序。這是這樣的設計的外觀:
實施保證的工作訂單不一定很容易,但是通常是可能的。如果可以的話,它可以極大地簡化備份,還原數據,復制數據等任務,因為所有這些都可以通過日志文件來完成。
組裝線的缺點
組裝流水線並發模型的主要缺點是,作業的執行通常分散在多個工作人員中,因此也分散在項目中的多個類中。因此,很難確切地知道給定作業正在執行什么代碼。
編寫代碼也可能會更困難。輔助代碼有時被編寫為回調處理程序。具有許多嵌套回調處理程序的代碼可能會導致某些開發人員稱之為回調地獄。回調地獄只是意味着很難跟蹤所有回調中代碼的實際作用,以及確保每個回調都可以訪問所需的數據。
使用並行工作程序並發模型,這往往會更容易。您可以打開工作程序代碼,並從頭到尾閱讀幾乎執行的代碼。當然,並行工作程序代碼也可以分布在許多不同的類上,但是執行順序通常更容易從代碼中讀取。
功能並行
功能並行是第三種並發模型,最近(2015年)被廣泛討論。
函數並行性的基本思想是使用函數調用實現程序。功能可以看作是相互發送消息的“代理”或“角色”,就像在組裝線並發模型(AKA反應或事件驅動系統)中一樣。當一個函數調用另一個函數時,這類似於發送消息。
傳遞給函數的所有參數都將被復制,因此接收函數之外的任何實體都無法操縱數據。該復制對於避免共享數據出現爭用情況至關重要。這使得函數執行類似於原子操作。每個函數調用都可以獨立於任何其他函數調用執行。
當每個函數調用可以獨立執行時,每個函數調用可以在單獨的CPU上執行。這就是說,功能上實現的算法可以在多個CPU上並行執行。
使用Java 7,我們獲得了java.util.concurrent
包含ForkAndJoinPool的軟件包,該軟件包 可以幫助您實現類似於功能並行性的東西。使用Java 8,我們獲得了並行流 ,可以幫助您並行化大型集合的迭代。請記住,有些開發人員對此表示批評ForkAndJoinPool
(您可以在本ForkAndJoinPool
教程中找到批評的鏈接)。
關於函數並行性的難點是知道要並行調用哪些函數。跨CPU協調函數調用會帶來開銷。一個功能完成的工作單元必須具有一定的大小,才能負擔此開銷。如果函數調用很小,則嘗試並行化它們實際上可能比單線程,單CPU執行慢。
根據我的理解(一點都不完美),您可以使用反應性,事件驅動的模型來實現算法,並實現類似於功能並行性的工作分解。使用均勻驅動的模型,您可以更好地控制要並行化的對象和數量(在我看來)。
另外,只有在該任務當前是程序唯一執行的任務時,才有意義地將任務分配給多個CPU,並產生開銷。但是,如果系統正在同時執行多個其他任務(例如,Web服務器,數據庫服務器和許多其他系統都在執行),則嘗試並行化單個任務毫無意義。無論如何,計算機中的其他CPU都將忙於其他任務,因此沒有理由嘗試以較慢的,功能上並行的任務來打擾它們。組裝流水線(反應式)並發模型可能會更好,因為它具有較少的開銷(以單線程模式順序執行),並且與底層硬件的工作方式更好地兼容。
哪種並發模型最好?
那么,哪種並發模型更好?
通常,答案是這取決於系統應該執行的操作。如果您的工作自然是並行的,獨立的並且不需要共享狀態,則可以使用並行工作器模型來實現系統。
但是,許多工作並非自然而然地平行和獨立。對於這些類型的系統,我相信組裝線並發模型的優點要大於缺點,比並行工作器模型要有更多的優點。
您甚至不必自己編寫所有組裝線基礎結構的代碼。像Vert.x這樣的現代平台 已經為您實現了很多功能。我個人將為下一個項目探索在Vert.x等平台上運行的設計。我覺得Java EE不再具有優勢。