網絡內核之TCP是如何發送和接收消息的
老規矩,帶着問題閱讀:
- 三次握手中服務端做了什么?
- 為什么要將accept()單獨一個線程而不是和讀寫的io線程共用一個線程池?netty分為boss和worker
- 當調用send()返回后數據就一定到對方或者在網線中傳輸了呢?
我們先來回顧一下,我們編寫一個網絡程序有哪些步驟? 基於socket的編程:
代碼如下:
public class Server {
public static void main(String[] args) throws Exception {
//創建一個socket套接字,開始監聽某個端口 對應了 socket() bind() listen()
ServerSocket serverSocket = new ServerSocket(8080);
// (1) 接收新連接線程
new Thread(() -> {
while (true) {
try {
// 等待客戶端連接,accept() 獲取一個新連接
Socket socket = serverSocket.accept();
new Thread(() -> {
try {
byte[] data = new byte[1024];
InputStream inputStream = socket.getInputStream();
while (true) {
int len;
// 讀取字節數組 對應read()
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
}
} catch (IOException e) {
}
}).start();
} catch (IOException e) {}
}
}).start();
}
}
public class Client {
public static void main(String[] args) {
try {
//對應 socket() 和 connect() 發起連接
Socket socket = new Socket("127.0.0.1", 8000);
while (true) {
try {
//對應 write() 方法
socket.getOutputStream().write((new Date() + ": hello world").getBytes());
socket.getOutputStream().flush();
Thread.sleep(2000);
} catch (Exception e) {
}
}
} catch (IOException e) {
}
}
}
服務端我們首先會創建一個監聽套接字,然后給這個套接字綁定一個ip和端口,這一步對應的方法就是bind(),之后就是調用listen()來監聽端口,端口是和應用程序對應的,網卡收到一個數據包的時候后需要知道這個包是給哪個程序用的,當然一個應用程序可以監聽多個端口。之后客戶端發起連接內核會分配一個隨機端口,然后tcp在經歷三次握手成功后,客戶端會創建一個套接字由connect()方法返回,而服務端的accept()方法也會返回一個套接字,之后雙方都會基於這個套接字進行讀寫操作。所以服務端會維護兩種類型的套接字,一種用於監聽,另一種用於和客戶端進行讀寫。

而在linux內核中,socket其實是一個文件,掛載於SocketFS文件類型下,有點類似於/proc,不過該文件不能像磁盤上的文件一樣進行正常的訪問和讀寫。既然是文件,就會有inode來表示索引,有具體的地方存儲數據不管是磁盤還是內存,而socket的數據是存儲在內存中的,每個報文的數據是存放在一個叫 sk_buff 的結構體里,要訪問文件我們一般會對應一個文件描述符,每個文件描述符都會有一個id,在jdk中也有相關定義。
public final class FileDescriptor {
private int fd;
jvm啟動后就是一個獨立進程,每個進程會維護一個數組,這個數組存放該進程已經打開的文件的描述符,數組前三個分別是標准輸入,標准輸出,錯誤輸出三個文件描述符,從第4個開始為用戶打開的文件,或者創建的socket,而數組的下標就是文件描述符的id,內核通過文件描述符可以找到對應的inode,然后在通過vfs找到對應的文件,進行read和write操作。
三次握手

linux內核中會維護兩個隊列,這兩個隊列的長度都是有限制且可以配置的,當客戶端發起connect()請求后,服務端收到syn包后將該信息放入sync隊列,之后客戶端回復ack后從sync隊列取出,放到accept隊列,之后服務端調用accept()方法會從accept隊列取出生成socket。
如果客戶端發起sync請求,但是不回復ack,將導致sync隊列滿載,之后會拒接新的連接。如果客戶端發起ack請求后,服務端一直不調用,或者調用accept隊列太慢,將導致accept隊列滿載,accept隊列滿了則收到ack后無法從syn隊列移出去,導致syn隊列也會堆積,最終拒絕連接。所以服務端一般會將accept單獨起一個線程執行,避免accept太慢導致數據丟棄。當然accept()方法也有阻塞和非阻塞兩種,當accept隊列為空的時候阻塞方法會一直等待,非阻塞方法會直接返回一個錯誤碼。
消息發送
連接建立好后,客戶端和服務端都有一個socket套接字,雙方都可以通過各自的套接字進行發送和接收消息,socket里面維護了兩個隊列,一個發送隊列,一個接收隊列。
發送的時候數據在用戶空間的內存中,當調用send()或者write()方法的時候,會將待發送的數據按照MSS進行拆分,然后將拆分好的數據包拷貝到內核空間的發送隊列,這個隊列里面存放的是所有已經發送的數據包,對應的數據結構就是sk_buff,每一個數據包也就是sk_buff都有一個序號,以及一個狀態,只有當服務端返回ack的時候,才會把狀態改為發送成功,並且會將這個ack報文的序號之前的報文都確認掉,如果長期沒有確認,會重新調用tcp_push繼續發送,如果發送隊列慢了,則從用戶空間拷貝到內核空間的操作就會阻塞,並觸發清理隊列中已確認發送成功的數據包。tcp層會將數據包加上ip頭然后發給ip層處理,ip層將數據包加入到一個qdisc隊列,網卡驅動程序檢測到qdisc隊列有數據就會調用DMA Engine將sk_buff拷貝到網卡並發送出去,網卡驅動通過ringbuffer來指向內核中的數據,所以qdisc的長度也會影響到網絡發送的吞吐量。


關於mss分片:mtu是數據鏈路層的最大傳輸單元,一般為1500字節,而一個ip包的最大長度為65535,所以ip層在發送數據前會根據mtu分片,這樣一個tcp包本來對應一個ip包,分片后將對應多個ip包,每個包都有一個ip頭,在接收端需要等到所有的ip包到達后,才能確定這個tcp收到然后才發送ack,這種方式無疑是低效的,所以tcp層會盡量阻止ip層進行分片,他會在從用戶空間拷貝的時候就會按照mtu進行拆分,將一個數據包拆分成多個數據包。但是鏈路中mtu是會改變的,為了完全避免ip層進行分片,可以在ip層設置一個df標記,如果一定要分片就慧慧一個icmp報文。
關於流控:
- 滑動窗口:接收方返回的一個最大發送序號。這個不是報文大小,而是一個序號,接收方每次會返回一個下次報文發送的序號不要超過的值。這個值主要和接收方內部緩存大小有關。
- 阻塞窗口:發送方根據網絡擁堵情況,根據已經發送到網絡但是還未確認的數據包的數量來計算。由於廣域網絡的復雜所以擁塞控制有一系列算法,如慢啟動等。
- nagle算法:為了避免機器發了大量的小數據包,nagle算法限制每次將多個小數據包達到一定大小后在發送。
由於tcp發送的時候會進行各種分片和合並,所以接收方會出現粘包現象,需要應用層進行處理。
消息接收
當服務端網卡收到一個報文后,網卡驅動調用DMA engine將數據包通過ringbuffer拷貝到內核緩沖區中,拷貝成功后,發起中斷通知中斷處理程序,這時候ip層會處理該數據包,之后交給tcp層,最終到達tcp層的recv buffer(接收隊列),這時候就會返回ack給客戶端,並沒有等到客戶端調用read將數據從內核拷貝到用戶空間,所以應用層也應該有相關的確認機制。如果recv buffer設置的太小,或者應用層一直不來取,那么也將阻塞數據接收,從而影響到滑動窗口大小,導致吞吐量降低。

tcp在收到數據包后會獲取序號,並且看是否應該正好放入接收隊列,如果此時收到一個大序號的報文,會將該報文緩存直到接收隊列中之前的報文已經插入。
另外如果網卡支持多隊列,可以將多個隊列綁定到不同的cpu上,這樣網卡收到報文后,不同的隊列就會通過中斷觸發不同的cpu,從而可以提高吞吐量。
c10k問題
c10k問題是指怎么支持單機1萬的並發請求,我們想到通過select的多路復用模式,用一個單獨的線程去掃描需要監聽的文件描述符,如果這些文件描述符里面有可讀或者可寫的就返回(tcp層在收到報文拷貝到內存后會修改這個文件描述符的狀態),沒有就阻塞,不過這種方式需要對文件描述符進行掃描,效率不高。而epoll方式采用紅黑樹去管理文件描述符,當文件可讀或者可寫的時候會通過一個回調函數通知用戶進行具體的io操作。
