本文是作者對(duì)于《七周七并發(fā)模型》一書的讀后總結(jié)歸納二汛,主要介紹了一些并發(fā)模型眼坏,這些模型本身是不依賴具體的語言的型豁,但是特定的語言對(duì)于特定的模型是有優(yōu)化的酬凳,因此在介紹模型的同時(shí)會(huì)有一些編程語言相關(guān)的內(nèi)容。
1 Java的線程與鎖
線程和鎖模型本質(zhì)上是共享可變狀態(tài)汽馋,在多線程情形下可能會(huì)產(chǎn)生如下的問題:
- 競(jìng)態(tài)條件—— 當(dāng)多個(gè)線程競(jìng)爭(zhēng)同一資源時(shí)侮东,如果對(duì)資源的訪問順序敏感,就稱存在競(jìng)態(tài)條件 豹芯。
- 內(nèi)存可見性—— 當(dāng)讀操作與寫操作在不同的線程中執(zhí)行時(shí),無法確保執(zhí)行讀操作的線程能適時(shí)地看到其他線程寫入的值驱敲。
- 死鎖—— 兩個(gè)或兩個(gè)以上的進(jìn)程在執(zhí)行過程中铁蹈,由于競(jìng)爭(zhēng)資源或者由于彼此通信而造成的一種阻塞的現(xiàn)象,若無外力作用众眨,它們都將無法推進(jìn)下去握牧。此時(shí)稱系統(tǒng)處于死鎖狀態(tài)或系統(tǒng)產(chǎn)生了死鎖,這些永遠(yuǎn)在互相等待的進(jìn)程稱為死鎖進(jìn)程娩梨。
1.1 內(nèi)置鎖
Java提供的最簡單的解決方案沿腰,但是存在如下的限制
- 線程因?yàn)榈却齼?nèi)置鎖而進(jìn)入阻塞后,就無法中斷該進(jìn)程狈定。
- 嘗試獲取內(nèi)置鎖時(shí)颂龙,無法設(shè)置超時(shí)习蓬。
- 獲得內(nèi)置鎖,必須使用synchronized語法措嵌。
1.2 Concurrent包
由于內(nèi)置鎖的局限性躲叼,Java在java.util.concurrent包中為并發(fā)提供了額外的支持
- 高級(jí)鎖
- CopyOnWrite容器(寫時(shí)加鎖復(fù)制,讀時(shí)不加鎖)
- 原子變量 CAS
1.3 總結(jié)
該模型靈活企巢,適用面廣枫慷,很容易集成入大部分編程語言,具有更接近于硬件的工作方式浪规。
然而只適用于單機(jī)系統(tǒng)內(nèi)的共享內(nèi)存模型或听,需要?jiǎng)e的技術(shù)的幫助才能夠支持分布式內(nèi)存模型。多線程代碼經(jīng)常會(huì)出現(xiàn)難以重現(xiàn)的bug笋婿,調(diào)試難神帅,維護(hù)難。
2 Clojure的函數(shù)式編程
由于共享可變狀態(tài)副作用很大萌抵,因此函數(shù)式編程直接選擇了不使用可變狀態(tài)找御。
Java非函數(shù)式編程示例:
public int sum(int[] numbers) {
int accumulator = 0;
for (int n: numbers) {
accumulator += n;
}
return accumulator;
}
Clojure函數(shù)式編程范例:
// 便于理解的冗長寫法
(defn recursive-sum [numbers]
(if (empty? numbers)
0
(+ (first numbers) (recursive-sum (rest numbers)))))
// 實(shí)際只需要這么寫
(defn sum [numbers]
(reduce + numbers))
? 函數(shù)式編程能輕松并行化的原因是每個(gè)函數(shù)都具有引用透明性。
? 即在任何調(diào)用函數(shù)的地方绍填,都可以用函數(shù)運(yùn)行的結(jié)果來替換函數(shù)的調(diào)用霎桅。講人話就是,每個(gè)函數(shù)之間都是互不依賴讨永,可以獨(dú)立執(zhí)行的滔驶。
2.1 Reducers
Clojure提供了reducers庫,描述了各種對(duì)集合進(jìn)行化簡的方法卿闹,這些方法被稱為reducer揭糕。
普通版本的map接受一個(gè)函數(shù)和一個(gè)(可能是懶惰的)序列,并返回另一個(gè)(可能是)懶惰的序列:
user=> (map (partial * 2) [1 2 3 4])
(2 4 6 8)
而clojure.core.reducers提供的map不同锻霎,接受相同的參數(shù)著角,但返回的是一個(gè)化簡器reducible:
user=> (require [clojure.core.reducers :as r])
user=> (r/map (partial * 2) [1 2 3 4])
#<reducers$folder$reify__1599 clojure.core.reducers$folder$reify__1599@151964cd
reducible不能直接使用,而是作為參數(shù)傳遞給reduce或者fold旋恼。
user=> (reduce conj [] (r/map (partial * 2) [1 2 3 4]))
[2 4 6 8]
user=> (into [] (r/map (partial * 2) [1 2 3 4]))
// into函數(shù)內(nèi)部使用了reduce吏口,所以和上面的代碼是等價(jià)的
[2 4 6 8]
化簡器,簡單講就是不直接求值冰更,而是返回計(jì)算的過程产徊。多個(gè)化簡器使用時(shí)會(huì)自動(dòng)組合為同一個(gè)化簡器。直到遇到reduce和fold才會(huì)進(jìn)行計(jì)算蜀细。好處是不用構(gòu)造中間狀態(tài)的序列舟铜。
2.2 Reducers中的fold
fold是reduce函數(shù)的并發(fā)版本,使用的是二分算法奠衔。
首先將集合分為兩組谆刨,每組繼續(xù)分為更小的兩組塘娶,以此類推,直到每個(gè)分組的規(guī)模小于某個(gè)限制值(默認(rèn)為512)痴荐。
然后fold會(huì)對(duì)每個(gè)分組內(nèi)的元素逐個(gè)化簡血柳。
最后,對(duì)個(gè)分組的結(jié)果進(jìn)行兩兩合并生兆,直到最終剩下一個(gè)最終的結(jié)果难捌。
3 Clojure的分離標(biāo)識(shí)與狀態(tài)
? Clojure也是支持共享可變狀態(tài)的,通過分離狀態(tài)與標(biāo)識(shí)來實(shí)現(xiàn)鸦难。而分離狀態(tài)與標(biāo)識(shí)根吁,則通過原子變量/代理/引用來實(shí)現(xiàn)
標(biāo)識(shí)是什么? 一個(gè)可變量的引用合蔽,就是標(biāo)識(shí)击敌。例如Int a;
狀態(tài)是什么拴事? a在不同時(shí)刻對(duì)應(yīng)的值沃斤。當(dāng)時(shí)刻1的時(shí)候線程x獲取到了a的值,時(shí)刻2的時(shí)候線程y修改了a的值刃宵,x獲取的時(shí)刻1的值依然不變衡瓶。
命令式語言中,一個(gè)變量混合了狀態(tài)與標(biāo)識(shí)——一個(gè)標(biāo)識(shí)只能擁有一個(gè)值牲证,這讓我們很容易忽略一個(gè)事實(shí):狀態(tài)實(shí)際上式隨時(shí)間變化的一系列值哮针。持久數(shù)據(jù)結(jié)構(gòu)將標(biāo)識(shí)與狀態(tài)分離開來,如果獲取一個(gè)標(biāo)識(shí)的當(dāng)前狀態(tài)坦袍,無論將來對(duì)這個(gè)標(biāo)識(shí)怎樣修改十厢,獲取的那個(gè)狀態(tài)將不再改變。
我的理解:可以參考我們?cè)贘ava中對(duì)原子變量的使用方式捂齐,每次對(duì)原子變量的操作都會(huì)通過基于CAS的swap蛮放!方法重試直至成功。類似Java中的AtomicXXX辛燥,如果線程a修改變量x的同時(shí)(例如值+1或者-1)筛武,有的線程b修改了該變量,那么線程a就會(huì)重新以x的新值進(jìn)行計(jì)算挎塌。對(duì)于Clojure來說,這是其中一種實(shí)現(xiàn)狀態(tài)與分離的方式内边。
3.1 原子變量
原子變量 通過 持久數(shù)據(jù)結(jié)構(gòu)來實(shí)現(xiàn)
持久數(shù)據(jù)結(jié)構(gòu):數(shù)據(jù)被修改時(shí)總是保留其之前的版本(基本上就是CopyOnWrite的機(jī)制)由于這種方式很浪費(fèi)空間榴都,因此原子變量之間可以共享數(shù)據(jù)結(jié)構(gòu)(例如list1的值為[1,2,3,4,5],list2的值為[3,2,3,4,5]漠其,那么list1和list2共享[2,3,4,5]的部分)
原子變量可以設(shè)置校驗(yàn)器(校驗(yàn)器在原子變量的值修改之前被調(diào)用嘴高,如果修改后的值符合校驗(yàn)器的要求竿音,則允許本次修改,否則將丟棄本次修改拴驮。例如:需要一個(gè)非負(fù)值的原子變量)春瞬。
原子變量也可以設(shè)置監(jiān)控器,監(jiān)控器會(huì)在原子變量的值修改后被調(diào)用套啤。(可以用來監(jiān)聽事件)
3.2 代理
和原子變量類似宽气。代理也是對(duì)一個(gè)值的引用,就像原子變量通過swap潜沦!來操作一樣萄涯,代理通過send來進(jìn)行操作。
但是有個(gè)區(qū)別唆鸡,就是send的行為是類似于NIO的涝影,send執(zhí)行后會(huì)馬上返回,但send中傳輸?shù)暮瘮?shù)可能要到之后才會(huì)被執(zhí)行争占。如果多個(gè)線程對(duì)一個(gè)值同時(shí)調(diào)用send函數(shù)燃逻,那么這些send函數(shù)將會(huì)被收集起來,并串行執(zhí)行臂痕,就像消息總線一樣伯襟。類似于ZStack中的XXXBase接收thdf處理的msg的方式。
3.3 引用
比原子變量和代理都要復(fù)雜刻蟹。通過引用可以實(shí)現(xiàn)軟件事務(wù)內(nèi)存(Software Transactional Memory逗旁,STM)。通過原子變量和代理每次僅能修改一個(gè)變量舆瘪,但是STM可以對(duì)多個(gè)變量進(jìn)行原子性地修改片效,就像數(shù)據(jù)庫的事務(wù)一樣。
STM具有以下特性:
- 原子性:在其他事務(wù)看來英古,當(dāng)前事務(wù)的所有副作用要么全部發(fā)生淀衣,要么都不發(fā)生。
- 一致性:事務(wù)保證全程遵守校驗(yàn)器定義的規(guī)范召调,如果事務(wù)中的任意一個(gè)校驗(yàn)失敗膨桥,那么所有的修改都不會(huì)發(fā)生。
- 隔離性:多個(gè)事務(wù)可以同時(shí)運(yùn)行唠叛,但同時(shí)運(yùn)行的事務(wù)的結(jié)果和串行運(yùn)行這些事務(wù)的結(jié)果是完全相同的只嚣。
如果STM運(yùn)行時(shí)檢測(cè)到多個(gè)并發(fā)事務(wù)的修改發(fā)生沖突,那么其中一個(gè)或者幾個(gè)事務(wù)將進(jìn)行重試艺沼,就像原子變量一樣册舞。
3.4 總結(jié)
Clojure支持共享可變狀態(tài)的三種機(jī)制,每種都有各自的適用場(chǎng)景障般。
- 原子變量:對(duì)一個(gè)對(duì)象進(jìn)行同步更新
- 代理:對(duì)一個(gè)對(duì)象進(jìn)行異步更新
- 引用:結(jié)合事務(wù)调鲸,對(duì)多個(gè)對(duì)象進(jìn)行一致的盛杰、同步的更新。
4. Elixir的Actor模型
actor類似于一個(gè)面向?qū)ο螅∣O)編程中的對(duì)象——其內(nèi)部封裝了狀態(tài)藐石,但通過消息和消息隊(duì)列來和其他actor通信即供。
每一個(gè)actor都會(huì)有一個(gè)專用的隊(duì)列用來接收消息,并不斷地串行消費(fèi)這些消息于微。
4.1 錯(cuò)誤處理內(nèi)核
對(duì)一個(gè)使用actor模型的程序而言逗嫡,由一個(gè)管理者進(jìn)程作為“錯(cuò)誤處理內(nèi)核”,管理子進(jìn)程——actor角雷,包括啟動(dòng)祸穷、重啟、停止等操作勺三。而actor不進(jìn)行防御式編程雷滚,而是“任其崩潰”。這樣做的好處顯而易見:
- 代碼會(huì)變得更加簡潔且容易理解吗坚。業(yè)務(wù)代碼和錯(cuò)誤處理代碼涇渭分明祈远。
- actor之間是互相獨(dú)立的,崩潰的actor不太可能殃及到別的actor商源。
- 管理者可以選擇不處理崩潰车份,而是記錄崩潰的原因,這樣我們就會(huì)得到崩潰通知并進(jìn)行后續(xù)處理牡彻。
4.2 Actor模型的特點(diǎn)
Actor模型天然支持分布式——它可以將消息發(fā)送到另一臺(tái)機(jī)器的actor扫沼,就像發(fā)送到本地計(jì)算機(jī)的actor一樣。
5 Clojure的通信順序進(jìn)程(CSP)
通訊順序進(jìn)程模型(Communicating Sequential Process)庄吼,和actor模型具有很多相似之處缎除,比如,CSP模型也是由獨(dú)立的、并發(fā)執(zhí)行的實(shí)體所組成总寻,實(shí)體之間也通過發(fā)送消息來進(jìn)行通訊器罐。但有一個(gè)根本區(qū)別。CSP模型中渐行,并不關(guān)注發(fā)送消息的實(shí)體轰坊,而是發(fā)送消息時(shí)使用的消息總線,在CSP中稱為channel(通道)祟印。channel和實(shí)體不像在actor模型中一樣肴沫,是緊耦合的,而是可以單獨(dú)創(chuàng)建和讀寫蕴忆,并在實(shí)體之間傳遞樊零。
在actor模型中,消息是從指定的一個(gè)actor發(fā)送指定的另一個(gè)actor中孽文;而在CSP模型中驻襟,使用channel發(fā)送消息的實(shí)體,并不知道誰是接收者芋哭,反之亦然沉衣。channel本身類似于一個(gè)數(shù)組,而且可以對(duì)channel里的元素進(jìn)行map减牺、filter等操作豌习。
5.1 Clojure的go塊
而Clojure針對(duì)CSP模型的一大利器就是go塊。線程本身是有一定的開銷的拔疚,因此現(xiàn)代程序一般都會(huì)使用線程池來進(jìn)行線程的復(fù)用肥隆。但是當(dāng)程序阻塞的時(shí)候,線程池就會(huì)造成麻煩稚失。
?例如在執(zhí)行IO密集型任務(wù)的時(shí)候栋艳,BIO導(dǎo)致的阻塞使進(jìn)程被無限期占用,最終導(dǎo)致線程耗盡句各。
解決方案一般是通過事件驅(qū)動(dòng)的方式來實(shí)現(xiàn)AIO吸占,然而這樣的代碼往往是難以閱讀和難以理解的,并且這些方案往往帶有大量的全局狀態(tài)凿宾,比如回調(diào)矾屯。而狀態(tài)和并發(fā)混用時(shí)往往會(huì)造成麻煩。
而go塊中的代碼會(huì)在底層被透明地重寫為事件驅(qū)動(dòng)的形式初厚。原理是其中的代碼會(huì)被轉(zhuǎn)換成一個(gè)狀態(tài)機(jī)件蚕,當(dāng)從channel中讀取或者寫入消息時(shí),狀態(tài)機(jī)會(huì)暫停产禾,并釋放它所占用的線程的控制權(quán)排作。當(dāng)代碼可以繼續(xù)運(yùn)行時(shí),狀態(tài)機(jī)將進(jìn)行狀態(tài)轉(zhuǎn)換下愈,并可能在另一個(gè)線程中繼續(xù)運(yùn)行纽绍。
5.2 CSP模型的特點(diǎn)
CSP模型最大優(yōu)點(diǎn)是靈活性和效率,可以自由調(diào)節(jié)并發(fā)的粒度势似,但容易出錯(cuò)拌夏。而actor模型則側(cè)重于容錯(cuò)性和分布式,但犧牲了并發(fā)性能履因。
6 Lambda架構(gòu)
6.1 原始數(shù)據(jù)
對(duì)于Lambda架構(gòu)而言障簿,數(shù)據(jù)有兩種類型:
- 原始數(shù)據(jù)
- 衍生信息
例如:
- 銀行賬戶的余額是衍生信息,而賬戶的收入和支出是原始數(shù)據(jù)栅迄;
- Facebook的好友列表是衍生信息站故,而添加好友和刪除好友的記錄是原始數(shù)據(jù)。
衍生信息是可變的,而原始數(shù)據(jù)是對(duì)實(shí)際發(fā)生事情的記錄西篓,是不變的愈腾。
6.2 批處理層
理論上來說,只需要批原始數(shù)據(jù)+批處理層岂津,就可以得到一個(gè)數(shù)據(jù)系統(tǒng)虱黄。
批處理層會(huì)不斷循環(huán)運(yùn)行,從原始數(shù)據(jù)中重新生成批處理視圖吮成。
每一個(gè)批處理完成后橱乱,對(duì)應(yīng)的結(jié)果都會(huì)被更新到數(shù)據(jù)庫中。
該數(shù)據(jù)系統(tǒng)有以下優(yōu)點(diǎn):
- 由于只需要處理不可變的原始數(shù)據(jù)粱甫,批處理層可以輕松實(shí)現(xiàn)并行化泳叠,原始數(shù)據(jù)可以分布到集群中處理,可以輕松處理TB級(jí)別的數(shù)據(jù)茶宵。
- 該數(shù)據(jù)系統(tǒng)容錯(cuò)性較強(qiáng)危纫。一方面,原始數(shù)據(jù)更容易備份节预。另一方面叶摄,如果存在bug,最壞的情況就是批處理視圖是暫時(shí)錯(cuò)誤的安拟,只要修復(fù)bug后重新計(jì)算批處理視圖即可蛤吓。
然鵝,存在著一個(gè)嚴(yán)重問題——延遲糠赦。
如果批處理層需要1個(gè)小時(shí)的運(yùn)行時(shí)間会傲,那么用戶查詢到的數(shù)據(jù)永遠(yuǎn)都有1個(gè)小時(shí)的延遲。
批處理層一般使用MapReduce架構(gòu)的Hadoop來處理拙泽,原理如下所示:
mapper將輸出映射為鍵值對(duì)淌山,reducer將鍵值對(duì)轉(zhuǎn)換為最終的輸出格式(通常還是鍵值對(duì))。
一個(gè)mapper產(chǎn)生的鍵值對(duì)可以發(fā)送給多個(gè)reducer顾瞻,而Hadoop會(huì)確保同一個(gè)鍵的鍵值對(duì)(不管是哪個(gè)mapper產(chǎn)生的)泼疑,都會(huì)被發(fā)送給同一個(gè)reducer處理。
6.3 加速層
批處理視圖速度過慢荷荤,因此產(chǎn)生了加速層退渗。加速層只需處理未被批處理層處理的數(shù)據(jù)(通常是幾個(gè)小時(shí)的數(shù)據(jù))。一旦批處理層趕上進(jìn)度蕴纳,就的數(shù)據(jù)就會(huì)從加速層移除会油。
由于加速層使用的是增量算法,因此要同時(shí)處理原始數(shù)據(jù)和衍生數(shù)據(jù)古毛。必須重新面對(duì)傳統(tǒng)數(shù)據(jù)庫的特性:隨機(jī)寫翻翩、鎖機(jī)制和事務(wù)機(jī)制等。
加速層寫入數(shù)據(jù)庫有同步和異步兩種方案。這里主要演示異步方案嫂冻。
6.4 總結(jié)
? Lambda非常適用于處理大規(guī)模數(shù)據(jù)的問題胶征,主要使用場(chǎng)景是報(bào)表和分析。
? Lambda不一定總是和MapReduce綁定絮吵,可以使用Apache Spark作為批處理層弧烤,使用DAG執(zhí)行引擎。并且Spark提供了流處理相關(guān)的API蹬敲,這意味著批處理層和加速層都可以用Spark實(shí)現(xiàn)。