[深入理解Java虛擬機器]執行緒

Duancf發表於2024-07-14

Java與協程

在Java時代的早期,Java語言抽象出來隱藏了各種作業系統執行緒差異性的統一執行緒介面,這曾經是它區別於其他程式語言的一大優勢。在此基礎上,湧現過無數多執行緒的應用與框架,譬如在網頁訪問時,HTTP請求可以直接與Servlet API中的一條處理執行緒繫結在一起,以“一對一服務”的方式處理由瀏覽器發來的資訊。語言與框架已經自動遮蔽了相當多同步和併發的複雜性,對於普通開發者而言,幾乎不需要專門針對多執行緒進行學習訓練就能完成一般的併發任務。時至今日,這種便捷的併發程式設計方式和同步的機制依然在有效地運作著,但是在某些場景下,卻也已經顯現出了疲態。

核心執行緒的侷限

筆者可以透過一個具體場景來解釋目前Java執行緒面臨的困境。今天對Web應用的服務要求,不論是在請求數量上還是在複雜度上,與十多年前相比已不可同日而語,這一方面是源於業務量的增長,另一方面來自於為了應對業務複雜化而不斷進行的服務細分。現代B/S系統中一次對外部業務請求的響應,往往需要分佈在不同機器上的大量服務共同協作來實現,這種服務細分的架構在減少單個服務複雜度、增加複用性的同時,也不可避免地增加了服務的數量,縮短了留給每個服務的響應時間。這要求每一個服務都必須在極短的時間內完成計算,這樣組合多個服務的總耗時才不會太長;也要求每一個服務提供者都要能同時處理數量更龐大的請求,這樣才不會出現請求由於某個服務被阻塞而出現等待。

Java目前的併發程式設計機制就與上述架構趨勢產生了一些矛盾,1:1的核心執行緒模型是如今Java虛擬機器執行緒實現的主流選擇,但是這種對映到作業系統上的執行緒天然的缺陷是切換、排程成本高昂,系統能容納的執行緒數量也很有限。以前處理一個請求可以允許花費很長時間在單體應用中,具有這種執行緒切換的成本也是無傷大雅的,但現在在每個請求本身的執行時間變得很短、數量變得很多的前提下,使用者執行緒切換的開銷甚至可能會接近用於計算本身的開銷,這就會造成嚴重的浪費。

傳統的Java Web伺服器的執行緒池的容量通常在幾十個到兩百之間,當程式設計師把數以百萬計的請求往執行緒池裡面灌時,系統即使能處理得過來,但其中的切換損耗也是相當可觀的。現實的需求在迫使Java去研究新的解決方案,同大家又開始懷念以前綠色執行緒的種種好處,綠色執行緒已隨著Classic虛擬機器的消失而被塵封到歷史之中,它還會有重現天日的一天嗎?

協程的復甦

經過前面對不同執行緒實現方式的鋪墊介紹,我們已經明白了各種執行緒實現方式的優缺點,所以多數讀者看到筆者寫“因為對映到了系統的核心執行緒中,所以切換排程成本會比較高昂”時並不會覺得有什麼問題,但相信還是有一部分治學特別嚴謹的讀者會提問:為什麼核心執行緒排程切換起來成本就要更高?

核心執行緒的排程成本主要來自於使用者態與核心態之間的狀態轉換,而這兩種狀態轉換的開銷主要來自於響應中斷、保護和恢復執行現場的成本。請讀者試想以下場景,假設發生了這樣一次執行緒切換:

執行緒A -> 系統中斷 -> 執行緒B

處理器要去執行執行緒A的程式程式碼時,並不是僅有程式碼程式就能跑得起來,程式是資料與程式碼的組合體,程式碼執行時還必須要有上下文資料的支撐。而這裡說的“上下文”,以程式設計師的角度來看,是方法呼叫過程中的各種區域性的變數與資源;以執行緒的角度來看,是方法的呼叫棧中儲存的各類資訊;而以作業系統和硬體的角度來看,則是儲存在記憶體、快取和暫存器中的一個個具體數值。物理硬體的各種儲存裝置和暫存器是被作業系統內所有執行緒共享的資源,當中斷髮生,從執行緒A切換到執行緒B去執行之前,作業系統首先要把執行緒A的上下文資料妥善保管好,然後把暫存器、記憶體分頁等恢復到執行緒B掛起時候的狀態,這樣執行緒B被重新啟用後才能彷彿從來沒有被掛起過。這種保護和恢復現場的工作,免不了涉及一系列資料在各種暫存器、快取中的來回複製,當然不可能是一種輕量級的操作。

如果說核心執行緒的切換開銷是來自於保護和恢復現場的成本,那如果改為採用使用者執行緒,這部分開銷就能夠省略掉嗎?答案是“不能”。但是,一旦把保護、恢復現場及排程的工作從作業系統交到程式設計師手上,那我們就可以開啟腦洞,透過玩出很多新的花樣來縮減這些開銷。

有一些古老的作業系統(譬如DOS)是單人單工作業形式的,天生就不支援多執行緒,自然也不會有多個呼叫棧這樣的基礎設施。而早在那樣的蠻荒時代,就已經出現了今天被稱為棧糾纏(Stack Twine)的、由使用者自己模擬多執行緒、自己保護恢復現場的工作模式。其大致的原理是透過在記憶體裡劃出一片額外空間來模擬呼叫棧,只要其他“執行緒”中方法壓棧、退棧時遵守規則,不破壞這片空間即可,這樣多段程式碼執行時就會像相互纏繞著一樣,非常形象。

到後來,作業系統開始提供多執行緒的支援,靠應用自己模擬多執行緒的做法自然是變少了許多,但也並沒有完全消失,而是演化為使用者執行緒繼續存在。由於最初多數的使用者執行緒是被設計成協同式排程(Cooperative Scheduling)的,所以它有了一個別名——“協程”(Coroutine)。又由於這時候的協程會完整地做呼叫棧的保護、恢復工作,所以今天也被稱為“有棧協程”(Stackfull Coroutine),起這樣的名字是為了便於跟後來的“無棧協程”(Stackless Coroutine)區分開。無棧協程不是本節的主角,不過還是可以簡單提一下它的典型應用,即各種語言中的await、async、yield這類關鍵字。無棧協程本質上是一種有限狀態機,狀態儲存在閉包裡,自然比有棧協程恢復呼叫棧要輕量得多,但功能也相對更有限。

協程的主要優勢是輕量,無論是有棧協程還是無棧協程,都要比傳統核心執行緒要輕量得多。如果進行量化的話,那麼如果不顯式設定-Xss或-XX:ThreadStackSize,則在64位Linux上HotSpot的執行緒棧容量預設是1MB,此外核心資料結構(Kernel Data Structures)還會額外消耗16KB記憶體。與之相對的,一個協程的棧通常在幾百個位元組到幾KB之間,所以Java虛擬機器裡執行緒池容量達到兩百就已經不算小了,而很多支援協程的應用中,同時並存的協程數量可數以十萬計。

協程當然也有它的侷限,需要在應用層面實現的內容(呼叫棧、排程器這些)特別多,這個缺點就不贅述了。除此之外,協程在最初,甚至在今天很多語言和框架中會被設計成協同式排程,這樣在語言執行平臺或者框架上的排程器就可以做得非常簡單。不過有不少資料上顯示,既然取了“協程”這樣的名字,它們之間就一定以協同排程的方式工作。筆者並沒有查證到這種“規定”的出處,只能說這種提法在今天太過狹隘了,非協同式、可自定義排程的協程的例子並不少見,而協同排程的優點與不足在12.4.2節已經介紹過。

具體到Java語言,還會有一些別的限制,譬如HotSpot這樣的虛擬機器,Java呼叫棧跟本地呼叫棧是做在一起的。如果在協程中呼叫了本地方法,還能否正常切換協程而不影響整個執行緒?另外,如果協程中遇傳統的執行緒同步措施會怎樣?譬如Kotlin提供的協程實現,一旦遭遇synchronize關鍵字,那掛起來的仍將是整個執行緒。

Java的解決方案

對於有棧協程,有一種特例實現名為纖程(Fiber),這個詞最早是來自微軟公司,後來微軟還推

出過系統層面的纖程包來方便應用做現場儲存、恢復和纖程排程。OpenJDK在2018年建立了Loom項

目,這是Java用來應對本節開篇所列場景的官方解決方案,根據目前公開的資訊,如無意外,日後該

專案為Java語言引入的、與現線上程模型平行的新併發程式設計機制中應該也會採用“纖程”這個名字,不

過這顯然跟微軟是沒有任何關係的。從Oracle官方對“什麼是纖程”的解釋裡可以看出,它就是一種典型

的有棧協程,如圖12-11所示。圖12-7 JVMLS 2018大會上Oracle對纖程的介紹

Loom專案背後的意圖是重新提供對使用者執行緒的支援,但與過去的綠色執行緒不同,這些新功能不是

為了取代當前基於作業系統的執行緒實現,而是會有兩個併發程式設計模型在Java虛擬機器中並存,可以在程

序中同時使用。新模型有意地保持了與目前執行緒模型相似的API設計,它們甚至可以擁有一個共同的

基類,這樣現有的程式碼就不需要為了使用纖程而進行過多改動,甚至不需要知道背後採用了哪個併發

程式設計模型。Loom團隊在JVMLS 2018大會上公佈了他們對Jetty基於纖程改造後的測試結果,同樣在

5000QPS的壓力下,以容量為400的執行緒池的傳統模式和每個請求配以一個纖程的新併發處理模式進行

對比,前者的請求響應延遲在10000至20000毫秒之間,而後者的延遲普遍在200毫秒以下,具體結果如

圖12-8所示。

圖12-8 Jetty在新併發模型下的壓力測試

在新併發模型下,一段使用纖程併發的程式碼會被分為兩部分——執行過程(Continuation)和排程

器(Scheduler)。執行過程主要用於維護執行現場,保護、恢復上下文狀態,而排程器則負責編排所

有要執行的程式碼的順序。將排程程式與執行過程分離的好處是,使用者可以選擇自行控制其中的一個或

者多個,而且Java中現有的排程器也可以被直接重用。事實上,Loom中預設的排程器就是原來已存在

的用於任務分解的Fork/Join池(JDK 7中加入的ForkJoinPool)。

Loom專案目前仍然在進行當中,還沒有明確的釋出日期,上面筆者介紹的內容日後都有被改動的

可能。如果讀者現在就想嘗試協程,那可以在專案中使用Quasar協程庫[1],這是一個不依賴Java虛擬

機的獨立實現的協程庫。不依賴虛擬機器來實現協程是完全可能的,Kotlin語言的協程就已經證明了這

一點。Quasar的實現原理是位元組碼注入,在位元組碼層面對當前被呼叫函式中的所有區域性變數進行儲存

和恢復。這種不依賴Java虛擬機器的現場保護雖然能夠工作,但很影響效能,對即時編譯器的干擾也非

常大,而且必須要求使用者手動標註每一個函式是否會在協程上下文被呼叫,這些都是未來Loom專案要

解決的問題。

[1] 如同JDK 5把Doug Lea的dl.util.concurrent專案引入,成為java.util.concurrent包,JDK 9時把Attila Szegedi的dynalink專案引入,成為jdk.dynalink模組。Loom專案的領導者Ron Pressler就是Quasar的作者

相關文章