很多有經驗的工程師在使用基於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線程而不是採用自己的調度器和遞增的棧模式的話,那麼他們能夠在運行時中減少數千行的代碼。對於很多使用者情境來說,這確實是更好的模型。複雜性可以被語言和庫的編寫者抽象出去,這樣軟體工程師就能編寫大量並發的程式了。
感謝Leah Alpert閱讀了本文的初稿。
補充材料:
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線程)。
文章來源:http://www.infoq.com/cn/articles/a-million-go-routines-but-only-1000-java-threads?useSponsorshipSuggestions=true
相關內容推薦:https://www.roncoo.com/course/list.html?courseName=%E5%B9%B6%E5%8F%91