Mar 1, 2016 • jolestarhtml
本文基於我在2月27日Gopher北京聚會演講整理而成,進行了一些補充以及調整。投稿給《高可用架構》公衆號首發。java
聊這個話題以前,先梳理下兩個概念,幾乎全部講併發的文章都要先講這兩個概念:linux
併發(concurrency) 併發的關注點在於任務切分。舉例來講,你是一個創業公司的CEO,開始只有你一我的,你一人分飾多角,一會作產品規劃,一會寫代碼,一會見客戶,雖然你不能見客戶的同時寫代碼,但因爲你切分了任務,分配了時間片,表現出來好像是多個任務一塊兒在執行。git
並行(parallelism) 並行的關注點在於同時執行。仍是上面的例子,你發現你本身太忙了,時間分配不過來,因而請了工程師,產品經理,市場總監,各司一職,這時候多個任務能夠同時執行了。github
因此總結下,併發並不要求必須並行,能夠用時間片切分的方式模擬,好比單核cpu上的多任務系統,併發的要求是任務能切分紅獨立執行的片斷。而並行關注的是同時執行,必須是多(核)cpu,要能並行的程序必須是支持併發的。本文大多數狀況下不會嚴格區分這兩個概念,默認併發就是指並行機制下的併發。golang
We believe that writing correct concurrent, fault-tolerant and scalable applications is too hard. Most of the time it’s because we are using the wrong tools and the wrong level of abstraction. —— Akka正則表達式
Akka官方文檔開篇這句話說的好,之因此寫正確的併發,容錯,可擴展的程序如此之難,是由於咱們用了錯誤的工具和錯誤的抽象。(固然該文檔原本的意思是Akka是正確的工具,但咱們能夠獨立的看待這句話)。數據庫
那咱們從最開始梳理下程序的抽象。開始咱們的程序是面向過程的,數據結構+func。後來有了面向對象,對象組合了數結構和func,咱們想用模擬現實世界的方式,抽象出對象,有狀態和行爲。但不管是面向過程的func仍是面向對象的func,本質上都是代碼塊的組織單元,自己並無包含代碼塊的併發策略的定義。因而爲了解決併發的需求,引入了Thread(線程)的概念。編程
線程(Thread)安全
系統內核態,更輕量的進程
由系統內核進行調度
同一進程的多個線程可共享資源
線程的出現解決了兩個問題,一個是GUI出現後急切須要併發機制來保證用戶界面的響應。第二是互聯網發展後帶來的多用戶問題。最先的CGI程序很簡單,將經過腳本將原來單機版的程序包裝在一個進程裏,來一個用戶就啓動一個進程。但明顯這樣承載不了多少用戶,而且若是進程間須要共享資源還得經過進程間的通訊機制,線程的出現緩解了這個問題。
線程的使用比較簡單,若是你以爲這塊代碼須要併發,就把它放在單獨的線程裏執行,由系統負責調度,具體何時使用線程,要用多少個線程,由調用方決定,但定義方並不清楚調用方會如何使用本身的代碼,不少併發問題都是由於誤用致使的,好比Go中的map以及Java的HashMap都不是併發安全的,誤用在多線程環境就會致使問題。另外也帶來複雜度:
競態條件(race conditions) 若是每一個任務都是獨立的,不須要共享任何資源,那線程也就很是簡單。但世界每每是複雜的,總有一些資源須要共享,好比前面的例子,開發人員和市場人員同時須要和CEO商量一個方案,這時候CEO就成了競態條件。
依賴關係以及執行順序 若是線程之間的任務有依賴關係,須要等待以及通知機制來進行協調。好比前面的例子,若是產品和CEO討論的方案依賴於市場和CEO討論的方案,這時候就須要協調機制保證順序。
爲了解決上述問題,咱們引入了許多複雜機制來保證:
Mutex(Lock) (Go裏的sync包, Java的concurrent包)經過互斥量來保護數據,但有了鎖,明顯就下降了併發度。
semaphore 經過信號量來控制併發度或者做爲線程間信號(signal)通知。
volatile Java專門引入了volatile關鍵詞來,來下降只讀狀況下的鎖的使用。
compare-and-swap 經過硬件提供的CAS機制保證原子性(atomic),也是下降鎖的成本的機制。
若是說上面兩個問題只是增長了複雜度,咱們經過深刻學習,嚴謹的CodeReview,全面的併發測試(好比Go語言中單元測試的時候加上-race參數),必定程度上能解決(固然這個也是有爭議的,有論文認爲當前的大多數併發程序沒出問題只是併發度不夠,若是CPU核數繼續增長,程序運行的時間更長,很難保證不出問題)。但最讓人頭痛的仍是下面這個問題:
系統裏到底須要多少線程?
這個問題咱們先從硬件資源入手,考慮下線程的成本:
內存(線程的棧空間)
每一個線程都須要一個棧(Stack)空間來保存掛起(suspending)時的狀態。Java的棧空間(64位VM)默認是1024k,不算別的內存,只是棧空間,啓動1024個線程就要1G內存。雖然能夠用-Xss參數控制,但因爲線程是本質上也是進程,系統假定是要長期運行的,棧空間過小會致使稍複雜的遞歸調用(好比複雜點的正則表達式匹配)致使棧溢出。因此調整參數治標不治本。
調度成本(context-switch)
我在我的電腦上作的一個非嚴格測試,模擬兩個線程互相喚醒輪流掛起,線程切換成本大約6000納秒/次。這個還沒考慮棧空間大小的影響。國外一篇論文專門分析線程切換的成本,基本上得出的結論是切換成本和棧空間使用大小直接相關。
CPU使用率
咱們搞併發最主要的一個目標就是咱們有了多核,想提升CPU利用率,最大限度的壓榨硬件資源,從這個角度考慮,咱們應該用多少線程呢?
這個咱們能夠經過一個公式計算出來,100/(15+5)*4=20,用20個線程最合適。但一方面網絡的時間不是固定的,另一方面,若是考慮到其餘瓶頸資源呢?好比鎖,好比數據庫鏈接池,就會更復雜。
做爲一個1歲多孩子的父親,認爲這個問題的難度比如你要寫個給孩子餵飯的程序,須要考慮『給孩子喂多少飯合適?』,這個問題有如下回答以及策略:
孩子不吃了就行了(但孩子貪玩,不吃了多是想去玩了)
孩子吃飽了就行了(廢話,你怎麼知道孩子吃飽了?孩子又不會說話)
逐漸增量,長期觀察,而後計算一個平均值(這多是咱們調整線程經常使用的策略,但增量增長到多少合適呢?)
孩子吃吐了就別餵了(若是用逐漸增量的模式,經過外部觀察,可能會到達這個邊界條件。系統性能若是由於線程的增長倒退了,就別增長線程了)
沒控制好邊界,把孩子給給撐壞了 (這熊爸爸也太恐怖了。但調整線程的時候每每不當心可能就把系統搞掛了)
經過這個例子咱們能夠看出,從外部系統來觀察,或者以經驗的方式進行計算,都是很是困難的。因而結論是:
讓孩子會說話,吃飽了本身說,本身學會吃飯,自管理是最佳方案。
然並卵,計算機不會本身說話,如何自管理?
但咱們從以上的討論能夠得出一個結論:
線程的成本較高(內存,調度)不可能大規模建立
應該由語言或者框架動態解決這個問題
Java1.5後,Doug Lea的Executor系列被包含在默認的JDK內,是典型的線程池方案。
線程池必定程度上控制了線程的數量,實現了線程複用,下降了線程的使用成本。但仍是沒有解決數量的問題,線程池初始化的時候仍是要設置一個最小和最大線程數,以及任務隊列的長度,自管理只是在設定範圍內的動態調整。另外不一樣的任務可能有不一樣的併發需求,爲了不互相影響可能須要多個線程池,最後致使的結果就是Java的系統裏充斥了大量的線程池。
從前面的分析咱們能夠看出,若是線程是一直處於運行狀態,咱們只需設置和CPU核數相等的線程數便可,這樣就能夠最大化的利用CPU,而且下降切換成本以及內存使用。但如何作到這一點呢?
陳力就列,不能者止
這句話是說,能幹活的代碼片斷就放在線程裏,若是幹不了活(須要等待,被阻塞等),就摘下來。通俗的說就是不要佔着茅坑不拉屎,若是拉不出來,須要醞釀下,先把茅坑讓出來,由於茅坑是稀缺資源。
要作到這點通常有兩種方案:
異步回調方案 典型如NodeJS,遇到阻塞的狀況,好比網絡調用,則註冊一個回調方法(其實還包括了一些上下文數據對象)給IO調度器(linux下是libev,調度器在另外的線程裏),當前線程就被釋放了,去幹別的事情了。等數據準備好,調度器會將結果傳遞給回調方法而後執行,執行其實不在原來發起請求的線程裏了,但對用戶來講無感知。但這種方式的問題就是很容易遇到callback hell,由於全部的阻塞操做都必須異步,不然系統就卡死了。還有就是異步的方式有點違反人類思惟習慣,人類仍是習慣同步的方式。
GreenThread/Coroutine/Fiber方案 這種方案其實和上面的方案本質上區別不大,關鍵在於回調上下文的保存以及執行機制。爲了解決回調方法帶來的難題,這種方案的思路是寫代碼的時候仍是按順序寫,但遇到IO等阻塞調用時,將當前的代碼片斷暫停,保存上下文,讓出當前線程。等IO事件回來,而後再找個線程讓當前代碼片斷恢復上下文繼續執行,寫代碼的時候感受好像是同步的,彷彿在同一個線程完成的,但實際上系統可能切換了線程,但對程序無感。
GreenThread
用戶空間 首先是在用戶空間,避免內核態和用戶態的切換致使的成本。
由語言或者框架層調度
更小的棧空間容許建立大量實例(百萬級別)
幾個概念
Continuation 這個概念不熟悉FP編程的人可能不太熟悉,不過這裏能夠簡單的顧名思義,能夠理解爲讓咱們的程序能夠暫停,而後下次調用繼續(contine)從上次暫停的地方開始的一種機制。至關於程序調用多了一種入口。
Coroutine 是Continuation的一種實現,通常表現爲語言層面的組件或者類庫。主要提供yield,resume機制。
Fiber 和Coroutine實際上是一體兩面的,主要是從系統層面描述,能夠理解成Coroutine運行以後的東西就是Fiber。
Goroutine其實就是前面GreenThread系列解決方案的一種演進和實現。
首先,它內置了Coroutine機制。由於要用戶態的調度,必須有可讓代碼片斷能夠暫停/繼續的機制。
其次,它內置了一個調度器,實現了Coroutine的多線程並行調度,同時經過對網絡等庫的封裝,對用戶屏蔽了調度細節。
最後,提供了Channel機制,用於Goroutine之間通訊,實現CSP併發模型(Communicating Sequential Processes)。由於Go的Channel是經過語法關鍵詞提供的,對用戶屏蔽了許多細節。其實Go的Channel和Java中的SynchronousQueue是同樣的機制,若是有buffer其實就是ArrayBlockQueue。
Goroutine調度器
這個圖通常講Goroutine調度器的地方都會引用,想要仔細瞭解的能夠看看原博客。這裏只說明幾點:
M表明系統線程,P表明處理器(核),G表明Goroutine。Go實現了M:N的調度,也就是說線程和Goroutine之間是多對多的關係。這點在許多GreenThread/Coroutine的調度器並無實現。好比Java1.1版本以前的線程實際上是GreenThread(這個詞就來源於Java),但因爲沒實現多對多的調度,也就是沒有真正實現並行,發揮不了多核的優點,因此後來改爲基於系統內核的Thread實現了。
某個系統線程若是被阻塞,排列在該線程上的Goroutine會被遷移。固然還有其餘機制,好比M空閒了,若是全局隊列沒有任務,可能會從其餘M偷任務執行,至關於一種rebalance機制。這裏再也不細說,有須要看專門的分析文章。
具體的實現策略和咱們前面分析的機制相似。系統啓動時,會啓動一個獨立的後臺線程(不在Goroutine的調度線程池裏),啓動netpoll的輪詢。當有Goroutine發起網絡請求時,網絡庫會將fd(文件描述符)和pollDesc(用於描述netpoll的結構體,包含由於讀/寫這個fd而阻塞的Goroutine)關聯起來,而後調用runtime.gopark方法,掛起當前的Goroutine。當後臺的netpoll輪詢獲取到epoll(linux環境下)的event,會將event中的pollDesc取出來,找到關聯的阻塞Goroutine,並進行恢復。
Goroutine是銀彈麼?
Goroutine很大程度上下降了併發的開發成本,是否是咱們全部須要併發的地方直接go func就搞定了呢?
Go經過Goroutine的調度解決了CPU利用率的問題。但遇到其餘的瓶頸資源如何處理?好比帶鎖的共享資源,好比數據庫鏈接等。互聯網在線應用場景下,若是每一個請求都扔到一個Goroutine裏,當資源出現瓶頸的時候,會致使大量的Goroutine阻塞,最後用戶請求超時。這時候就須要用Goroutine池來進行控流,同時問題又來了:池子裏設置多少個Goroutine合適?
因此這個問題仍是沒有從更本上解決。
Actor對沒接觸過這個概念的人可能不太好理解,Actor的概念其實和OO裏的對象相似,是一種抽象。面對對象編程對現實的抽象是對象=屬性+行爲(method),但當使用方調用對象行爲(method)的時候,其實佔用的是調用方的CPU時間片,是否併發也是由調用方決定的。這個抽象其實和現實世界是有差別的。現實世界更像Actor的抽象,互相都是經過異步消息通訊的。好比你對一個美女say hi,美女是否迴應,如何迴應是由美女本身決定的,運行在美女本身的大腦裏,並不會佔用發送者的大腦。
因此Actor有如下特徵:
Processing – actor能夠作計算的,不須要佔用調用方的CPU時間片,併發策略也是由本身決定。
Storage – actor能夠保存狀態
Communication – actor之間能夠經過發送消息通信
Actor遵循如下規則:
發送消息給其餘的Actor
建立其餘的Actor
接受並處理消息,修改本身的狀態
Actor的目標:
Actor可獨立更新,實現熱升級。由於Actor互相之間沒有直接的耦合,是相對獨立的實體,可能實現熱升級。
無縫彌合本地和遠程調用 由於Actor使用基於消息的通信機制,不管是和本地的Actor,仍是遠程Actor交互,都是經過消息,這樣就彌合了本地和遠程的差別。
容錯 Actor之間的通訊是異步的,發送方只管發送,不關心超時以及錯誤,這些都由框架層和獨立的錯誤處理機制接管。
易擴展,自然分佈式 由於Actor的通訊機制彌合了本地和遠程調用,本地Actor處理不過來的時候,能夠在遠程節點上啓動Actor而後轉發消息過去。
Actor的實現:
Erlang/OTP Actor模型的標杆,其餘的實現基本上都必定程度參照了Erlang的模式。實現了熱升級以及分佈式。
Akka(Scala,Java)基於線程和異步回調模式實現。因爲Java中沒有Fiber,因此是基於線程的。爲了不線程被阻塞,Akka中全部的阻塞操做都須要異步化。要麼是Akka提供的異步框架,要麼經過Future-callback機制,轉換成回調模式。實現了分佈式,但還不支持熱升級。
Quasar (Java) 爲了解決Akka的阻塞回調問題,Quasar經過字節碼加強的方式,在Java中實現了Coroutine/Fiber。同時經過ClassLoader的機制實現了熱升級。缺點是系統啓動的時候要經過javaagent機制進行字節碼加強。
兩者的格言都是:
Don’t communicate by sharing memory, share memory by communicating
經過消息通訊的機制來避免競態條件,但具體的抽象和實現上有些差別。
CSP模型裏消息和Channel是主體,處理器是匿名的。
也就是說發送方須要關心本身的消息類型以及應該寫到哪一個Channel,但不須要關心誰消費了它,以及有多少個消費者。Channel通常都是類型綁定的,一個Channel只寫同一種類型的消息,因此CSP須要支持alt/select機制,同時監聽多個Channel。Channel是同步的模式(Golang的Channel支持buffer,支持必定數量的異步),背後的邏輯是發送方很是關心消息是否被處理,CSP要保證每一個消息都被正常處理了,沒被處理就阻塞着。
Actor模型裏Actor是主體,Mailbox(相似於CSP的Channel)是透明的。
也就是說它假定發送方會關心消息發給誰消費了,但不關心消息類型以及通道。因此Mailbox是異步模式,發送者不能假定發送的消息必定被收到和處理。Actor模型必須支持強大的模式匹配機制,由於不管什麼類型的消息都會經過同一個通道發送過來,須要經過模式匹配機制作分發。它背後的邏輯是現實世界原本就是異步的,不肯定(non-deterministic)的,因此程序也要適應面對不肯定的機制編程。自從有了並行以後,原來的肯定編程思惟模式已經受到了挑戰,而Actor直接在模式中蘊含了這點。
從這樣看來,CSP的模式比較適合Boss-Worker模式的任務分發機制,它的侵入性沒那麼強,能夠在現有的系統中經過CSP解決某個具體的問題。它並不試圖解決通訊的超時容錯問題,這個仍是須要發起方進行處理。同時因爲Channel是顯式的,雖然能夠經過netchan(原來Go提供的netchan機制因爲過於複雜,被廢棄,在討論新的netchan)實現遠程Channel,但很難作到對使用方透明。而Actor則是一種全新的抽象,使用Actor要面臨整個應用架構機制和思惟方式的變動。它試圖要解決的問題要更廣一些,好比容錯,好比分佈式。但Actor的問題在於以當前的調度效率,哪怕是用Goroutine這樣的機制,也很難達到直接方法調用的效率。當前要像OO的『一切皆對象』同樣實現一個『一切皆Actor』的語言,效率上確定有問題。因此折中的方式是在OO的基礎上,將系統的某個層面的組件抽象爲Actor。
Rust解決併發問題的思路是首先認可現實世界的資源老是有限的,想完全避免資源共享是很難的,不試圖徹底避免資源共享,它認爲併發的問題不在於資源共享,而在於錯誤的使用資源共享。好比咱們前面提到的,大多數語言定義類型的時候,並不能限制調用方如何使用,只能經過文檔或者標記的方式(好比Java中的@ThreadSafe ,@NotThreadSafe annotation)說明是否併發安全,但也只能僅僅作到提示的做用,不能阻止調用方誤用。雖然Go提供了-race機制,能夠經過運行單元測試的時候帶上這個參數來檢測競態條件,但若是你的單元測試併發度不夠,覆蓋面不到也檢測不出來。因此Rust的解決方案就是:
定義類型的時候要明確指定該類型是不是併發安全的
引入了變量的全部權(Ownership)概念 非併發安全的數據結構在多個線程間轉移,也不必定就會致使問題,致使問題的是多個線程同時操做,也就是說是由於這個變量的全部權不明確致使的。有了全部權的概念後,變量只能由擁有全部權的做用域代碼操做,而變量傳遞會致使全部權變動,從語言層面限制了競態條件出現的狀況。
有了這機制,Rust能夠在編譯期而不是運行期對競態條件作檢查和限制。雖然開發的時候增長了心智成本,但下降了調用方以及排查併發問題的心智成本,也是一種有特點的解決方案。
革命還沒有成功 同志任需努力
本文帶你們一塊兒回顧了併發的問題,和各類解決方案。雖然各家有各家的優點以及使用場景,但併發帶來的問題還遠遠沒到解決的程度。因此還需努力,你們也有機會啊。
分佈式 解決了單機效率問題,是否是能夠嘗試解決下分佈式效率問題?
和容器集羣融合 當前的自動伸縮方案基本上都是經過監控服務器或者LoadBalancer,設置一個閥值來實現的。相似於我前面提到的餵飯的例子,是基於經驗的方案,但若是系統內和外部集羣結合,這個事情就能夠作的更細緻和智能。
自管理 前面的兩點最終的目標都是實現一個能夠自管理的系統。作過系統運維的同窗都知道,咱們照顧系統就像照顧孩子同樣,時刻要監控系統的各類狀態,接受系統的各類報警,而後排查問題,進行緊急處理。孩子有長大的一天,那能不能讓系統也本身成長,作到自管理呢?雖然這個目標如今看來還比較遠,但我以爲是能夠期待的。
引用以及擴展閱讀
FAQ:
高可用架構公衆號網友『闖』:有個問題 想請教一下 你說1024個線程須要1G的空間做爲棧空間 到時線程和進程的地址空間都是虛擬空間 當你沒有真正用到這塊虛地址時 是不會把物理內存頁映射到虛擬內存上的 也就是說每一個線程若是調用沒那麼深 是不會將全部棧空間關鍵到內存上 也就是說1024個線程實際不會消耗那麼多內存
答: 你說的是對的,java的堆以及stack的內存都是虛擬內存,實際上啓動一個線程不會馬上佔用那麼多內存。但線程是長期運行的,stack增加後,空間並不會被回收,也就是說會逐漸增長到xss的限制。這裏只是說明線程的成本。另外即使是空線程(啓動後就sleep),據個人測試,1核1G的服務器,啓動3萬多個線程左右系統就掛掉了(須要先修改系統線程最大數限制,在/proc/sys/kernel/threads-max中),和理想中的百萬級別仍是有很大差距的。