socket 到底是什麼?

我相信大家剛開始學 socket 的時候,都跟我一樣。

雲裏霧裏的,對 socket 的概念很模糊。

這篇文章我打算從一個初學者的角度開始聊起,讓大家瞭解下我眼裏的 socket 是什麼以及 socket 的原理和內核實現。

socket 的概念

故事要從一個插頭說起。

插頭與插座

當我將插頭插入插座,那看起來就像是將兩者連起來了。

風扇與電力系統建立 "連接"

而插座的英文,又叫socket

巧了,我們程序員搞網絡編程時也會用到一個叫socket的東西。

其實兩者非常相似。通過socket,我們可以與某臺機子建立 " 連接 ",建立" 連接 " 的過程,就像是將插口插入插槽一樣。

大概概念是瞭解了,但我相信各位對socket其實還是很模糊。

我們從大家最熟悉的使用場景開始說起。

socket 的使用場景

我們想要將數據從 A 電腦的某個進程發到 B 電腦的某個進程。

這時候我們需要選擇將數據發過去的方式,如果需要確保數據要能發給對方,那就選可靠的TCP協議,如果數據丟了也沒關係,看天意,那就選擇不可靠的UDP協議。

初學者毫無疑問,首選TCP

TCP 是什麼

那這時候就需要用socket進行編程。

於是第一步就是創建個關於 TCP 的socket。就像下面這樣。

sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

這個方法會返回socket_fd,它是 socket 文件的句柄,是個數字,相當於 socket 的身份證號。

得到了socket_fd之後,對於服務端,就可以依次執行bind()listen()accept()方法,然後坐等客戶端的連接請求。

對於客戶端,得到socket_fd之後,你就可以執行connect()方法向服務端發起建立連接的請求,此時就會發生 TCP 三次握手。

握手建立連接流程

連接建立完成後,客戶端可以執行send() 方法發送消息,服務端可以執行recv()方法接收消息,反過來,服務器也可以執行send()客戶端執行recv()方法。

到這裏爲止,就是我們大部分程序員最熟悉的使用場景。

socket 的設計

現在,socket 我們見過,也用過,但對大部分程序員來說,它是個黑盒

那既然是黑盒,我們索性假設我們忘了 socket。重新設計一個內核網絡傳輸功能。

網絡傳輸,從操作上來看,無非就是,發數據和遠端之間互相收發數據。也就是對應着寫數據讀數據

讀寫收發

但顯然,事情沒那麼簡單。

這裏還有兩個問題。

第一個是,接收端和發送端可能不止一個,因此我們需要一些信息做下區分,這個大家肯定很熟悉,可以用 IP 和端口。IP 用來定位是哪臺電腦,端口用來定位是這臺電腦上的哪個進程。

第二個是,發送端和接收端的傳輸方式有很多區別,可以是可靠的TCP協議,也可以是不可靠的UDP協議,甚至還需要支持基於icmp協議ping命令

sock 是什麼

寫過代碼的都知道,爲了支持這些功能,我們需要定義一個數據結構去支持這些功能。

這個數據結構,叫sock

爲了解決上面的第一個問題,我們可以在sock里加入 IP 和端口字段。

sock 加入 IP 和端口字段

而第二個問題,我們會發現這些協議雖然各不相同,但還是有一些功能相似的地方,比如收發數據時的一些邏輯完全可以複用。按面向對象編程的思想,我們可以將不同的協議當成是不同的對象類(或結構體),將公共的部分提取出來,通過 " 繼承 " 的方式,複用功能。

基於各種 sock 實現網絡傳輸功能

於是,我們將功能重新劃分下,定義了一些數據結構。

繼承 sock 的各類 sock

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 層

可以想象得到,這裏面的代碼肯定非常複雜,同時還操作了網卡硬件,需要比較高的操作系統權限,再考慮到性能和安全,於是決定將它放在操作系統內核裏。

既然網絡傳輸功能做在內核裏,那用戶空間的應用程序想要用這部分功能的話,該怎麼辦呢?

這個好辦,本着不重複造輪子的原則,我們將這部分功能抽象成一個個簡單的接口。以後別人只需要調用這些接口,就可以驅動我們寫好的這一大堆複雜的數據結構去發送數據。

那麼問題來了,怎麼樣將這部分功能暴露出去呢?讓其他程序員更方便的使用呢?

既然跟遠端服務端進程收發數據可以抽象爲 “讀和寫”,操作文件也可以抽象爲 " 讀和寫 ",正好有句話叫,"linux 裏一切皆是文件 ",那我們索性,將內核的 sock 封裝成文件就好了。創建sock的同時也創建一個文件文件有個句柄 fd,說白了就是個文件系統裏的身份證號碼,通過它可以唯一確定是哪個sock

這個文件句柄 fd 其實就是 sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) 裏的sock_fd

將句柄暴露給用戶,之後用戶就可以像操作文件句柄那樣去操作這個 sock 句柄。在用戶空間裏操作這個句柄,文件系統就會將操作指向內核sock結構。

是的,操作這個特殊的文件就相當於操作內核裏對應的sock

通過文件找到 sock

有了sock_fd句柄之後,我們就需要提供一些接口方法,讓用戶更方便的實現特定的網絡編程功能。這些接口,我們列了一下,發現需要有send()recv()bind()listen()connect()這些。到這裏,我們的內核網絡傳輸功能就算設計完成了。

現在是不是眼熟了,上面這些接口方法其實就是 socket 提供出來的接口

所以說,socket 其實就是個代碼庫 or 接口層,它介於內核和應用程序之間,提供了一些高度封裝過的接口,讓我們去使用內核網絡傳輸功能

基於 sock 實現網絡傳輸功能

到這裏,我們應該明白了。我們平時寫的應用程序裏代碼裏雖然用了 socket 實現了收發數據包的功能,但其實真正執行網絡通信功能的,不是應用程序,而是 linux 內核。相當於應用程序通過 socket 提供的接口,將網絡傳輸的這部分工作外包給了 linux 內核

這聽起來像不像我們最熟悉的前後端分離的服務架構,雖然這麼說不太嚴謹,但看上去 linux 就像是被分成了應用程序和內核兩個服務。內核就像是後端,暴露了好多個 api 接口,其中一類就是 socket 的send()recv()這些方法。應用程序就像是前端,負責調用內核提供的接口來實現想要的功能。

進程通過 socket 調用內核功能

看到這裏,我擔心大家會有點混亂,來做個小的總結

在操作系統內核空間裏,實現網絡傳輸功能的結構是 sock,基於不同的協議和應用場景,會被泛化爲各種類型的 xx_sock,它們結合硬件,共同實現了網絡傳輸功能。爲了將這部分功能暴露給用戶空間的應用程序使用,於是引入了 socket 層,同時將 sock 嵌入到文件系統的框架裏,sock 就變成了一個特殊的文件,用戶就可以在用戶空間使用文件句柄,也就是 socket_fd 來操作內核 sock 的網絡傳輸能力。

這個socket_fd是一個 int 類型的數字。現在回去看socket的中文翻譯,套接字將它理解爲一用於連的數,是不是就覺得特別合理了。

網絡分層與基於 sock 實現網絡傳輸功能

socket 如何實現網絡通信

上面關於怎麼實現網絡通信功能這一塊一筆帶過了。

現在我們來聊聊。

這套 sock 的結構其實非常複雜。我們以最常用的 TCP 協議爲例,簡單瞭解下它是怎麼實現網絡傳輸功能的。

我將它分爲兩階段,分別是建立連接數據傳輸

建立連接

對於 TCP,要傳數據,就得先在客戶端和服務端中間建立連接

在客戶端,代碼執行 socket 提供的connect(sockfd, "ip:port")方法時,會通過 sockfd 句柄找到對應的文件,再根據文件裏的信息指向內核的sock結構。通過這個 sock 結構主動發起三次握手。

TCP 三次握手

在服務端握手次數還沒達到 "三次" 的連接,叫半連接,完成好三次握手的連接,叫全連接。它們分別會用半連接隊列全連接隊列來存放,這兩個隊列會在你執行listen()方法的時候創建好。當服務端執行accept()方法時,就會從全連接隊列裏拿出一條全連接。

半連接隊列和全連接隊列

至此,連接就算準備好了,之後,就可以開始傳輸數據

雖然都叫隊列,但半連接隊列其實是個 hash 表,而全連接隊列其實是個鏈表。

那麼問題來了,爲什麼半連接隊列要設計成哈希表而全連接隊列是個鏈表?這個在我在我之前寫的《沒有 accept,能建立 TCP 連接嗎?》 已經提到過,不再重複。

數據傳輸

爲了實現發送和接收數據的功能,sock 結構體裏帶了一個發送緩衝區和一個接收緩衝區,說是緩衝區,但其實就是個鏈表,上面掛着一個個準備要發送或接收的數據。

當應用執行send()方法發送數據時,同樣也會通過sock_fd句柄找到對應的文件,根據文件指向的sock結構,找到這個sock結構裏帶的發送緩衝區,將數據會放到發送緩衝區,然後結束流程,內核看心情決定什麼時候將這份數據發送出去。

接收數據流程也類似,當數據送到 linux 內核後,數據不是立馬給到應用程序的,而是先放在接收緩衝區中,數據靜靜躺着,卑微的等待應用程序什麼時候執行recv()方法來拿一下。就像我的文章,躺在你的推文列表裏,卑微的等一個點贊關注轉發三連。懂?

sock 的發送和接收緩衝區

IP 和端口其實不在 sock 下,而在 inet_sock 下,上面這麼畫只是爲了簡化。。。

那麼問題來了,發送數據是應用程序主動發起,這個大家都沒問題。

那接收數據呢?數據從遠端發過來了,怎麼通知並給到應用程序呢?

這就需要用到等待隊列

sock 內的等待隊列

當你的應用進程執行recv()方法嘗試獲取(阻塞場景下)接收緩衝區的數據時。

recv 時無數據進程進入等待隊列

有時候,你會看到多個進程通過fork的方式,listen了同一個socket_fd。在內核,它們都是同一個 sock,多個進程執行 listen() 之後,都嗷嗷等待連接進來,所以都會將自身的進程信息註冊到這個 socket_fd 對應的內核 sock 的等待隊列中。如果這時真來了一個連接,是該喚醒等待隊列裏的哪個進程來接收連接呢?這個問題的答案比較有趣。

驚羣效應

看到這裏,問題又來了。

服務端 listen 的時候,那麼多數據到一個 socket 怎麼區分多個客戶端的?

以 TCP 爲例,服務端執行 listen 方法後,會等待客戶端發送數據來。客戶端發來的數據包上會有源 IP 地址和端口,以及目的 IP 地址和端口,這四個元素構成一個四元組,可以用於唯一標記一個客戶端。

其實說四元組並不嚴謹,因爲過程中還有很多其他信息,也可以說是五元組。。。但大概理解就好,就這樣吧。。。

四元組

服務端會創建一個新的內核 sock,並用四元組生成一個hash key,將它放入到一個hash表中。

四元組映射成 hash 鍵

下次再有消息進來的時候,通過消息自帶的四元組生成hash key再到這個hash表裏重新取出對應的 sock 就好了。所以說服務端是通過四元組來區分多個客戶端的

多個 hash_key 對應多個客戶端

sock 怎麼實現 "繼承"

最後遺留一個問題。

大家都知道 linux 內核是 C 語言實現的,而 C 語言沒有類也沒有繼承的特性,是怎麼做到 "繼承" 的效果的呢?

在 C 語言裏,結構體裏的內存是連續的,將要繼承的 "父類",放到結構體的第一位,就像下面這樣。

struct tcp_sock {
    /* inet_connection_sock has to be the first member of tcp_sock */
    struct inet_connection_sock inet_conn;
        // 其他字段
}

struct inet_connection_sock {
    /* inet_sock has to be the first member! */
    struct inet_sock   icsk_inet;
        // 其他字段
}

然後我們就可以通過結構體名的長度來強行截取內存,這樣就能轉換結構體,從而實現類似 "繼承" 的效果。

// sock 轉爲 tcp_sock
static inline struct tcp_sock *tcp_sk(const struct sock *sk)
{
    return (struct tcp_sock *)sk;
}

內存佈局

總結

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VazobOgY9QVaADpEur81ng