深入 Linux 核心理解 socket 的本質
本文將從一個初學者的角度開始聊起,讓大家了解 Socket 是什麼以及它的原理和核心實作。
一、Socket 的概念
Socket 就如同我們日常生活中的插頭與插座的連結關係。在網路程式設計中,Socket 是一種實現網路通訊的介面或機制。 想像一下,插頭插入插座後,電流得以流通,實現了能量的傳遞。而在網路世界裡,當一個程式使用 Socket 與另一台機子建立「連接」時,就如同插頭成功插入了插座,資料能夠在兩者之間進行流通和交換。
例如,当我们在网上聊天时,发送方的程序通过 Socket 将消息发送出去,接收方的程序通过对应的 Socket 接收这些消息。又比如在下载文件时,下载程序通过 Socket 与提供文件的服务器建立连接,从而能够获取到所需的文件数据。
二、Socket 的使用场景
我们想要将数据从 A 电脑的某个进程发到 B 电脑的某个进程。如果需要确保数据能发给对方,就选可靠的 TCP 协议;如果数据丢了也没关系,就选择不可靠的 UDP 协议。初学者一般首选 TCP。
这时就需要用 socket 进行编程,首先创建关于 TCP 的 socket:
這個方法會回傳 sock_fd,它是 socket 檔案的句柄。
對於服務端,得到 sock_fd 後,依序執行 bind()、listen()、accept() 方法,等待客戶端的連線請求;對於用戶端,得到 sock_fd 後,執行 connect() 方法向服務端發起建立連線的請求,此時會發生 TCP 三次握手。
連線建立完成後,客戶端可以執行 send() 方法傳送訊息,服務端可以執行 recv() 方法接收訊息,反之亦然。
三、Socket 的設計
現在我們拋開socket,重新設計一個內核網路傳輸功能。我們想要將資料從 A 電腦的某個進程發到 B 電腦的某個進程,從操作來看,就是發資料給遠端和從遠端接收資料,也就是寫資料和讀取資料。
但這裡有兩個問題:
接收端和發送端可能不只一個,因此需要用 IP 和連接埠做區分,IP 用來定位是哪一台電腦,而連接埠用來定位是這台電腦上的哪個進程。
發送端和接收端的傳輸方式有很多區別,例如可靠的 TCP 協定、不可靠的 UDP 協議,甚至還需要支援基於 icmp 協定的 ping 命令。
為了支援這些功能,需要定義一個資料結構 sock,在 sock 裡加入 IP 和連接埠欄位。這些協議雖然各不相同,但有一些功能相似的地方,可以將不同的協議當成不同的物件類別(或結構體),將公共的部分提取出來,透過「繼承」的方式重複使用功能。
於是,定義了一些資料結構:
sock 是最基礎的架構,維護一些任何協定都有可能會用到的收發資料緩衝區。
在 Linux 核心 2.6 相關的原始碼中,sock 結構體的定義可能類似於:
inet_sock 特別指用了網路傳輸功能的 sock,在 sock 的基礎上也加入了 TTL、連接埠、IP 位址這些跟網路傳輸相關的欄位資訊。例如 Unix domain socket,用於本機進程之間的通信,直接讀寫文件,不需要經過網路協定棧。
可能的定義:
inet_connection_sock 是指面向連接的 sock,在 inet_sock 的基礎上加入面向連接的協議里相關字段,例如 accept 隊列、數據包分片大小、握手失敗重試次數等。雖然現在提到連線導向的協定就是指 TCP,但設計上 Linux 需要支援擴展其他面向連線的新協定。
例如:
tcp_sock 是正兒八經的 TCP 協定專用的 sock 結構,在 inet_connection_sock 基礎上也加入了 TCP 特有的滑動視窗、壅塞避免等功能。同樣 UDP 協定也會有一個專用的資料結構,叫 udp_sock。
大概如下:
有了這套資料結構,將它跟著硬體網路卡對接一下,就實現了網路傳輸的功能。
四、提供 Socket 層
由於這裡面的程式碼複雜,也操作了網卡硬件,需要較高的作業系統權限,再考慮到效能和安全,於是將它放在作業系統核心裡。
為了讓用戶空間的應用程式使用這部分功能,將這部分功能抽象成簡單的接口,將核心的 sock 封裝成檔案。創建 sock 的同時也創建一個文件,文件有個文件描述符 fd,透過它可以唯一確定是哪個 sock。將fd暴露給用戶,用戶就可以像操作文件句柄一樣去操作這個 sock 。
建立socket時,其實就是建立了一個檔案結構體,並將private_data欄位指向sock。
有了 sock_fd 句柄後,提供了一些接口,如 send()、recv()、bind()、listen()、connect() 等,這些就是 socket 提供出來的接口。
所以說,socket 其實就是個程式碼庫或介面層,它介於核心和應用程式之間,提供了一堆接口,讓我們去使用核心功能,本質上就是一堆高度封裝過的介面。
我們平常寫的應用程式裡程式碼裡雖然用了socket實現了收發資料包的功能,但其實真正執行網路通訊功能的,不是應用程序,而是linux核心。
在作業系統核心空間裡,實現網路傳輸功能的結構是sock,基於不同的協定和應用場景,會被泛化為各種類型的xx_sock,它們結合硬件,共同實現了網路傳輸功能。為了將這部分功能暴露給用戶空間的應用程式使用,於是引入了socket層,同時將sock嵌入到文件系統的框架裡,sock就變成了一個特殊的文件,用戶就可以在用戶空間使用文件句柄,也就是socket_fd來操作內核sock的網絡傳輸能力。
五、Socket 如何實現網路通訊
以最常用的 TCP 協定為例,實現網路傳輸功能分為建立連線和資料傳輸兩個階段。
1. 建立連接
在客戶端,執行 socket 提供的 connect(sockfd, "ip:port") 方法時,會透過 sockfd 句柄找到對應的文件,再根據文件裡的資訊指向內核的 sock 結構,透過這個 sock 結構主動發起三次握手。
在服務端,握手次數還沒達到「三次」的連接叫半連接,完成好三次握手的連接叫全連接,它們分別會用半連接隊列和全連接隊列來存放,這兩個隊列會在執行 listen() 方法的時候創建好。當服務端執行 accept() 方法時,就會從全連線佇列拿出一個全連線。
雖然都叫隊列,但半連接隊列其實是個哈希表,而全連接隊列其實是個鍊錶。
在 Linux 核心 2.6 版本的原始碼中,相關的程式碼實作可能位於網路子系統的部分。例如,建立連線的過程可能涉及 tcp_connect() 等函數。
2. 資料傳輸
為了實現發送和接收資料的功能,sock 結構體裡帶了一個發送緩衝區和一個接收緩衝區,其實就是一個鍊錶,上面掛著一個個準備要發送或接收的資料。
當應用執行 send() 方法發送資料時,會透過 sock_fd 句柄找到對應的文件,根據文件指向的 sock 結構,找到這個 sock 結構裡帶的發送緩衝區,將資料放到發送緩衝區,然後結束流程,內核看心情決定什麼時候將這份資料發送出去。
接收資料流程也類似,當資料送到 Linux 核心後,先放在接收緩衝區中,等待應用程式執行 recv() 方法來拿。
當應用程式執行 recv() 方法嘗試取得(阻塞場景下)接收緩衝區的資料時,如果有數據,取走就好;如果沒數據,就會將自己的進程資訊註冊到這個 sock 用的等待佇列裡,然後進程休眠。如果這時候有資料從遠端發過來了,資料進入到接收緩衝區時,核心就會取出 sock 的等待佇列裡的進程,喚醒進程來取資料。
當多個行程通過 fork 的方式 listen 了同一個 socket_fd,在核心它們都是同一個 sock,多個行程執行 listen() 之後,都會將自身的行程資訊註冊到這個 socket_fd 對應的核心 sock 的等待佇列中。在 Linux 2.6 以前,會喚醒等待佇列裡的所有進程,但最後其實只有一個進程會處理這個連線請求,其他進程又重新進入休眠,會消耗一定的資源,這就是驚群效應。在 Linux 2.6 之後,只會喚醒等待佇列裡的其中一個進程,這個問題被修復了。
服務端 listen 的時候,那麼多資料到一個 socket 怎麼區分多個客戶端的?以 TCP 為例,服務端執行 listen 方法後,會等待客戶端傳送資料來。客戶端發送的資料包上會有來源 IP 位址和端口,以及目的 IP 位址和端口,這四個元素構成一個四元組,可以用於唯一標記一個客戶端。服務端會建立一個新的核心 sock,並用四元組產生一個 hash key,將它放入到一個 hash 表中。下次再有消息進來的時候,透過訊息自帶的四元組產生 hash key 再到這個 hash 表 裡重新取出對應的 sock 就好了。
六、Socket 怎麼實現“繼承”
Linux 核心是 C 語言實現的,而 C 語言沒有類別也沒有繼承的特性,是透過結構體裡的記憶體是連續的這一特點來實現「繼承」的效果。將要繼承的“父類”,放到結構體的第一位,然後通過結構體名的長度來強行截取內存,這樣就能轉換結構體,從而實現類似“繼承”的效果。
七、總結
socket 中文套接字,可理解為一套用於連接的數字。
sock 在內核,socket_fd 在使用者空間,socket 層介於內核與使用者空間之間。
在作業系統核心空間裡,實現網路傳輸功能的結構是 sock,基於不同的協定和應用場景,會被泛化為各種類型的 xx_sock,它們結合硬件,共同實現了網路傳輸功能。為了將這部分功能暴露給用戶空間的應用程式使用,於是引入了 socket 層,同時將 sock 嵌入到文件系統的框架裡,sock 就變成了一個特殊的文件,用戶就可以在用戶空間使用文件句柄,也就是 socket_fd 來操作內核 sock 的網絡傳輸能力。
服務端可以透過四元組來區分多個客戶端。
內核透過 C 語言「結構體裡的記憶體是連續的」這一特徵實現了類似繼承的效果。