BIO、NIO 到多路復(fù)用的演進(jìn)路徑,你明白了嗎?
從 NIO 到 Netty
IO 是編程中一個(gè)重要的概念,不論是數(shù)據(jù)存儲和網(wǎng)絡(luò)通信,底層都是會用到,理解 IO 對面試和工作都有很大的幫助,也能從基礎(chǔ)理論層面扎實(shí)基礎(chǔ),理解其上層應(yīng)用就簡單的多了。在常用的軟件中,例如 Nginx、Redis、Dubbo、Kafka 都涉及到了 NIO 的一些基礎(chǔ)知識,本文就從簡單的 IO 開始剖析,從 BIO 到 NIO 再到 Netty,從理論到實(shí)踐進(jìn)行深入的理解。
計(jì)算機(jī)組成
計(jì)算機(jī)的組成包括 CPU、內(nèi)存存儲、網(wǎng)卡、磁盤存儲和其它外部設(shè)備。在 Linux 操作系統(tǒng)中,一切皆文件(即文件描述符 fd,file descriptor),在服務(wù)啟動時(shí),會加載內(nèi)核程序到 CPU 中運(yùn)行。為了保證服務(wù)的正常運(yùn)行,內(nèi)核程序具有較高的優(yōu)先級,所占用的空間為內(nèi)核空間,其它應(yīng)用程序所占用的空間為用戶空間。以 Java 程序?yàn)槔齺碇v,其也是一個(gè)程序并且占用一定的內(nèi)存空間,在應(yīng)用運(yùn)行過程中,如果有 IO 操作或者計(jì)算需求,則需要將其轉(zhuǎn)交給內(nèi)核程序來完成。因?yàn)閮?nèi)核(kernel)保護(hù)模式的存在,應(yīng)用程序是沒有權(quán)限調(diào)用 CPU 的,一切的操作都需要通過內(nèi)核程序來完成,只有這樣才能保證一旦應(yīng)用程序錯(cuò)誤,內(nèi)核程序不會受影響,整個(gè)系統(tǒng)就沒有宕機(jī)的風(fēng)險(xiǎn)。
內(nèi)核程序和應(yīng)用程序之間通過中斷(通常的有 80 中斷)來完成操作的切換,應(yīng)用系統(tǒng)通過內(nèi)核程序提供的系統(tǒng)調(diào)用(System Call,是一系列的系統(tǒng)操作函數(shù),是內(nèi)核系統(tǒng)暴露出來的 API)來實(shí)現(xiàn)對 CPU 或 IO 的操作,CPU 通過 FCFS(非搶占式的先來先服務(wù)算法)分配各個(gè)任務(wù)的時(shí)間片,來實(shí)現(xiàn)各個(gè)任務(wù)并發(fā)運(yùn)算。在 Java 的多線程應(yīng)用中,有個(gè)上下文切換的概念,這就是應(yīng)用線程將任務(wù)切換到內(nèi)核線程,在 CPU 的時(shí)間片內(nèi)繼續(xù)進(jìn)行操作,完成操作后將內(nèi)核線程切換到應(yīng)用線程。
進(jìn)程是系統(tǒng)分配資源的基本單位,線程是 cpu 執(zhí)行調(diào)度的基本單位,線程也稱之為輕量級的進(jìn)程(LWP)。java 的線程就是通過內(nèi)核的系統(tǒng)調(diào)用,在操作系統(tǒng)中獲取到的輕量級進(jìn)程。
阻塞與非阻塞/同步與異步
這里線說一下小編理解的阻塞與非阻塞以及同步和異步的概念:
阻塞和非阻塞描述的是用戶線程調(diào)用內(nèi)核 IO 操作的方式,阻塞是發(fā)起調(diào)用后需要等待直至內(nèi)核給出結(jié)果數(shù)據(jù)是否可讀可寫,非阻塞是發(fā)起用調(diào)用后無需等待結(jié)果,給出狀態(tài)值-1 表示正在處理。
同步和異步描述的是用戶線程和內(nèi)核的交互數(shù)據(jù)的方式,同步是需要用戶線程自己獲取數(shù)據(jù),即使是多路復(fù)用器也是解決了阻塞的問題,還需要用戶線程自己獲取數(shù)據(jù),依舊是同步 IO 模型。而異步是用戶線程發(fā)起調(diào)用后不需要主動獲取數(shù)據(jù),而是內(nèi)核處理完畢后將數(shù)據(jù)放入用戶空間中再通知用戶線程繼續(xù)業(yè)務(wù)處理。
在常見 socket 編程中,如下所示:
//把Socket服務(wù)端啟動
ServerSocket server = new ServerSocket(8986);
while (true) {
// 阻塞方法,等待客戶端的接入
Socket client = server.accept();
// 得到輸入流
InputStream input = client.getInputStream();
// 建立緩沖區(qū)
byte[] buff = new byte[4096];
int len = input.read(buff);
// 只要一直有數(shù)據(jù)寫入,len就會一直大于0
if (len > 0) {
String msg = new String(buff, 0, len);
System.out.println("收到" + msg);
}
}
在操作系統(tǒng)中運(yùn)行使,如何監(jiān)聽其操作系統(tǒng)級別的指令呢?首先需要將創(chuàng)建 java 文件
# 創(chuàng)建 java 文件
Bio001Test.java
# 然后使用 javac 命令編譯成 Bio001Test.class
javac Bio001Test.java
# 執(zhí)行java 代碼
java Bio001Test
# 使用 strace 命令進(jìn)行監(jiān)聽系統(tǒng)調(diào)用的情況,其底層是使用內(nèi)核的ptrace 特性來實(shí)現(xiàn)的
strace -ff -o out java Bio001Test
下圖是 java 代碼打印出的信息,顯示了 http 的請求記錄:
相比 BIO 的代碼, NIO 的代碼就比較復(fù)雜了,BIO 是阻塞的,NIO 是非阻塞的, BIO 是面向流的,只能單向讀寫,NIO 是面向緩沖的, 可以雙向讀寫。
# bio 的阻塞方法
server.accept()
# nio 的非阻塞方法,提出 channel selector buffer 的概念來解決io,利用事件注冊狀態(tài)來處理請求信息
selecter.select()
使用 man socket 來查看操作系統(tǒng)中 socket 傳入的參數(shù),如下所示:
# 操作系統(tǒng)的函數(shù)都是 C 語言編寫的,java 也是類C 的語言
socket()
# 創(chuàng)建一個(gè)用于通信的文件描述符
creates an endpoint for communication and returns a descriptor.
...
# 設(shè)置非阻塞參數(shù)項(xiàng)
SOCK_NONBLOCK
Set the O_NONBLOCK file status flag on the new open file description. Using this flag saves extra calls to fcntl(2) to achieve the same result.
socket 稱之為套接字、或者插座,屬于網(wǎng)絡(luò)應(yīng)用程序接口。即是應(yīng)用層到傳輸層的接口,也是用戶進(jìn)程與系統(tǒng)內(nèi)核交互的接口。一個(gè) TCP 連接的標(biāo)記為四元組,即源 ip:源 port + 目標(biāo) ip:目標(biāo) port, 我們都知道計(jì)算機(jī)的端口范圍為 0-65535,也就是說一個(gè)客戶端最多可以向目標(biāo)服務(wù)器發(fā)起 65535 個(gè)連接。
BIO 的模型
當(dāng)應(yīng)用發(fā)起調(diào)用后,在 kernel 沒有準(zhǔn)備好數(shù)據(jù)之前,應(yīng)用進(jìn)程一直會阻塞 block 進(jìn)入等待階段,當(dāng) kernel 準(zhǔn)備好數(shù)據(jù)之后,才會返回?cái)?shù)據(jù),此時(shí)應(yīng)用進(jìn)程阻塞解除。
NIO 的模型
因?yàn)?kernel 是阻塞的,在引入了 nio 之后,在應(yīng)用發(fā)起調(diào)用后會立即返回結(jié)果-1,代表內(nèi)核尚未準(zhǔn)備好數(shù)據(jù),應(yīng)用進(jìn)程無需等待,可以輪詢查看結(jié)果,直到數(shù)據(jù)準(zhǔn)備好為止,此時(shí)應(yīng)用進(jìn)程阻塞獲取數(shù)據(jù)。
多路復(fù)用器
即便是 nio 解決了阻塞的問題,但是無效的輪詢會造成 cpu 空轉(zhuǎn),浪費(fèi)資源,使用 IO 多路復(fù)用技術(shù),當(dāng)內(nèi)核將數(shù)據(jù)準(zhǔn)備好之后,通知應(yīng)用進(jìn)程來獲取數(shù)據(jù),就解決了這個(gè)問題,根據(jù)其操作的方式不同,分為 select/poll/epoll 三種多路復(fù)用器。 由內(nèi)核 kernel 監(jiān)控所有的 socket 當(dāng)數(shù)據(jù)準(zhǔn)備好之后,發(fā)起系統(tǒng)調(diào)用,即 system call 將數(shù)據(jù)從內(nèi)核拷貝到用戶進(jìn)程。
所以,I/O 多路復(fù)用的特點(diǎn)是通過一種機(jī)制一個(gè)進(jìn)程能同時(shí)等待多個(gè)文件描述符,而這些文件描述符(套接字描述符)其中的任意一個(gè)進(jìn)入讀就緒狀態(tài),select()函數(shù)就可以返回。
I/O 多路復(fù)用的優(yōu)勢是:同時(shí)處理多個(gè)連接請求。
select 是操作系統(tǒng)提供的系統(tǒng)調(diào)用函數(shù),通過它,可以把一個(gè)文件描述符的數(shù)組發(fā)給操作系統(tǒng), 讓操作系統(tǒng)去遍歷,確定哪個(gè)文件描述符可以讀寫, 然后告訴我們?nèi)ヌ幚恚?/p>
select 是操作系統(tǒng)提供的調(diào)用函數(shù),通過這個(gè)函數(shù)可以把一組 fd 傳給操作系統(tǒng),操作系統(tǒng)遍歷 fd,將完成準(zhǔn)備的文件描述符個(gè)數(shù)返回給用戶線程,用戶線程再去逐個(gè)遍歷 fd 查看哪個(gè) fd 已經(jīng)處于就緒的狀態(tài),然后再去處理。
select 的特點(diǎn)如下:
- 用戶需要將監(jiān)聽的 fd list 傳入到操作系統(tǒng)內(nèi)核中,內(nèi)核來完成遍歷操作并將解決返回,這樣在高并發(fā)場景數(shù)組的復(fù)制操作下會過多的消耗資源。select 的這一操作僅解決了系統(tǒng)的上下文切換的開銷,遍歷數(shù)組是依舊存在的。select 返回結(jié)果是就緒的 fd 個(gè)數(shù),用戶線程還需要判斷哪個(gè) fd 處于就緒狀態(tài)。
- select 可以傳入一組 socket 然后等待內(nèi)核的處理結(jié)果,但是其 list 大小只有 1024 個(gè),每次調(diào)用 select 都需要將 fd 數(shù)組從用戶態(tài)復(fù)制到內(nèi)核態(tài),其開銷比較大。調(diào)用 select 后返回的是就緒 fd 數(shù)量,還需要用戶再次遍歷。
針對 select 的缺點(diǎn),poll 為了增加單次監(jiān)聽 socket 的個(gè)數(shù),采用了鏈表的結(jié)構(gòu),放棄了數(shù)組的結(jié)構(gòu),但是其核心需要遍歷的缺點(diǎn)依然沒有解決。
針對 select 和 poll 的缺點(diǎn),epoll 應(yīng)運(yùn)而生,其核心主要包括三個(gè)方法:
# 在內(nèi)核開辟一個(gè)區(qū)域用來存放需要監(jiān)聽的fd
epoll_create
# 向內(nèi)核中添加、修改、刪除需要監(jiān)控的fd
epoll_ctl
# 返回已經(jīng)就緒的fd
epoll_wait
核心如下:
- 內(nèi)核中存儲了一份文件描述符 fd 的集合,無需用戶每次都從用戶態(tài)傳入,只需要告訴內(nèi)核修改的部分就可以。
- 內(nèi)核中不再通過輪詢的方式找到就緒的文件描述符 fd,而是通過異步 IO 事件進(jìn)行喚醒。
- 內(nèi)核會將有 IO 事件發(fā)生的文件描述符 fd 返回給用戶,用戶不需要自己進(jìn)行遍歷。
epoll 的數(shù)據(jù)操作有兩種模式:水平模式 LT(level trigger)和邊緣模式 ET(edge trigger)。LT 是 epoll 的默認(rèn)操作模式
- LT 模式: epollwait 函數(shù)檢測到有事件發(fā)生時(shí)需要通知應(yīng)用程序,但是應(yīng)用程序不一定及時(shí)進(jìn)行處理,當(dāng) epollwait 函數(shù)再次檢測到該事件的時(shí)還會通知應(yīng)用程序,直到事件被處理??梢岳斫鉃?mq 發(fā)送消息的 at least once 模型。
- ET 模式:epollwait 函數(shù)檢測到事件發(fā)生只會通知應(yīng)用程序一次,后續(xù) epollwait 函數(shù)將不再監(jiān)控該事件。因此 ET 模式降低了同一個(gè)事件被 epoll 觸發(fā)的次數(shù),效率比 LT 模式高。可以理解為 mq 發(fā)送消息的 exactly once 模型。
IO 多路復(fù)用方式有 select,poll 以及 epoll,該函數(shù)都是內(nèi)核層面的,從 BIO 的代碼中可以看到 accept 函數(shù),從之前的分析可以知道該方法是阻塞的,
Netty 實(shí)戰(zhàn)
大家都可能注意到了,在實(shí)際的操作中 NIO 的代碼是比較復(fù)雜的,Netty 就是對 NIO 做了包裝,保證在實(shí)際操作中方便使用。 針對 Server 端的代碼如下:
//Netty的Reactor線程池,初始化了一個(gè)NioEventLoop數(shù)組,用來處理I/O操作,如接受新的連接和讀/寫數(shù)據(jù)
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup work = new NioEventLoopGroup(8);
try {
//用于啟動NIO服務(wù)
ServerBootstrap serverBoot = new ServerBootstrap();
serverBoot.group(boss, work)
//通過工廠方法設(shè)計(jì)模式實(shí)例化一個(gè)channel
.channel(NioServerSocketChannel.class)
//設(shè)置監(jiān)聽端口
.localAddress(new InetSocketAddress(port))
// 設(shè)置 server 端的一些參數(shù)項(xiàng)
.childOption(ChannelOption.CONNECT_TIMEOUT_MILLIS,30000)
.childOption(ChannelOption.MAX_MESSAGES_PER_READ,16)
.childOption(ChannelOption.WRITE_SPIN_COUNT,16)
// 設(shè)置監(jiān)聽的處理 channel initializer
.childHandler(new AppServerChannelInitializer());
//綁定服務(wù)器,該實(shí)例將提供有關(guān)IO操作的結(jié)果或狀態(tài)的信息
ChannelFuture channelFuture = serverBoot.bind().sync();
System.out.println("在" + channelFuture.channel().localAddress() + "上開啟監(jiān)聽");
//阻塞操作,closeFuture()開啟了一個(gè)channel的監(jiān)聽器(這期間channel在進(jìn)行各項(xiàng)工作),直到鏈路斷開
channelFuture.channel().closeFuture().sync();
} catch (Exception e) {
log.error("encounter exception and detail is {}", e.getMessage());
} finally {
boss.shutdownGracefully().sync();//關(guān)閉EventLoopGroup并釋放所有資源,包括所有創(chuàng)建的線程
work.shutdownGracefully().sync();//關(guān)閉EventLoopGroup并釋放所有資源,包括所有創(chuàng)建的線程
}
一般情況下 IO 的壓力都是在服務(wù)端,默認(rèn)情況下客戶端也是采用的 BIO,除非是在客戶端也是需要提供服務(wù)。
// 配置相應(yīng)的參數(shù),提供連接到遠(yuǎn)端的方法
// I/O線程池
EventLoopGroup group = new NioEventLoopGroup();
try {
//客戶端輔助啟動類
Bootstrap bs = new Bootstrap();
bs.group(group)
//實(shí)例化一個(gè)Channel
.channel(NioSocketChannel.class)
.remoteAddress(new InetSocketAddress(host, port))
//通道初始化配置
.handler(new AppClientChannelInitializer());
//連接到遠(yuǎn)程節(jié)點(diǎn);等待連接完成
ChannelFuture future = bs.connect().sync();
//發(fā)送消息到服務(wù)器端,編碼格式是utf-8
future.channel().writeAndFlush(Unpooled.copiedBuffer("Hello World", CharsetUtil.UTF_8));
//阻塞操作,closeFuture()開啟了一個(gè)channel的監(jiān)聽器(這期間channel在進(jìn)行各項(xiàng)工作),直到鏈路斷開
future.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
}
總結(jié)
IO 從開始的瓶頸就是在操作系統(tǒng)的 read 數(shù)據(jù)讀取方法,由于這個(gè)阻塞的方法導(dǎo)致了 BIO 的產(chǎn)生,為了解決阻塞 IO 的問題,同時(shí)提高效率,就產(chǎn)生了使用多線程技術(shù)操作 IO 來提升性能,但是 IO 的瓶頸問題并沒有解決。后來操作系統(tǒng)做出了改變,提供了非阻塞的 read 函數(shù),這樣應(yīng)用程序在發(fā)起調(diào)用后不需要等待解決,而是采用輪詢的方式查詢數(shù)據(jù)有沒有準(zhǔn)備好,這樣相比 BIO 在同一時(shí)間內(nèi)就可以完成更多的 fd 操作,這就是 NIO。但是在高并發(fā)的場景下,對文件描述符的遍歷和讀取帶來了更多的輪詢操作,額外增加的系統(tǒng)調(diào)用增加了 cpu 的負(fù)擔(dān),并沒有帶來期望的性能提升。
后來操作系統(tǒng)做出了改進(jìn),將遍歷文描述符的操作放進(jìn)了內(nèi)核來實(shí)現(xiàn),這就是 IO 多路復(fù)用技術(shù)。多路復(fù)用的技術(shù)分為三個(gè)函數(shù), select、poll 和 epoll。 poll 解決了 select 單次傳入文件描述符的限制,但是沒有解決客戶端遍歷查詢文件描述符的問題,epoll 的產(chǎn)生解決了這個(gè)問題,只是將數(shù)據(jù)準(zhǔn)備好的 fd 返回給客戶端,減少了客戶端的遍歷操作。IO 模型的演進(jìn)也是根據(jù)應(yīng)用的需求而升級,倒逼操作系統(tǒng)的內(nèi)核增加更多的提升性能的操作。