套接字 socket 和 tcp 連接過程

一、socket 和 fd(file descriptor)是什麼?

Unix/Linux 基本哲學之一就是"一切皆文件",即一切都可以用 "open -> read/write -> close" 來操作,socket 也可以理解成是一種特殊的文件。

fd(file descriptor):文件描述符,非負整數,是內核爲了高效的管理已經被打開的文件所創建的索引,內核(kernel)利用文件描述符來訪問文件。

需要明確的是,每個 tcp 連接的兩端都會關聯一個套接字和該套接字指向的文件描述符。

二、tcp 連接過程

要通過 TCP 連接發送出去的數據都先拷貝到 send buffer,可能是從用戶空間進程的 app buffer 拷入的,也可能是從內核的 kernel buffer 拷入的,拷入的過程是通過 send() 函數完成的,由於也可以使用 write() 函數寫入數據,所以也把這個過程稱爲寫數據,相應的s end buffer 也就有了別稱 write buffer。

最終數據是通過網卡流出去的,所以 send buffer 中的數據需要拷貝到網卡中。由於一端是內存,一端是網卡設備,可以直接使用 DMA 的方式進行拷貝,無需 CPU 的參與。也就是說,send buffer 中的數據通過 DMA 的方式拷貝到網卡中並通過網絡傳輸給 TCP 連接的另一端。

當通過 TCP 連接接收數據時,數據肯定是先通過網卡流入的,然後同樣通過 DMA 的方式拷貝到 recv buffer 中,再通過 recv() 函數將數據從 recv buffer 拷入到用戶空間進程的 app buffer 中。

三、tcp 連接細節

a. 進程創建一個 socket ----> int s = socket(AF_INET, SOCK_STREAM, 0); //返回句柄 fd
b. 綁定端口 ----> bind(s, ...);
c. 設置監聽端口 ----> listen(s, ...);
d. 接收客戶端連接,阻塞 ----> int c = accept(s, ...) //返回句柄 fd
f. 接收客戶端數據,阻塞 ----> recv(c, ...)
e. 關閉客戶端連接 ----> close()

服務端處理客戶端連接,大抵經歷了以上幾個步驟,下面我們要逐一對這些步驟進行解釋。

1. socket() 函數

socket() 函數的作用就是生成一個用於通信的套接字文件描述符 sockfd(socket() creates an endpoint for communication and returns a descriptor),這個文件描述符可以作爲稍後 bind() 函數的綁定對象。

2. bind() 函數

服務程序通過分析配置文件,從中解析出想要監聽的地址和端口,再加上可以通過 socket() 函數生成的套接字 sockfd,就可以使用 bind() 函數將這個套接字綁定到要監聽的地址和端口組合 "addr:port" 上,綁定了端口的套接字可以作爲 listen() 函數的監聽對象。

3. listen() 函數

listen() 函數就是監聽已經通過 bind() 綁定了 "addr+port" 的套接字的。監聽之後,套接字就從 CLOSE 狀態轉變爲 LISTEN 狀態,於是這個套接字就可以對外提供 TCP 連接的窗口了。

listen() 函數維護了兩個隊列:連接未完成隊列(syn queue)和連接已完成隊列(accept queue),用來配合內核完成 TCP 三次握手和四次揮手過程(注意,這時還不涉及用戶線程),當監聽的 sockfd 接收到某個客戶端發來的 SYN 並回復了 SYN+ACK 之後,就會在連接未完成隊列(syn queue)的尾部創建一個關於這個客戶端的條目,並設置它的狀態爲 SYN_RECV,顯然,這個條目中必須包含客戶端的地址和端口相關信息,當監聽的該條目再次收到這個客戶端發送的 ACK 信息之後,就會把這個條目移入到連接已完成隊列(accept queue),並設置它的狀態爲 ESTABLISHED 。

Linux 內核參數 /proc/sys/net/ipv4/tcp_max_syn_backlog 用來設置連接未完成隊列(syn queue)的最大長度;/proc/sys/net/core/somaxconn 用來設置連接已完成隊列(accept queue)的最大長度;

4. connect() 函數

connect() 函數是用於向某個已監聽的套接字發起連接請求,也就是發起 TCP 的三次握手過程。可以看出,連接請求方(如客戶端)纔會使用 connect() 函數,當然,在發起 connect() 之前,連接發起方也需要生成一個 sockfd,且使用的很可能是綁定了隨機端口的套接字。既然 connect() 函數是向某個套接字發起連接的,自然在使用 connect() 函數時需要帶上連接的目的地,即目標地址和目標端口,這正是服務端的監聽套接字上綁定的地址和端口。同時,它還要帶上自己的地址和端口,對於服務端來說,這就是連接請求的源地址和源端口。於是,TCP 連接的兩端的套接字都已經成了五元組的完整格式。

5. accept() 函數

listen() 函數的連接已完成隊列(accept queue)中維護着已經完成三次握手的連接,accpet() 函數的作用是讀取已完成連接隊列中的第一項(讀完就從隊列中移除),並對此項生成一個用於後續連接的套接字描述符(姑且用 connfd 來表示),有了新的連接套接字,用戶進程/線程(稱其爲工作者)就可以通過這個連接套接字和客戶端進行數據傳輸,而前文所說的監聽套接字(sockfd)則仍然被監聽者監聽。

accept() 函數是由用戶空間進程發起,由內核空間消費操作,只要經過 accept() 過的連接,連接將從已完成隊列(accept queue)中移除,也就表示 TCP 已經建立完成了,兩端的用戶空間進程可以通過這個連接進行真正的數據傳輸了,直到使用 close() 或 shutdown() 關閉連接時的四次揮手,中間再也不需要內核的參與。

經過 accept() 函數後,tcp 連接的套接字從 sockfd 變成了 connfd ,也就是說,經過 accept() 之後,這個連接和 sockfd 套接字已經沒有任何關係了。

6. send() 和recv() 函數

send() 函數是將數據從 app buffer 複製到 send buffer 中(當然,也可能直接從內核的 kernel buffer 中複製),recv() 函數則是將 recv buffer 中的數據複製到 app buffer 中。當然,對於 tcp 套接字來說,更多的是使用 write() 和 read() 函數來發送、讀取 socket buffer 數據,這裏使用 send()/recv() 來說明僅僅只是它們的名稱針對性更強而已。

這兩個函數都涉及到了 socket buffer,但是在調用 send() 或 recv() 時,複製的源 buffer 中是否有數據、複製的目標 buffer 中是否已滿而導致不可寫是需要考慮的問題。不管哪一方,只要不滿足條件,調用 send()/recv() 時進程/線程會被阻塞(假設套接字設置爲阻塞式IO模型)。當然,可以將套接字設置爲非阻塞 IO 模型,這時在 buffer 不滿足條件時調用 send()/recv() 函數,調用函數的進程/線程將返回錯誤狀態信息 EWOULDBLOCK 或 EAGAIN ;buffer中是否有數據、是否已滿而導致不可寫,其實可以使用 select()/poll()/epoll 去監控對應的文件描述符(對應socket buffer則監控該socket描述符),當滿足條件時,再去調用 send()/recv() 就可以正常操作了;還可以將套接字設置爲信號驅動 IO 或異步 IO 模型,這樣數據準備好、複製好之前就不用再做無用功去調用
send()/recv() 了。(BIO、NIO、AIO 簡單介紹

7. close()、shutdown() 函數

通用的 close() 函數可以關閉一個文件描述符,當然也包括面向連接的網絡套接字描述符。當調用 close() 時,將會嘗試發送 send buffer 中的所有數據。但是 close() 函數只是將這個套接字引用計數減 1,就像 rm 一樣,刪除一個文件時只是移除一個硬鏈接數,只有這個套接字的所有引用計數都被刪除,套接字描述符纔會真的被關閉,纔會開始後續的四次揮手過程。對於父子進程共享套接字的併發服務程序,調用 close() 關閉子進程的套接字並不會真的關閉套接字,因爲父進程的套接字還處於打開狀態,如果父進程一直不調用 close() 函數,那麼這個套接字將一直處於打開狀態,將一直進入不了四次揮手過程。

而 shutdown() 函數專門用於關閉網絡套接字的連接,和 close() 對引用計數減 1 不同的是,它直接掐斷套接字的所有連接,從而引發四次揮手的過程。可以指定3種關閉方式:

  1. 關閉寫。此時將無法向 send buffer 中再寫數據,send buffer 中已有的數據會一直髮送直到完畢。
  2. 關閉讀。此時將無法從 recv buffer 中再讀數據,recv buffer 中已有的數據只能被丟棄。
  3. 關閉讀和寫。此時無法讀、無法寫,send buffer 中已有的數據會發送直到完畢,但 recv buffer 中已有的數據將被丟棄。

無論是 shutdown() 還是 close(),每次調用它們,在真正進入四次揮手的過程中,它們都會發送一個 FIN。



參考文章:不可不知的socket和TCP連接過程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章