前言
這篇文章主要是總結自己對於網絡編程中異步,同步,阻塞和非阻塞的理解,這個問題自從學習NIO以來一直困擾着我,,其實想來很久就想寫了,只不過當時理解不夠,無從下手。最近在學習VertX框架,又去熟悉了下Netty的代碼,因為了對於多線程也有了更深的理解,所以才開始對於這些概念有了理解,用於理清思路,本文需要有良好的多線程和網絡編程基礎,不適合初學者。
一、異步,同步,阻塞和非阻塞的理解
關於這四個概念在IO方面的理解我貼兩個鏈接,他們已經有了很好的說明我就不再講述:
以前在學習c++中muduo只是記得陳碩說的epoll是一個同步非阻塞的模型,但是網上很多人說Reactor模型是一個異步阻塞的模型,在學習Netty的時候官網是這么介紹的:
Netty is an asynchronous event-driven network application framework
for rapid development of maintainable high performance protocol servers & clients.
Netty是一個異步的高性能網絡框架,那么到底是誰說錯了?
其實大家都沒有錯誤,只是角度不同。
先說說什么IO是異步的?異步其實是針對數據從內核拷貝到用戶進程空間這個操作是誰完成的,同步IO非常好理解,當用戶進程發起一個read操作的時候發生一次系統調用,然后內核檢查有沒有數據,如果有則復制數據到進程空間,用戶進程繼續執行。而異步IO中復制數據到進程空間這個操作是內核幫你完成的,等完成之后再來通知你,執行你的邏輯。Reactor模型中,EventLoop線程在select到有可讀數據之后,然后在自己去讀取數據,所以從這個角度來講Reactor模型確實是同步的,在Linux的五種IO模型中只有異步IO是異步的。
那么為什么Netty說他是一個異步網絡庫呢,這其實是另一個角度的闡述,對於網絡庫的作者來說,他們面向的是Linux提供的這些api,所以說多路復用的Reactor是同步的沒問題。那么對於Netty的使用者來說,我們面向的是Netty,Netty進一步封裝了IO操作,在我們發起IO操作的時候它返回了一個Future,我們可以提供一個監聽器來傳入我們的回調,當IO操作完成時會執行我們的邏輯,我們的這個操作相對於Netty就是異步的。
所以Reactor是同步非阻塞的,Netty是異步非阻塞的。
二、異步編程從用戶層面和框架層面不同角度的理解
Java中的Future是異步的嗎?
對於這個問題,我想相信很多同學都會認為是異步的,這里我認為是同步的,下面談談我的理解。
先想想一個異步操作需要哪些元素,我認為需要發起者,執行者,執行邏輯,回調邏輯。流程: 發起者請求執行者去執行所需邏輯,然后在成功之后調用回調邏輯。Future中缺了什么?沒錯,就是那個回調!
我們使用Future的模式一般是:投遞一個任務到線程池得有個Future,然后去執行其他可以並行的操作,操作完之后去調用Future的get方法獲取結果或者isDone判斷是否執行完畢。這里的Future只是對於計算結果的一個建模,我們在后面需要使用的時候再去輪詢(輪詢也是同步非阻塞的一個標志)或者阻塞,他提供的了一個非常好的特性:非阻塞!所以我認為Future是一個同步非阻塞的實現。也正是因為Future沒有實現異步的特性,在jdk1.8之后新增了CompletableFuture提供了異步的特性。
注意異步元素的發起者和執行者可以是同一個線程,最常見的例子就是NodeJs的單線程模型。拿Netty的線程來具體,你在EventLoop中發起一個寫請求后得到一個Future,你可以設置回調,下次執行這個回調的還是EventLoop線程
用戶角度的理解
這里主要說說在使用異步編程的一點理解,因為平時還是用為主,我們作為框架的使用者有必要了解一些常見的使用范式。就我目前接觸的最多還是CompletableFuture,Netty和VertX,當時也寫過一點Js,Js主要也是回調的用法。我知道的用法如下:
- 回調 這種是最常見的,相信也是最容易理解的,Js和VertX很多都采用了這個實現,我們在調用一個函數的時候提供一個響應結果的回調。響應式編程就是結合函數式和異步回調的一個產物,我相信以后會越來越常見
- 監聽器 這個是Netty的實現,Netty將很多同步的地方改成了異步同時返回一個Future,我們可以通過這個Future添加監聽器,執行得到結果時的邏輯
- 組合式 相對於回調式,在實現多個回調時代碼扁平化,可以了解下CompletableFuture的用法和實現真的是非常的優雅
因為異步的高性能,很多時候我們自己也想把一個操作封裝成異步的,就需要明白到底什么是異步,明白異步需要的元素,你會發現如果不借助以后的異步組件將一個操作封裝成異步非常的困難,所以最簡單的方案就是將你的回調最終傳遞到已有異步的組件中。
舉2個簡單的例子:
- 我們利用
CompletableFuture.supplyAsync(Object::new).thenAccept(o -> System.out.println(o));
這一行非常簡單的代碼實現了一個異步,Object::new
會被投遞到線程池中,然后執行完成后執行打印語句。 - VertX的例子,VertX將很多同步的操作封裝成了異步的操作,比如場景的發起Http請求的,他的底層實現就是將這個操作委托給了Netty
框架角度的理解
框架層面的理解有助於我們在寫代碼中不會用錯。有沒有想過一個異步操作框架給你做了什么?
當你發起一個操作的時候,框架會去執行你的邏輯,在執行完畢時(成功或異常)去修改狀態並執行你的回調。修改狀態並執行你的回調這個操作在JDK中放在了CompletableFuture中,在Netty中則單獨采用了Promise接口,其實兩者的實現是非常類似的(方法名都取的差不多)。以Netty舉例分為Future和Promise兩個方法,作為用戶我們更應該關心Future的接口,Promise是框架層面需要實現的,我們在自己去實現的時候值得我們去學習里面的思想。
不過我認為我們直接使用Promise的這種接口的機會很少,Netty和VertX場景下還是有機會用到,在用到Promise接口的時候應該考慮下是否合理,檢查下是不是在同一個線程中,是不是可以簡單的接口代替。給一個簡單的錯誤示例:
這里說下Promise,我們知道Js中也有一個Promise,千萬不要當成類似的東西,兩者毫無干系,Netty的Promise是對完成操作的行為的建模,Js的Promise是為了組合各個異步的調用。
import io.VertX.core.Future;
public class AuctionHandler {
public Future<Void> handle() {
// 請求級別變量
Context context = new Context();
context.future.tryComplete();
return context.future;
}
public static class Context {
Future<Void> future = Future.future();
}
public static void main(String[] args) {
// 注意這里的handle方法返回的Future是VertX的。
// 這里的方法都是在同一個線程中執行的,完全沒有異步化,所以可以改成傳遞一個普通的接口即可
new AuctionHandler().handle().setHandler(event1 -> System.out.println("handler exec!"));
}
}
雖然這個的代碼錯誤看上去很低級,但是在開發VertX應用時需要時刻保持警惕。另外還有一點需要說明:當返回給你的Future已經是完成狀態時,如上面的代碼示例,你再增加回調,這個回調還會被執行,Netty和CompletableFuture在添加回調的時候都是檢查狀態是否完成,完成的話直接投遞到相應線程執行。
三、為什么使用異步
為什么要使用異步,相信很多同學都知道是為了高性能,那么異步為什么高性能?
這里先談談NodeJs和Java,對於NodeJs,很多人聽說性能十分高,"秒殺"Java。我當時一直無法理解,為什么Js能超過Java,
首先Node是單線程的,雖然可以借助第三方庫來實現多線程,另外Jvm作為業界最優秀的虛擬機,那么Node到底是靠了什么超過了Java?這里的關鍵就在於Node的Io模型采用了Reactor模型,可以處理大量的連接。Java中的Web開發是以Servlet為主導,采用了同步阻塞模型,雖然用線程池實現n個連接用m和線程做優化,但是當有大量連接時,線程數量過多導致的線程調度成本會很高,另外在線程處理Io的時候也是同步阻塞,如果對方返回很難會導致當前線程一直無法釋放,所以Tomcat這種不適合處理大量連接的場景。
我們知道Jetty的底層實現就是Reactor模型,Tomcat在8之后默認也用了Reactor是不是會大幅提高性能?不幸的是,雖然可以提高一些性能但是還是無法和Node一較高低,他解決的是Http連接那一塊的阻塞問題,但是由於Servlet的編程模型,大量的同步阻塞操作還是無法避免,比如你在一個請求中去訪問了數據庫,這個線程就會一直被占用,一定程度上你可以通過增加線程來緩解但是線程過多又會增加調度的成本,可能會導致虛擬機假死。所以如果你的處理中有這種耗時操作,那他就是你的瓶頸,你的qps的上限就很低。在高並發場景下,Servlet的瓶頸會十分突出,只能通過大量的堆機器來水平擴展,但是沒有很好的榨干服務器的性能。
所以我們需要的是編程模型的改變,像Nodejs那樣在同步阻塞的地方進行異步非阻塞或者異步阻塞化。Spring5.0中的 WebFlux給了一個對應的解決方案,提供了響應式編程的模型用以取代Servlet,他對常見同步阻塞的地方進行了重寫,如Redis和Mysql等常見的IO。很早之前VertX(早期名字Node.X,Java版的Nodejs)框架也提供了這樣的編程模型,對很多同步阻塞的地方進行了重寫,這個框架十分輕量級,社區活躍度非常高,使用起來非常方便。這兩個底層都是Netty,不得不說Netty實在是太強大了。也從另外一個角度說明設計的重要性,語言反而是其次。NodeJs,WebFlux和VertX都采用了類似的Reactor模型,高性能服務器領域這個模型幾乎已經是最佳實踐,理解這個模型就和多線程一樣重要。我覺得拿Servlet和NodeJs來做性能的對比,是十分不公平的。NodeJs在Java領悟的對手應該是VertX這種框架,關於高性能Web框架的對比,techempower這個網站已經給出了詳細的排名,排名前十的大部分是Jvm語言,Nodejs在五十名之后了,所以不要在拿Servlet去和NodeJs做對比了,Servlet這種模型在高並發領悟一定會被逐漸取代。所以要深入理解響應式編程,擁抱響應式編程,現有的代碼以及未來的開發都可以用響應式編程來做優化。
那么異步到底解決了什么問題?
上面舉的例子只是簡單說明了現有的異步非阻塞框架的性能優勢。但是這個問題我也無法給出准確的解釋,只是談談我自己的理解:
- 非阻塞很好理解,如果是阻塞的,那個當前的用戶線程一定被hang住,直到數據寫完或者讀完(這個過程中這個線程就是沒用的,所以我們需要開啟大量的線程),如果非阻塞可以立即返回,繼續處理其他任務。
- 異步的理解我用一個例子來說名:Netty中發起一個寫操作時立即返回了一個Future,用戶可以提供一個監聽器執行寫操作完成后的邏輯。試想如果這里是同步非阻塞的,即調用Future的sync方法(不要在EventLoop中調用,導致死鎖),那就會白白浪費一個線程,
程序運行過程中始終是圍繞着兩個主題:IO、CPU。CPU和IO的速度差距十分大,異步和Reactor模型都是為了平衡這個差距,讓CPU能充分利用起來,不要因為IO和其他同步操作導致線程Hang住,始終處於可運行的狀態,可以使用少量的線程充分利用CPU。
四、理解這些能在實際中的應用
很多人可能會疑問就算了把這些弄的明明白白到底有什么用?其實如果你很好的掌握了Reactor的編程模型,很多問題就能想明白了下面談下自己理解的有用的地方:
- 如果用過Redis都了解他是單線程來處理用戶的請求的,他實際就是采用了Reactor模型來處理請求,也就很好的理解了為什么Redis單線程能保持很好的性能。知道了他的實現,在使用的過程中就知道盡量避免大對象的傳輸,因為是單線程處理,如果一個連接傳輸大對象那么別的連接的請求將不能被及時處理。還有Redis需要處理過期的鍵,它內部有定時任務去清理過期鍵,那么既然Redis是單線程的這個任務由誰去執行呢?還是那個處理請求的EventLoop線程,EventLoop線程其實不光處理IO請求,還會處理一些任務和定時任務用來避免鎖(具體可以參考Netty的網絡模型)
- 明白NodeJs的高性能,我覺得也是一個應用,在技術選型的過程中不用人雲亦雲。Java也有拿得出手的框架:Netty
- 采用響應式框架編寫代碼。,在開發響應式代碼中心中也能保持警惕自己所寫的代碼會不會導致EventLoop的阻塞(阻塞EventLoop是相當嚴重的問題)。如果阻塞最好是能通過異步的api實現業務邏輯,如果避免不了阻塞或者耗時操作,則需要把任務投遞到另外的線程池中去處理,任何情況下都不要去阻塞EventLoop,像VertX框架中如操作Mysql,PostgreSql這種都已經有了異步的實現。響應式編程一種趨勢,從現在開始擁抱它吧!
- 在學習Dubbo的時候他默認的Rpc協議Dubbo協議底層就是Netty,消費者和提供者之間是單一長連接,所以官網也指出他更適合小數據量大並發,因為單個連接的帶寬上限在7MByte左右。如果要傳輸文件,可以采用Http,這樣的帶寬上限就是物理網卡的上限,Http可以開啟多個連接。
- 上面四條說了Reactor結合異步的,其實Jdk8中的CompletableFuture是一個非常優秀的異步實現,我們在需要異步化邏輯時(比如調用第三方接口)可以充分利用這個類,我曾經也寫過一點關於這個類:異步編程降低延遲
最后還想說一句,Netty這個框架實在是太強大了,線程模型設計十分優秀,VertX把很多異步操作委托給了底層的Netty,因為Netty實現中的EventLoop具有天然的線程隔離(一個EventLoop對應一個線程,只會被這個線程調用),很多地方免去了同步,VertX同樣繼承了這個優點,有機會一定好好看看Netty的設計和源碼。
六、困惑
阻塞和同步的四種組合,對於異步阻塞還是無法理解,這種模式真的存在嗎?
參考文章
- 回調地獄的今生前世
- 怎樣理解阻塞非阻塞與同步異步的區別? - 嚴肅的回答 - 知乎
- 怎樣理解阻塞非阻塞與同步異步的區別? - 陳碩的回答 - 知乎
- Netty官網
- IO - 同步,異步,阻塞,非阻塞
- nodejs真的是單線程嗎?
- 作為一個服務器,node.js 是性能最高的嗎? - 圓胖腫的回答 - 知乎
- web框架性能排名
- Java8實戰第11章
- Java並發編程實戰
- Netty權威指南第二版