在傳輸層有兩個主流的協議 TCP 和 UDP,socket 程序設計也是主要操作這兩個協議。這兩個協議的區別是什么呢?通常的答案是下面這樣的。
- TCP 是面向連接的,UDP 是面向無連接的。
- TCP 提供可靠交付,無差錯、不丟失、不重復、并且按序到達;UDP 不提供可靠交付,不保證不丟失,不保證按順序到達。
- TCP 是面向字節流的,發送時發的是一個流,沒頭沒尾;UDP 是面向數據報的,一個一個地發送。
- TCP 是可以提供流量控制和擁塞控制的,既防止對端被壓垮,也防止網絡被壓垮。
從本質上來講,所謂的建立連接,其實是為了在客戶端和服務端維護連接,而建立一定的數據結構來維護雙方交互的狀態,并用這樣的數據結構來保證面向連接的特性。TCP 無法左右中間的任何通路,也沒有什么虛擬的連接,中間的通路根本意識不到兩端使用了 TCP 還是 UDP。
所謂的連接,就是兩端數據結構狀態的協同,兩邊的狀態能夠對得上。符合 TCP 協議的規則,就認為連接存在;兩面狀態對不上,連接就算斷了。
所謂的可靠,也是兩端的數據結構做的事情。不丟失其實是數據結構在“點名”,順序到達其實是數據結構在“排序”,面向數據流其實是數據結構將零散的包,按照順序捏成一個流發給應用層。總而言之,“連接”兩個字讓人誤以為功夫在通路,其實功夫在兩端。
socket 函數用于創建一個 socket 的文件描述符,唯一標識一個 socket。我們把它叫作文件描述符,因為在內核中,我們會創建類似文件系統的數據結構,并且后續的操作都有用到它。
socket 函數有三個參數。
- domain:表示使用什么 IP 層協議。AF_INET 表示 IPv4,AF_INET6 表示 IPv6。
- type:表示 socket 類型。SOCK_STREAM,顧名思義就是 TCP 面向流的,SOCK_DGRAM 就是 UDP 面向數據報的,SOCK_RAW 可以直接操作 IP 層,或者非 TCP 和 UDP 的協議。例如 ICMP。
- protocol 表示的協議,包括 IPPROTO_TCP、IPPTOTO_UDP。
通信結束后,我們還要像關閉文件一樣,關閉 socket。
針對 TCP,我們應該如何編程。
TCP 的服務端要先監聽一個端口,一般是先調用 bind 函數,給這個 socket 賦予一個端口和 IP 地址。
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);struct sockaddr_in {__kernel_sa_family_t sin_family; /* Address family */__be16 sin_port; /* Port number */struct in_addr sin_addr; /* Internet address *//* Pad to size of `struct sockaddr'. */unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -sizeof(unsigned short int) - sizeof(struct in_addr)];
};struct in_addr {__be32 s_addr;
};
其中,sockfd 是上面我們創建的 socket 文件描述符。在 sockaddr_in 結構中,sin_family 設置為 AF_INET,表示 IPv4;sin_port 是端口號;sin_addr 是 IP 地址。
服務端所在的服務器可能有多個網卡、多個地址,可以選擇監聽在一個地址,也可以監聽 0.0.0.0 表示所有的地址都監聽。服務端一般要監聽在一個眾所周知的端口上,例如,Nginx 一般是 80,Tomcat 一般是 8080。
如果你看上面代碼中的數據結構,里面的變量名稱都有“be”兩個字母,代表的意思是“big-endian”。如果在網絡上傳輸超過 1 Byte 的類型,就要區分大端(Big Endian)和小端(Little Endian)。
最低位放在最后一個位置,我們叫作小端,最低位放在第一個位置,叫作大端。TCP/IP 棧是按照大端來設計的,而 x86 機器多按照小端來設計,因而發出去時需要做一個轉換。
接下來,就要建立 TCP 的連接了,也就是著名的三次握手,其實就是將客戶端和服務端的狀態通過三次網絡交互,達到初始狀態是協同的狀態。下圖就是三次握手的序列圖以及對應的狀態轉換。
接下來,服務端要調用 listen 進入 LISTEN 狀態,等待客戶端進行連接。
int listen(int sockfd, int backlog);
連接的建立過程,也即三次握手,是 TCP 層的動作,是在內核完成的,應用層不需要參與。
接著,服務端只需要調用 accept,等待內核完成了至少一個連接的建立,才返回。如果沒有一個連接完成了三次握手,accept 就一直等待;如果有多個客戶端發起連接,并且在內核里面完成了多個三次握手,建立了多個連接,這些連接會被放在一個隊列里面。accept 會從隊列里面取出一個來進行處理。如果想進一步處理其他連接,需要調用多次 accept,所以 accept 往往在一個循環里面。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
接下來,客戶端可以通過 connect 函數發起連接。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
我們先在參數中指明要連接的 IP 地址和端口號,然后發起三次握手。內核會給客戶端分配一個臨時的端口。一旦握手成功,服務端的 accept 就會返回另一個 socket。
這里需要注意的是,監聽的 socket 和真正用來傳送數據的 socket,是兩個 socket,一個叫作監聽 socket,一個叫作已連接 socket。成功連接建立之后,雙方開始通過 read 和 write 函數來讀寫數據,就像往一個文件流里面寫東西一樣。
針對 UDP 應該如何編程。
UDP 是沒有連接的,所以不需要三次握手,也就不需要調用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口號,因而也需要 bind。
對于 UDP 來講,沒有所謂的連接維護,也沒有所謂的連接的發起方和接收方,甚至都不存在客戶端和服務端的概念,大家就都是客戶端,也同時都是服務端。只要有一個 socket,多臺機器就可以任意通信,不存在哪兩臺機器是屬于一個連接的概念。因此,每一個 UDP 的 socket 都需要 bind。每次通信時,調用 sendto 和 recvfrom,都要傳入 IP 地址和端口。
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
TCP 協議的 socket 調用的過程:
- 服務端和客戶端都調用 socket,得到文件描述符;
- 服務端調用 listen,進行監聽;
- 服務端調用 accept,等待客戶端連接;
- 客戶端調用 connect,連接服務端;
- 服務端 accept 返回用于傳輸的 socket 的文件描述符;
- 客戶端調用 write 寫入數據;服務端調用 read 讀取數據。
此文章為11月Day23學習筆記,內容來源于極客時間《趣談Linux操作系統》,推薦該課程。