概述
在實際的併發系統中,根據不同的業務場景一般使用不同的併發處理手段,這些不同的手段我們就稱之爲併發模型。不同的併發模型採用不同的方式拆分任務,同時這些併發線程間的交互方式也大不相同。本篇我們就介紹常見的幾種併發模型:
併發模型
本篇我們主要分以下三個模塊展開:
- 併發模型和分佈式系統的相似點:
- 常見的三種併發模型
- 哪種併發模型最好
1、併發模型和分佈式系統的關係
我們知道在分佈式系統中,各個系統之間通過各種方式交互來實現業務目標。其中每個系統我們可以看做是一個單獨的進程,那麼分佈式系統也就可以理解爲是 多進程交互。回到我們的併發模型。在併發模型中,一般通過 多線程交互 的方式來達成某種目標。而線程和進程本身就具有很大的相似性,這也是爲什麼絕大多數併發模型和分佈式系統類似的原因。
其次從目的上來說,分佈式系統通過 分佈 的方式將複雜業務拆開到不同機器上,避免單臺機器處理所有類型請求的壓力。而併發模型也可以理解爲將大任務拆分爲小任務,分配到不同的線程中執行,避免單線程壓力過大以至阻塞,導致無法正常服務。
最後從缺陷來說,因爲分佈式系統將應用部署在不同機器,因此很容易面臨處理網絡失效、遠程主機或進程宕掉等額外挑戰。而在大型併發模型中,某塊CPU、網卡,磁盤的失效也有可能帶來同樣的影響。
經過上面的介紹,我們會發現併發模型和分佈式系統是非常相似的。因此我們在設計併發模型過程中可以借鑑分佈式系統的思想,分佈式系統也可以從併發模型中獲得啓發,減少不必要的麻煩。
2、常見的三種併發模型
常見的併發模型有以下三種:
- 並行工作模型
- 流水線模型
- 函數式並行模型
2-1、並行工作模型
並行工作模型的核心思想是:將所有任務分配到不同的線程中,每個線程執行子任務的所有內容。
舉個簡單的例子:修車廠每天需要修理很多車輛,該工廠將需要修理的車輛按數量分配給所有的工人,每個工人負責從頭到尾修理他所分配的車輛。
在這個例子中要修理的車輛即所有任務,工人就類似線程,每個工人從頭到尾負責它所分配的車輛就是說每個線程完成分配給它的子任務。
下來我們通過簡單代碼來模擬該情況:
public class ThreadModel {
static class Car {
private String name;
public Car(String name) {
this.name = name;
}
}
static class currentWoker implements Runnable {
Car car = null;
public currentWoker(Car car) {
this.car = car;
}
@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println("工人:" + name + "----修理了車輛" + car.name);
}
}
public static void main(String[] args) {
List<Car> carList = new ArrayList<>();
for (int i = 0; i < 10; i++) {
carList.add(new Car("汽車" + i));
}
for (int i = 0; i < 10; i++) {
new Thread(new currentWoker(carList.get(i))).start();
}
}
}
運行結果:
上述代碼中沒有用到線程池,無法複用線程,因此假設每個工人只會分到一輛車輛。關於線程池的介紹可以 點擊這裏 查看我之前的博客。
簡單來說,並行工作模型就是將相同類型的任務交互給不同的線程來做,這種處理思想有點類似集羣:單臺機器處理壓力過大,將不同請求分配到不同機器的相同應用中來處理。
並行工作模型的優點:容易理解
- 容易理解:該方式簡單來說就像企業招人:100件事一個人幹太慢了,招100個人每人分配一件來幹就快很多。
並行工作模型的缺陷:共享狀態複雜,工作次序不確定
-
共享狀態複雜:當所有線程需要訪問某一塊內存資源時,需要保證每個線程的處理對其他線程可見,並且線程間還需要保證不出現 死鎖 等線程併發性問題。
就拿上述修車這個案例來說,工人在修車過程中是互不影響的。假設修車完畢後需要上報到總修車數。工人甲看到公司今天總修車數量爲5,自己修了3輛車,按理說應該更新總修車數爲8。但在工人甲看到的同時,工人乙正在進行上報。此時工人甲看到的總修車數量是不包括工人乙上報內容的,那麼等甲上報後就會出現問題了,系統丟失了部分工人乙上報的數據。
在真正開發環境中,共享數據常常集中在業務數據,系統緩存、數據庫連接池等模塊。
在處理這部分共享數據時,一般需要通過 鎖 的方式來確保同時只有一條線程操作該數據。這種方式會導致其他線程處於等待狀態,影響線程之間的併發性。同時這種處理方式意味着,每條線程在操作共享數據的時候,都需要同步最新的數據。如果共享數據在數據庫中,每次從數據庫同步數據是非常影響效率的。
-
工作次序不確定:並行工作模式下,所有的線程搶佔CPU資源,並不會按照線程啓動的順序執行,更多時候是一種隨機的方式,也就是說線程運行的次序是不確定的。關於這點我們可以看上述代碼的運行結果,很明顯不是按照線程啓動順序執行的,並且每次運行結果都不相同
2-2、流水線模型
流水線模式的核心思想是:每個線程專注於自己的任務,在它完成任務後交接給下一個線程。線程之間 互不 共享工作狀態。
這裏依然用修車廠來舉例:修車廠有A、B、C,D四個員工,每個員工處理不同的車輛問題。當車輛有問題時,首先A員工處理自己負責的模塊,處理完成後B接着處理,依此類推。
在上述例子中:修車這件事就類似大任務,A、B、C,D員工類比四條線程,每種線程有自己負責的任務小模塊,並且線程之間順序執行。
下面我們通過簡單代碼來模擬流水線模型:
public class ThreadModel2 {
static class A implements Runnable {
CountDownLatch start;
CountDownLatch end;
public A(CountDownLatch start, CountDownLatch end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
try {
start.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("第一步修理車門");
end.countDown();
}
}
static class B implements Runnable {
CountDownLatch start;
CountDownLatch end;
public B(CountDownLatch start, CountDownLatch end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
try {
start.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("第二步修理車窗");
end.countDown();
}
}
static class C implements Runnable {
CountDownLatch start;
CountDownLatch end;
public C(CountDownLatch start, CountDownLatch end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
try {
start.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("第三步修理輪胎");
end.countDown();
}
}
static class D implements Runnable {
CountDownLatch start;
CountDownLatch end;
public D(CountDownLatch start, CountDownLatch end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
try {
start.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("第四步修理油門");
end.countDown();
}
}
public static void main(String[] args) {
CountDownLatch sign1 = new CountDownLatch(1);
CountDownLatch sign2 = new CountDownLatch(1);
CountDownLatch sign3 = new CountDownLatch(1);
CountDownLatch sign4 = new CountDownLatch(1);
CountDownLatch sign5 = new CountDownLatch(1);
new Thread(new A(sign1, sign2)).start();
new Thread(new B(sign2, sign3)).start();
new Thread(new C(sign3, sign4)).start();
new Thread(new D(sign4, sign5)).start();
sign1.countDown();
try {
sign5.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("運行結束");
}
}
運行結果:
通過運行結果我們可以看出,四個併發線程按順序依次執行。
在上述代碼中,我是通過 CountDownLatch 類實現線程的次序執行,然而在實際編碼過程中,也可以通過 回調函數 或是 監聽事件 的方式來確保流水線的順序性。
在實際的應用場景中,並不是只有一條流水線,而是很多組流水線同時進行,也就是說同時有很多線程在同步執行,並且各個流水線之間存在 交叉執行 的可能性。
流水線模型的優點:線程無須共享狀態,更好的處理性能,工作次序可控制
-
線程無須共享狀態:在流水線模型中,線程之間不會訪問相同的內存,因此也不用考慮共享數據問題,也不用每次在操作內存時都進行同步操作。
-
更好的處理性能:因爲不存在線程安全性問題,因此在實現業務邏輯是可以選擇更高效的數據結構和算法,提高硬件效率。同時因爲不存在線程安全性問題,省去了加解鎖等操作,提高軟件效率。
-
工作次序可控制:在流水線模型中,只有前一個任務完成後纔會執行下一個任務,任務之間是有序的,這種業務場景更符合當下很多業務需求。
流水線模型的缺點:開發困難,排查問題困難
-
開發困難:在流水線模型中,線程之間往往處理不同的業務,這也就導致整個業務被拆分並分別實現在不同的JAVA類當中,這種方式無疑會增加開發者的難度。
-
排查問題困難:流水線模式需要首先確定出現問題的模塊,然後鎖定對應業務類再做排查。而並行模型一般業務處理邏輯都在一起,只需要按照代碼順序向下排查即可。
在並行模式中,爲了確保任務的次序執行,有時候會用到 回調函數。當代碼中嵌入過多的回調函數時,排查問題是非常困難的:我們無法確定每次回調過程都做了哪些操作,以及無法保證每次回調的數據是否正確。
2-3、函數式並行模型
函數式併發模型的核心思想是:使用函數調用實現程序。
在瞭解函數式並行模型之前,我們首先了解以下什麼叫 函數式編程。
在 JAVA 語言中最常強調的思想就是 面向對象,即世間萬物都可以抽象爲對象。而函數式編程不是這樣的,函數式編程是將萬物都抽象爲事件和關係。即 A 事物因爲某種關係變爲 B事物 。我們把這個過程抽象爲 ** 函數式**。
函數式並行可以抽象的理解爲:函數之間通過發送消息互相調用。如果這些函數可以獨立的運行,那就意味着它們可以分散到不同的CPU上執行,也就是說在多處理器上並行的執行函數。
函數式並行模型的優點:線程安全
- 線程安全:函數都是通過拷貝來傳遞參數的,所以除了接收函數外沒有其他實體可以操作數據。
函數式並行模型的缺點:實現困難,場景單一,效率不高
-
實現困難:一般情況下,我們很難確定可以並行的函數調用,也就是說我們很難確定兩個函數是否可以在兩個不同CPU上並行執行。
-
場景單一:將任務拆分給多個CPU進行函數調用所產生的開銷,僅僅在當前任務是程序唯一調用時纔有意義。如果當前系統還再並行執行其他任務,那麼將單個任務進行拆分是沒有意義的。
-
效率不高:跨 CPU 調用函數本身也有很大的開銷,如果某個函數調用本身所能產生的效益小於這部分開銷,那麼還不如使用單線程模型。
3、哪種併發模型最好?
我認爲沒有最好的併發模型的,只有當前場景下最 合適 的併發模型。選擇何種併發模型和你的業務場景息息相關。
- 如果業務比較簡單,並且業務量比較大,那麼考慮並行工作模型
- 如果業務本身就是獨立的,業務之間不存在線程安全問題,那麼考慮並行工作模型
- 如果業務本身特別複雜,想要拆分業務併發執行,那麼考慮流水線模型
- 如果業務本身有次序,需要按照一定順序執行,那麼考慮流水線模型
- 如果業務經常修改,並行功能模型和流水線模型都可以實現,不過我比較偏向流水線模型,因爲流水線模型在新加業務或者刪除業務時,只需要添加或刪除相應的業務類即可,無須修改代碼邏輯。