正文開始之前先拋出一個思考:讓一個靜態網站滿足海量用戶訪問本質上是一個并行問題還是并發問題?
并發的世界
并發這個概念在真實的世界比在程序的世界更加普遍更加自然,程序員們只是將其抽象出來,以在代碼中更真實的還原現實世界。那,什么是并發呢?飯桌上,你喝一口湯,扒幾下飯,啃個大雞腿,抬頭看了眼電視再回頭和家人閑聊幾句,這就是并發。時間永遠是往前走,但事情不總是先后來,而是會同時來到你面前,你需要同時去處理它們。隨著軟件越來越深的滲透到現實世界,越來越復雜的需求場景反映到程序世界,就是日益復雜的軟件系統,那,簡化系統設計的路子是什么呢?答案是模仿現實世界的行為,也就是將程序并發化。
一見到并發二字,立刻想到網站,立刻想到高訪問量,立刻想到性能優化,立刻想到服務器不要掛。魯迅說這是不對滴。
回到吃飯的問題,假設作為程序員的你需要為一個機器人寫一個模仿人吃飯的程序,可能會這樣寫:
喝一口湯 —> 扒一下飯 —> 再扒一下飯 —> 啃一口雞腿 —> 如果有人和你說話,就和對方說話 —> 如果沒有,這次先扒下飯 —> 嗯再喝一口湯..........這是個不支持并發的程序,硬生生將并發的真實世界場景寫成單邏輯流程的,程序的復雜性由此而來。
這次你打算重構一下,加上并發設計:
1,吃米飯,吃一會停一會
2,喝湯,喝一會停一會
3,啃雞腿,啃一會停一會
4,和人說話,說一會停一會
5,判斷,如果嘴閑著,就隨機調1~4干。現在開始。
是不是看上去感覺簡潔了很多呢?顯然,這樣的并發設計和性能優化(吃快點?)一點關系都沒有,主要是為了將程序拆分成各個獨立的執行單元從而簡化程序設計。而要支持這種設計,需要一個調度系統(5)的實現,這個實現可以是在硬件層,或操作系統層,或語言層中進行,甚至自己實現一個出來也未嘗不可。
goroutines
Golang對并發的支持,是我見過最簡單但不簡陋的。
do some things...
go someFunc()
do some things...
只要在一個可執行函數前面加一個關鍵詞go,就能開啟一個獨立的goroutine從而并發的去執行這個函數,是不是超級簡單呢。goroutine實際上與協程的概念和作用是一樣的,都可以理解為輕量級的線程。然而goroutine的開銷非常非常小,一個程序可以開到的goroutine需以百萬計。除卻程序設計上的考慮,這種輕量級的線程還可以讓我們任性且輕松的處理密集IO場景的問題,而不用擔心帶來代碼量的膨脹。(golang對并發支持的具體語法細節將在下一篇討論)
舉個栗子,一個日志收集中轉處理程序,需要將聚合的一條條記錄發送到遠程后臺,如果是這樣寫:
for i in list
http.send(i)
那大量的時間將浪費在等待網絡連接和數據傳輸上,為了提高性能,可以這樣寫:
for i in list
go http.send(i)
所有的連接請求都會先后發出而不等待函數返回,每個請求等待網絡連接和數據傳輸的時間很大部分將會出現重合,這個重合的時間,就是我們節省的時間,從而整個的執行時間將會大大縮短。這是不是就是異步呢?(對js了解的同學可以發現這里和異步回調是有著異曲同工之妙的)。是的,并發使異步成為了可能,后面將會更仔細的討論并發和異步的關系。現在先來簡單了解下goroutines的底層實現設計。
goroutines調度器的實現
總所周知,進程是靠著自己獨立的堆棧來維護運行狀態,線程則是進程的進一步擴展。多任務運行時,內核負責對它們進行輪換,由于進程和線程都帶著很多信息,所以切換操作非常昂貴。對于小而多的并發操作來說,每次開一個線程去執行可能會得不償失,過多的CPU時間浪費在了上下文切換上。我們需要的,是這樣一種“線程” : 極小的堆棧 + 無身份信息。這樣就會換來極低的切換成本。
自然引出的問題,就是這樣一種低級別的線程和OS線程如何對應?
1)1 :1 對應,這樣就goroutines == Thread了,要來何用= =
2)N :1,多個goroutines在一個Thread上面跑
這種上下文切換就會很快,OS對進程的切換我們管不著,而Goroutine的堆棧和任務切換是我們自己打理的,只要保證goroutine足夠輕量,切換成本就會很少。但N:1有個缺點,就是利用不了多核,在多核機器上,同一個進程下的多個線程實際是可以并行處理的。
3)N :M,多對多的關系,Golang所采用的方式,在多核機器上,能充分利用CPU的資源。
寫一句go func(),還要指定它是在哪一個Thread上運行,這就很累了,所以在代碼邏輯上,最好的做法是能夠隱藏掉Thread層,只管goroutine,這就給調度器的實現帶來了挑戰。
golang的處理是,在一個全局的調度器下面,為每一個Thread再創建一個局部的調度器,維護著調度的上下文,看起來是這樣子的:
G代表全局調度器,維護著Context和Thread的1:1對應關系,Context代表局部調度器,維護著Thread和goroutine的N : 1對應關系,每新添一個goroutine,G負責將其加入一個Context中,Context負責對由它管理的goroutine進行輪換執行。這樣在整體上,就產生了N : M的對應關系,我們只需打理goroutine,剩下的交給G和Context去打理。
OS級別上,內核有權暫停一個線程轉而去執行另一個線程,但是在線程級別,就沒有這種能力了,因為從線程的角度看下去,每一個goroutine都是一段普通的代碼而已,并做不到主動去暫停一個goroutine的運行從而調度其他的goroutine,暫停一個goroutine需要它自己主動去申請暫停,Context和Thread這一層才能感知到該進行下一次調度了。所以,該怎么做呢?在早期的實現中,借用了很多system call的異步操作,在看似完全是同步操作的goroutine中,你執行了一個需要調用到system call的地方,都會被盡可能地轉化成異步操作,在這個封裝的異步操作里面,會主動發出一次暫停申請,告訴Context,我該歇歇了,Context就進行一次調度,去執行其他goroutine了,等到下次回來的時候,這個異步操作差不多已經返回了。這樣就實現了goroutine層面的調度。這里有一個比較坑的地方,也是使用這種方法的代價,想一下,如果在這個goroutine里面,沒有一條語句用到system call呢?
如果沒有一條語句會用到異步操作,那么這種goroutine將會一直運行不會停下,,,
所以一個完全CPU密集運算的goroutine是非常自私的,不會去主動讓出這個線程。
go PrintOneToTen()
go PrintOneToTen()
像這樣的代碼,你以為可以打印出兩串1到10的數字互相穿插的結果,實際很可能是這樣的
1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10
我在一臺虛擬機上面測試(單核),就是上面的這種結果,多核的就不會,因為兩個goroutine被掛到不同的Context上面了,獨占線程也沒事。然而問題在多核機器上也并沒有解決,因為你還是防止不了本該并發執行的兩個goroutine被掛到同一個Context上面的悲劇。所以在后來的實現中,加入了調用普通函數時的隨機踢掉換人,如果goroutine中連一個調用函數操作都沒有,你還可以顯式的去調用runtime.GoSched( ),主動讓出資源通知Context該進行調度了。
并發和異步的關系
這里的異步是指宏觀上的異步,一個系統的各個組成部分之間,如果不需要互相之間信息流完全的同步,就可以說這個系統組件之間是異步的。
以一個游戲廣告為例(工作碰到的一個系統的簡化版),假設有一個展示的圖片A,鏈接是a,點擊后會跳到某APP商店此游戲的下載鏈接b,用戶可以選擇下載還是不下載,我們這個系統需要記錄這個過程以便結算廣告費。
過程是這樣的:
最簡單的程序莫過于單邏輯流程的結構:用戶點擊 —> 記錄點擊 —> 返回下載鏈接b —> 等待用戶決定是否下載 —> 若用戶下載,記錄下載 —> 進行費用結算。這樣的設計可以說毫無并發性可言,雖然你可以每收到一次點擊就開一個線程去處理,但是這樣的邏輯還是一杠子捅到底的,資源耗費非常大,承載不了太多用戶的同時訪問。
就像文章開頭說的,應該模仿現實世界的行為來設計程序,在簡化程序設計的同時,提高并發性。
這樣的系統其實很像街邊賣水果的是不是,一邊吆喝(展示A),一邊跟客人說價格(接受點擊a),一邊和已經買的客人結賬(下載記錄)。賣水果看起來是一件事情,其實可以是很多件小事情的組合。像極了大商場里,吆喝的是一群工作人員,討價還價又是另外一群人,門口還有個負責結賬的。處理上述游戲廣告系統問題就是要學會怎么去拆分,拆分成有各自獨立邏輯的組件:各自獨立的邏輯流程,時間線上可以交叉重疊。
我們的系統被拆分了主要的三部分,一部分專門處理點擊,一部分專門處理下載,剩下的處理費用結算,每次用戶行為被id標記以便區分。注意,這里三部分依然還是在同一份代碼同一個程序里面,但是現在已經是并發化了。如果是寫成三個函數,那么go func1();go func2();go func3();就已經足以表示這個系統的邏輯了,簡單吧,這就是并發設計帶來的簡潔性。
那性能呢?在單機單核里面,這樣的并發設計并不會顯著的提高性能,但是三部分獨立的邏輯流程,為性能提高創造了條件。如果單機不再單核,而是有三個CPU了,三個goroutine分別掛到三個Context上,三個goroutine得以并行處理,性能就得以提高,更有顯著益處的是,當訪問峰值來臨時,可以創建大量的處理點擊的goroutine,而不必同時提高另外兩者,尤其結算組件甚至可以暫停挪出資源給另外兩個用,等到峰值過去后,再調度結算組件進行剩下的工作。
單機撐不住了,加機器就是了,本來邏輯獨立的三部分就可以拆分成三個程序在不同的機器上跑。所以并發設計一旦完成,就已經可以將程序拆分了,只是有沒必要而已。學會將一個問題設計成可以并發處理的多個子問題,就是本文的目的。
限制單邏輯流程程序的,是數據的同步問題,數據從產生到處理完畢,一杠子必需捅到底,這一條處理完了才能處理下一條,從而限制了處理效率。并發設計其實就是將邏輯環節之間的數據傳遞異步化,每一個環節只處理一部分問題,然后就傳遞給下游而不管下游怎么去處理,上面拆分的三部分,沒拆分之前數據處理是一環扣一環的,必須等到每一環處理完成后才能重新來過,而拆分之后,每部分只需要和數據庫打交道就可以了,這個數據庫其實就充當了異步讀寫的硬件鎖存器。所以并發設計是將一個系統的數據流進行拆分,在各個環節之間變同步為異步,從而將每個環節的邏輯過程獨立出來。
并發不是并行
并發和并行是兩個非常容易搞混的概念,看完上面的討論后,相信你可以得出結論:并發是在講程序/系統的結構。
并行則不然,并行講的是程序的執行,更多的是物理相關的,兩段程序同時在兩個CPU被處理,才說它們是并行的。但是,如果只從執行效果上面看,并行可以說是并發的一個子集,畢竟可以并發處理的程序,就可以并行處理。有一個著名的視頻和幻燈片,講的就是并發不是并行,如果你能堅持看到這里,建議你去看下這個30分鐘左右的視頻,非常有意思 : )
文章開頭的思考,有結論了嗎,是并行問題還是并發問題呢???
原文轉自謝培陽的博客