Java服務器的模型—TCP連接/流量優(yōu)化
通常,我們的應用程序不需要并行處理成千上萬的用戶,也不需要在一秒鐘內處理成千上萬的消息。我們只需要應付數(shù)十或數(shù)百個并發(fā)連接的用戶,就可以在內部應用程序或某些微服務應用程序中承受如此大的負擔。
在這種情況下,我們可以使用某些高級框架/庫,這些框架/庫在線程模型/使用的內存方面沒有得到優(yōu)化,并且仍然可以承受一些合理的資源和相當快的交付時間。
然而,有時我們會遇到這樣的情況:我們的系統(tǒng)的一部分需要比其他應用程序更好地擴展。用傳統(tǒng)的方法或框架編寫系統(tǒng)的這一部分可能會導致巨大的資源消耗,并且需要啟動同一服務的許多實例來處理負載。導致處理成千上萬個連接的算法和方法也被稱為C10K問題。
在本文中,我將主要關注在TCP連接/流量方面可以進行的優(yōu)化,以優(yōu)化(微型)服務實例以盡可能少地浪費資源,深入了解操作系統(tǒng)如何與TCP和Sockets一起工作,以及最后但并非最不重要的是,如何深入了解所有這些事情。我們開始吧。
I/O編程策略
讓我們描述一下我們目前擁有什么類型的I/O編程模型,以及在設計應用程序時需要從哪些選項中進行選擇。首先,沒有好的或壞的方法,只有更適合我們當前用例的方法。選擇錯誤的方法在將來會產(chǎn)生非常不方便的后果。它可能導致資源浪費,甚至從頭開始重新編寫應用程序。
帶阻塞處理的阻塞I/O
每個連接服務器的線程數(shù)
這種方法背后的想法是,如果沒有任何專用/空閑線程,就不接受套接字連接(稍后我們將展示它的含義)。在這種情況下,阻塞意味著特定的線程被綁定到連接,并且總是在讀取或寫入連接時阻塞。
- public static void main(String[] args) throws IOException {
- try (ServerSocket serverSocket = new ServerSocket(5050)) {
- while (true) {
- Socket clientSocket = serverSocket.accept();
- var dis = new DataInputStream(clientSocket.getInputStream());
- var dos = new DataOutputStream(clientSocket.getOutputStream());
- new Thread(new ClientHandler(dis, dos)).start();
- }
- }
- }
最簡單的套接字服務器版本,從端口5050開始,以阻塞的方式從InputStream讀取并寫入OutputStream。當我們需要通過一個連接傳輸少量對象時很有用,然后在需要時關閉它并啟動一個新的對象。
- 即使沒有任何高級庫,它也可以實現(xiàn)。
- 使用阻塞流進行讀/寫(等待阻塞InputStream讀操作,該操作按當時TCP接收緩沖區(qū)中可用的字節(jié)填充提供的字節(jié)數(shù)組,并返回字節(jié)數(shù)或-1-流的結尾)和消耗字節(jié),直到我們有足夠的數(shù)據(jù)來構造請求。
- 當我們開始為無邊界的傳入連接創(chuàng)建線程時,會出現(xiàn)一個大問題和效率低下。我們將為非常昂貴的線程創(chuàng)建和內存影響付出代價,這與將一個Java線程映射到一個內核線程是密不可分的。
- 它不適合“真正的”生產(chǎn),除非我們真的需要一個內存占用率低的應用程序,并且不想加載屬于某些框架的很多類。
帶阻塞處理的非阻塞I/O
基于線程池的服務器
這是大多數(shù)知名企業(yè)HTTP服務器所屬的類別。一般來說,該模型使用多個線程池,使多cpu環(huán)境下的處理更高效,更適合企業(yè)應用程序。有幾種方法可以配置線程池,但基本思想在所有HTTP服務器中是完全相同的。請參閱HTTP Grizzly I/O策略,了解通??梢愿鶕?jù)基于線程池的非阻塞服務器配置的所有可能策略。
- 用于接受新連接的第一個線程池。如果一個線程能夠管理傳入連接的速度,它甚至可以是一個單線程池。通常有兩個積壓可以填補和下一個傳入連接拒絕。如果可能,請檢查是否正確使用了持久連接。
- 用于以非阻塞方式(選擇器線程或IO線程)從/寫入套接字的第二個線程池。每個選擇器線程處理多個客戶端(通道)。
- 第三個線程池,用于分離請求處理的非阻塞部分和阻塞部分(通常稱為工作線程)。某些阻止操作無法阻止選擇器線程,因為所有其他通道都無法取得任何進展(通道組只有一個線程,此線程將被阻止)。
- 非阻塞讀/寫是使用緩沖區(qū)實現(xiàn)的,只要處理請求的特定線程不滿意(因為它們沒有足夠的數(shù)據(jù)來構造例如HTTP請求),選擇器線程就會從套接字讀取新字節(jié)并寫入專用緩沖區(qū)(池緩沖區(qū))。
我們需要澄清非阻塞術語:
- 我們在Socket服務器的上下文中對話,那么非阻塞意味著線程沒有綁定到打開的連接,并且不等待傳入的數(shù)據(jù)(甚至在TCP發(fā)送緩沖區(qū)已滿的情況下寫入數(shù)據(jù)),只要嘗試讀取,如果沒有字節(jié),那么就不會將任何字節(jié)添加到緩沖區(qū)中以進行進一步處理(構造請求),給定的選擇器線程將繼續(xù)從另一個打開的連接讀取。
- 然而,在處理請求方面,代碼在大多數(shù)情況下是阻塞的,這意味著我們執(zhí)行一些代碼來阻塞當前線程,這個線程等待I/O綁定處理(數(shù)據(jù)庫查詢、HTTP調用、從磁盤讀取等)或一些長時間CPU綁定處理(計算哈希/階乘,加密挖掘,…)。如果執(zhí)行完成,則會喚醒線程,并在某些業(yè)務邏輯中繼續(xù)執(zhí)行。
業(yè)務邏輯的阻塞特性是工作池如此龐大的主要原因,我們只需要讓大量線程發(fā)揮作用來提高吞吐量。否則,在負載較高的情況下(例如,更多的HTTP請求),我們可能會導致所有線程都處于阻塞狀態(tài),并且沒有可用于請求處理的線程(沒有處于可運行狀態(tài)的線程可以在CPU上執(zhí)行)。
優(yōu)勢
即使請求的數(shù)量相當高,并且我們的許多工作線程在某些阻塞操作上被阻塞,我們也能夠接受新的連接,即使我們可能無法立即處理它們的請求,并且數(shù)據(jù)必須在TCP接收緩沖區(qū)中等待。
這種編程模型被許多框架/庫(Spring Controllers,Jersey,…)和HTTP服務器(Jetty,Tomcat,Grizzly…)暗中使用,因為它非常容易編寫業(yè)務代碼,如果真的需要的話,讓線程阻塞。
缺點
并行性通常不是由CPU的數(shù)量決定的,而是由阻塞操作的性質和工作線程的數(shù)量限制的。一般來說,這意味著如果阻塞操作(I/O)和進一步執(zhí)行(在請求過程中)的時間比率過高,那么我們可以得到:
- 阻塞操作(數(shù)據(jù)庫查詢…)上的許多阻塞線程
- 等待處理工作線程的大量請求,以及
- 由于沒有線程可以繼續(xù)執(zhí)行而非常未使用的CPU
較大的線程池導致上下文切換和CPU緩存的低效使用。
如何設置線程池
好的,我們有一個或多個線程池來處理阻塞的業(yè)務操作。但是,線程池的最佳大小是多少?我們可能會遇到兩個問題:
- 線程池太小,我們沒有足夠的線程來覆蓋所有線程被阻塞的時間,比如說等待I/O操作,而您的CPU沒有得到有效使用。
- 線程池太大,我們要為很多實際空閑的線程付出代價(見下面運行很多線程的代價)。
我覺得可以參考Brian Goetz的一本書Java并發(fā)實踐,書中說調整線程池的大小并不是一門精確的科學,它更多的是關于理解您的環(huán)境和任務的性質。
- 您的環(huán)境有多少CPU和多少內存?
- 任務主要執(zhí)行計算、I/O或某種組合嗎?
- 它們是否需要稀缺資源(JDBC連接)?線程池和連接池會相互影響,當我們充分利用連接池時,增加線程池以獲得更好的吞吐量可能沒有意義。
如果我們的程序包含I/O或其他阻塞操作,您需要一個更大的池,因為您的線程不允許一直放在CPU上。您需要使用一些分析器或基準來估計等待時間與計算任務時間的比率,并觀察生產(chǎn)工作負載不同階段(高峰時間與非高峰時間)的CPU利用率。
非阻塞處理的非阻塞I/O
基于與CPU核心相同的線程數(shù)的服務器
如果我們能夠以非阻塞的方式管理大部分工作負載,那么這種策略是最有效的。這意味著處理套接字(接受連接、讀、寫)是使用非阻塞算法實現(xiàn)的,但即使是業(yè)務處理也不包含任何阻塞操作。
這個策略的典型代表是Netty框架,所以讓我們深入了解一下如何實現(xiàn)這個框架的架構基礎,以了解為什么它最適合解決C10K問題。如果您想詳細了解它的工作原理,那么我可以推薦以下資源:
Netty in Action——作者是諾曼·莫爾。由Netty Framework Norman Mauer的作者撰寫。這是了解如何使用具有各種協(xié)議的處理程序基于Netty實現(xiàn)客戶端或服務器的寶貴資源。
具有異步編程模型的I/O庫
Netty是一個I/O庫和框架,它簡化了非阻塞IO編程,并為服務器生命周期和傳入連接期間發(fā)生的事件提供了異步編程模型。我們只需要用我們的lambdas連接回撥,我們就可以免費得到所有東西。
很多協(xié)議都可以在不依賴于某個大型庫的情況下使用。
開始用純JDK NIO構建應用程序是非常令人沮喪的,但Netty包含的特性使程序員保持在較低的級別,并提供了使許多事情更高效的可能性。Netty已經(jīng)包含了大多數(shù)眾所周知的協(xié)議,這意味著我們可以比在更高級別的庫(例如Jersey/Spring MVC for HTTP/REST)中使用大量樣板文件更有效地使用它們。
識別正確的非阻塞用例以充分利用Netty的能力
I/O處理、協(xié)議實現(xiàn)和所有其他處理程序都應該使用非阻塞操作來永不停止當前線程。我們總是可以使用額外的線程池來阻塞操作。但是,如果我們需要將每個請求的處理切換到專用的線程池來執(zhí)行阻塞操作,那么我們幾乎沒有使用Netty的功能,因為我們很可能會遇到與非阻塞IO相同的情況,即阻塞處理-一個大的線程池正好位于應用程序的不同部分。
在上圖中,我們可以看到Netty架構的主要組件。
EventLoopGroup-收集事件循環(huán)并提供要注冊到其中一個事件循環(huán)的通道。
event loop-處理給定事件循環(huán)的已注冊通道的所有I/O操作。EventLoop只在一個線程上運行。因此,對于一個EventLoopGroup,事件循環(huán)的最佳數(shù)量是cpu的數(shù)量(有些框架在出現(xiàn)頁面錯誤時使用多個cpu+1來擁有額外的線程)。
管道-保持處理程序的執(zhí)行順序(當發(fā)生某個輸入或輸出事件時排序和執(zhí)行的組件包含實際的業(yè)務邏輯)。管道和處理程序在屬于EventLoop的線程上執(zhí)行,因此,處理程序中的阻塞操作會阻塞給定EventLoop上的所有其他處理/通道。