JVM的多路復用器實現原理
- Linux 2.5以前:select/poll
- Linux 2.6以后: epoll
- Windows: Winsock的select模型(感謝評論指正,僅Java NIO.2使用了Windows IOCP,由於Netty沒有采用NIO.2此處不展開)
- Free BSD, OS X: kqueue
下面僅講解Linux的多路復用。
Linux中的IO
Linux的IO將所有外部設備都看作文件來操作,與外部設備的操作都可以看做文件操作,其讀寫都使用內核提供的系統調用,內核會返回一個文件描述符(fd, file descriptor),例如socket讀寫使用socketfd。描述符是一個索引,指向內核中一個結構體,應用程序對文件的讀寫通過描述符完成。
一個基本的IO,涉及兩個系統對象:調用這個IO進程的對象,系統內核,read操作發生時流程如下:
- 通過read系統調用向內核發起讀請求。
- 內核向硬件發送讀指令,並等待讀就緒。
- 內核把將要讀取的數據復制到描述符所指向的內核緩存區中。
- 將數據從內核緩存區拷貝到用戶進程空間中。
Linux I/O模型簡介
- 阻塞I/O模型:最常用,所有文件操作都是阻塞的。
- 非阻塞I/O模型:緩沖區無數據則返回,一般采用輪詢的方式做狀態檢查。
- I/O復用模型:詳細見下
- 信號驅動I/O:使用信號回調應用,內核通知用戶何時開啟一個I/O操作。
- 異步I/O:內核操作完成后進行通知,內核通知用戶何時完成一個I/O操作。
Linux IO 多路復用
使用場景
- 客戶處理多個描述符(交互輸入,網絡套接口)
- 客戶處理多個套接口(少見)
- TCP服務器既要處理監聽套接口,又要處理已連接套接口。
- 一個服務器既要處理TCP,又要處理UDP
- 一個服務器處理多個服務/多個協議
與多進程/多線程對比
I/O多路復用系統開銷小,系統不必創建進程/線程,也不需要維護這些進程/線程。
系統調用
目前支持I/O多路復用的系統調用包括select,pselect,poll,epoll,I/O多路復用即通過一種機制,一個進程可以監視多個描述符,一旦某個描述符准備就緒,就能夠通知程序進行相應的讀寫操作。
select/poll
select目前在所有平台支持,select函數監視文件操作符(將fd加入fdset),循環遍歷fdset內的fd獲取是否有資源的信息,若遍歷完所有fdset內的fd后無資源可用,則select讓該進程睡眠,直到有資源可用或超時則喚醒select進程,之后select繼續循環遍歷,找到就緒的fd后返回。select單個進程打開的fd有一定限制,由FD_SETSIZE設置,默認為1024(32位)和2048(64位)。
poll與select的主要區別是不使用fdset,而是使用pollfd結構(本質鏈表結構),因而沒有fd數目限制。
poll和select共有的問題:
- 每次select/poll找到就緒的fd,都需要把fdset/pollfd進行內存復制。
- select/poll,都要在內核中遍歷所有傳遞來的fd來尋找就緒的fd,隨着監視的fd數量增加,效率也會下降。
epoll
Linux 2.6內核中提出了epoll,epoll包括epoll_create,epoll_ctl,epoll_wait三個函數分別負責創建epoll,注冊監聽的事件和等待事件產生。
- epoll每次注冊新的事件到epoll中時,都會把所有fd拷貝進內核,而不是在epoll_wait時重復拷貝,保證每個fd在整個過程中僅拷貝一次。此外,epoll將內核與用戶進程空間mmap到同一塊內存,將fd消息存於該內存避免了不必要的拷貝。
- epoll使用事件的就緒通知方式,通過epoll_ctl注冊fd,一旦該fd就緒,內核就通過回調函數把就緒的fd加入一個就緒鏈表,喚醒epoll_wait進入睡眠的進程,epoll_wait通知消息給應用程序后再次睡眠。因此epoll不隨着fd數目增加效率下降,只有活躍fd才會調用回調函數,效率與連接總數無關。
- epoll沒有最大並發連接的限制,1G內存約能監聽10萬個端口。
epoll有LT模式和ET模式:
- LT模式:epoll_wait檢測到fd並通知后,應用程序可以不立刻處理,下次調用epoll_wait,會再次通知;
- ET模式:應用程序必須立刻處理,下次調用,不會再通知此事件。ET模式效率更高,epoll工作在ET模式下必須使用非阻塞套接字。
性能對比
- 如果有大量的idle-connection或dead-connection,epoll效率比select/poll高很多。
- 連接少連接十分活躍的情況下,select/poll的性能可能比epoll好。
Java的IO模式
- BIO:即傳統Socket編程,線程數:客戶端訪問數為1:1,由於線程數膨脹后系統性能會急劇下降,導致了BIO的低效。
- 偽異步I/O:為了解決一個鏈路一個線程的問題,引入線程池處理多個客戶端接入請求,可以靈活調配線程資源,可以限制線程數量防止膨脹,但底層仍是阻塞模型,高客戶端訪問時,會有通信阻塞的問題。
- NIO:Java NIO的核心為Channels, Buffers, Selectors。Channel有點像流,數據可以從Channel讀到Buffer中,也可以從Buffer寫到Channel內。而Selector則被用於多路復用,Java NIO可以把Channel注冊到Selector上,之后,Selector會獲取進入就緒狀態的Channel(Selector進行循環的select/poll/epoll/IOCP操作),並進行后續操作。Selector是NIO實現的關鍵。Java NIO編程較為復雜。
- AIO:NIO.2引入的異步通道概念,不需要多路復用器對注冊的通道輪詢即可異步讀寫,簡化了NIO的編程。(但是Netty作者稱AIO的性能並不比NIO和epoll好)
Netty
使用Netty而非直接使用Java NIO出於以下原因:
- Java NIO的API過於繁雜。
- Java NIO開發需要了解Reactor模型,Java多線程等。
- Java NIO低可靠性。
- Java NIO有很多臭名昭著的BUG,如NIO的epoll空輪詢bug
下面簡單介紹下Netty的部分功能。
ByteBuf
Netty的ByteBuf依然是Byte數組緩沖區,提供對基礎類型,byte[]數組,ByteBuffer,ByteBuf的讀寫,緩沖區自身的copy和slice,操作指針,字節序,構造實例等功能。相對於ByteBuffer,ByteBuf的讀寫采用兩個指針而非flip方案,增加了可靠性,並提供了自動擴展方案。
ByteBuf的內存池實現比較復雜,但是否使用內存池,有較大的性能差異。隨着JVM和JIT的發展,對象的分配和回收是個輕量級的工作,但是對於緩沖區Buffer,特別是堆外直接內存的分配和回收則仍很耗時。Netty提供了基於內存池的緩沖區重用機制,帶來了性能提高。UnpooledByteBufAllocator在Netty4仍然是默認的allocator,但在大多情況下,PooledByteBufAllocator將帶來更高性能。更改默認方式僅需在初始化時加以設置:
客戶端
b.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
服務端
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.config().setAllocator(PooledByteBufAllocator.DEFAULT);
Channel和Unsafe
Netty的Channel和NIO的Channel類似,但有自己的子類和實現。Unsafe則封裝了Netty不希望用戶調用的API,作為Channel的輔助類。
Channel包括而不限於網絡的讀,寫,客戶端發起連接,主動關閉連接,鏈路關閉,獲取雙方通信地址等功能。Channel也包括了Netty框架的相關功能,如獲取該Channel的EventLoop,獲取緩沖區分配器ByteBufAllocator和Pipeline等。Channel封裝了Java NIO不統一的SocketChannel和ServerSockerChannel,其接口定義大而全。
Unsafe是Channel的輔助接口,實際的I/O讀寫操作都是由Unsafe完成的。包括register,bind,disconnect,close,write,flush幾個接口,可以看到它更接近於原本的Java NIO Channel。
ChannelPipeline和ChannelHandler
Netty的pipeline和handler機制類似於Servlet和Filter,為了方便攔截和業務邏輯定制。Netty將Channel的管道抽象為ChannelPipeline,讓消息在其中流動,ChannelPipeline持有消息攔截器ChannelHandler列表,可以通過增加和刪除handler來改變業務邏輯,而不是對已有的handler進行修改。
ChannelHandler的種類繁多,且用戶可以自定義,自定義時,通常只需要繼承ChannelHandlerAdapter並重寫為了實現業務邏輯的必要方法即可。
此外,ChannelPipeline支持運行時動態添加或刪除ChannelHandler,某些場景下這個特性很實用。
ChannelPipeline是線程安全的,但ChannelHandler不是線程安全的,需要用戶自己進行保障。
EventLoop和EventLoopGroup
Netty的線程模型得以無鎖化依賴於其NioEventLoop。因此,此處詳細展開。
Netty的線程模型
- Reactor模型:所有I/O都在NIO線程完成,NIO線程作為服務端,接收所有客戶端TCP連接,並處理鏈路。
- Reactor多線程模型:由專門的一個Acceptor線程監聽服務端,接收客戶端的TCP請求,並調度一個subReactor線程池,該線程池維護多個處理線程,一個NIO線程可以處理N條鏈路。
- 主從Reactor多線程模型:在上述Reactor多線程模型基礎上,服務端接收客戶端TCP請求的不再是一個NIO線程,而是一個獨立的NIO線程池,Acceptor線程池僅用於客戶端的登錄和認證,鏈路建立成功就交給subReactor線程池做后續操作。
Netty線程池:服務端啟動時,創建bossGroup, workerGroup兩個NioEventLoopGroup,實際上是兩個Reactor線程池,一個用於接收客戶端TCP請求,一個用於處理I/O讀寫或執行業務。
- 接收線程池(bossGroup)職責:接收客戶端TCP連接,初始化Channel參數,將鏈路狀態變更通知給ChannelPipeline。
- I/O處理線程池(workerGroup)職責:異步讀取通信對端數據,發送讀事件給ChannelPipeline;異步發送消息到通信對端,調用ChannelPipeline的消息發送接口;執行業務或系統調用/定時任務等工作。
通過調整bossGroup和workerGroup的線程個數,group()函數參數數量,是否共享線程池等,Netty的Reactor模型可以在單線程,多線程,主從多線程等模式中切換。
NioEventLoop
Netty的NioEventLoop讀取到消息之后,直接調用ChannelPipeline的fireChannelRead方法,只要用戶不切換線程,一直都由NioEventLoop調用用戶的Handler,期間不切換線程,而是串行化運行handler,避免了多線程操作的鎖的競爭,達到性能最優。
NioEventLoop不純粹是一個IO線程,它既可以處理系統Task又可以處理定時任務。
Future和Promise
Future起源於JDK的Future,Netty的Future命名為ChannelFuture,與Channel操作有關。Netty中所有操作都是異步的,因此,獲取異步操作結果,就要交給ChannelFuture。ChannelFuture有completed何uncompleted兩種狀態,創建后處於uncompleted狀態,一旦I/O操作完成,則被設置成completed狀態,此時可能操作失敗,操作成功或操作被取消。和JDK的Future類似,ChannelFuture有很多方便的API,包括獲取操作結果,添加事件監聽器,取消I/O操作,同步等待等。
Promise是可寫的Future,用於設置I/O額結果。Netty發起I/O操作時,會創建一個新的Promise對象。
參考文獻
聊聊IO多路復用之select、poll、epoll詳解
關於同步,異步,阻塞,非阻塞,IOCP/epoll,select/poll,AIO ,NIO ,BIO的總結
【Java】從BIO、NIO到Linux下的IO多路復用
OSX/iOS中多路I/O復用總結
java nio及操作系統底層原理
Select函數實現原理分析
Netty4底層用對象池和不用對象池實踐優化
設置Netty接收Buff為堆內存模式
關於java nio在windows下實現
相關閱讀:NIO.2
NIO.2 uses IOCP
在 Java 7 中體會 NIO.2 異步執行的快樂
Java IO & NIO & NIO2
5種調優Java NIO和NIO.2的方式