3.2.1 創(chuàng)建socket
創(chuàng)建socket這一步和客戶端沒啥區(qū)別,不同的是這個(gè)socket我們稱之為 等待連接socket(或監(jiān)聽socket) 。
3.2.2 綁定端口號(hào)
bind()
函數(shù)會(huì)將端口號(hào)寫入上一步生成的監(jiān)聽socket中,這樣一來,監(jiān)聽socket就完整保存了服務(wù)端的IP
和端口號(hào)
。
3.2.3 listen()的真正作用
listen(<Server描述符>, <最大連接數(shù)>);
很多小伙伴一定會(huì)對(duì)這個(gè)listen()
有疑問,監(jiān)聽socket都已經(jīng)創(chuàng)建完了,端口也已經(jīng)綁定完了,為什么還要多調(diào)用一個(gè)listen()
呢?
我們剛說過監(jiān)聽socket和客戶端創(chuàng)建的socket沒什么區(qū)別,問題就出在這個(gè)沒什么區(qū)別上。
socket被創(chuàng)建出來的時(shí)候都默認(rèn)是一個(gè) 主動(dòng)socket ,也就說,內(nèi)核會(huì)認(rèn)為這個(gè)socket之后某個(gè)時(shí)候會(huì)調(diào)用connect()
主動(dòng)向別的設(shè)備發(fā)起連接。這個(gè)默認(rèn)對(duì)客戶端socket來說很合理,但是監(jiān)聽socket可不行,它只能等著客戶端連接自己,因此我們需要調(diào)用listen()
將監(jiān)聽socket從主動(dòng)設(shè)置為被動(dòng),明確告訴內(nèi)核:你要接受指向這個(gè)監(jiān)聽socket的連接請(qǐng)求!
此外,listen()
的第2個(gè)參數(shù)也大有來頭!監(jiān)聽socket真正接受的應(yīng)該是已經(jīng)完整完成3次握手的客戶端,那么還沒完成的怎么辦?總得找個(gè)地方放著吧。于是內(nèi)核為每一個(gè)監(jiān)聽socket都維護(hù)了兩個(gè)隊(duì)列:
- 半連接隊(duì)列(未完成連接的隊(duì)列)
這里存放著暫未徹底完成3次握手的socket(為了防止半連接攻擊,這里存放的其實(shí)是占用內(nèi)存極小的request _sock,但是我們直接理解成socket就行了),這些socket的狀態(tài)稱為SYN_RCVD
。
- 已完成連接隊(duì)列
每個(gè)已完成TCP3次握手的客戶端連接對(duì)應(yīng)的socket就放在這里,這些socket的狀態(tài)為ESTABLISHED
。
文字太多了,有點(diǎn)干,上個(gè)圖!
listen與3次握手
解釋一下動(dòng)圖中的內(nèi)容:
- 客戶端調(diào)用
connect()
函數(shù),開始3次握手,首先發(fā)送一個(gè)SYN X
的報(bào)文(X
是個(gè)數(shù)字,下同); - 服務(wù)端收到來自客戶端的
SYN
,然后在監(jiān)聽socket對(duì)應(yīng)的半連接隊(duì)列中創(chuàng)建一個(gè)新的socket,然后對(duì)客戶端發(fā)回響應(yīng)SYN Y
,捎帶手對(duì)客戶端的報(bào)文給個(gè)ACK
; - 直到客戶端完成第3次握手,剛才新創(chuàng)建的socket就會(huì)被轉(zhuǎn)移到已連接隊(duì)列;
- 當(dāng)進(jìn)程調(diào)用
accept()
時(shí),會(huì)將已連接隊(duì)列頭部的socket返回;如果已連接隊(duì)列為空,那么進(jìn)程將被睡眠,直到已連接隊(duì)列中有新的socket,進(jìn)程才會(huì)被喚醒,將這個(gè)socket返回 。
第4步就是阻塞的本質(zhì)啊,朋友們!
3.3 答疑時(shí)間
Q1.隊(duì)列中的對(duì)象是socket嗎?
呃。。。乖,咱就把它當(dāng)成socket就好了,這樣容易理解,其實(shí)具體里邊存放的數(shù)據(jù)結(jié)構(gòu)是啥,我也很想知道,等我寫完這篇文章,我研究完了告訴你。
Q2.accept()這個(gè)函數(shù)你還沒講是啥意思呢?
accept()
函數(shù)是由服務(wù)端調(diào)用的,用于從已連接隊(duì)列中返回一個(gè)socket描述符;如果socket為阻塞式的,那么如果已連接隊(duì)列為空,accept()
進(jìn)程就會(huì)被睡眠。BIO恰好就是這個(gè)樣子。
Q3.accept()為什么不直接把監(jiān)聽socket返回呢?
因?yàn)樵陉?duì)列中的socket經(jīng)過3次握手過程的控制信息交換,socket的4元組的信息已經(jīng)完整了,用做socket完全沒問題。
監(jiān)聽socket就像一個(gè)客服,我們給客服打電話,然后客服找到解決問題的人,幫助我們和解決問題的人建立聯(lián)系,如果直接把監(jiān)聽socket返回,而不使用連接socket,就沒有socket繼續(xù)等待連接了。
哦對(duì)了,accept()
返回的socket也有個(gè)名字,叫 連接socket 。
3.4 BIO究竟阻塞在哪里
拿Server端的BIO來說明這個(gè)問題,阻塞在了serverSocket.accept()
以及bufferedReader.readLine()
這兩個(gè)地方。有什么辦法可以證明阻塞嗎?
簡單的很!你在serverSocket.accept();
的下一行打個(gè)斷點(diǎn),然后debug模式運(yùn)行BIOServerSocket
,在沒有客戶端連接的情況下,這個(gè)斷點(diǎn)絕不會(huì)觸發(fā)!同樣,在bufferedReader.readLine();
下一行打個(gè)斷點(diǎn),在已連接的客戶端發(fā)送數(shù)據(jù)之前,這個(gè)斷點(diǎn)絕不會(huì)觸發(fā)!
readLine()
的阻塞還帶來一個(gè)非常嚴(yán)重的問題,如果已經(jīng)連接的客戶端一直不發(fā)送消息,readLine()
進(jìn)程就會(huì)一直阻塞(處于睡眠狀態(tài)),結(jié)果就是代碼不會(huì)再次運(yùn)行到accept()
,這個(gè)ServerSocket
沒辦法接受新的客戶端連接。
解決這個(gè)問題的核心就是別讓代碼卡在readLine()
就可以了,我們可以使用新的線程來readLine()
,這樣代碼就不會(huì)阻塞在readLine()
上了。
3.5 改造BIO
改造之后的BIO長這樣,這下子服務(wù)端就可以隨時(shí)接受客戶端的連接了,至于啥時(shí)候能read到客戶端的數(shù)據(jù),那就讓線程去處理這個(gè)事情吧。
public class BIOServerSocketWithThread {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(8099);
System.out.println("啟動(dòng)服務(wù):監(jiān)聽端口:8099");
// 等待客戶端的連接過來,如果沒有連接過來,就會(huì)阻塞
while (true) {
// 表示阻塞等待監(jiān)聽一個(gè)客戶端連接,返回的socket表示連接的客戶端信息
Socket socket = serverSocket.accept(); //連接阻塞
System.out.println("客戶端:" + socket.getPort());
// 表示獲取客戶端的請(qǐng)求報(bào)文
new Thread(new Runnable() {
@Override
public void run() {
try {
BufferedReader bufferedReader = new BufferedReader(
new InputStreamReader(socket.getInputStream())
);
String clientStr = bufferedReader.readLine();
System.out.println("收到客戶端發(fā)送的消息:" + clientStr);
BufferedWriter bufferedWriter = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream())
);
bufferedWriter.write("ok\\n");
bufferedWriter.flush();
} catch (Exception e) {
//...
}
}
}).start();
}
} catch (IOException e) {
// 錯(cuò)誤處理
} finally {
// 其他處理
}
}
}
事情的順利進(jìn)展不禁讓我們飄飄然,我們居然是使用高階的多線程技術(shù)解決了BIO的阻塞問題,雖然目前每個(gè)客戶端都需要一個(gè)單獨(dú)的線程來處理,但accept()
總歸不會(huì)被readLine()
卡死了。
BIO改造之后
所以我們改造完之后的程序是不是就是非阻塞IO了呢?
想多了。。。我們只是用了點(diǎn)奇技淫巧罷了,改造完的代碼在系統(tǒng)調(diào)用層面該阻塞的地方還是阻塞,說白了,Java提供的API完全受限于操作系統(tǒng)提供的系統(tǒng)調(diào)用,在Java語言級(jí)別沒能力改變底層BIO的事實(shí)!
Java沒這個(gè)能力!
3.6 掀開BIO的遮羞布
接下來帶大家看一下改造之后的BIO代碼在底層都調(diào)用了哪一些系統(tǒng)調(diào)用,讓我們?cè)诘讓由蠈?duì)上文的內(nèi)容加深一下理解。
給大家打個(gè)氣,接下來的內(nèi)容其實(shí)非常好理解,大家跟著文章一步步地走,一定能看得懂,如果自己動(dòng)手操作一遍,那就更好了。
對(duì)了,我下來使用的JDK版本是JDK8。
strace
是Linux上的一個(gè)程序,該程序可以追蹤并記錄參數(shù)后邊運(yùn)行的進(jìn)程對(duì)內(nèi)核進(jìn)行了哪些系統(tǒng)調(diào)用。
strace -ff -o out java BIOServerSocketWithThread
其中:
-o
:
將系統(tǒng)調(diào)用的追蹤信息輸出到out
文件中,不加這個(gè)參數(shù),默認(rèn)會(huì)輸出到標(biāo)準(zhǔn)錯(cuò)誤stderr
。
-ff
如果指定了-o
選項(xiàng),strace
會(huì)追蹤和程序相關(guān)的每一個(gè)進(jìn)程的系統(tǒng)調(diào)用,并將信息輸出到以進(jìn)程id為后綴的out文件中。舉個(gè)例子,比如BIOServerSocketWithThread
程序運(yùn)行過程中有一個(gè)ID為30792的進(jìn)程,那么該進(jìn)程的系統(tǒng)調(diào)用日志會(huì)輸出到out.30792這個(gè)文件中。
我們運(yùn)行strace
命令之后,生成了很多個(gè)out文件。
這么多進(jìn)程怎么知道哪個(gè)是我們需要追蹤的呢?我就挑了一個(gè)容量最大的文件進(jìn)行查看,也就是out.30792,事實(shí)上,這個(gè)文件也恰好是我們需要的,截取一下里邊的內(nèi)容給大家看一下。
可以看到圖中的有非常多的行,說明我們寫的這么幾行代碼其實(shí)默默調(diào)用了非常多的系統(tǒng)調(diào)用,拋開細(xì)枝末節(jié),看一下上圖中我重點(diǎn)標(biāo)注的系統(tǒng)調(diào)用,是不是就是上文中我解釋過的函數(shù)?我再詳細(xì)解釋一下每一步,大家聯(lián)系上文,會(huì)對(duì)BIO的底層理解的更加通透。
- 生成監(jiān)聽socket,并返回socket描述符
7
,接下來對(duì)socket進(jìn)行操作的函數(shù)都會(huì)有一個(gè)參數(shù)為7
; - 將
8099
端口綁定到監(jiān)聽socket,bind
的第一個(gè)參數(shù)就是7
,說明就是對(duì)監(jiān)聽socket進(jìn)行的操作; listen()
將監(jiān)聽socket(參數(shù)為7)設(shè)置為被動(dòng)接受連接的socket,并且將隊(duì)列的長度設(shè)置為50;- 實(shí)際上就是
System.out.println("啟動(dòng)服務(wù):監(jiān)聽端口:8099");
這一句的系統(tǒng)調(diào)用,只不過中文被編碼了,所以我特意把:8099
圈出來證明一下;
額外說兩點(diǎn):
其一:可以看到,這么一句簡單的打印輸出在底層實(shí)際調(diào)用了兩次
write
系統(tǒng)調(diào)用,這就是為什么不推薦在生產(chǎn)環(huán)境下使用打印語句的原因,多少會(huì)影響系統(tǒng)性能;其二:
write()
的第一個(gè)參數(shù)為1
,也是文件描述符,表示的是標(biāo)準(zhǔn)輸出stdout
。
- 系統(tǒng)調(diào)用阻塞在了
poll()
函數(shù),怎么看出來的阻塞?out文件的每一行運(yùn)行完畢都會(huì)有一個(gè)= 返回值
,而poll()
目前沒有返回值,因此阻塞了。實(shí)際上poll()
系統(tǒng)調(diào)用對(duì)應(yīng)的Java語句就是serverSocket.accept();
。
不對(duì)啊?為什么底層調(diào)用的不是accept()
而是poll()
?poll()
應(yīng)該是多路復(fù)用才是啊。在JDK4之前,底層確實(shí)直接調(diào)用的是accept()
,但是之后的JDK對(duì)這一步進(jìn)行了優(yōu)化,除了調(diào)用accept()
,還加上了poll()
。poll()
的細(xì)節(jié)我們下文再說,這里可以起碼證明了poll()
函數(shù)依然是阻塞的,所以整個(gè)BIO的阻塞邏輯沒有改變。
接下來我們起一個(gè)客戶端對(duì)程序發(fā)起連接,直接用Linux上的nc
程序即可,比較簡單:
nc localhost 8099
發(fā)起連接之后(但并未主動(dòng)發(fā)送信息),out.30792的內(nèi)容發(fā)生了變化:
poll()
函數(shù)結(jié)束阻塞,程序接著調(diào)用accept()
函數(shù)返回一個(gè)連接socket,該socket的描述符為8
;- 就是
System.out.println("客戶端:" + socket.getPort());
的底層調(diào)用; - 底層使用
clone()
創(chuàng)造了一個(gè)新進(jìn)程去處理連接socket,該進(jìn)程的pid為31168
,因此JDK8的線程在底層其實(shí)就是輕量級(jí)進(jìn)程; - 回到
poll()
函數(shù)繼續(xù)阻塞等待新客戶端連接。
由于創(chuàng)建了一個(gè)新的進(jìn)程,因此在目錄下對(duì)多出一個(gè)out.31168的文件,我們看一下該文件的內(nèi)容:
發(fā)現(xiàn)子進(jìn)程阻塞在了recvfrom()
這個(gè)系統(tǒng)調(diào)用上,對(duì)應(yīng)的Java源碼就是bufferedReader.readLine();
,直到客戶端主動(dòng)給服務(wù)端發(fā)送消息,阻塞才會(huì)結(jié)束。
3.7 BIO總結(jié)
到此為止,我們就通過底層的系統(tǒng)調(diào)用證明了BIO在accept()
以及readLine()
上的阻塞。最后用一張圖來結(jié)束BIO之旅。
BIO模型
BIO之所以是BIO,是因?yàn)橄到y(tǒng)底層調(diào)用是阻塞的,上圖中的進(jìn)程調(diào)用recv
,其系統(tǒng)調(diào)用直到數(shù)據(jù)包準(zhǔn)備好并且被復(fù)制到應(yīng)用程序的緩沖區(qū)或者發(fā)生錯(cuò)誤為止才會(huì)返回,在此整個(gè)期間,進(jìn)程是被阻塞的,啥也干不了。
-
Socket
+關(guān)注
關(guān)注
0文章
212瀏覽量
34694 -
函數(shù)
+關(guān)注
關(guān)注
3文章
4331瀏覽量
62622 -
Redis
+關(guān)注
關(guān)注
0文章
375瀏覽量
10878
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論