現在面試都不滿足于問進程線程,開始問起協程了?
用 Go 語言的小伙伴對協程應該都非常熟悉了,而 Java 直到 2022 年 9 月 20 日,JDK19 才終于提供了協程(官方說法是 Virtual Thread 虛擬線程,不過看介紹就是協程 Coroutine)的測試版本功能。
在 Java 中,我們一直依賴線程作為并發服務器應用程序的構建基礎。每個方法中的每個語句都在線程內執行,并且每個線程都提供一個堆棧來存儲局部變量和協調方法調用,以及出錯時的上下文,開發人員可以使用線程的堆棧來跟蹤程序的具體執行過程。
以下參考 OpenJDK 官方文檔:https://openjdk.org/jeps/425
Thread-Per-Request
Thread-Per-Request,翻譯過來就是一個請求一個線程。服務器應用程序通常處理相互獨立的并發用戶請求,因此應用程序通過在某個請求的持續時間內將一個線程專門用于處理這個請求是非常有意義且必要的。這種 thread-per-request 風格易于理解、易于編程、易于調試和分析,因為它使用平臺的并發單元來表示應用程序的線程數量,比如你有 100 個并發請求,那就對應 100 個線程。
但是,服務器應用程序的可伸縮性受 Little 定律支配,它與延遲、并發性和吞吐量相關,這里我簡單介紹下 Little 定律,不是什么重點知識,大伙兒隨便看下就行:
Little 定律是由 John Little 在 1961 年提出的,在一個具有穩定流量和容量的隊列中,平均用戶數等于平均流量和平均服務時間的乘積。
具體來說,假設我們有一個隊列,它有一定的容量,同時有一定的流量在進出隊列。如果我們令隊列中平均用戶數為 L,平均流量為 λ,平均服務時間為 W,則 Little 定律可以表示為:
這個定律適用于任何類型的任務,包括服務請求、進程、線程、作業、數據包等等。它可以用來預測系統的吞吐量、延遲和并發性,并且在系統設計和性能優化中非常有用。
- 所謂平均服務時間 W -> 其實就是請求處理的時間
- 平均用戶數 L -> 就是同時處理的請求數量
- 平均流量 λ -> 就是吞吐量
如果我們想要在平均服務時間 W(請求處理時間)不變的情況下,增大平均流量 λ(吞吐量),那么平均用戶數(L)勢必要同比例增長,換句話說,對于給定的請求處理持續時間(即延遲),應用程序同時處理的請求數(即并發性)必須與吞吐量成比例增長。
例如,假設一個平均延遲為 50ms(W = 0.05) 的應用程序通過并發處理 10 個請求(L = 10)來實現每秒 200 個請求的吞吐量(λ = 200)。為了使該應用程序擴展到每秒 2000 個請求的吞吐量(λ = 2000),它需要并發處理 100 個請求(L = 100)。
如果每個請求都需要一個單獨的線程進行處理,那么隨著吞吐量的增加,線程數量將會急劇增加。
不幸的是,可用線程的數量是有限的,因為 JDK 線程的本質其實是操作系統線程,詳細可看下這篇文章 Java 線程和操作系統的線程有啥區別?,而操作系統線程成本很高,所以我們不可能擁有太多線程,這使得 Thread-Per-Request 風格難以實現。如果每個請求在其持續時間內消耗一個 Java 線程,并因此消耗一個操作系統線程,那么在其他資源(例如 CPU 或網絡連接)耗盡之前,線程的數量必定會成為性能限制的重要因素,所以 JDK 當前的線程實現使得應用程序的吞吐量被限制在遠低于硬件可以支持的水平,有同學可能會說不是有線程池嗎?即使線程被池化也會發生這種情況,因為池化雖然有助于避免啟動新線程的高成本,但并不會增加線程總數。
使用異步
為了充分利用硬件,開發者們放棄了 Thread-Per-Request 的風格,轉而采用線程共享(Thread-Sharing)。不是在一個線程上從頭到尾處理一整個請求,而是在等待 I/O 操作完成時將該線程返回到線程池中,以便該線程可以為其他請求提供服務, I/O 操作完成后再利用回調函數進行通知。
通俗來說,在異步風格中,請求的每個階段可能在不同的線程上執行,并且每個線程以交錯的方式運行屬于不同請求的階段。這種細粒度的線程共享允許大量并發操作而不會消耗大量線程,消除了操作系統線程稀缺對吞吐量的限制。
舉個例子,假設有一個網絡服務器程序,需要處理來自客戶端的請求并進行數據庫查詢,然后將結果返回給客戶端。如果使用傳統的線程池來處理請求,每當有一個請求到來時,就需要從線程池中取出一個線程進行處理。但是在請求過程中,當線程需要等待數據庫查詢結果時,它就會被阻塞,無法進行其他的請求處理,浪費了一個線程資源。如果使用異步 IO 操作,當線程需要進行數據庫查詢時,它可以將這個線程釋放給線程池中的其他請求,等到數據庫查詢完成后,再將線程恢復執行,將查詢結果返回給客戶端。這樣,一個線程就可以處理多個請求,從而提高并發能力。
但是由于不是一個線程處理一整個請求,這就導致我們必須將請求處理邏輯分解為小階段,通常編寫為 lambda 表達式,然后使用 API 將它們組合成一個順序管道(比如 CompletableFuture)。
如果實際用過 lambda 表達式的同學肯定會深有感觸,這簡直是對 Debug 的災難性打擊:
- 堆棧跟蹤不提供可用的上下文
- 調試器無法單步執行請求處理邏輯
- 分析器無法將操作的成本與其調用者相關聯
并且,從另一個角度來說,這種編程風格與 Java 平臺不一致,因為應用程序的并發單元(異步管道)不再是平臺的并發單元(簡單來說就是 100 個并發請求不是對應 100 個線程了,可能就對應 10 個線程)。
使用協程
除開上述兩種編程風格的缺點考慮,使用進程/線程模型還有一個不容忽視的弊端,那就是上下文切換的開銷。而協程的上下文切換代價較小,其優勢在于可以將一個線程切換為多個協程,每個協程之間可以輕松地進行切換,從而提高應用程序的吞吐量。
舉個例子,我們只需要啟動 100 個線程,每個線程上運行 100 個協程,這樣不僅減少了線程切換開銷,而且還能夠同時處理 100 * 100 = 10000 個請求。
所以什么是協程(Coroutine)?
- 協程是一種運行在線程之上的「用戶態」模型,也稱為纖程(Fiber),協程并沒有增加線程數量,只是在線程的基礎之上通過分時復用(并發)的方式運行多個協程。
- 協程的切換在用戶態完成(完全由用戶控制,這一點就顯著區別于進程/線程模型),它是一種非搶占式的調度方式,當一個協程執行完成后,可以選擇主動讓出,讓另一個協程運行在當前線程之上。協程擁有自己的寄存器上下文和棧,協程調度切換時,將寄存器上下文和棧保存到線程的堆區,在切回來的時候,恢復先前保存的寄存器上下文和棧,直接操作棧則基本沒有內核切換的開銷,可以不加鎖的訪問全局變量,所以上下文的切換非???,比線程從用戶態到內核態的代價小很多,
分析下協程相對于進程/線程的好處:
- 輕量性:協程只需要保存少量的上下文信息,占用的資源更少,可以創建更多的協程。相比之下,線程/進程需要占用較大的內核資源,創建線程的開銷也更大。
- 高效性:協程切換不需要內核態/用戶態切換,可以在用戶態直接切換上下文,速度更快。
- 靈活性:協程的切換由程序員主動控制,可以靈活地在不同協程之間切換,實現并發執行。而線程/進程的切換由操作系統內核進行調度,限制了并發度和靈活性。
- 可維護性:由于協程是在代碼層面進行控制,可以更容易地編寫和維護。而線程之間的同步和共享資源需要復雜的鎖機制和線程間通信。
使用協程的注意事項
協程運行在線程之上,所以必然受到線程的限制。
如果協程調用了一個阻塞 IO 操作,由于操作系統并不知道協程的存在(因為協程運行在用戶態),它只知道線程,因此在協程調用阻塞 IO 操作的時候,操作系統會讓協程之上對應的線程陷入阻塞狀態,也就是說當前的協程和其它綁定在該線程之上的協程都會陷入阻塞而得不到調度。
因此在協程中要么就別調用導致線程阻塞的操作,要么就采用異步編程的方式。