並發之痛 Thread,Goroutine,Actor

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

本文基於我在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)

  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參數控制,但由於線程是本質上也是進程,系統假定是要長期啟動並執行,棧空間太小會導致稍複雜的遞迴調用(比如複雜點的Regex匹配)導致棧溢出。所以調整參數治標不治本。
  • 調度成本(context-switch)
    我在個人電腦上做的一個非嚴格測試,類比兩個線程互相喚醒輪流掛起,線程切換成本大約6000納秒/次。這個還沒考慮棧空間大小的影響。國外一篇論文專門分析線程切換的成本,基本上得出的結論是切換成本和棧空間使用大小直接相關。

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

    這個我們可以通過一個公式計算出來,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調度器

這個圖一般講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,設定一個閥值來實現的。類似於我前面提到的喂飯的例子,是基於經驗的方案,但如果系統內和外部叢集結合,這個事情就可以做的更細緻和智能。
  • 自管理 前面的兩點最終的目標都是實現一個可以自管理的系統。做過系統營運的同學都知道,我們照顧系統就像照顧孩子一樣,時刻要監控系統的各種狀態,接受系統的各種警示,然後排查問題,進行緊急處理。孩子有長大的一天,那能不能讓系統也自己成長,做到自管理呢?雖然這個目標現在看來還比較遠,但我覺得是可以期待的。

引用以及擴充閱讀

  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中),和理想中的百萬層級還是有很大差距的。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.