并發(fā)實(shí)現(xiàn):掌握不同并發(fā)框架的選擇和使用秘訣
不同編程語言中的并發(fā)框架種類繁多。例如,Java 語言有 Thread Pool 框架、Akka 框架、Reactor 響應(yīng)式框架等;C++ 語言有 CAF 框架、Theron 框架等;Go 語言有 goroutine 等。
這些并發(fā)框架之間差異很大,若選擇或使用不當(dāng),容易導(dǎo)致開發(fā)出的軟件性能較差。并且,現(xiàn)在仍有不少程序員對(duì)這些并發(fā)框架缺乏系統(tǒng)認(rèn)識(shí),在選擇和使用時(shí)較為隨意。
接下來,就讓我們從最為熟悉的 Thread Pool 框架開始學(xué)習(xí)吧。
Java Thread Pool 框架
Java 的 Thread Pool 框架目前極為流行,原因在于其使用便捷且適用場(chǎng)景眾多。同時(shí),它還是其他并發(fā)框架(例如 Akka)內(nèi)部實(shí)現(xiàn)所依賴的技術(shù),所以對(duì)這個(gè)框架的理解和學(xué)習(xí)至關(guān)重要。那么,為了能夠更好地理解 Java Thread Pool 框架,我們先來查看一下它的框架模型圖:
圖片
從圖的左側(cè)開始看,具體實(shí)現(xiàn)了接口 Runnable、Callable 的任務(wù),在調(diào)用 ExecutorService.submit 接口時(shí),會(huì)將任務(wù)提交到 ExecutorService 內(nèi)部的一個(gè)任務(wù)隊(duì)列中。
與此同時(shí),ExecutorService 內(nèi)部還存在一個(gè)預(yù)先申請(qǐng)的線程池(Thread Pool)。線程池中的線程會(huì)從任務(wù)隊(duì)列中領(lǐng)取一個(gè)任務(wù)來執(zhí)行。
由此我們能夠發(fā)現(xiàn),在 Java 語言中,與直接在代碼中創(chuàng)建線程相比,采用 Thread Pool 這種機(jī)制有很多好處。
第一個(gè)好處是,在 Thread Pool 中,可以重復(fù)利用已創(chuàng)建的線程資源,從而減少線程創(chuàng)建和銷毀造成的額外開銷。第二個(gè)好處是,當(dāng)有新業(yè)務(wù)請(qǐng)求到達(dá)時(shí),可以直接使用已創(chuàng)建的線程來處理業(yè)務(wù),所以還可以最大化地減少處理時(shí)延。
實(shí)際上,對(duì)于一個(gè)軟件系統(tǒng)而言,線程是非常重要的稀缺資源。而線程池技術(shù)也是有效管理線程資源、最大化提升軟件性能的關(guān)鍵手段之一。
然而,我見過不少的軟件系統(tǒng),在使用線程池時(shí)根本沒有章法。每個(gè)開發(fā)人員在自己的業(yè)務(wù)模塊中隨意創(chuàng)建線程池。而對(duì)于整個(gè)軟件系統(tǒng)來說,業(yè)務(wù)代碼中一共創(chuàng)建了多少個(gè)線程池、每個(gè)線程池的資源規(guī)模配置如何,都是很含糊的。從而就導(dǎo)致開發(fā)出的軟件性能總是處于不可控的狀態(tài)。
所以通常情況下,在使用線程池設(shè)計(jì)并發(fā)系統(tǒng)時(shí),需要對(duì)線程池的創(chuàng)建與配置進(jìn)行全局設(shè)計(jì)。這里就產(chǎn)生了問題,即應(yīng)該依據(jù)什么規(guī)則來劃分線程池組?以及如何配置線程池使用的資源呢?實(shí)際上,以我的實(shí)踐經(jīng)驗(yàn)來看,我認(rèn)為應(yīng)該從以下兩個(gè)維度來劃分線程池組。
首先,應(yīng)根據(jù)不同的業(yè)務(wù)邏輯特點(diǎn)進(jìn)行劃分。例如,可以把以 CPU 計(jì)算為主和以 IO 處理為主的業(yè)務(wù)邏輯劃分到不同的線程池組中。其次,還可以根據(jù)不同業(yè)務(wù)功能的優(yōu)先級(jí)來劃分出不同的線程池組。
當(dāng)線程池組的劃分確定之后,就可以根據(jù) JVM 中可用的 CPU 核資源數(shù)目(可使用 Runtime.getRuntime ().availableProcessors () 獲取 JVM 可用的 CPU 核數(shù)),為不同的線程池組分配合理的線程資源額度。
然后,在為線程池組配置可用的線程資源時(shí),需要針對(duì)不同線程池上的業(yè)務(wù)特點(diǎn),選擇不同的線程資源配置策略。比如,針對(duì) CPU 計(jì)算密集型業(yè)務(wù),只需讓線程池配置的可用線程數(shù)與可分配的 CPU 核數(shù)相等即可;而針對(duì) IO 密集型業(yè)務(wù),由于業(yè)務(wù)中的阻塞請(qǐng)求較多,所以可以將配置的線程數(shù)提高到可用 CPU 核數(shù)的兩倍以上。
當(dāng)然,我這里只是介紹了大體的配置思路,當(dāng)你在為線程池組配置可用的線程時(shí),最好是基于真實(shí)的業(yè)務(wù)運(yùn)行特性分析,并從全局統(tǒng)籌分配之后,再為每個(gè)線程池配置合適的線程資源。
好的,現(xiàn)在我們?cè)俅位仡櫱懊娴木€程池框架模型圖。不知你是否注意到,這個(gè)框架模型中并未考慮線程之間的通信機(jī)制應(yīng)如何實(shí)現(xiàn)。此時(shí)你可能會(huì)思考:當(dāng)業(yè)務(wù)中的線程之間存在信息交互時(shí),該怎么辦呢?這時(shí),你肯定會(huì)想到可以基于 Java 并發(fā)消息隊(duì)列進(jìn)行通信,還可以使用各種同步互斥鎖。的確,在 Java 語言中,內(nèi)置的并發(fā)消息隊(duì)列與互斥鎖等機(jī)制幾乎可以滿足線程間的各種同步交互需求,若合理設(shè)計(jì)并使用,也能很好地發(fā)揮軟件性能。然而,在真實(shí)的業(yè)務(wù)開發(fā)過程中,并發(fā)消息隊(duì)列和鎖機(jī)制若使用不當(dāng),不僅容易導(dǎo)致軟件出現(xiàn)嚴(yán)重故障,還容易使系統(tǒng)中的某些線程長時(shí)間阻塞,從而不能很好地滿足業(yè)務(wù)的性能需求。另外,并發(fā)消息隊(duì)列和鎖在解決同步互斥和數(shù)據(jù)一致性問題時(shí)帶來的內(nèi)部開銷也會(huì)在一定程度上消耗軟件的性能。那么,有沒有不需要直接使用并發(fā)消息隊(duì)列和鎖就能設(shè)計(jì)和實(shí)現(xiàn)高并發(fā)系統(tǒng)的框架呢?答案當(dāng)然是有,接下來我要為你介紹的 Akka 并發(fā)框架,就是為了解決這個(gè)問題。
Akka 并發(fā)框架
首先我們知道,Akka 是基于 Actor 模型實(shí)現(xiàn)的一套并發(fā)框架。所以這里,我們同樣是先通過一個(gè) Actor 核心模型圖,來了解下 Akka 并發(fā)框架的特點(diǎn):
圖片
在這個(gè)模型圖中,每個(gè) Actor 代表著可以被調(diào)度執(zhí)行的輕量單元。如圖所示,當(dāng) Actor A 和 Actor C 向 Actor B 發(fā)送消息時(shí),所有消息會(huì)被底層框架發(fā)送到 Actor B 的 Mailbox 中。然后,底層的 Akka 框架調(diào)度代碼會(huì)觸發(fā) Actor B,使其接收并執(zhí)行消息的后續(xù)處理。
這樣一來,基于 Actor 模型的這套并發(fā)框架,首先保證了消息能夠安全地在各個(gè) Actor 之間傳遞,同時(shí)也確保了每個(gè) Actor 實(shí)例可以串行處理接收到的所有消息。
因此,在采用基于 Actor 模型的 Akka 框架開發(fā)實(shí)現(xiàn)軟件時(shí),你無需關(guān)注底層的并發(fā)交互同步問題,只需聚焦于業(yè)務(wù)中每個(gè) Actor 實(shí)現(xiàn)的業(yè)務(wù)邏輯,即它需要接收什么消息,又需要向誰發(fā)送什么消息。
另外,由于 Actor 模型中的消息機(jī)制實(shí)現(xiàn)了消息在 Actor 之間傳遞時(shí)會(huì)被串行處理,所以天然避免了在消息交互中需要解決的數(shù)據(jù)一致性問題。也就是說,針對(duì)系統(tǒng)中并發(fā)單元間存在大量信息交互的場(chǎng)景,選用 Akka 并發(fā)框架在性能上會(huì)有一定優(yōu)勢(shì)。
其實(shí),Actor 模型還有一個(gè)更大的優(yōu)勢(shì),那就是 Actor 非常輕量。它可以支持很大規(guī)模的并發(fā),并負(fù)載均衡到各個(gè) CPU 核上,從而充分發(fā)揮硬件資源,進(jìn)一步提升軟件的運(yùn)行性能。那么接下來,為了更好地理解這個(gè)原理,我們來看一個(gè)任務(wù)拆分示意圖。它描述了兩種不同的任務(wù)拆分方式,以及將拆分的子任務(wù)映射到 CPU 具體核上的執(zhí)行過程。
圖片
在圖中,左側(cè)的方法 1 代表傳統(tǒng)基于線程的粒度并發(fā)拆分??梢园l(fā)現(xiàn),在這里想要拆分成大小均勻的并發(fā)子任務(wù)其實(shí)很有挑戰(zhàn)。當(dāng)拆分出的子任務(wù)大小規(guī)模差別較大時(shí),將它們映射到底層 CPU 的核上執(zhí)行,會(huì)造成 CPU 核上的負(fù)載不均衡。也就是說,傳統(tǒng)的任務(wù)拆分方式會(huì)出現(xiàn)某些核處于空閑狀態(tài),而另外的核上還有線程在執(zhí)行的場(chǎng)景。在這種情況下,CPU 多核的性能空間就無法發(fā)揮到極致。
而圖中右側(cè)的方法 2 代表 Actor 的細(xì)粒度任務(wù)拆分。它可以把業(yè)務(wù)功能拆分成大量輕量級(jí)的 Actor 子任務(wù)。由于每個(gè) Actor 都非常輕量,Akka 的底層調(diào)度框架就可以將這些 Actor 子任務(wù)均勻地分布到多個(gè) CPU 硬件核上,從而最大化地發(fā)揮 CPU 的性能。
所以,在實(shí)際的業(yè)務(wù)開發(fā)中要注意,如果在使用 Actor 時(shí),沒有利用好 Actor 輕量級(jí)的特性,開發(fā)出來的 Actor 承載的業(yè)務(wù)邏輯太多,導(dǎo)致 Actor 的任務(wù)粒度過大,那么就很難發(fā)揮出 Actor 的最佳性能表現(xiàn)。
OK,在理解了這種并發(fā)框架的使用優(yōu)勢(shì)之后,你可能仍然存在一個(gè)問題,就是究竟什么樣的業(yè)務(wù)系統(tǒng)會(huì)存在大量的并發(fā)信息交互,比較適合采用 Akka 并發(fā)框架呢?
按照我的實(shí)踐經(jīng)驗(yàn),一般情況下,CPU 計(jì)算密集型的軟件系統(tǒng)會(huì)比較適合采用 Akka 并發(fā)框架。如果發(fā)現(xiàn)業(yè)務(wù)系統(tǒng)中存在大量基于并發(fā)消息隊(duì)列的通信,且核心業(yè)務(wù)都是圍繞著 CPU 計(jì)算邏輯,而 IO 請(qǐng)求并非核心業(yè)務(wù)邏輯,那么這樣的系統(tǒng)很可能比較適用 Akka 并發(fā)框架。實(shí)際上,很多種計(jì)算執(zhí)行引擎就是比較典型的代表。比如,我之前開發(fā)的智能對(duì)話引擎,需要將多個(gè)計(jì)算模型的計(jì)算結(jié)果放在一起進(jìn)行比較分析,它就非常適合采用 Akka 并發(fā) Actor 框架模型。
不過,對(duì)于一些典型的互聯(lián)網(wǎng)微服務(wù)來說,當(dāng)它們收到 REST 請(qǐng)求后,實(shí)現(xiàn)的核心業(yè)務(wù)邏輯主要是針對(duì)數(shù)據(jù)庫 CRUD 或是針對(duì)其他服務(wù)的 REST 接口調(diào)用,同時(shí),這些不同的 REST 請(qǐng)求業(yè)務(wù)還是相對(duì)獨(dú)立的。那么,這類系統(tǒng)就應(yīng)該屬于 IO 密集型業(yè)務(wù),所以選擇采用 Akka 并發(fā)框架,往往優(yōu)勢(shì)不是很大。
那么,針對(duì) IO 密集型業(yè)務(wù),選用線程池并發(fā)框架是不是就是性能最佳的方案呢?其實(shí)也不一定。下面我們就一起看下 Reactor 并發(fā)框架的實(shí)現(xiàn)特點(diǎn),并了解下它在解決 IO 密集型業(yè)務(wù)時(shí)存在的優(yōu)勢(shì)吧。
Reactor 響應(yīng)式框架
Reactor 架構(gòu)是一種基于數(shù)據(jù)流的響應(yīng)式架構(gòu)模式,嚴(yán)格來講它或許不能算是完整的并發(fā)框架,但卻內(nèi)置了靈活調(diào)整并發(fā)的機(jī)制和能力。對(duì)于不太熟悉函數(shù)式編程范式的程序員而言,理解和使用 Reactor 架構(gòu)可能會(huì)有些挑戰(zhàn)。不過沒關(guān)系,在今天的課程中,我會(huì)幫你弄清楚 Reactor 架構(gòu)模型的基本原理和優(yōu)勢(shì),你不必受限于細(xì)節(jié)。當(dāng)你在實(shí)際業(yè)務(wù)中需要決策是否使用這款并發(fā)框架時(shí),再選擇深入學(xué)習(xí)具體的用法也不晚。
好,首先,我們一起來看看 Reactor 框架的工作原理圖。
圖片
如上圖所示,輸入流 Flux 是 Reactor 中典型的異步消息流,它代表著一個(gè)包含 0 個(gè)到 N 個(gè)的消息序列。另外,圖中的 Rule 代表的是一個(gè)基于消息的處理邏輯或規(guī)則,輸入流中的消息可以被中間多個(gè)處理邏輯組合連續(xù)加工之后,再生成一個(gè)包含 0 個(gè)到 N 個(gè)的輸出消息流 Flux。
在看完原理圖之后,我們需要思考一個(gè)問題:Reactor 為什么要采用這樣的計(jì)算模型呢?它又能給軟件的性能帶來什么樣的優(yōu)勢(shì)呢?其實(shí),這里主要有兩個(gè)比較明顯的優(yōu)勢(shì),接下來我為你重點(diǎn)介紹。
第一個(gè)較大的性能優(yōu)勢(shì)是它提供了背壓機(jī)制。通俗來講,就是圖中的中間處理規(guī)則(Rule)在接收處理消息時(shí)采用的是 Pull 模式,所以不存在數(shù)據(jù)消息積壓的情況。對(duì)于傳統(tǒng)的分布式并發(fā)系統(tǒng)而言,內(nèi)部消息堆積是一個(gè)很普遍的影響性能的因素,而使用 Reactor 框架就可以避免這種情況發(fā)生。
第二個(gè)較大的性能優(yōu)勢(shì)是在中間的消息處理規(guī)則實(shí)現(xiàn)中,針對(duì) IO 的交互操作可以采用非阻塞的異步交互。在原來傳統(tǒng)的基于線程與 IO 交互的實(shí)現(xiàn)過程中,不管是使用直接的 IO 請(qǐng)求,還是基于 Future 的 get 機(jī)制,都不可避免地會(huì)發(fā)生當(dāng)前線程被阻塞的情況。所以基于 Reactor 的異步響應(yīng)式交互模式,在處理多 IO 請(qǐng)求時(shí)性能會(huì)更出色。
另外,在 Spring Boot 2.0 版本之后,也提供了對(duì) Reactor 的全面支持,可以支持你去實(shí)現(xiàn)事件驅(qū)動(dòng)模型的后端開發(fā),從而更好地發(fā)揮軟件的性能優(yōu)勢(shì)。