正文開(kāi)始之前先拋出一個(gè)思考:讓一個(gè)靜態(tài)網(wǎng)站滿足海量用戶訪問(wèn)本質(zhì)上是一個(gè)并行問(wèn)題還是并發(fā)問(wèn)題?
并發(fā)的世界
并發(fā)這個(gè)概念在真實(shí)的世界比在程序的世界更加普遍更加自然,程序員們只是將其抽象出來(lái),以在代碼中更真實(shí)的還原現(xiàn)實(shí)世界。那,什么是并發(fā)呢?飯桌上,你喝一口湯,扒幾下飯,啃個(gè)大雞腿,抬頭看了眼電視再回頭和家人閑聊幾句,這就是并發(fā)。時(shí)間永遠(yuǎn)是往前走,但事情不總是先后來(lái),而是會(huì)同時(shí)來(lái)到你面前,你需要同時(shí)去處理它們。隨著軟件越來(lái)越深的滲透到現(xiàn)實(shí)世界,越來(lái)越復(fù)雜的需求場(chǎng)景反映到程序世界,就是日益復(fù)雜的軟件系統(tǒng),那,簡(jiǎn)化系統(tǒng)設(shè)計(jì)的路子是什么呢?答案是模仿現(xiàn)實(shí)世界的行為,也就是將程序并發(fā)化。
一見(jiàn)到并發(fā)二字,立刻想到網(wǎng)站,立刻想到高訪問(wèn)量,立刻想到性能優(yōu)化,立刻想到服務(wù)器不要掛。魯迅說(shuō)這是不對(duì)滴。
回到吃飯的問(wèn)題,假設(shè)作為程序員的你需要為一個(gè)機(jī)器人寫一個(gè)模仿人吃飯的程序,可能會(huì)這樣寫:
喝一口湯 —> 扒一下飯 —> 再扒一下飯 —> 啃一口雞腿 —> 如果有人和你說(shuō)話,就和對(duì)方說(shuō)話 —> 如果沒(méi)有,這次先扒下飯 —> 嗯再喝一口湯..........這是個(gè)不支持并發(fā)的程序,硬生生將并發(fā)的真實(shí)世界場(chǎng)景寫成單邏輯流程的,程序的復(fù)雜性由此而來(lái)。
這次你打算重構(gòu)一下,加上并發(fā)設(shè)計(jì):
1,吃米飯,吃一會(huì)停一會(huì)
2,喝湯,喝一會(huì)停一會(huì)
3,啃雞腿,啃一會(huì)停一會(huì)
4,和人說(shuō)話,說(shuō)一會(huì)停一會(huì)
5,判斷,如果嘴閑著,就隨機(jī)調(diào)1~4干?,F(xiàn)在開(kāi)始。
是不是看上去感覺(jué)簡(jiǎn)潔了很多呢?顯然,這樣的并發(fā)設(shè)計(jì)和性能優(yōu)化(吃快點(diǎn)?)一點(diǎn)關(guān)系都沒(méi)有,主要是為了將程序拆分成各個(gè)獨(dú)立的執(zhí)行單元從而簡(jiǎn)化程序設(shè)計(jì)。而要支持這種設(shè)計(jì),需要一個(gè)調(diào)度系統(tǒng)(5)的實(shí)現(xiàn),這個(gè)實(shí)現(xiàn)可以是在硬件層,或操作系統(tǒng)層,或語(yǔ)言層中進(jìn)行,甚至自己實(shí)現(xiàn)一個(gè)出來(lái)也未嘗不可。
goroutines
Golang對(duì)并發(fā)的支持,是我見(jiàn)過(guò)最簡(jiǎn)單但不簡(jiǎn)陋的。
do some things...
go someFunc()
do some things...
只要在一個(gè)可執(zhí)行函數(shù)前面加一個(gè)關(guān)鍵詞go,就能開(kāi)啟一個(gè)獨(dú)立的goroutine從而并發(fā)的去執(zhí)行這個(gè)函數(shù),是不是超級(jí)簡(jiǎn)單呢。goroutine實(shí)際上與協(xié)程的概念和作用是一樣的,都可以理解為輕量級(jí)的線程。然而goroutine的開(kāi)銷非常非常小,一個(gè)程序可以開(kāi)到的goroutine需以百萬(wàn)計(jì)。除卻程序設(shè)計(jì)上的考慮,這種輕量級(jí)的線程還可以讓我們?nèi)涡郧逸p松的處理密集IO場(chǎng)景的問(wèn)題,而不用擔(dān)心帶來(lái)代碼量的膨脹。(golang對(duì)并發(fā)支持的具體語(yǔ)法細(xì)節(jié)將在下一篇討論)
舉個(gè)栗子,一個(gè)日志收集中轉(zhuǎn)處理程序,需要將聚合的一條條記錄發(fā)送到遠(yuǎn)程后臺(tái),如果是這樣寫:
for i in list
http.send(i)
那大量的時(shí)間將浪費(fèi)在等待網(wǎng)絡(luò)連接和數(shù)據(jù)傳輸上,為了提高性能,可以這樣寫:
for i in list
go http.send(i)
所有的連接請(qǐng)求都會(huì)先后發(fā)出而不等待函數(shù)返回,每個(gè)請(qǐng)求等待網(wǎng)絡(luò)連接和數(shù)據(jù)傳輸?shù)臅r(shí)間很大部分將會(huì)出現(xiàn)重合,這個(gè)重合的時(shí)間,就是我們節(jié)省的時(shí)間,從而整個(gè)的執(zhí)行時(shí)間將會(huì)大大縮短。這是不是就是異步呢?(對(duì)js了解的同學(xué)可以發(fā)現(xiàn)這里和異步回調(diào)是有著異曲同工之妙的)。是的,并發(fā)使異步成為了可能,后面將會(huì)更仔細(xì)的討論并發(fā)和異步的關(guān)系。現(xiàn)在先來(lái)簡(jiǎn)單了解下goroutines的底層實(shí)現(xiàn)設(shè)計(jì)。
goroutines調(diào)度器的實(shí)現(xiàn)
總所周知,進(jìn)程是靠著自己獨(dú)立的堆棧來(lái)維護(hù)運(yùn)行狀態(tài),線程則是進(jìn)程的進(jìn)一步擴(kuò)展。多任務(wù)運(yùn)行時(shí),內(nèi)核負(fù)責(zé)對(duì)它們進(jìn)行輪換,由于進(jìn)程和線程都帶著很多信息,所以切換操作非常昂貴。對(duì)于小而多的并發(fā)操作來(lái)說(shuō),每次開(kāi)一個(gè)線程去執(zhí)行可能會(huì)得不償失,過(guò)多的CPU時(shí)間浪費(fèi)在了上下文切換上。我們需要的,是這樣一種“線程” : 極小的堆棧 + 無(wú)身份信息。這樣就會(huì)換來(lái)極低的切換成本。
自然引出的問(wèn)題,就是這樣一種低級(jí)別的線程和OS線程如何對(duì)應(yīng)?
1)1 :1 對(duì)應(yīng),這樣就goroutines == Thread了,要來(lái)何用= =
2)N :1,多個(gè)goroutines在一個(gè)Thread上面跑

這種上下文切換就會(huì)很快,OS對(duì)進(jìn)程的切換我們管不著,而Goroutine的堆棧和任務(wù)切換是我們自己打理的,只要保證goroutine足夠輕量,切換成本就會(huì)很少。但N:1有個(gè)缺點(diǎn),就是利用不了多核,在多核機(jī)器上,同一個(gè)進(jìn)程下的多個(gè)線程實(shí)際是可以并行處理的。
3)N :M,多對(duì)多的關(guān)系,Golang所采用的方式,在多核機(jī)器上,能充分利用CPU的資源。

寫一句go func(),還要指定它是在哪一個(gè)Thread上運(yùn)行,這就很累了,所以在代碼邏輯上,最好的做法是能夠隱藏掉Thread層,只管goroutine,這就給調(diào)度器的實(shí)現(xiàn)帶來(lái)了挑戰(zhàn)。
golang的處理是,在一個(gè)全局的調(diào)度器下面,為每一個(gè)Thread再創(chuàng)建一個(gè)局部的調(diào)度器,維護(hù)著調(diào)度的上下文,看起來(lái)是這樣子的:

G代表全局調(diào)度器,維護(hù)著Context和Thread的1:1對(duì)應(yīng)關(guān)系,Context代表局部調(diào)度器,維護(hù)著Thread和goroutine的N : 1對(duì)應(yīng)關(guān)系,每新添一個(gè)goroutine,G負(fù)責(zé)將其加入一個(gè)Context中,Context負(fù)責(zé)對(duì)由它管理的goroutine進(jìn)行輪換執(zhí)行。這樣在整體上,就產(chǎn)生了N : M的對(duì)應(yīng)關(guān)系,我們只需打理goroutine,剩下的交給G和Context去打理。
OS級(jí)別上,內(nèi)核有權(quán)暫停一個(gè)線程轉(zhuǎn)而去執(zhí)行另一個(gè)線程,但是在線程級(jí)別,就沒(méi)有這種能力了,因?yàn)閺木€程的角度看下去,每一個(gè)goroutine都是一段普通的代碼而已,并做不到主動(dòng)去暫停一個(gè)goroutine的運(yùn)行從而調(diào)度其他的goroutine,暫停一個(gè)goroutine需要它自己主動(dòng)去申請(qǐng)暫停,Context和Thread這一層才能感知到該進(jìn)行下一次調(diào)度了。所以,該怎么做呢?在早期的實(shí)現(xiàn)中,借用了很多system call的異步操作,在看似完全是同步操作的goroutine中,你執(zhí)行了一個(gè)需要調(diào)用到system call的地方,都會(huì)被盡可能地轉(zhuǎn)化成異步操作,在這個(gè)封裝的異步操作里面,會(huì)主動(dòng)發(fā)出一次暫停申請(qǐng),告訴Context,我該歇歇了,Context就進(jìn)行一次調(diào)度,去執(zhí)行其他goroutine了,等到下次回來(lái)的時(shí)候,這個(gè)異步操作差不多已經(jīng)返回了。這樣就實(shí)現(xiàn)了goroutine層面的調(diào)度。這里有一個(gè)比較坑的地方,也是使用這種方法的代價(jià),想一下,如果在這個(gè)goroutine里面,沒(méi)有一條語(yǔ)句用到system call呢?
如果沒(méi)有一條語(yǔ)句會(huì)用到異步操作,那么這種goroutine將會(huì)一直運(yùn)行不會(huì)停下,,,
所以一個(gè)完全CPU密集運(yùn)算的goroutine是非常自私的,不會(huì)去主動(dòng)讓出這個(gè)線程。
go PrintOneToTen()
go PrintOneToTen()
像這樣的代碼,你以為可以打印出兩串1到10的數(shù)字互相穿插的結(jié)果,實(shí)際很可能是這樣的
1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10
我在一臺(tái)虛擬機(jī)上面測(cè)試(單核),就是上面的這種結(jié)果,多核的就不會(huì),因?yàn)閮蓚€(gè)goroutine被掛到不同的Context上面了,獨(dú)占線程也沒(méi)事。然而問(wèn)題在多核機(jī)器上也并沒(méi)有解決,因?yàn)槟氵€是防止不了本該并發(fā)執(zhí)行的兩個(gè)goroutine被掛到同一個(gè)Context上面的悲劇。所以在后來(lái)的實(shí)現(xiàn)中,加入了調(diào)用普通函數(shù)時(shí)的隨機(jī)踢掉換人,如果goroutine中連一個(gè)調(diào)用函數(shù)操作都沒(méi)有,你還可以顯式的去調(diào)用runtime.GoSched( ),主動(dòng)讓出資源通知Context該進(jìn)行調(diào)度了。
并發(fā)和異步的關(guān)系
這里的異步是指宏觀上的異步,一個(gè)系統(tǒng)的各個(gè)組成部分之間,如果不需要互相之間信息流完全的同步,就可以說(shuō)這個(gè)系統(tǒng)組件之間是異步的。
以一個(gè)游戲廣告為例(工作碰到的一個(gè)系統(tǒng)的簡(jiǎn)化版),假設(shè)有一個(gè)展示的圖片A,鏈接是a,點(diǎn)擊后會(huì)跳到某APP商店此游戲的下載鏈接b,用戶可以選擇下載還是不下載,我們這個(gè)系統(tǒng)需要記錄這個(gè)過(guò)程以便結(jié)算廣告費(fèi)。
過(guò)程是這樣的:

最簡(jiǎn)單的程序莫過(guò)于單邏輯流程的結(jié)構(gòu):用戶點(diǎn)擊 —> 記錄點(diǎn)擊 —> 返回下載鏈接b —> 等待用戶決定是否下載 —> 若用戶下載,記錄下載 —> 進(jìn)行費(fèi)用結(jié)算。這樣的設(shè)計(jì)可以說(shuō)毫無(wú)并發(fā)性可言,雖然你可以每收到一次點(diǎn)擊就開(kāi)一個(gè)線程去處理,但是這樣的邏輯還是一杠子捅到底的,資源耗費(fèi)非常大,承載不了太多用戶的同時(shí)訪問(wèn)。
就像文章開(kāi)頭說(shuō)的,應(yīng)該模仿現(xiàn)實(shí)世界的行為來(lái)設(shè)計(jì)程序,在簡(jiǎn)化程序設(shè)計(jì)的同時(shí),提高并發(fā)性。
這樣的系統(tǒng)其實(shí)很像街邊賣水果的是不是,一邊吆喝(展示A),一邊跟客人說(shuō)價(jià)格(接受點(diǎn)擊a),一邊和已經(jīng)買的客人結(jié)賬(下載記錄)。賣水果看起來(lái)是一件事情,其實(shí)可以是很多件小事情的組合。像極了大商場(chǎng)里,吆喝的是一群工作人員,討價(jià)還價(jià)又是另外一群人,門口還有個(gè)負(fù)責(zé)結(jié)賬的。處理上述游戲廣告系統(tǒng)問(wèn)題就是要學(xué)會(huì)怎么去拆分,拆分成有各自獨(dú)立邏輯的組件:各自獨(dú)立的邏輯流程,時(shí)間線上可以交叉重疊。

我們的系統(tǒng)被拆分了主要的三部分,一部分專門處理點(diǎn)擊,一部分專門處理下載,剩下的處理費(fèi)用結(jié)算,每次用戶行為被id標(biāo)記以便區(qū)分。注意,這里三部分依然還是在同一份代碼同一個(gè)程序里面,但是現(xiàn)在已經(jīng)是并發(fā)化了。如果是寫成三個(gè)函數(shù),那么go func1();go func2();go func3();就已經(jīng)足以表示這個(gè)系統(tǒng)的邏輯了,簡(jiǎn)單吧,這就是并發(fā)設(shè)計(jì)帶來(lái)的簡(jiǎn)潔性。
那性能呢?在單機(jī)單核里面,這樣的并發(fā)設(shè)計(jì)并不會(huì)顯著的提高性能,但是三部分獨(dú)立的邏輯流程,為性能提高創(chuàng)造了條件。如果單機(jī)不再單核,而是有三個(gè)CPU了,三個(gè)goroutine分別掛到三個(gè)Context上,三個(gè)goroutine得以并行處理,性能就得以提高,更有顯著益處的是,當(dāng)訪問(wèn)峰值來(lái)臨時(shí),可以創(chuàng)建大量的處理點(diǎn)擊的goroutine,而不必同時(shí)提高另外兩者,尤其結(jié)算組件甚至可以暫停挪出資源給另外兩個(gè)用,等到峰值過(guò)去后,再調(diào)度結(jié)算組件進(jìn)行剩下的工作。
單機(jī)撐不住了,加機(jī)器就是了,本來(lái)邏輯獨(dú)立的三部分就可以拆分成三個(gè)程序在不同的機(jī)器上跑。所以并發(fā)設(shè)計(jì)一旦完成,就已經(jīng)可以將程序拆分了,只是有沒(méi)必要而已。學(xué)會(huì)將一個(gè)問(wèn)題設(shè)計(jì)成可以并發(fā)處理的多個(gè)子問(wèn)題,就是本文的目的。
限制單邏輯流程程序的,是數(shù)據(jù)的同步問(wèn)題,數(shù)據(jù)從產(chǎn)生到處理完畢,一杠子必需捅到底,這一條處理完了才能處理下一條,從而限制了處理效率。并發(fā)設(shè)計(jì)其實(shí)就是將邏輯環(huán)節(jié)之間的數(shù)據(jù)傳遞異步化,每一個(gè)環(huán)節(jié)只處理一部分問(wèn)題,然后就傳遞給下游而不管下游怎么去處理,上面拆分的三部分,沒(méi)拆分之前數(shù)據(jù)處理是一環(huán)扣一環(huán)的,必須等到每一環(huán)處理完成后才能重新來(lái)過(guò),而拆分之后,每部分只需要和數(shù)據(jù)庫(kù)打交道就可以了,這個(gè)數(shù)據(jù)庫(kù)其實(shí)就充當(dāng)了異步讀寫的硬件鎖存器。所以并發(fā)設(shè)計(jì)是將一個(gè)系統(tǒng)的數(shù)據(jù)流進(jìn)行拆分,在各個(gè)環(huán)節(jié)之間變同步為異步,從而將每個(gè)環(huán)節(jié)的邏輯過(guò)程獨(dú)立出來(lái)。
并發(fā)不是并行
并發(fā)和并行是兩個(gè)非常容易搞混的概念,看完上面的討論后,相信你可以得出結(jié)論:并發(fā)是在講程序/系統(tǒng)的結(jié)構(gòu)。
并行則不然,并行講的是程序的執(zhí)行,更多的是物理相關(guān)的,兩段程序同時(shí)在兩個(gè)CPU被處理,才說(shuō)它們是并行的。但是,如果只從執(zhí)行效果上面看,并行可以說(shuō)是并發(fā)的一個(gè)子集,畢竟可以并發(fā)處理的程序,就可以并行處理。有一個(gè)著名的視頻和幻燈片,講的就是并發(fā)不是并行,如果你能堅(jiān)持看到這里,建議你去看下這個(gè)30分鐘左右的視頻,非常有意思 : )
文章開(kāi)頭的思考,有結(jié)論了嗎,是并行問(wèn)題還是并發(fā)問(wèn)題呢???
原文轉(zhuǎn)自謝培陽(yáng)的博客