0.中斷
0.1 簡(jiǎn)介
中斷是指計(jì)算機(jī)運(yùn)行過(guò)程中,出現(xiàn)某些意外情況需主機(jī)干預(yù)時(shí),機(jī)器能自動(dòng)停止正在運(yùn)行的程序并轉(zhuǎn)入處理新情況的程序,處理完畢后又返回原被暫停的程序繼續(xù)運(yùn)行
-------- 引用百度百科
例如, 你正在家看電影,突然外賣(mài)送到了,此時(shí)你不得不暫停電影,停止播放,去拿外賣(mài)
這個(gè)過(guò)程就是中斷,在計(jì)算中中斷分為兩種:
- 硬中斷
- 軟中斷
0.2 硬中斷
硬中斷通常是由硬件發(fā)起的中斷,電腦外設(shè),網(wǎng)卡等發(fā)起的中斷,例如最常見(jiàn)的鍵盤(pán)打字場(chǎng)景如下:
CPU正在運(yùn)行用戶程序,那么當(dāng)用戶在鍵盤(pán)輸入字符時(shí),那么鍵盤(pán)就會(huì)發(fā)起中斷,CPU的中斷引腳(INTR)就會(huì)接受到中斷信號(hào)(唯一的數(shù)字標(biāo)識(shí)),從而調(diào)用內(nèi)核程序?qū)⑤斎氲淖址故驹趯?duì)應(yīng)的地方。
當(dāng)然CPU不可能給每個(gè)硬件設(shè)備都弄一個(gè)中斷引腳,因此中斷信號(hào)(唯一的數(shù)字標(biāo)識(shí))時(shí)通過(guò)中斷控制器去發(fā)送到CPU的中斷引腳,如下圖所示:

8259A是一個(gè)中斷控制器,每個(gè)接口都連接不同的硬件設(shè)備,當(dāng)硬件設(shè)備超過(guò)8個(gè)時(shí)可以再連接一個(gè)中斷控制器
中斷控制器大概工作流程就是 中斷請(qǐng)求寄存器保存多個(gè)硬件的中斷請(qǐng)求信息,接下來(lái)通過(guò)優(yōu)先級(jí)解析器對(duì) 中斷請(qǐng)求進(jìn)行排序,數(shù)字越小越先執(zhí)行,最后將正在執(zhí)行的中斷請(qǐng)求存入
正在服務(wù)寄存器中,如下圖:

當(dāng)然每個(gè)中斷信號(hào)其實(shí)本質(zhì)就是一個(gè)位置的數(shù)字,那么每個(gè)數(shù)字即要執(zhí)行什么樣的程序,這個(gè)由中斷向量表,即 把系統(tǒng)中所有的中斷類(lèi)型碼及其對(duì)應(yīng)的中斷向量按一定的規(guī)律存放在一個(gè)區(qū)域內(nèi),這個(gè)存儲(chǔ)區(qū)域就叫中斷向量表
在內(nèi)核程序啟動(dòng)時(shí)就會(huì)去加載這個(gè)表,這樣就知道了,不同的硬件設(shè)備中斷就會(huì)執(zhí)行不同的程序
0.3 軟中斷
眾所周知,系統(tǒng)運(yùn)行速度越快越好,最好是實(shí)時(shí)系統(tǒng),因此,Liunx為了滿足這點(diǎn),當(dāng)中斷發(fā)生時(shí),將耗時(shí)較短的操作的操作交給硬中斷處理,而一些耗時(shí)較長(zhǎng)的的工作交給軟中斷處理。
例如,網(wǎng)卡接收到數(shù)據(jù)時(shí),就會(huì)發(fā)送一個(gè)硬中斷請(qǐng)求,CPU接收到了這個(gè)中斷,就會(huì)快速將網(wǎng)卡中的數(shù)據(jù)存放到內(nèi)存中,然后發(fā)送一個(gè)軟中斷,當(dāng)軟中斷信號(hào)被喚醒后,CPU就會(huì)處理內(nèi)存中的數(shù)據(jù),在處理期間還是能夠響應(yīng)其他的硬中斷,如下圖:

1.FD
在Linux中,一切皆文件,內(nèi)核則是利用文件描述符來(lái)操作文件,當(dāng)新建了一個(gè)文件或者打開(kāi)現(xiàn)存文件都會(huì)返回一個(gè)文件描述符fd(整型數(shù)字),然后通過(guò)文件描述符來(lái)進(jìn)行操作。
例如可以使用exec文件來(lái)給文件創(chuàng)建fd,例如:
touch test.txt
exec 5<test.txt
exec 6>test.txt
數(shù)字5 就代表 test.txt這個(gè)文件的讀操作,操作fd5,即操作這個(gè)文件都操作
數(shù)字6 就代表 test.txt這個(gè)文件的寫(xiě)操作,操作fd5,即操作這個(gè)文件都操作
#代表將hello寫(xiě)入到test.txt文件中
echo 'hello' >& 6
當(dāng)然在linux中,任何程序都具備三個(gè)基礎(chǔ)文件描述符0,1,2,含義如下:
-
0代表標(biāo)準(zhǔn)輸入 -
1代表標(biāo)準(zhǔn)輸出 -
2代表錯(cuò)誤輸出
可以以下命令查詢當(dāng)前進(jìn)程的文件描述符
# $$ 代表當(dāng)前進(jìn)程
ls /proc/$$/fd

2. 狀態(tài)
在操作系統(tǒng)中CPU有兩種狀態(tài),分別是
-
內(nèi)核態(tài)
運(yùn)行內(nèi)核程序的狀態(tài)稱為內(nèi)核態(tài)
-
用戶態(tài)
運(yùn)行用戶程序成為用戶態(tài)
這種狀態(tài)會(huì)時(shí)常進(jìn)行切換,例如當(dāng)發(fā)生中斷時(shí),CPU就會(huì)從用戶態(tài)切換到內(nèi)核態(tài)
3. Server
每個(gè)服務(wù)端程序一旦運(yùn)行都會(huì)去創(chuàng)建socket,當(dāng)然socket也是文件描述符進(jìn)行表示的,
socket一旦創(chuàng)建,就會(huì)進(jìn)入bind階段,將地址綁定到socket上,綁定完成后就會(huì)開(kāi)始進(jìn)行監(jiān)聽(tīng),監(jiān)聽(tīng)完成并開(kāi)始進(jìn)行調(diào)用accpet方法
以下對(duì)上述步驟進(jìn)行詳細(xì)演示
3.1 socket
這里使用
nc命令進(jìn)行創(chuàng)建服務(wù)端程序,如果沒(méi)有可以安裝
# 創(chuàng)建服務(wù)端程序 并且端口號(hào)為8989
nc -l 8989

一旦執(zhí)行此命令,CPU就會(huì)調(diào)用內(nèi)核去執(zhí)行內(nèi)核的socket && bind && listen方法
當(dāng)然也可以先找到nc的進(jìn)程id,然后查看該進(jìn)程的所有文件描述符
可以另起一個(gè)tab頁(yè),之前的程序不要關(guān)閉
ps -ef | grep nc

例如我這里是6040,那么我就需要去6040下去找對(duì)應(yīng)的文件描述符,如下:
ll /proc/6040/fd

可以發(fā)現(xiàn)多了兩個(gè)文件描述符3,4,而且分別指向了socket
為了更加了解nc -l 8989運(yùn)行以后,內(nèi)核程序是如何執(zhí)行的,可以通過(guò)strace命令查看到應(yīng)用程序與內(nèi)核的交互過(guò)程,如下:
先停掉之前的程序
# strace 追蹤與內(nèi)核交互程序
# -ff 如果一個(gè)程序啟動(dòng)有多個(gè)線程,那么就會(huì)按照線程號(hào)記錄到每個(gè)文件中
# -o kk 將內(nèi)容輸出到kk文件中
strace -ff -o kk nc -l 8989

另起一個(gè)tab頁(yè),就可以看到輸出的kk文件,如下圖所示:

打開(kāi)文件,查看器文件內(nèi)容,可知,當(dāng)執(zhí)行完指令后創(chuàng)建了兩個(gè)socket,如下圖:


之所以有兩個(gè)
socket,是因?yàn)橐粋€(gè)是IPV4,一個(gè)是IPV6
同時(shí)當(dāng)socket方法執(zhí)行完成以后,返回了文件描述符3,4
為了更加了解socket方法可以通過(guò)man指令查看socket的描述,如下:
# 2 代表查看2類(lèi)命令
man 2 socket

從描述可以出,socket方法調(diào)用代表創(chuàng)建一個(gè)通信端點(diǎn),并且返回一個(gè)fd
調(diào)用時(shí)需給這個(gè)方法傳入一個(gè) 域名參數(shù),類(lèi)型參數(shù),和協(xié)議參數(shù)
因此只要是服務(wù)端程序一旦啟動(dòng),就一定會(huì)調(diào)用socket
調(diào)用內(nèi)核程序,也叫系統(tǒng)調(diào)用
3.2 bind
繼續(xù)查看kk文件內(nèi)容可知,當(dāng)socket創(chuàng)建完成以后,緊接著會(huì)調(diào)用bind內(nèi)核程序,如下圖所示:

從圖中代碼可以知道,是將端口和地址綁定到了fd4這個(gè)socket上,為了更加了解清楚
可以繼續(xù)查看手冊(cè),了解其細(xì)節(jié) ,如下:


從上圖中可知,當(dāng)socket創(chuàng)建成功后,并沒(méi)有分配給scoket地址,因此借助bind需要給socket分配地址。
當(dāng)分配成功后就會(huì)返回一個(gè)0,注意這個(gè)0并不是fd
3.3 listen
繼續(xù)查看kk文件,發(fā)現(xiàn)當(dāng)bind過(guò)后緊接著調(diào)用了listen程序,如下圖:

同理查看手冊(cè)去了解該方法的作用

從上圖可知,該方法是用來(lái)監(jiān)聽(tīng)是否有客戶端來(lái)連接這個(gè)socket,如果一旦連接就會(huì)回調(diào)accept方法
至此可以發(fā)現(xiàn),任何一個(gè)服務(wù)端程序啟動(dòng)都會(huì)經(jīng)過(guò)
socket bind listen三個(gè)步驟
4.client
4.1 accpet
當(dāng)使用nc程序連接服務(wù)端時(shí),從上面描述可知會(huì)先調(diào)用accpet方法,與服務(wù)端建立連接
nc localhost 8989

繼續(xù)查看kk文件,可以發(fā)現(xiàn)調(diào)用了accpet方法建立了連接,如下:

注意: 此時(shí)并沒(méi)有給服務(wù)端發(fā)消息,只是建立三次握手
同理查看手冊(cè)去了解該方法的說(shuō)明

從圖中可知,該它提取掛起連接隊(duì)列上的第一個(gè)連接請(qǐng)求偵聽(tīng)套接字的連接sockfd創(chuàng)建一個(gè)新的已連接套接字,并返回引用該套接字的新文件描述符
意思就是創(chuàng)建一個(gè)客戶端連接的socket,然后返回給socket的文件描述符,從之前的圖可知,目前的socket描述為7
注意:這里說(shuō)的
socket指的是客戶端連接的socket
4.2 recvfrom
當(dāng)客戶端給服務(wù)端發(fā)送消息,服務(wù)端就會(huì)調(diào)用recvfrom程序接受網(wǎng)卡中的消息,并且通過(guò)調(diào)用write方法去寫(xiě)入到用戶程序上,如下:

因此整個(gè)流程客戶端與服務(wù)端通信過(guò)程是:
-
服務(wù)端啟動(dòng)后先系統(tǒng)調(diào)用
socket,bind,listen多路復(fù)用器
select后續(xù)再說(shuō) -
客戶端與服務(wù)端建立三次握手時(shí),先將數(shù)據(jù)包發(fā)送到網(wǎng)卡,網(wǎng)卡發(fā)起硬中斷,然后由
用戶態(tài)切換到內(nèi)核態(tài),調(diào)用
accpet方法建立連接創(chuàng)建客戶端socket 客戶端發(fā)送消息時(shí),還是會(huì)發(fā)送到網(wǎng)卡,然后發(fā)起中斷,狀態(tài)切換,最后接收消息,然后通過(guò)
write調(diào)用將消息寫(xiě)回用戶程序
5.BIO
BIO(阻塞式IO),即socket返回的文件描述符都是阻塞的,如果說(shuō)連接的數(shù)據(jù)沒(méi)有到達(dá)那么就會(huì)一直阻塞在那,等到數(shù)據(jù)到達(dá)。這種效率可想而知是非常低的
5.1 案例
該案例簡(jiǎn)單模擬一下BIO
在系統(tǒng)中安裝java環(huán)境,并且準(zhǔn)備一個(gè)java程序,內(nèi)容如下:
import java.io.*;
import java.net.*;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(8888);
System.out.println("create server socket");
while(true) {
Socket socket = ss.accept();
System.out.println("client port:" + socket.getPort());
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
while(true) {
System.out.println(br.readLine());
if(br.readLine().equals("exit")) break;
}
}
}
}
從上圖程序可知,當(dāng)接收到一個(gè)socket后,會(huì)開(kāi)啟一個(gè)
死循環(huán)讀取這個(gè)socket發(fā)送過(guò)來(lái)的消息如果消息沒(méi)有發(fā)送過(guò)來(lái)就會(huì)一直等待,這樣即使有第二個(gè)socket發(fā)送消息過(guò)來(lái)也只能是等待
輸入以下指令執(zhí)行程序
strace -ff -o bbb java Server
發(fā)現(xiàn)程序一直阻塞著等待客戶端的連接,如下圖:

此時(shí)可以再起一個(gè)tab頁(yè),查看用戶內(nèi)核交互記錄

發(fā)現(xiàn)產(chǎn)生了很多文件,這是因?yàn)?code>jvm啟動(dòng)時(shí)會(huì)創(chuàng)建一些線程去處理一些事情,例如垃圾回收等等之類(lèi)
輸入jps指令,可以發(fā)現(xiàn)Server程序運(yùn)行在2039進(jìn)程id上,如下圖所示:

通過(guò)以下命令,進(jìn)入進(jìn)程對(duì)應(yīng)的目錄中
cd /proc/2039/fd
查看內(nèi)容詳情,如下圖:

-
3是因?yàn)槌绦騿?dòng)時(shí)會(huì)加載rt.jar,所以會(huì)有一個(gè)文件描述符 3 -
4,5代表創(chuàng)建的socket,之所以有兩個(gè)是因?yàn)橐粋€(gè)是IPV4 Socket,一個(gè)是IPV6 Socket
nc命令可以與任何服務(wù)端程序建立連接
通過(guò)以下命令與服務(wù)端程序(Server)建立通信,如下:
# 注意:在兩個(gè)tab頁(yè)同時(shí)輸入該命令
nc localhost 8888

可以從輸出的消息可以看出,只輸出了一個(gè)客戶端的消息,而第二個(gè)客戶端的消息并沒(méi)有輸出,如下:

這種通信模型即BIO模型,也就是說(shuō)其socket是阻塞的,一直等待客戶端消息發(fā)送過(guò)來(lái)
如果不發(fā)送一直阻塞,其他客戶端也無(wú)法連接過(guò)來(lái)
5.2 thread
從上圖也可知,當(dāng)在高并發(fā)的情況下并不是適合,不可能10w個(gè)客戶端一直要等服務(wù)端挨個(gè)處理完成
為了提高程序的并發(fā)能力,采用一些人采取了多線程的方式,如下:
import java.io.*;
import java.net.*;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(8888);
System.out.println("create server socket");
while(true) {
Socket socket = ss.accept();
new Thread(() -> {
try {
System.out.println("client port:" +
socket.getPort());
BufferedReader br =
new BufferedReader(
new InputStreamReader(
socket.getInputStream()));
while(true) {
System.out.println(br.readLine());
}
} catch(Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
輸入以下指令運(yùn)行程序
# 最好是將之前的bbb文件刪除嗎,防止引起混亂
strace -ff -o mm java Server
運(yùn)行成功以后再通過(guò)nc程序去連接服務(wù)端,這里還是啟動(dòng)兩個(gè)客戶端去連接服務(wù)端程序
nc localhost 8888
從服務(wù)端輸出的結(jié)果可以看出完美解決了上述問(wèn)題,如下圖

5.3 缺點(diǎn)
上述多線程的方式,解決了阻塞問(wèn)題,但是從本質(zhì)來(lái)講socket還是阻塞的,只不過(guò)是換成了多線程的方式
查看與內(nèi)核交互的日志,去了解當(dāng)創(chuàng)建線程時(shí),內(nèi)核干了什么事情

從上圖可知,文件有多個(gè),我們并不知道主線程對(duì)應(yīng)的是哪個(gè)文件,因此可以篩選以下,因?yàn)橹骶€程啟動(dòng)時(shí)會(huì)輸出create server socket,這樣可以通過(guò)以下命令進(jìn)行篩選
grep 'create server socket' ./mm.*

從圖中可以看出,主線程相關(guān)內(nèi)容是在6341文件上,因此查看該文件內(nèi)容
vim mm.6341
從文件內(nèi)容上可以看出,創(chuàng)建線程其本質(zhì)是調(diào)用內(nèi)核的clone方法,如下圖所示:

且與線程的文件一一對(duì)應(yīng),如下圖:

在此我們得出結(jié)論,每創(chuàng)建一個(gè)線程就會(huì)發(fā)生一個(gè)系統(tǒng)調(diào)用,假設(shè)10W個(gè)客戶端連接進(jìn)來(lái),那么就意味著要?jiǎng)?chuàng)建10W個(gè)線程,發(fā)生10W次系統(tǒng)調(diào)用
且不說(shuō)能不能能不能支持這么多線程數(shù),即使能支持,CPU的線程調(diào)度,系統(tǒng)調(diào)用也會(huì)大大消耗資源
因此如果這種方式并不適合在并發(fā)的情況下使用
3.NIO
基于上述原因,因在在Linux中提供了另外一個(gè)socket,即非阻塞式socket,主要是為了解決BIO 問(wèn)題,如下:

有了非阻塞Socket,那么就意味著不用向之前那樣進(jìn)行等待阻塞又或者是創(chuàng)建多個(gè)線程去處理客戶端,即一個(gè)線程也可以去處理多個(gè)客戶端程序,而不阻塞
此時(shí)工作方式就變成了以下方式:
socket(...) = 4
bind(4,...) = 0
listen(4)
accpet(4,...) = -1 # 返回的socket不再阻塞等待客戶端連接,沒(méi)有數(shù)據(jù)到達(dá)返回 -1
# 當(dāng)有下一個(gè)客戶端連接過(guò)來(lái)會(huì)繼續(xù) accpet
accpet(4,...) = 5
listen(5)
recvfrom(5) = -1 # 沒(méi)有數(shù)據(jù)到達(dá)返回 -1
因此修改之前的程序代碼,如下:
public static void main(String[] args) throws IOException {
List<SocketChannel> socketChannels = new ArrayList<>(10);
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress(8888));
while (true) {
// 不會(huì)阻塞 如果客戶端沒(méi)有發(fā)送數(shù)據(jù)那么channel為空
SocketChannel channel = ss.accept();
if (channel == null) {
System.out.println("發(fā)數(shù)據(jù)為空");
} else {
ss.configureBlocking(false);
socketChannels.add(channel);
}
socketChannels.forEach(t -> {
// 讀取客戶端消息
});
}
}
從上述偽代碼可知,這種工作模型一個(gè)線程可以接收多個(gè)客戶端請(qǐng)求,然后在程序中即用戶態(tài)中判斷客戶端發(fā)送的消息是否到達(dá)。到達(dá)則處理數(shù)據(jù),不到搭達(dá)則進(jìn)行下一次循環(huán)
每次查詢是否到達(dá)都需要調(diào)用
recv...方法,也就是系統(tǒng)調(diào)用,而用戶態(tài)與內(nèi)核態(tài)的切換時(shí)比較小號(hào)資源的當(dāng)有
10W個(gè)客戶端,意味著每次循環(huán)都要經(jīng)歷10W次,那么這個(gè)效率肯定時(shí)非常地下的
4. 多路復(fù)用
4.1 select
為了解決上述在用戶態(tài)循環(huán)多次系統(tǒng)調(diào)用問(wèn)題,內(nèi)核增加了一個(gè)系統(tǒng)調(diào)用select,通過(guò)命令man 2 select 查看其介紹可知:



從上圖中可以看出,select函數(shù),可以一次監(jiān)聽(tīng)多個(gè)文件描述符,一旦調(diào)用就會(huì)進(jìn)入阻塞狀態(tài),當(dāng)客戶端的消息到達(dá)時(shí),就會(huì)返回對(duì)應(yīng)客戶端的fd
也就是說(shuō)以前需要在程序中循環(huán)判斷消息是否到達(dá),而現(xiàn)在只需要將多個(gè)socket的fd傳給select,在內(nèi)核內(nèi)部就會(huì)判斷,如果到達(dá)就會(huì)返回fd,用戶態(tài)就可以再次操作,如下:

這種相當(dāng)于以前是在用戶態(tài)即用戶程序員那邊去判斷,現(xiàn)在只需要調(diào)用一次select即可判斷,這樣系統(tǒng)調(diào)用次數(shù)也就降低了
這樣由n次系統(tǒng)調(diào)用降低為了1,調(diào)用一次select就知道哪些文件描述符時(shí)可用的,之前的poll函數(shù)就是類(lèi)似功能
同時(shí)從參數(shù)select方法參數(shù)可知,如下圖

-
nfds待監(jiān)聽(tīng)的最大fd指+1,最大值為1024 -
*readfds待監(jiān)聽(tīng)的可讀fd集合 -
*writefds待監(jiān)聽(tīng)可寫(xiě)fd集合 -
exceptfds帶監(jiān)聽(tīng)異常fd集合 -
timeout超時(shí)時(shí)間
select雖然降低了NIO或者BIO過(guò)程中的多次系統(tǒng)調(diào)用,但是有以下缺點(diǎn)無(wú)法解決:
- select是直接在
readfds和writefds操作,導(dǎo)致這兩個(gè)數(shù)組不可重用,每次都需要重新賦值- 每次select都要便利全量的fds
5.EPOLL
epoll是Linux所特有的
epoll本質(zhì)是一個(gè)組合系統(tǒng)掉i用,由三部分組成
- epoll_create
- epoll_ctl
- epoll_waite
當(dāng)然也可以從其手冊(cè)上看出,如下圖所示:

5.1 epoll_create
當(dāng)服務(wù)端程序,調(diào)用epoll_create時(shí),就會(huì)創(chuàng)建一個(gè)epoll實(shí)例,在內(nèi)核中開(kāi)辟一塊空間,并且返回一個(gè)文件描述符,這個(gè)文件描述符就是用來(lái)描述這塊開(kāi)辟的空間,當(dāng)然也可以從其手冊(cè)中可以看出來(lái),如下:

從圖中的描述可以看出,當(dāng)調(diào)用epoll_create方法時(shí),需要傳入一個(gè)size,但是這個(gè)size從2.6.8版本后已經(jīng)忽略掉了
開(kāi)辟的內(nèi)核空間,其本質(zhì)就是一個(gè)結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體主要是是由紅黑樹(shù) + 就緒鏈表組成
5.2 epoll_ctl
從手冊(cè)中可以看出,當(dāng)調(diào)用該方法需要傳入之前epoll_create返回的文件描述符,如下圖:

關(guān)于參數(shù)解釋如下:
epfdepoll實(shí)例的fd-
op對(duì)空間紅黑數(shù)進(jìn)行操作,操作參數(shù)如下圖image-20210801110859014-
epoll_ctl_add表示將fd添加到之前在內(nèi)核開(kāi)辟的內(nèi)存空間,也就是將fd添加到紅黑樹(shù)上 - 同時(shí)
mod和del,則表示修改和刪除
-
fdsocket的fd
5.3 epoll_wait
當(dāng)調(diào)用epoll_wait方法時(shí),就會(huì)直接獲取到達(dá)消息文件描述符,應(yīng)用程序進(jìn)行讀寫(xiě)操作
整個(gè)工作流程如下:

