網路核心之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操作。