併發之痛 Thread,Goroutine,Actor

併發之痛 Thread,Goroutine,Actor

Mar 1, 2016 • jolestarhtml

本文基於我在2月27日Gopher北京聚會演講整理而成,進行了一些補充以及調整。投稿給《高可用架構》公衆號首發。java

聊這個話題以前,先梳理下兩個概念,幾乎全部講併發的文章都要先講這兩個概念:linux

concurrent_vs_parallel

  • 併發(concurrency) 併發的關注點在於任務切分。舉例來講,你是一個創業公司的CEO,開始只有你一我的,你一人分飾多角,一會作產品規劃,一會寫代碼,一會見客戶,雖然你不能見客戶的同時寫代碼,但因爲你切分了任務,分配了時間片,表現出來好像是多個任務一塊兒在執行。git

  • 並行(parallelism) 並行的關注點在於同時執行。仍是上面的例子,你發現你本身太忙了,時間分配不過來,因而請了工程師,產品經理,市場總監,各司一職,這時候多個任務能夠同時執行了。github

因此總結下,併發並不要求必須並行,能夠用時間片切分的方式模擬,好比單核cpu上的多任務系統,併發的要求是任務能切分紅獨立執行的片斷。而並行關注的是同時執行,必須是多(核)cpu,要能並行的程序必須是支持併發的。本文大多數狀況下不會嚴格區分這兩個概念,默認併發就是指並行機制下的併發。golang

爲何併發程序這麼難?


We believe that writing correct concurrentfault-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)安全

  1. 系統內核態,更輕量的進程

  2. 由系統內核進行調度

  3. 同一進程的多個線程可共享資源

線程的出現解決了兩個問題,一個是GUI出現後急切須要併發機制來保證用戶界面的響應。第二是互聯網發展後帶來的多用戶問題。最先的CGI程序很簡單,將經過腳本將原來單機版的程序包裝在一個進程裏,來一個用戶就啓動一個進程。但明顯這樣承載不了多少用戶,而且若是進程間須要共享資源還得經過進程間的通訊機制,線程的出現緩解了這個問題。

線程的使用比較簡單,若是你以爲這塊代碼須要併發,就把它放在單獨的線程裏執行,由系統負責調度,具體何時使用線程,要用多少個線程,由調用方決定,但定義方並不清楚調用方會如何使用本身的代碼,不少併發問題都是由於誤用致使的,好比Go中的map以及Java的HashMap都不是併發安全的,誤用在多線程環境就會致使問題。另外也帶來複雜度:

  1. 競態條件(race conditions) 若是每一個任務都是獨立的,不須要共享任何資源,那線程也就很是簡單。但世界每每是複雜的,總有一些資源須要共享,好比前面的例子,開發人員和市場人員同時須要和CEO商量一個方案,這時候CEO就成了競態條件。

  2. 依賴關係以及執行順序 若是線程之間的任務有依賴關係,須要等待以及通知機制來進行協調。好比前面的例子,若是產品和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納秒/次。這個還沒考慮棧空間大小的影響。國外一篇論文專門分析線程切換的成本,基本上得出的結論是切換成本和棧空間使用大小直接相關。

    context switch

  • CPU使用率
    咱們搞併發最主要的一個目標就是咱們有了多核,想提升CPU利用率,最大限度的壓榨硬件資源,從這個角度考慮,咱們應該用多少線程呢?
    cpu ratio

    這個咱們能夠經過一個公式計算出來,100/(15+5)*4=20,用20個線程最合適。但一方面網絡的時間不是固定的,另一方面,若是考慮到其餘瓶頸資源呢?好比鎖,好比數據庫鏈接池,就會更復雜。

做爲一個1歲多孩子的父親,認爲這個問題的難度比如你要寫個給孩子餵飯的程序,須要考慮『給孩子喂多少飯合適?』,這個問題有如下回答以及策略:

  • 孩子不吃了就行了(但孩子貪玩,不吃了多是想去玩了)

  • 孩子吃飽了就行了(廢話,你怎麼知道孩子吃飽了?孩子又不會說話)

  • 逐漸增量,長期觀察,而後計算一個平均值(這多是咱們調整線程經常使用的策略,但增量增長到多少合適呢?)

  • 孩子吃吐了就別餵了(若是用逐漸增量的模式,經過外部觀察,可能會到達這個邊界條件。系統性能若是由於線程的增長倒退了,就別增長線程了)

  • 沒控制好邊界,把孩子給給撐壞了 (這熊爸爸也太恐怖了。但調整線程的時候每每不當心可能就把系統搞掛了)

經過這個例子咱們能夠看出,從外部系統來觀察,或者以經驗的方式進行計算,都是很是困難的。因而結論是:

讓孩子會說話,吃飽了本身說,本身學會吃飯,自管理是最佳方案。

然並卵,計算機不會本身說話,如何自管理?

但咱們從以上的討論能夠得出一個結論:

  • 線程的成本較高(內存,調度)不可能大規模建立

  • 應該由語言或者框架動態解決這個問題

線程池方案


Java1.5後,Doug Lea的Executor系列被包含在默認的JDK內,是典型的線程池方案。

線程池必定程度上控制了線程的數量,實現了線程複用,下降了線程的使用成本。但仍是沒有解決數量的問題,線程池初始化的時候仍是要設置一個最小和最大線程數,以及任務隊列的長度,自管理只是在設定範圍內的動態調整。另外不一樣的任務可能有不一樣的併發需求,爲了不互相影響可能須要多個線程池,最後致使的結果就是Java的系統裏充斥了大量的線程池。

新的思路


從前面的分析咱們能夠看出,若是線程是一直處於運行狀態,咱們只需設置和CPU核數相等的線程數便可,這樣就能夠最大化的利用CPU,而且下降切換成本以及內存使用。但如何作到這一點呢?

陳力就列,不能者止

這句話是說,能幹活的代碼片斷就放在線程裏,若是幹不了活(須要等待,被阻塞等),就摘下來。通俗的說就是不要佔着茅坑不拉屎,若是拉不出來,須要醞釀下,先把茅坑讓出來,由於茅坑是稀缺資源。

要作到這點通常有兩種方案:

  1. 異步回調方案 典型如NodeJS,遇到阻塞的狀況,好比網絡調用,則註冊一個回調方法(其實還包括了一些上下文數據對象)給IO調度器(linux下是libev,調度器在另外的線程裏),當前線程就被釋放了,去幹別的事情了。等數據準備好,調度器會將結果傳遞給回調方法而後執行,執行其實不在原來發起請求的線程裏了,但對用戶來講無感知。但這種方式的問題就是很容易遇到callback hell,由於全部的阻塞操做都必須異步,不然系統就卡死了。還有就是異步的方式有點違反人類思惟習慣,人類仍是習慣同步的方式。

  2. GreenThread/Coroutine/Fiber方案 這種方案其實和上面的方案本質上區別不大,關鍵在於回調上下文的保存以及執行機制。爲了解決回調方法帶來的難題,這種方案的思路是寫代碼的時候仍是按順序寫,但遇到IO等阻塞調用時,將當前的代碼片斷暫停,保存上下文,讓出當前線程。等IO事件回來,而後再找個線程讓當前代碼片斷恢復上下文繼續執行,寫代碼的時候感受好像是同步的,彷彿在同一個線程完成的,但實際上系統可能切換了線程,但對程序無感。

GreenThread

  • 用戶空間 首先是在用戶空間,避免內核態和用戶態的切換致使的成本。

  • 由語言或者框架層調度

  • 更小的棧空間容許建立大量實例(百萬級別)

幾個概念

  • Continuation 這個概念不熟悉FP編程的人可能不太熟悉,不過這裏能夠簡單的顧名思義,能夠理解爲讓咱們的程序能夠暫停,而後下次調用繼續(contine)從上次暫停的地方開始的一種機制。至關於程序調用多了一種入口。

  • Coroutine 是Continuation的一種實現,通常表現爲語言層面的組件或者類庫。主要提供yield,resume機制。

  • Fiber 和Coroutine實際上是一體兩面的,主要是從系統層面描述,能夠理解成Coroutine運行以後的東西就是Fiber。

Goroutine


Goroutine其實就是前面GreenThread系列解決方案的一種演進和實現。

  • 首先,它內置了Coroutine機制。由於要用戶態的調度,必須有可讓代碼片斷能夠暫停/繼續的機制。

  • 其次,它內置了一個調度器,實現了Coroutine的多線程並行調度,同時經過對網絡等庫的封裝,對用戶屏蔽了調度細節。

  • 最後,提供了Channel機制,用於Goroutine之間通訊,實現CSP併發模型(Communicating Sequential Processes)。由於Go的Channel是經過語法關鍵詞提供的,對用戶屏蔽了許多細節。其實Go的Channel和Java中的SynchronousQueue是同樣的機制,若是有buffer其實就是ArrayBlockQueue。

Goroutine調度器

go-scheduler

這個圖通常講Goroutine調度器的地方都會引用,想要仔細瞭解的能夠看看原博客。這裏只說明幾點:

  1. M表明系統線程,P表明處理器(核),G表明Goroutine。Go實現了M:N的調度,也就是說線程和Goroutine之間是多對多的關係。這點在許多GreenThread/Coroutine的調度器並無實現。好比Java1.1版本以前的線程實際上是GreenThread(這個詞就來源於Java),但因爲沒實現多對多的調度,也就是沒有真正實現並行,發揮不了多核的優點,因此後來改爲基於系統內核的Thread實現了。

  2. 某個系統線程若是被阻塞,排列在該線程上的Goroutine會被遷移。固然還有其餘機制,好比M空閒了,若是全局隊列沒有任務,可能會從其餘M偷任務執行,至關於一種rebalance機制。這裏再也不細說,有須要看專門的分析文章。

  3. 具體的實現策略和咱們前面分析的機制相似。系統啓動時,會啓動一個獨立的後臺線程(不在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對沒接觸過這個概念的人可能不太好理解,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機制進行字節碼加強。

Golang CSP VS Actor


兩者的格言都是:

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


Rust解決併發問題的思路是首先認可現實世界的資源老是有限的,想完全避免資源共享是很難的,不試圖徹底避免資源共享,它認爲併發的問題不在於資源共享,而在於錯誤的使用資源共享。好比咱們前面提到的,大多數語言定義類型的時候,並不能限制調用方如何使用,只能經過文檔或者標記的方式(好比Java中的@ThreadSafe ,@NotThreadSafe annotation)說明是否併發安全,但也只能僅僅作到提示的做用,不能阻止調用方誤用。雖然Go提供了-race機制,能夠經過運行單元測試的時候帶上這個參數來檢測競態條件,但若是你的單元測試併發度不夠,覆蓋面不到也檢測不出來。因此Rust的解決方案就是:

  • 定義類型的時候要明確指定該類型是不是併發安全的

  • 引入了變量的全部權(Ownership)概念 非併發安全的數據結構在多個線程間轉移,也不必定就會致使問題,致使問題的是多個線程同時操做,也就是說是由於這個變量的全部權不明確致使的。有了全部權的概念後,變量只能由擁有全部權的做用域代碼操做,而變量傳遞會致使全部權變動,從語言層面限制了競態條件出現的狀況。

有了這機制,Rust能夠在編譯期而不是運行期對競態條件作檢查和限制。雖然開發的時候增長了心智成本,但下降了調用方以及排查併發問題的心智成本,也是一種有特點的解決方案。

結論

革命還沒有成功 同志任需努力

本文帶你們一塊兒回顧了併發的問題,和各類解決方案。雖然各家有各家的優點以及使用場景,但併發帶來的問題還遠遠沒到解決的程度。因此還需努力,你們也有機會啊。

最後拋個磚 構想:在Goroutine上實現Actor?

  • 分佈式 解決了單機效率問題,是否是能夠嘗試解決下分佈式效率問題?

  • 和容器集羣融合 當前的自動伸縮方案基本上都是經過監控服務器或者LoadBalancer,設置一個閥值來實現的。相似於我前面提到的餵飯的例子,是基於經驗的方案,但若是系統內和外部集羣結合,這個事情就能夠作的更細緻和智能。

  • 自管理 前面的兩點最終的目標都是實現一個能夠自管理的系統。作過系統運維的同窗都知道,咱們照顧系統就像照顧孩子同樣,時刻要監控系統的各類狀態,接受系統的各類報警,而後排查問題,進行緊急處理。孩子有長大的一天,那能不能讓系統也本身成長,作到自管理呢?雖然這個目標如今看來還比較遠,但我以爲是能夠期待的。

qrcode_jolestar_blog2

引用以及擴展閱讀


  1. 本文的演講視頻

  2. 本文的演講pdf

  3. CSP model paper

  4. Actor model paper

  5. Quantifying The Cost of Context Switch

  6. JCSP 在Java中實現CSP模型的庫

  7. Overview of Modern Concurrency and Parallelism Concepts

  8. Golang netchan 的討論

  9. quasar vs akka

  10. golang 官方博客的 concurrency is not parallelism

  11. go scheduler, 文中的調度器圖片來源

  12. handling-1-million-requests-per-minute-with-golang 一個用Goroutine的控流實踐

FAQ:


高可用架構公衆號網友『闖』:有個問題 想請教一下 你說1024個線程須要1G的空間做爲棧空間 到時線程和進程的地址空間都是虛擬空間 當你沒有真正用到這塊虛地址時 是不會把物理內存頁映射到虛擬內存上的 也就是說每一個線程若是調用沒那麼深 是不會將全部棧空間關鍵到內存上 也就是說1024個線程實際不會消耗那麼多內存

答: 你說的是對的,java的堆以及stack的內存都是虛擬內存,實際上啓動一個線程不會馬上佔用那麼多內存。但線程是長期運行的,stack增加後,空間並不會被回收,也就是說會逐漸增長到xss的限制。這裏只是說明線程的成本。另外即使是空線程(啓動後就sleep),據個人測試,1核1G的服務器,啓動3萬多個線程左右系統就掛掉了(須要先修改系統線程最大數限制,在/proc/sys/kernel/threads-max中),和理想中的百萬級別仍是有很大差距的。

相關文章
相關標籤/搜索