為什麽Goroutine能有上百萬個,Java線程卻只能有上千個?
阿新 • • 發佈:2018-08-13
這不 tin 成本 描述 生產環境 優先 linux 操作 stroke 恢復
作者|Russell Cohen
譯者|張衛濱
本文通過 Java 和 Golang 在底層原理上的差異,分析了 Java 為什麽只能創建數千個線程,而 Golang 可以有數百萬的 Goroutines,並在上下文切換、棧大小方面對兩者的實現原理進行了剖析。
很多有經驗的工程師在使用基於 JVM 的語言時,都會看到這樣的錯誤:
[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread: [error] java.lang.OutOfMemoryError: unable to create native thread: [error] at java.base/java.lang.Thread.start0(Native Method) [error] at java.base/java.lang.Thread.start(Thread.java:813) ... [error] at java.base/java.lang.Thread.run(Thread.java:844)
呃,這是由線程所造成的OutOfMemory。在我的筆記本電腦上運行 Linux 操作系統時,僅僅創建 11500 個線程之後,就會出現這個錯誤。
如果你在 Go 語言上做相同的事情,啟動永遠處於休眠狀態的 Goroutines,那麽你會看到非常不同的結果。在我的筆記本電腦上,在我覺得實在乏味無聊之前,我能夠創建七千萬個 Goroutines。那麽,為什麽 Goroutines 的數量能夠遠遠超過線程呢?要揭示問題的答案,我們需要一直向下沿著操作系統進行一次往返旅行。這不僅僅是一個學術問題,它對你如何設計軟件有現實的影響。在生產環境中,我曾經多次遇到 JVM 線程的限制,有些是因為糟糕的代碼泄露線程,有的則是因為工程師沒有意識到 JVM 的線程限制。
那到底什麽是線程?
術語“線程”可以用來描述很多不同的事情。在本文中,我會使用它來代指一個邏輯線程。也就是:按照線性順序的一系列操作;一個執行的邏輯路徑。CPU 的每個核心只能真正並發同時執行一個邏輯線程 [1]。這就帶來一個固有的問題:如果線程的數量多於內核的數量,那麽有的線程必須要暫停以便於其他的線程來運行工作,當再次輪到自己的執行的時候,會將任務恢復。為了支持暫停和恢復,線程至少需要如下兩件事情:
在 JVM 中:上下文切換的延遲
從上下文切換的角度來說,使用操作系統線程只能有數萬個線程
因為 JVM 使用了操作系統線程,所以依賴操作系統內核來調度它們。操作系統有一個所有正在運行的進程和線程的列表,並試圖為它們分配“公平”的 CPU 運行時間 [5]。當內核從一個線程切換至另一個線程時,有很多的工作要做。新運行的線程和進程必須要將其他線程也在同一個 CPU 上運行的事實抽象出去。我不會在這裏討論細節問題,但是如果你對此感興趣的話,可以閱讀更多的材料。這裏比較重要的就是,切換上下文要消耗 1 到 100 微秒。這看上去時間並不多,相對現實的情況是每次切換 10 微秒,如果你想要每秒鐘內至少調度每個線程一次的話,那麽每個核心上只能運行大約 10 萬個線程。這實際上還沒有給線程時間來執行有用的工作。
Go 的行為有何不同:在一個操作系統線程上運行多個 Goroutines
Golang 實現了自己的調度器,允許眾多的 Goroutines 運行在相同的 OS 線程上。就算 Go 會運行與內核相同的上下文切換,但是它能夠避免切換至 ring-0 以運行內核,然後再切換回來,這樣就會節省大量的時間。但是,這只是紙面上的分析。為了支持上百萬的 Goroutines,Go 需要完成更復雜的事情。
即便 JVM 將線程放到用戶空間,它也無法支持上百萬的線程。假設在按照這樣新設計系統中,新線程之間的切換只需要 100 納秒。即便你所做的只是上下文切換,如果你想要每秒鐘調度每個線程十次的話,你也只能運行大約 100 萬個線程。更重要的是,為了完成這一點,我們需要最大限度地利用 CPU。要支持真正的大並發需要另外一項優化:當你知道線程能夠做有用的工作時,才去調度它。如果你運行大量線程的話,其實只有少量的線程會執行有用的工作。Go 通過集成通道(channel)和調度器(scheduler)來實現這一點。如果某個 Goroutine 在一個空的通道上等待,那麽調度器會看到這一點並且不會運行該 Goroutine。Go 更近一步,將大多數空閑的線程都放到它的操作系統線程上。通過這種方式,活躍的 Goroutine(預期數量會少得多)會在同一個線程上調度執行,而數以百萬計的大多數休眠的 Goroutine 會單獨處理。這樣有助於降低延遲。
除非 Java 增加語言特性,允許調度器進行觀察,否則的話,是不可能支持智能調度的。但是,你可以在“用戶空間”中構建運行時調度器,它能夠感知線程何時能夠執行工作。這構成了像 Akka 這種類型的框架的基礎,它能夠支持上百萬的 Actor[6].
結論
操作系統線程模型與輕量級、用戶空間的線程模型之間的轉換在不斷發生,未來可能還會繼續 [7]。對於高度並發的用戶場景來說,這是唯一的選擇。然而,它具有相當的復雜性。如果 Go 選擇采用 OS 線程而不是采用自己的調度器和遞增的棧模式的話,那麽他們能夠在運行時中減少數千行的代碼。對於很多用戶場景來說,這確實是更好的模型。復雜性可以被語言和庫的編寫者抽象出去,這樣軟件工程師就能編寫大量並發的程序了。
補充材料
- 某種類型的指令指針。也就是,當我暫停的時候,我正在執行哪行代碼?
- 一個棧。也就是,我當前的狀態是什麽?棧中包含了本地變量以及指向變量所分配的堆的指針。同一個進程中的所有線程共享相同的堆 [2]。
- 超線程會將核心的效果加倍。指令流(instruction pipelining)也能增加 CPU 的並行效果。但是就當前來說,它還是 O(numCores)。
- 可能在有些特殊場景中,這種說法是不正確的,我想肯定有人會提醒我這一點。
- 這實際上是一種攻擊。JavaScript 可以檢測鍵盤中斷所導致的在計時上的細微差別。惡意的站點用它來監聽計時,而不是監聽擊鍵。參見:https://mlq.me/download/keystroke_js.pdf。
- Golang 首先采用了一個分段的棧模型,在這個模型中,棧實際上會擴展至單獨的內存區域,這個過程中使用非常聰明的記錄功能進行跟蹤。隨後的實現在特定的場景下提升了性能,使用連續的棧來取代對棧的拆分,這很像對 hashtable 重新調整大小,分配一個新的更大的棧,並通過一些非常有技巧的指針操作,所有的內容都能夠仔細地復制到新的、更大的棧中。
- 線程可以通過調用nice(參見man nice)來標記優先級,從而能夠更好地控制它們調度的頻率。
- Actor 通過支持大規模並發,為 Scala/Java 實現了與 Goroutines 相同目的的特性。與 Goroutines 類似,Actor 調度器能夠看到哪個 Actor 的收件箱中有消息,從而只運行那些能夠執行真正有用工作的 Actor。我們所能擁有的 Actor 的數量甚至還能超過 Goroutines,因為 Actor 並不需要棧。但是,這也意味著,如果 Actor 無法快速處理消息的話,調度器將會阻塞(因為 Actor 沒有自己的棧,所以它無法在 Actor 處理消息的過程中暫停)。阻塞的調度器意味著消息不能進行處理,系統很快會出現問題。這就是一種權衡。
- 在 Apache 中,每個請求都是由一個 OS 線程來處理的,這限制了 Apache 只能有效處理數千個並發連接。Nginx 選擇了另外一種模型,一個 OS 線程能夠應對上百個甚至上千的並發連接,從而允許更高程度的並發。Erlang 使用了一個類似的模型,它允許數百萬 Actor 並發執行。Gevent 為 Python 帶來了 greenlet(用戶空間線程),它能夠實現比以往更高程度的並發性 (Python 線程是 OS 線程)。
為什麽Goroutine能有上百萬個,Java線程卻只能有上千個?