深入 Linux 內(nèi)核理解 socket 的本質(zhì)
本文將從一個初學(xué)者的角度開始聊起,讓大家了解 Socket 是什么以及它的原理和內(nèi)核實現(xiàn)。
一、Socket 的概念
Socket 就如同我們?nèi)粘I钪械牟孱^與插座的連接關(guān)系。在網(wǎng)絡(luò)編程中,Socket 是一種實現(xiàn)網(wǎng)絡(luò)通信的接口或機制。 想象一下,插頭插入插座后,電流得以流通,實現(xiàn)了能量的傳遞。而在網(wǎng)絡(luò)世界里,當(dāng)一個程序使用 Socket 與另一臺機子建立“連接”時,就如同插頭成功插入了插座,數(shù)據(jù)能夠在兩者之間進行流通和交換。
例如,當(dāng)我們在網(wǎng)上聊天時,發(fā)送方的程序通過 Socket 將消息發(fā)送出去,接收方的程序通過對應(yīng)的 Socket 接收這些消息。又比如在下載文件時,下載程序通過 Socket 與提供文件的服務(wù)器建立連接,從而能夠獲取到所需的文件數(shù)據(jù)。
二、Socket 的使用場景
我們想要將數(shù)據(jù)從 A 電腦的某個進程發(fā)到 B 電腦的某個進程。如果需要確保數(shù)據(jù)能發(fā)給對方,就選可靠的 TCP 協(xié)議;如果數(shù)據(jù)丟了也沒關(guān)系,就選擇不可靠的 UDP 協(xié)議。初學(xué)者一般首選 TCP。
這時就需要用 socket 進行編程,首先創(chuàng)建關(guān)于 TCP 的 socket:
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return 1;
}
// 后續(xù)代碼...
return 0;
}
這個方法會返回 sock_fd,它是 socket 文件的句柄。
對于服務(wù)端,得到 sock_fd 后,依次執(zhí)行 bind()、listen()、accept() 方法,等待客戶端的連接請求;對于客戶端,得到 sock_fd 后,執(zhí)行 connect() 方法向服務(wù)端發(fā)起建立連接的請求,此時會發(fā)生 TCP 三次握手。
連接建立完成后,客戶端可以執(zhí)行 send() 方法發(fā)送消息,服務(wù)端可以執(zhí)行 recv() 方法接收消息,反之亦然。
三、Socket 的設(shè)計
現(xiàn)在我們拋開socket,重新設(shè)計一個內(nèi)核網(wǎng)絡(luò)傳輸功能。我們想要將數(shù)據(jù)從 A 電腦的某個進程發(fā)到 B 電腦的某個進程,從操作上來看,就是發(fā)數(shù)據(jù)給遠端和從遠端接收數(shù)據(jù),也就是寫數(shù)據(jù)和讀數(shù)據(jù)。
但這里有兩個問題:
- 接收端和發(fā)送端可能不止一個,因此需要用 IP 和端口做區(qū)分,IP 用來定位是哪臺電腦,端口用來定位是這臺電腦上的哪個進程。
- 發(fā)送端和接收端的傳輸方式有很多區(qū)別,如可靠的 TCP 協(xié)議、不可靠的 UDP 協(xié)議,甚至還需要支持基于 icmp 協(xié)議的 ping 命令。
為了支持這些功能,需要定義一個數(shù)據(jù)結(jié)構(gòu) sock,在 sock 里加入 IP 和端口字段。這些協(xié)議雖然各不相同,但有一些功能相似的地方,可以將不同的協(xié)議當(dāng)成不同的對象類(或結(jié)構(gòu)體),將公共的部分提取出來,通過“繼承”的方式復(fù)用功能。
于是,定義了一些數(shù)據(jù)結(jié)構(gòu):
sock 是最基礎(chǔ)的結(jié)構(gòu),維護一些任何協(xié)議都有可能會用到的收發(fā)數(shù)據(jù)緩沖區(qū)。
在 Linux 內(nèi)核 2.6 相關(guān)的源碼中,sock 結(jié)構(gòu)體的定義可能類似于:
struct sock {
// 相關(guān)字段
struct sk_buff_head sk_receive_queue; // 接收數(shù)據(jù)緩沖區(qū)
struct sk_buff_head sk_write_queue; // 發(fā)送數(shù)據(jù)緩沖區(qū)
// 其他可能的字段
};
inet_sock 特指用了網(wǎng)絡(luò)傳輸功能的 sock,在 sock 的基礎(chǔ)上還加入了 TTL、端口、IP 地址這些跟網(wǎng)絡(luò)傳輸相關(guān)的字段信息。比如 Unix domain socket,用于本機進程之間的通信,直接讀寫文件,不需要經(jīng)過網(wǎng)絡(luò)協(xié)議棧。
可能的定義:
struct inet_sock {
struct sock sk; // 繼承自 sock
__be32 port; // 端口
__be32 saddr; // IP 地址
// 其他相關(guān)字段
};
inet_connection_sock 是指面向連接的 sock,在 inet_sock 的基礎(chǔ)上加入面向連接的協(xié)議里相關(guān)字段,比如 accept 隊列、數(shù)據(jù)包分片大小、握手失敗重試次數(shù)等。雖然現(xiàn)在提到面向連接的協(xié)議就是指 TCP,但設(shè)計上 Linux 需要支持擴展其他面向連接的新協(xié)議。
例如:
struct inet_connection_sock {
struct inet_sock inet; // 繼承自 inet_sock
struct request_sock_queue accept_queue; // accept 隊列
// 其他相關(guān)字段
};
tcp_sock 就是正兒八經(jīng)的 TCP 協(xié)議專用的 sock 結(jié)構(gòu),在 inet_connection_sock 基礎(chǔ)上還加入了 TCP 特有的滑動窗口、擁塞避免等功能。同樣 UDP 協(xié)議也會有一個專用的數(shù)據(jù)結(jié)構(gòu),叫 udp_sock。
大概如下:
struct tcp_sock {
struct inet_connection_sock icsk; // 繼承自 inet_connection_sock
// TCP 特有的字段,如滑動窗口、擁塞避免等相關(guān)字段
};
有了這套數(shù)據(jù)結(jié)構(gòu),將它跟硬件網(wǎng)卡對接一下,就實現(xiàn)了網(wǎng)絡(luò)傳輸?shù)墓δ堋?/p>
四、提供 Socket 層
由于這里面的代碼復(fù)雜,還操作了網(wǎng)卡硬件,需要較高的操作系統(tǒng)權(quán)限,再考慮到性能和安全,于是將它放在操作系統(tǒng)內(nèi)核里。
為了讓用戶空間的應(yīng)用程序使用這部分功能,將這部分功能抽象成簡單的接口,將內(nèi)核的 sock 封裝成文件。創(chuàng)建 sock 的同時也創(chuàng)建一個文件,文件有個文件描述符 fd,通過它可以唯一確定是哪個 sock。將fd暴露給用戶,用戶就可以像操作文件句柄那樣去操作這個 sock 。
struct file{
//文件相關(guān)的字段
.....
void *private_data; //指向sock
}
創(chuàng)建socket時,其實就是創(chuàng)建了一個文件結(jié)構(gòu)體,并將private_data字段指向sock。
有了 sock_fd 句柄后,提供了一些接口,如 send()、recv()、bind()、listen()、connect() 等,這些就是 socket 提供出來的接口。
所以說,socket 其實就是個代碼庫或接口層,它介于內(nèi)核和應(yīng)用程序之間,提供了一堆接口,讓我們?nèi)ナ褂脙?nèi)核功能,本質(zhì)上就是一堆高度封裝過的接口。
我們平時寫的應(yīng)用程序里代碼里雖然用了socket實現(xiàn)了收發(fā)數(shù)據(jù)包的功能,但其 實真正執(zhí)行網(wǎng)絡(luò)通信功能的,不是應(yīng)用程序,而是linux內(nèi)核。
在操作系統(tǒng)內(nèi)核空間里,實現(xiàn)網(wǎng)絡(luò)傳輸功能的結(jié)構(gòu)是sock,基于不同的協(xié)議和應(yīng)用場景,會被泛化為各種類型的xx_sock,它們結(jié)合硬件,共同實現(xiàn)了網(wǎng)絡(luò)傳輸功能。為了將這部分功能暴露給用戶空間的應(yīng)用程序使用,于是引入了socket層,同時將sock嵌入到文件系統(tǒng)的框架里,sock就變成了一個特殊的文件,用戶就可以在用戶空間使用文件句柄,也就是socket_fd來操作內(nèi)核sock的網(wǎng)絡(luò)傳輸能力。
五、Socket 如何實現(xiàn)網(wǎng)絡(luò)通信
以最常用的 TCP 協(xié)議為例,實現(xiàn)網(wǎng)絡(luò)傳輸功能分為建立連接和數(shù)據(jù)傳輸兩個階段。
1. 建立連接
在客戶端,執(zhí)行 socket 提供的 connect(sockfd, "ip:port") 方法時,會通過 sockfd 句柄找到對應(yīng)的文件,再根據(jù)文件里的信息指向內(nèi)核的 sock 結(jié)構(gòu),通過這個 sock 結(jié)構(gòu)主動發(fā)起三次握手。
在服務(wù)端,握手次數(shù)還沒達到“三次”的連接叫半連接,完成好三次握手的連接叫全連接,它們分別會用半連接隊列和全連接隊列來存放,這兩個隊列會在執(zhí)行 listen() 方法的時候創(chuàng)建好。當(dāng)服務(wù)端執(zhí)行 accept() 方法時,就會從全連接隊列里拿出一條全連接。
雖然都叫隊列,但半連接隊列其實是個哈希表,而全連接隊列其實是個鏈表。
在 Linux 內(nèi)核 2.6 版本的源碼中,相關(guān)的代碼實現(xiàn)可能位于網(wǎng)絡(luò)子系統(tǒng)的部分。例如,建立連接的過程可能涉及到 tcp_connect() 等函數(shù)。
2. 數(shù)據(jù)傳輸
為了實現(xiàn)發(fā)送和接收數(shù)據(jù)的功能,sock 結(jié)構(gòu)體里帶了一個發(fā)送緩沖區(qū)和一個接收緩沖區(qū),其實就是個鏈表,上面掛著一個個準(zhǔn)備要發(fā)送或接收的數(shù)據(jù)。
當(dāng)應(yīng)用執(zhí)行 send() 方法發(fā)送數(shù)據(jù)時,會通過 sock_fd 句柄找到對應(yīng)的文件,根據(jù)文件指向的 sock 結(jié)構(gòu),找到這個 sock 結(jié)構(gòu)里帶的發(fā)送緩沖區(qū),將數(shù)據(jù)放到發(fā)送緩沖區(qū),然后結(jié)束流程,內(nèi)核看心情決定什么時候?qū)⑦@份數(shù)據(jù)發(fā)送出去。
接收數(shù)據(jù)流程也類似,當(dāng)數(shù)據(jù)送到 Linux 內(nèi)核后,先放在接收緩沖區(qū)中,等待應(yīng)用程序執(zhí)行 recv() 方法來拿。
當(dāng)應(yīng)用進程執(zhí)行 recv() 方法嘗試獲?。ㄗ枞麍鼍跋拢┙邮站彌_區(qū)的數(shù)據(jù)時,如果有數(shù)據(jù),取走就好;如果沒數(shù)據(jù),就會將自己的進程信息注冊到這個 sock 用的等待隊列里,然后進程休眠。如果這時候有數(shù)據(jù)從遠端發(fā)過來了,數(shù)據(jù)進入到接收緩沖區(qū)時,內(nèi)核就會取出 sock 的等待隊列里的進程,喚醒進程來取數(shù)據(jù)。
當(dāng)多個進程通過 fork 的方式 listen 了同一個 socket_fd,在內(nèi)核它們都是同一個 sock,多個進程執(zhí)行 listen() 之后,都會將自身的進程信息注冊到這個 socket_fd 對應(yīng)的內(nèi)核 sock 的等待隊列中。在 Linux 2.6 以前,會喚醒等待隊列里的所有進程,但最后其實只有一個進程會處理這個連接請求,其他進程又重新進入休眠,會消耗一定的資源,這就是驚群效應(yīng)。在 Linux 2.6 之后,只會喚醒等待隊列里的其中一個進程,這個問題被修復(fù)了。
服務(wù)端 listen 的時候,那么多數(shù)據(jù)到一個 socket 怎么區(qū)分多個客戶端的?以 TCP 為例,服務(wù)端執(zhí)行 listen 方法后,會等待客戶端發(fā)送數(shù)據(jù)來??蛻舳税l(fā)來的數(shù)據(jù)包上會有源 IP 地址和端口,以及目的 IP 地址和端口,這四個元素構(gòu)成一個四元組,可以用于唯一標(biāo)記一個客戶端。服務(wù)端會創(chuàng)建一個新的內(nèi)核 sock,并用四元組生成一個 hash key,將它放入到一個 hash 表中。下次再有消息進來的時候,通過消息自帶的四元組生成 hash key 再到這個 hash 表 里重新取出對應(yīng)的 sock 就好了。
六、Socket 怎么實現(xiàn)“繼承”
Linux 內(nèi)核是 C 語言實現(xiàn)的,而 C 語言沒有類也沒有繼承的特性,是通過結(jié)構(gòu)體里的內(nèi)存是連續(xù)的這一特點來實現(xiàn)“繼承”的效果。將要繼承的“父類”,放到結(jié)構(gòu)體的第一位,然后通過結(jié)構(gòu)體名的長度來強行截取內(nèi)存,這樣就能轉(zhuǎn)換結(jié)構(gòu)體,從而實現(xiàn)類似“繼承”的效果。
例如:
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 轉(zhuǎn)為 tcp_sock
static inline struct tcp_sock *tcp_sk(const struct sock *sk) {
return (struct tcp_sock *)sk;
}
七、總結(jié)
socket 中文套接字,可理解為一套用于連接的數(shù)字。
sock 在內(nèi)核,socket_fd 在用戶空間,socket 層介于內(nèi)核和用戶空間之間。
在操作系統(tǒng)內(nèi)核空間里,實現(xiàn)網(wǎng)絡(luò)傳輸功能的結(jié)構(gòu)是 sock,基于不同的協(xié)議和應(yīng)用場景,會被泛化為各種類型的 xx_sock,它們結(jié)合硬件,共同實現(xiàn)了網(wǎng)絡(luò)傳輸功能。為了將這部分功能暴露給用戶空間的應(yīng)用程序使用,于是引入了 socket 層,同時將 sock 嵌入到文件系統(tǒng)的框架里,sock 就變成了一個特殊的文件,用戶就可以在用戶空間使用文件句柄,也就是 socket_fd 來操作內(nèi)核 sock 的網(wǎng)絡(luò)傳輸能力。
服務(wù)端可以通過四元組來區(qū)分多個客戶端。
內(nèi)核通過 C 語言“結(jié)構(gòu)體里的內(nèi)存是連續(xù)的”這一特點實現(xiàn)了類似繼承的效果。