網(wǎng)絡(luò)內(nèi)核之TCP是如何發(fā)送和接收消息的

網(wǎng)絡(luò)內(nèi)核之TCP是如何發(fā)送和接收消息的

老規(guī)矩,帶著問題閱讀:

  • 三次握手中服務(wù)端做了什么?
  • 為什么要將accept()單獨(dú)一個(gè)線程而不是和讀寫的io線程共用一個(gè)線程池?netty分為boss和worker
  • 當(dāng)調(diào)用send()返回后數(shù)據(jù)就一定到對(duì)方或者在網(wǎng)線中傳輸了呢?

我們先來(lái)回顧一下,我們編寫一個(gè)網(wǎng)絡(luò)程序有哪些步驟? 基于socket的編程:

代碼如下:

public class Server {
    public static void main(String[] args) throws Exception {

        //創(chuàng)建一個(gè)socket套接字,開始監(jiān)聽某個(gè)端口  對(duì)應(yīng)了 socket() bind() listen()
        ServerSocket serverSocket = new ServerSocket(8080);

        // (1) 接收新連接線程
        new Thread(() -> {
            while (true) {
                try {
                    //   等待客戶端連接,accept() 獲取一個(gè)新連接
                    Socket socket = serverSocket.accept();
                    new Thread(() -> {
                        try {
                            byte[] data = new byte[1024];
                            InputStream inputStream = socket.getInputStream();
                            while (true) {
                                int len;
                                // 讀取字節(jié)數(shù)組 對(duì)應(yīng)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 {
                //對(duì)應(yīng) socket() 和 connect() 發(fā)起連接
                Socket socket = new Socket("127.0.0.1", 8000);
                while (true) {
                    try {
                        //對(duì)應(yīng) write() 方法
                        socket.getOutputStream().write((new Date() + ": hello world").getBytes());
                        socket.getOutputStream().flush();
                        Thread.sleep(2000);
                    } catch (Exception e) {
                    }
                }
            } catch (IOException e) {
            }
        
    }
}

服務(wù)端我們首先會(huì)創(chuàng)建一個(gè)監(jiān)聽套接字,然后給這個(gè)套接字綁定一個(gè)ip和端口,這一步對(duì)應(yīng)的方法就是bind(),之后就是調(diào)用listen()來(lái)監(jiān)聽端口,端口是和應(yīng)用程序?qū)?yīng)的,網(wǎng)卡收到一個(gè)數(shù)據(jù)包的時(shí)候后需要知道這個(gè)包是給哪個(gè)程序用的,當(dāng)然一個(gè)應(yīng)用程序可以監(jiān)聽多個(gè)端口。之后客戶端發(fā)起連接內(nèi)核會(huì)分配一個(gè)隨機(jī)端口,然后tcp在經(jīng)歷三次握手成功后,客戶端會(huì)創(chuàng)建一個(gè)套接字由connect()方法返回,而服務(wù)端的accept()方法也會(huì)返回一個(gè)套接字,之后雙方都會(huì)基于這個(gè)套接字進(jìn)行讀寫操作。所以服務(wù)端會(huì)維護(hù)兩種類型的套接字,一種用于監(jiān)聽,另一種用于和客戶端進(jìn)行讀寫。

基于socket的網(wǎng)絡(luò)編程過程

而在linux內(nèi)核中,socket其實(shí)是一個(gè)文件,掛載于SocketFS文件類型下,有點(diǎn)類似于/proc,不過該文件不能像磁盤上的文件一樣進(jìn)行正常的訪問和讀寫。既然是文件,就會(huì)有inode來(lái)表示索引,有具體的地方存儲(chǔ)數(shù)據(jù)不管是磁盤還是內(nèi)存,而socket的數(shù)據(jù)是存儲(chǔ)在內(nèi)存中的,每個(gè)報(bào)文的數(shù)據(jù)是存放在一個(gè)叫 sk_buff 的結(jié)構(gòu)體里,要訪問文件我們一般會(huì)對(duì)應(yīng)一個(gè)文件描述符,每個(gè)文件描述符都會(huì)有一個(gè)id,在jdk中也有相關(guān)定義。

public final class FileDescriptor {

    private int fd;

jvm啟動(dòng)后就是一個(gè)獨(dú)立進(jìn)程,每個(gè)進(jìn)程會(huì)維護(hù)一個(gè)數(shù)組,這個(gè)數(shù)組存放該進(jìn)程已經(jīng)打開的文件的描述符,數(shù)組前三個(gè)分別是標(biāo)準(zhǔn)輸入,標(biāo)準(zhǔn)輸出,錯(cuò)誤輸出三個(gè)文件描述符,從第4個(gè)開始為用戶打開的文件,或者創(chuàng)建的socket,而數(shù)組的下標(biāo)就是文件描述符的id,內(nèi)核通過文件描述符可以找到對(duì)應(yīng)的inode,然后在通過vfs找到對(duì)應(yīng)的文件,進(jìn)行read和write操作。

三次握手

三次握手

linux內(nèi)核中會(huì)維護(hù)兩個(gè)隊(duì)列,這兩個(gè)隊(duì)列的長(zhǎng)度都是有限制且可以配置的,當(dāng)客戶端發(fā)起connect()請(qǐng)求后,服務(wù)端收到syn包后將該信息放入sync隊(duì)列,之后客戶端回復(fù)ack后從sync隊(duì)列取出,放到accept隊(duì)列,之后服務(wù)端調(diào)用accept()方法會(huì)從accept隊(duì)列取出生成socket。

如果客戶端發(fā)起sync請(qǐng)求,但是不回復(fù)ack,將導(dǎo)致sync隊(duì)列滿載,之后會(huì)拒接新的連接。如果客戶端發(fā)起ack請(qǐng)求后,服務(wù)端一直不調(diào)用,或者調(diào)用accept隊(duì)列太慢,將導(dǎo)致accept隊(duì)列滿載,accept隊(duì)列滿了則收到ack后無(wú)法從syn隊(duì)列移出去,導(dǎo)致syn隊(duì)列也會(huì)堆積,最終拒絕連接。所以服務(wù)端一般會(huì)將accept單獨(dú)起一個(gè)線程執(zhí)行,避免accept太慢導(dǎo)致數(shù)據(jù)丟棄。當(dāng)然accept()方法也有阻塞和非阻塞兩種,當(dāng)accept隊(duì)列為空的時(shí)候阻塞方法會(huì)一直等待,非阻塞方法會(huì)直接返回一個(gè)錯(cuò)誤碼。

消息發(fā)送

連接建立好后,客戶端和服務(wù)端都有一個(gè)socket套接字,雙方都可以通過各自的套接字進(jìn)行發(fā)送和接收消息,socket里面維護(hù)了兩個(gè)隊(duì)列,一個(gè)發(fā)送隊(duì)列,一個(gè)接收隊(duì)列。

發(fā)送的時(shí)候數(shù)據(jù)在用戶空間的內(nèi)存中,當(dāng)調(diào)用send()或者write()方法的時(shí)候,會(huì)將待發(fā)送的數(shù)據(jù)按照MSS進(jìn)行拆分,然后將拆分好的數(shù)據(jù)包拷貝到內(nèi)核空間的發(fā)送隊(duì)列,這個(gè)隊(duì)列里面存放的是所有已經(jīng)發(fā)送的數(shù)據(jù)包,對(duì)應(yīng)的數(shù)據(jù)結(jié)構(gòu)就是sk_buff,每一個(gè)數(shù)據(jù)包也就是sk_buff都有一個(gè)序號(hào),以及一個(gè)狀態(tài),只有當(dāng)服務(wù)端返回ack的時(shí)候,才會(huì)把狀態(tài)改為發(fā)送成功,并且會(huì)將這個(gè)ack報(bào)文的序號(hào)之前的報(bào)文都確認(rèn)掉,如果長(zhǎng)期沒有確認(rèn),會(huì)重新調(diào)用tcp_push繼續(xù)發(fā)送,如果發(fā)送隊(duì)列慢了,則從用戶空間拷貝到內(nèi)核空間的操作就會(huì)阻塞,并觸發(fā)清理隊(duì)列中已確認(rèn)發(fā)送成功的數(shù)據(jù)包。tcp層會(huì)將數(shù)據(jù)包加上ip頭然后發(fā)給ip層處理,ip層將數(shù)據(jù)包加入到一個(gè)qdisc隊(duì)列,網(wǎng)卡驅(qū)動(dòng)程序檢測(cè)到qdisc隊(duì)列有數(shù)據(jù)就會(huì)調(diào)用DMA Engine將sk_buff拷貝到網(wǎng)卡并發(fā)送出去,網(wǎng)卡驅(qū)動(dòng)通過ringbuffer來(lái)指向內(nèi)核中的數(shù)據(jù),所以qdisc的長(zhǎng)度也會(huì)影響到網(wǎng)絡(luò)發(fā)送的吞吐量。

消息發(fā)送
消息發(fā)送

關(guān)于mss分片:mtu是數(shù)據(jù)鏈路層的最大傳輸單元,一般為1500字節(jié),而一個(gè)ip包的最大長(zhǎng)度為65535,所以ip層在發(fā)送數(shù)據(jù)前會(huì)根據(jù)mtu分片,這樣一個(gè)tcp包本來(lái)對(duì)應(yīng)一個(gè)ip包,分片后將對(duì)應(yīng)多個(gè)ip包,每個(gè)包都有一個(gè)ip頭,在接收端需要等到所有的ip包到達(dá)后,才能確定這個(gè)tcp收到然后才發(fā)送ack,這種方式無(wú)疑是低效的,所以tcp層會(huì)盡量阻止ip層進(jìn)行分片,他會(huì)在從用戶空間拷貝的時(shí)候就會(huì)按照mtu進(jìn)行拆分,將一個(gè)數(shù)據(jù)包拆分成多個(gè)數(shù)據(jù)包。但是鏈路中mtu是會(huì)改變的,為了完全避免ip層進(jìn)行分片,可以在ip層設(shè)置一個(gè)df標(biāo)記,如果一定要分片就慧慧一個(gè)icmp報(bào)文。

關(guān)于流控:

  • 滑動(dòng)窗口:接收方返回的一個(gè)最大發(fā)送序號(hào)。這個(gè)不是報(bào)文大小,而是一個(gè)序號(hào),接收方每次會(huì)返回一個(gè)下次報(bào)文發(fā)送的序號(hào)不要超過的值。這個(gè)值主要和接收方內(nèi)部緩存大小有關(guān)。
  • 阻塞窗口:發(fā)送方根據(jù)網(wǎng)絡(luò)擁堵情況,根據(jù)已經(jīng)發(fā)送到網(wǎng)絡(luò)但是還未確認(rèn)的數(shù)據(jù)包的數(shù)量來(lái)計(jì)算。由于廣域網(wǎng)絡(luò)的復(fù)雜所以擁塞控制有一系列算法,如慢啟動(dòng)等。
  • nagle算法:為了避免機(jī)器發(fā)了大量的小數(shù)據(jù)包,nagle算法限制每次將多個(gè)小數(shù)據(jù)包達(dá)到一定大小后在發(fā)送。

由于tcp發(fā)送的時(shí)候會(huì)進(jìn)行各種分片和合并,所以接收方會(huì)出現(xiàn)粘包現(xiàn)象,需要應(yīng)用層進(jìn)行處理。

消息接收

當(dāng)服務(wù)端網(wǎng)卡收到一個(gè)報(bào)文后,網(wǎng)卡驅(qū)動(dòng)調(diào)用DMA engine將數(shù)據(jù)包通過ringbuffer拷貝到內(nèi)核緩沖區(qū)中,拷貝成功后,發(fā)起中斷通知中斷處理程序,這時(shí)候ip層會(huì)處理該數(shù)據(jù)包,之后交給tcp層,最終到達(dá)tcp層的recv buffer(接收隊(duì)列),這時(shí)候就會(huì)返回ack給客戶端,并沒有等到客戶端調(diào)用read將數(shù)據(jù)從內(nèi)核拷貝到用戶空間,所以應(yīng)用層也應(yīng)該有相關(guān)的確認(rèn)機(jī)制。如果recv buffer設(shè)置的太小,或者應(yīng)用層一直不來(lái)取,那么也將阻塞數(shù)據(jù)接收,從而影響到滑動(dòng)窗口大小,導(dǎo)致吞吐量降低。

消息接收

tcp在收到數(shù)據(jù)包后會(huì)獲取序號(hào),并且看是否應(yīng)該正好放入接收隊(duì)列,如果此時(shí)收到一個(gè)大序號(hào)的報(bào)文,會(huì)將該報(bào)文緩存直到接收隊(duì)列中之前的報(bào)文已經(jīng)插入。

另外如果網(wǎng)卡支持多隊(duì)列,可以將多個(gè)隊(duì)列綁定到不同的cpu上,這樣網(wǎng)卡收到報(bào)文后,不同的隊(duì)列就會(huì)通過中斷觸發(fā)不同的cpu,從而可以提高吞吐量。

c10k問題

c10k問題是指怎么支持單機(jī)1萬(wàn)的并發(fā)請(qǐng)求,我們想到通過select的多路復(fù)用模式,用一個(gè)單獨(dú)的線程去掃描需要監(jiān)聽的文件描述符,如果這些文件描述符里面有可讀或者可寫的就返回(tcp層在收到報(bào)文拷貝到內(nèi)存后會(huì)修改這個(gè)文件描述符的狀態(tài)),沒有就阻塞,不過這種方式需要對(duì)文件描述符進(jìn)行掃描,效率不高。而epoll方式采用紅黑樹去管理文件描述符,當(dāng)文件可讀或者可寫的時(shí)候會(huì)通過一個(gè)回調(diào)函數(shù)通知用戶進(jìn)行具體的io操作。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容