1. 程式人生 > >為什麽Goroutine能有上百萬個,Java線程卻只能有上千個?

為什麽Goroutine能有上百萬個,Java線程卻只能有上千個?

這不 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]。這就帶來一個固有的問題:如果線程的數量多於內核的數量,那麽有的線程必須要暫停以便於其他的線程來運行工作,當再次輪到自己的執行的時候,會將任務恢復。為了支持暫停和恢復,線程至少需要如下兩件事情:
  1. 某種類型的指令指針。也就是,當我暫停的時候,我正在執行哪行代碼?
  2. 一個棧。也就是,我當前的狀態是什麽?棧中包含了本地變量以及指向變量所分配的堆的指針。同一個進程中的所有線程共享相同的堆 [2]。
於以上兩點,系統在將線程調度到 CPU 上時就有了足夠的信息,能夠暫停某個線程、允許其他的線程運行,隨後再次恢復原來的線程。這種操作通常對線程來說是完全透明的。從線程的角度來說,它是連續運行的。線程能夠感知到重新調度的唯一方式是測量連續操作之間的計時 [3]。 回到我們最原始的問題:我們為什麽能有這麽多的 Goroutines 呢? JVM 使用操作系統線程 盡管並非規範所要求,但是據我所知所有的現代、通用 JVM 都將線程委托給了平臺的操作系統線程來處理。在接下來的內容中,我將會使用“用戶空間線程(user space thread)”來代指由語言進行調度的線程,而不是內核 /OS 所調度的線程。操作系統實現的線程有兩個屬性,這兩個屬性極大地限制了它們可以存在的數量;任何將語言線程和操作系統線程進行 1:1 映射的解決方案都無法支持大規模的並發。 在 JVM 中,固定大小的棧 使用操作系統線程將會導致每個線程都有固定的、較大的內存成本 采用操作系統線程的另一個主要問題是每個 OS 線程都有大小固定的棧。盡管這個大小是可以配置的,但是在 64 位的環境中,JVM 會為每個線程分配 1M 的棧。你可以將默認的棧空間設置地更小一些,但是你需要權衡內存的使用,因為這會增加棧溢出的風險。代碼中的遞歸越多,就越有可能出現棧溢出。如果你保持默認值的話,那麽 1000 個線程就將使用 1GB 的 RAM。雖然現在 RAM 便宜了很多,但是幾乎沒有人會為了運行上百萬個線程而準備 TB 級別的 RAM。 Go 的行為有何不同:動態大小的棧 Golang 采取了一種很聰明的技巧,防止系統因為運行大量的(大多數是未使用的)棧而耗盡內存:Go 的棧是動態分配大小的,隨著存儲數據的數量而增長和收縮。這並不是一件簡單的事情,它的設計經歷了多輪的叠代 [4]。我並不打算講解內部的細節(關於這方面的知識,有很多的博客文章和其他材料進行了詳細的闡述),但結論就是每個新建的 Goroutine 只有大約 4KB 的棧。每個棧只有 4KB,那麽在一個 1GB 的 RAM 上,我們就可以有 250 萬個 Goroutine 了,相對於 Java 中每個線程的 1MB,這是巨大的提升。 技術分享圖片
在 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 線程而不是采用自己的調度器和遞增的棧模式的話,那麽他們能夠在運行時中減少數千行的代碼。對於很多用戶場景來說,這確實是更好的模型。復雜性可以被語言和庫的編寫者抽象出去,這樣軟件工程師就能編寫大量並發的程序了。 補充材料
  1. 超線程會將核心的效果加倍。指令流(instruction pipelining)也能增加 CPU 的並行效果。但是就當前來說,它還是 O(numCores)。
  2. 可能在有些特殊場景中,這種說法是不正確的,我想肯定有人會提醒我這一點。
  3. 這實際上是一種攻擊。JavaScript 可以檢測鍵盤中斷所導致的在計時上的細微差別。惡意的站點用它來監聽計時,而不是監聽擊鍵。參見:https://mlq.me/download/keystroke_js.pdf。
  4. Golang 首先采用了一個分段的棧模型,在這個模型中,棧實際上會擴展至單獨的內存區域,這個過程中使用非常聰明的記錄功能進行跟蹤。隨後的實現在特定的場景下提升了性能,使用連續的棧來取代對棧的拆分,這很像對 hashtable 重新調整大小,分配一個新的更大的棧,並通過一些非常有技巧的指針操作,所有的內容都能夠仔細地復制到新的、更大的棧中。
  5. 線程可以通過調用nice(參見man nice)來標記優先級,從而能夠更好地控制它們調度的頻率。
  6. Actor 通過支持大規模並發,為 Scala/Java 實現了與 Goroutines 相同目的的特性。與 Goroutines 類似,Actor 調度器能夠看到哪個 Actor 的收件箱中有消息,從而只運行那些能夠執行真正有用工作的 Actor。我們所能擁有的 Actor 的數量甚至還能超過 Goroutines,因為 Actor 並不需要棧。但是,這也意味著,如果 Actor 無法快速處理消息的話,調度器將會阻塞(因為 Actor 沒有自己的棧,所以它無法在 Actor 處理消息的過程中暫停)。阻塞的調度器意味著消息不能進行處理,系統很快會出現問題。這就是一種權衡。
  7. 在 Apache 中,每個請求都是由一個 OS 線程來處理的,這限制了 Apache 只能有效處理數千個並發連接。Nginx 選擇了另外一種模型,一個 OS 線程能夠應對上百個甚至上千的並發連接,從而允許更高程度的並發。Erlang 使用了一個類似的模型,它允許數百萬 Actor 並發執行。Gevent 為 Python 帶來了 greenlet(用戶空間線程),它能夠實現比以往更高程度的並發性 (Python 線程是 OS 線程)。
原文鏈接: https://rcoh.me/posts/why-you-can-have-a-million-go-routines-but-only-1000-java-threads 課程推薦 很多人都聽過持續交付能提高效率,但要說實施得多好、多徹底,估計很多人會面面相覷,我將結合我的個人多年實踐經驗與你分享如何設計、實施以及落地。 限時優惠 45 元,最後兩天!

為什麽Goroutine能有上百萬個,Java線程卻只能有上千個?