UNIX系統(tǒng)的I/O模型
同步阻塞I/O不瓶、同步非阻塞I/O、I/O多路復(fù)用灾杰、信號(hào)驅(qū)動(dòng)I/O和異步I/O蚊丐。
什么是 I/O
就是計(jì)算機(jī)內(nèi)存與外部設(shè)備之間拷貝數(shù)據(jù)的過程。
為什么需要 I/O
CPU訪問內(nèi)存的速度遠(yuǎn)遠(yuǎn)高于外部設(shè)備艳吠,因此CPU是先把外部設(shè)備的數(shù)據(jù)讀到內(nèi)存里麦备,然后再進(jìn)行處理。
當(dāng)你的程序通過CPU向外部設(shè)備發(fā)出一個(gè)讀指令昭娩,數(shù)據(jù)從外部設(shè)備拷貝到內(nèi)存需要一段時(shí)間凛篙,這時(shí)CPU沒事干,你的程序是:
- 主動(dòng)把CPU讓給別人
- 還是讓CPU不停查:數(shù)據(jù)到了嗎栏渺?數(shù)據(jù)到了嗎呛梆?…
這就是I/O模型要解決的問題。
Java I/O模型
對(duì)于一個(gè)網(wǎng)絡(luò)I/O通信過程磕诊,比如網(wǎng)絡(luò)數(shù)據(jù)讀取削彬,會(huì)涉及兩個(gè)對(duì)象:
- 調(diào)用這個(gè)I/O操作的用戶線程
- 操作系統(tǒng)內(nèi)核
一個(gè)進(jìn)程的地址空間分為用戶空間和內(nèi)核空間全庸,用戶線程不能直接訪問內(nèi)核空間。
當(dāng)用戶線程發(fā)起I/O操作后(Selector發(fā)出的select調(diào)用就是一個(gè)I/O操作)融痛,網(wǎng)絡(luò)數(shù)據(jù)讀取操作會(huì)經(jīng)歷兩個(gè)步驟:
- 用戶線程等待內(nèi)核將數(shù)據(jù)從網(wǎng)卡拷貝到內(nèi)核空間
- 內(nèi)核將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間
有人會(huì)好奇壶笼,內(nèi)核數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間,這樣會(huì)不會(huì)有點(diǎn)浪費(fèi)雁刷?
畢竟實(shí)際上只有一塊內(nèi)存覆劈,能否直接把內(nèi)存地址指向用戶空間可以讀取沛励?
Linux中有個(gè)叫mmap的系統(tǒng)調(diào)用责语,可以將磁盤文件映射到內(nèi)存,省去了內(nèi)核和用戶空間的拷貝目派,但不支持網(wǎng)絡(luò)通信場(chǎng)景坤候!
各種I/O模型的區(qū)別就是這兩個(gè)步驟的方式不一樣。
同步阻塞I/O
用戶線程發(fā)起read調(diào)用后就阻塞了企蹭,讓出CPU白筹。內(nèi)核等待網(wǎng)卡數(shù)據(jù)到來,把數(shù)據(jù)從網(wǎng)卡拷貝到內(nèi)核空間谅摄,接著把數(shù)據(jù)拷貝到用戶空間徒河,再把用戶線程叫醒。
同步非阻塞I/O
用戶進(jìn)程主動(dòng)發(fā)起read調(diào)用送漠,這是個(gè)系統(tǒng)調(diào)用顽照,CPU由用戶態(tài)切換到內(nèi)核態(tài),執(zhí)行內(nèi)核代碼闽寡。
內(nèi)核發(fā)現(xiàn)該socket上的數(shù)據(jù)已到內(nèi)核空間代兵,將用戶線程掛起,然后把數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間爷狈,再喚醒用戶線程奢人,read調(diào)用返回。
用戶線程不斷發(fā)起read調(diào)用淆院,數(shù)據(jù)沒到內(nèi)核空間時(shí)何乎,每次都返回失敗,直到數(shù)據(jù)到了內(nèi)核空間,這次read調(diào)用后臭增,在等待數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間這段時(shí)間里,線程還是阻塞的各墨,等數(shù)據(jù)到了用戶空間再把線程叫醒。
I/O多路復(fù)用
用戶線程的讀取操作分成兩步:
- 線程先發(fā)起select調(diào)用启涯,問內(nèi)核:數(shù)據(jù)準(zhǔn)備好了嗎贬堵?
- 等內(nèi)核把數(shù)據(jù)準(zhǔn)備好了恃轩,用戶線程再發(fā)起read調(diào)用
- 在等待數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間這段時(shí)間里,線程還是阻塞的
為什么叫I/O多路復(fù)用黎做?
因?yàn)橐淮蝧elect調(diào)用可以向內(nèi)核查多個(gè)數(shù)據(jù)通道(Channel)的狀態(tài)叉跛。
NIO API可以不用Selector,就是同步非阻塞蒸殿。使用了Selector就是IO多路復(fù)用筷厘。
異步I/O
用戶線程發(fā)起read調(diào)用的同時(shí)注冊(cè)一個(gè)回調(diào)函數(shù),read立即返回宏所,等內(nèi)核將數(shù)據(jù)準(zhǔn)備好后酥艳,再調(diào)用指定的回調(diào)函數(shù)完成處理。在這個(gè)過程中爬骤,用戶線程一直沒有阻塞充石。
信號(hào)驅(qū)動(dòng)I/O
可以把信號(hào)驅(qū)動(dòng)I/O理解為“半異步”,非阻塞模式是應(yīng)用不斷發(fā)起read調(diào)用查詢數(shù)據(jù)到了內(nèi)核沒有霞玄,而信號(hào)驅(qū)動(dòng)把這個(gè)過程異步了骤铃,應(yīng)用發(fā)起read調(diào)用時(shí)注冊(cè)了一個(gè)信號(hào)處理函數(shù),其實(shí)是個(gè)回調(diào)函數(shù)溃列,數(shù)據(jù)到了內(nèi)核后劲厌,內(nèi)核觸發(fā)這個(gè)回調(diào)函數(shù)膛薛,應(yīng)用在回調(diào)函數(shù)里再發(fā)起一次read調(diào)用去讀內(nèi)核的數(shù)據(jù)听隐。
所以是半異步。
NioEndpoint組件
Tomcat的NioEndpoint實(shí)現(xiàn)了I/O多路復(fù)用模型哄啄。
工作流程
Java的多路復(fù)用器的使用:
- 創(chuàng)建一個(gè)Selector雅任,在其上注冊(cè)感興趣的事件,然后調(diào)用select方法咨跌,等待感興趣的事情發(fā)生
- 感興趣的事情發(fā)生了沪么,比如可讀了,就創(chuàng)建一個(gè)新的線程從Channel中讀數(shù)據(jù)
NioEndpoint包含LimitLatch锌半、Acceptor禽车、Poller、SocketProcessor和Executor共5個(gè)組件刊殉。
LimitLatch
連接控制器殉摔,控制最大連接數(shù),NIO模式下默認(rèn)是8192记焊。
當(dāng)連接數(shù)到達(dá)最大時(shí)阻塞線程逸月,直到后續(xù)組件處理完一個(gè)連接后將連接數(shù)減1。
到達(dá)最大連接數(shù)后遍膜,os底層還是會(huì)接收客戶端連接碗硬,但用戶層已不再接收瓤湘。
核心代碼:
public class LimitLatch {
private class Sync extends AbstractQueuedSynchronizer {
@Override
protected int tryAcquireShared() {
long newCount = count.incrementAndGet();
if (newCount > limit) {
count.decrementAndGet();
return -1;
} else {
return 1;
}
}
@Override
protected boolean tryReleaseShared(int arg) {
count.decrementAndGet();
return true;
}
}
private final Sync sync;
private final AtomicLong count;
private volatile long limit;
// 線程調(diào)用該方法,獲得接收新連接的許可恩尾,線程可能被阻塞
public void countUpOrAwait() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// 調(diào)用這個(gè)方法來釋放一個(gè)連接許可弛说,則前面阻塞的線程可能被喚醒
public long countDown() {
sync.releaseShared(0);
long result = getCount();
return result;
}
}
用戶線程調(diào)用LimitLatch#countUpOrAwait拿到鎖,若無法獲取特笋,則該線程會(huì)被阻塞在AQS隊(duì)列剃浇。
AQS又是怎么知道是阻塞還是不阻塞用戶線程的呢?
由AQS的使用者決定猎物,即內(nèi)部類Sync決定虎囚,因?yàn)镾ync類重寫了AQS#tryAcquireShared():若當(dāng)前連接數(shù)count < limit,線程能獲取鎖蔫磨,返回1淘讥,否則返回-1。
如何用戶線程被阻塞到了AQS的隊(duì)列堤如,由Sync內(nèi)部類決定什么時(shí)候喚醒蒲列,Sync重寫AQS#tryReleaseShared(),當(dāng)一個(gè)連接請(qǐng)求處理完了搀罢,又可以接收新連接蝗岖,這樣前面阻塞的線程將會(huì)被喚醒。
LimitLatch用來限制應(yīng)用接收連接的數(shù)量榔至,Acceptor用來限制系統(tǒng)層面的連接數(shù)量抵赢,首先是LimitLatch限制,應(yīng)用層處理不過來了唧取,連接才會(huì)堆積在操作系統(tǒng)的Queue铅鲤,而Queue的大小由acceptCount控制。
Acceptor
Acceptor實(shí)現(xiàn)了Runnable接口枫弟,因此可以跑在單獨(dú)線程里邢享,在這個(gè)死循環(huán)里調(diào)用accept接收新連接。一旦有新連接請(qǐng)求到達(dá)淡诗,accept方法返回一個(gè)Channel對(duì)象骇塘,接著把Channel對(duì)象交給Poller去處理。
一個(gè)端口號(hào)只能對(duì)應(yīng)一個(gè)ServerSocketChannel韩容,因此這個(gè)ServerSocketChannel是在多個(gè)Acceptor線程之間共享的款违,它是Endpoint的屬性,由Endpoint完成初始化和端口綁定宙攻。
可以同時(shí)有過個(gè)Acceptor調(diào)用accept方法奠货,accept是線程安全的。
初始化
protected void initServerSocket() throws Exception {
if (!getUseInheritedChannel()) {
serverSock = ServerSocketChannel.open();
socketProperties.setProperties(serverSock.socket());
InetSocketAddress addr = new InetSocketAddress(getAddress(), getPortWithOffset());
serverSock.socket().bind(addr,getAcceptCount());
} else {
// Retrieve the channel provided by the OS
Channel ic = System.inheritedChannel();
if (ic instanceof ServerSocketChannel) {
serverSock = (ServerSocketChannel) ic;
}
if (serverSock == null) {
throw new IllegalArgumentException(sm.getString("endpoint.init.bind.inherited"));
}
}
// 阻塞模式
serverSock.configureBlocking(true); //mimic APR behavior
}
- bind方法的 getAcceptCount() 參數(shù)表示os的等待隊(duì)列長(zhǎng)度座掘。當(dāng)應(yīng)用層的連接數(shù)到達(dá)最大值時(shí)递惋,os可以繼續(xù)接收連接柔滔,os能繼續(xù)接收的最大連接數(shù)就是這個(gè)隊(duì)列長(zhǎng)度,可以通過acceptCount參數(shù)配置萍虽,默認(rèn)是100
ServerSocketChannel通過accept()接受新的連接睛廊,accept()方法返回獲得SocketChannel對(duì)象,然后將SocketChannel對(duì)象封裝在一個(gè)PollerEvent對(duì)象中杉编,并將PollerEvent對(duì)象壓入Poller的Queue里超全。
這是個(gè)典型的“生產(chǎn)者-消費(fèi)者”模式,Acceptor與Poller線程之間通過Queue通信邓馒。
Poller
本質(zhì)是一個(gè)Selector嘶朱,也跑在單獨(dú)線程里。
Poller在內(nèi)部維護(hù)一個(gè)Channel數(shù)組光酣,它在一個(gè)死循環(huán)里不斷檢測(cè)Channel的數(shù)據(jù)就緒狀態(tài)疏遏,一旦有Channel可讀,就生成一個(gè)SocketProcessor任務(wù)對(duì)象扔給Executor去處理救军。
內(nèi)核空間的接收連接是對(duì)每個(gè)連接都產(chǎn)生一個(gè)channel财异,該channel就是Acceptor里accept方法得到的scoketChannel,后面的Poller在用selector#select監(jiān)聽內(nèi)核是否準(zhǔn)備就緒唱遭,才知道監(jiān)聽內(nèi)核哪個(gè)channel戳寸。
維護(hù)了一個(gè) Queue:
SynchronizedQueue的方法比如offer、poll拷泽、size和clear都使用synchronized修飾疫鹊,即同一時(shí)刻只有一個(gè)Acceptor線程讀寫Queue。
同時(shí)有多個(gè)Poller線程在運(yùn)行跌穗,每個(gè)Poller線程都有自己的Queue订晌。
每個(gè)Poller線程可能同時(shí)被多個(gè)Acceptor線程調(diào)用來注冊(cè)PollerEvent虏辫。
Poller的個(gè)數(shù)可以通過pollers參數(shù)配置蚌吸。
職責(zé)
- Poller不斷地通過內(nèi)部的Selector對(duì)象向內(nèi)核查詢Channel狀態(tài),一旦可讀就生成任務(wù)類SocketProcessor交給Executor處理
Poller循環(huán)遍歷檢查自己所管理的SocketChannel是否已超時(shí)砌庄。若超時(shí)就關(guān)閉該SocketChannel
SocketProcessor
Poller會(huì)創(chuàng)建SocketProcessor任務(wù)類交給線程池處理羹唠,而SocketProcessor實(shí)現(xiàn)了Runnable接口,用來定義Executor中線程所執(zhí)行的任務(wù)娄昆,主要就是調(diào)用Http11Processor組件處理請(qǐng)求:Http11Processor讀取Channel的數(shù)據(jù)來生成ServletRequest對(duì)象佩微。
Http11Processor并非直接讀取Channel。因?yàn)門omcat支持同步非阻塞I/O萌焰、異步I/O模型哺眯,在Java API中,對(duì)應(yīng)Channel類不同扒俯,比如有AsynchronousSocketChannel和SocketChannel奶卓,為了對(duì)Http11Processor屏蔽這些差異一疯,Tomcat設(shè)計(jì)了一個(gè)包裝類叫作SocketWrapper,Http11Processor只調(diào)用SocketWrapper的方法去讀寫數(shù)據(jù)夺姑。
Executor
線程池墩邀,負(fù)責(zé)運(yùn)行SocketProcessor任務(wù)類,SocketProcessor的run方法會(huì)調(diào)用Http11Processor來讀取和解析請(qǐng)求數(shù)據(jù)盏浙。我們知道眉睹,Http11Processor是應(yīng)用層協(xié)議的封裝,它會(huì)調(diào)用容器獲得響應(yīng)废膘,再把響應(yīng)通過Channel寫出竹海。
Tomcat定制的線程池,它負(fù)責(zé)創(chuàng)建真正干活的工作線程丐黄。就是執(zhí)行SocketProcessor#run站削,即解析請(qǐng)求并通過容器來處理請(qǐng)求,最終調(diào)用Servlet孵稽。
Tomcat的高并發(fā)設(shè)計(jì)
高并發(fā)就是能快速地處理大量請(qǐng)求许起,需合理設(shè)計(jì)線程模型讓CPU忙起來,盡量不要讓線程阻塞菩鲜,因?yàn)橐蛔枞跋福珻PU就閑了。
有多少任務(wù)接校,就用相應(yīng)規(guī)模的線程數(shù)去處理猛频。
比如NioEndpoint要完成三件事情:接收連接、檢測(cè)I/O事件和處理請(qǐng)求蛛勉,關(guān)鍵就是把這三件事情分別定制線程數(shù)處理:
- 專門的線程組去跑Acceptor鹿寻,并且Acceptor的個(gè)數(shù)可以配置
- 專門的線程組去跑Poller,Poller的個(gè)數(shù)也可以配置
- 具體任務(wù)的執(zhí)行也由專門的線程池來處理诽凌,也可以配置線程池的大小
總結(jié)
I/O模型是為了解決內(nèi)存和外部設(shè)備速度差異毡熏。
- 所謂阻塞或非阻塞是指應(yīng)用程序在發(fā)起I/O操作時(shí),是立即返回還是等待
- 同步和異步侣诵,是指應(yīng)用程序在與內(nèi)核通信時(shí)痢法,數(shù)據(jù)從內(nèi)核空間到應(yīng)用空間的拷貝,是由內(nèi)核主動(dòng)發(fā)起還是由應(yīng)用程序來觸發(fā)杜顺。
Tomcat#Endpoint組件的主要工作就是處理I/O财搁,而NioEndpoint利用Java NIO API實(shí)現(xiàn)了多路復(fù)用I/O模型。
讀寫數(shù)據(jù)的線程自己不會(huì)阻塞在I/O等待上躬络,而是把這個(gè)工作交給Selector尖奔。
當(dāng)客戶端發(fā)起一個(gè)HTTP請(qǐng)求時(shí),首先由Acceptor#run中的
socket = endpoint.serverSocketAccept();
接收連接,然后傳遞給名稱為Poller的線程去偵測(cè)I/O事件提茁,Poller線程會(huì)一直select仗嗦,選出內(nèi)核將數(shù)據(jù)從網(wǎng)卡拷貝到內(nèi)核空間的 channel(也就是內(nèi)核已經(jīng)準(zhǔn)備好數(shù)據(jù))然后交給名稱為Catalina-exec的線程去處理,這個(gè)過程也包括內(nèi)核將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間這么一個(gè)過程甘凭,所以對(duì)于exec線程是阻塞的稀拐,此時(shí)用戶空間(也就是exec線程)就接收到了數(shù)據(jù),可以解析然后做業(yè)務(wù)處理了丹弱。
作者:JavaEdge.
原文鏈接:https://blog.csdn.net/qq_33589510/article/details/119082311