這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
本文基於我在2月27日Gopher北京聚會演講整理而成,進行了一些補充以及調整。投稿給《高可用架構》公眾號首發。
聊這個話題之前,先梳理下兩個概念,幾乎所有講並發的文章都要先講這兩個概念:
- 並發(concurrency) 並發的關注點在於任務切分。舉例來說,你是一個創業公司的CEO,開始只有你一個人,你一人分飾多角,一會做產品規劃,一會寫代碼,一會見客戶,雖然你不能見客戶的同時寫代碼,但由於你切分了任務,分配了時間片,表現出來好像是多個任務一起在執行。
- 並行(parallelism) 並行的關注點在於同時執行。還是上面的例子,你發現你自己太忙了,時間分配不過來,於是請了工程師,產品經理,市場總監,各司一職,這時候多個任務可以同時執行了。
所以總結下,並發並不要求必須並行,可以用時間片切分的方式類比,比如單核cpu上的多任務系統,並發的要求是任務能切分成獨立執行的片段。而並行關注的是同時執行,必須是多(核)cpu,要能並行的程式必須是支援並發的。本文大多數情況下不會嚴格區分這兩個概念,預設並發就是指並行機制下的並發。
為什麼並發程式這麼難?
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參數控制,但由於線程是本質上也是進程,系統假定是要長期啟動並執行,棧空間太小會導致稍複雜的遞迴調用(比如複雜點的Regex匹配)導致棧溢出。所以調整參數治標不治本。
-
調度成本(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
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對沒接觸過這個概念的人可能不太好理解,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,設定一個閥值來實現的。類似於我前面提到的喂飯的例子,是基於經驗的方案,但如果系統內和外部叢集結合,這個事情就可以做的更細緻和智能。
- 自管理 前面的兩點最終的目標都是實現一個可以自管理的系統。做過系統營運的同學都知道,我們照顧系統就像照顧孩子一樣,時刻要監控系統的各種狀態,接受系統的各種警示,然後排查問題,進行緊急處理。孩子有長大的一天,那能不能讓系統也自己成長,做到自管理呢?雖然這個目標現在看來還比較遠,但我覺得是可以期待的。
引用以及擴充閱讀
- 本文的演講視頻
- 本文的演講pdf
- CSP model paper
- Actor model paper
- Quantifying The Cost of Context Switch
- JCSP 在Java中實現CSP模型的庫
- Overview of Modern Concurrency and Parallelism Concepts
- Golang netchan 的討論
- quasar vs akka
- golang 官方部落格的 concurrency is not parallelism
- go scheduler, 文中的調度器圖片來源
- 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中),和理想中的百萬層級還是有很大差距的。