快速入門Socket編程——封裝一套便捷的Socket編程——導論
前言
? 這里是筆者打算做的Socket編程的第二部分,也就是核心的討論我們Socket編程本身。
導論
? 我們知道,一個經典的服務器套接字的處理流程是如下的:
- 創建一個指定傳輸層和網絡層協議的套接字(socket)
- 申請和綁定操作系統到指定的端口上(bind)
- 運行使能申請到的資源,也就是監聽(listen)
- 接受和開啟對客戶端之間的通信(accept后做read && write。完成業務后如果需要關閉則關閉之)
? 對于客戶端,事情就會簡單一些,我們只需要創建套接字(socket)后指定要連接的遠程對象就好(connect),之后就可以跟服務器之間做IO通信了。
? 那么,我們就是準備好做封裝。封裝之前就需要了解一下基本的系統API,這里需要注意的是,我們只討論Linux系列的Socket API,盡管Windows在一定層次上對我們的Socket編程接口存在兼容,但是仍有不少的差距(比如說必須加載Socket編程庫,完事后還有Cleanup,關閉套接字的API也跟Linux的存在差距)
socket創建一個套接字
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
? 這個API實際上就是創建了一個指定了IP版本協議,傳輸層協議簇和傳輸層協議的API,
? domain參數描述的是我們的IP網絡層協議采用的決定,這個參數指定了套接字使用的地址族(Address Family),它決定了套接字可以與哪種類型的網絡進行通信。最常見的選項有:
AF_INET
:用于 IPv4 協議。這是目前最常用的選項,它允許您使用 32 位的 IP 地址進行通信。AF_INET6
:用于 IPv6 協議。如果您需要使用 128 位的 IP 地址,則應選擇此選項。AF_UNIX
:用于 本地進程間通信(IPC)。它不涉及網絡,而是在同一臺機器的不同進程間進行通信,效率更高。
? 一般而言,我們會采用的是AF_INET,這個我想大家最熟悉。
? type定義了套接字的服務類型,也就是數據傳輸的方式。這里說的是傳輸層我們采納的協議控制。
SOCK_STREAM
:流式套接字。它提供可靠的、面向連接的通信服務,使用 TCP (Transmission Control Protocol) 協議。數據會按順序、無差錯地傳輸,適用于網頁瀏覽、文件傳輸等需要高可靠性的場景。(面向連接的)SOCK_DGRAM
:數據報套接字。它提供不可靠的、無連接的通信服務,使用 UDP (User Datagram Protocol) 協議。數據報可能會丟失、重復或亂序到達,但它具有低延遲的特點,適用于實時音視頻、在線游戲等對實時性要求高但允許少量數據丟失的場景。(面向數據的)SOCK_RAW
:原始套接字。這種類型的套接字允許您直接訪問 IP 層,可以自己構造 IP 數據包,常用于網絡協議分析工具(如ping
)或一些特殊的網絡應用。
? 對于protocol這個參數用于指定在特定的協議族和套接字類型下的具體協議。通常情況下,咱都是將其設置為 0
。
當 protocol
為 0
時,系統會根據 domain
和 type
的組合自動選擇最合適的默認協議。例如:
socket(AF_INET, SOCK_STREAM, 0)
:系統會自動選擇 TCP 協議。socket(AF_INET, SOCK_DGRAM, 0)
:系統會自動選擇 UDP 協議。
只有在某些特殊情況下(例如使用 SOCK_RAW
),您才需要顯式指定協議編號。
bind監聽一個端口
? bind在我導論的時候就說過,實際上就是申請且綁定應用程序需要的端口。它負責將一個創建好的套接字(Socket)與一個特定的本地 IP 地址和端口號關聯起來。簡單來說,就是給你的套接字在網絡世界中“分配一個門牌號”。
?
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
? bind函數的接口比較復雜,第一個填入的就是套接字的文件描述符,這個沒啥可說的,因為我們就是需要我們的套接字具備可用的端口。
? 需要知道的是這類API都會返回負數表示失敗,所以可以檢查一下,這個函數很容易失敗,因為我們要是退出服務的時候沒有正確的釋放端口資源(或者說,存在比較長的TIME_WAIT的端口),就會沒辦法進行再次綁定。
? struct sockaddr *addr
是一個指向 struct sockaddr
類型常量的指針。struct sockaddr
是一個通用的套接字地址結構體,但它是一個泛型結構,實際使用中,我們通常會將其轉換為更具體的地址結構體,例如:
struct sockaddr_in
:用于 IPv4 地址。struct sockaddr_in6
:用于 IPv6 地址。struct sockaddr_un
:用于 UNIX 域套接字(本地進程間通信)。
這個結構體中包含了我們希望綁定的 IP 地址和端口號。
#include <netinet/in.h> // 包含 sockaddr_in 的定義
#include <arpa/inet.h> // 包含 inet_addr() 和 htons()struct sockaddr_in {sa_family_t sin_family; // 地址族,通常設置為 AF_INETin_port_t sin_port; // 端口號,必須是網絡字節序struct in_addr sin_addr; // IP 地址,必須是網絡字節序// char sin_zero[8]; // 填充字節,通常不需要顯式設置
};
struct in_addr {in_addr_t s_addr; // 32位IPv4地址
};
在設置 sin_port
和 sin_addr
時,請務必注意字節序轉換。網絡上的數據傳輸通常使用網絡字節序(大端字節序),而我們的主機可能使用主機字節序(大端或小端)。為了確保不同系統間的兼容性,必須進行轉換:
htons()
(host to network short): 將主機字節序的短整型(通常是端口號)轉換為網絡字節序。htonl()
(host to network long): 將主機字節序的長整型(通常是 IP 地址)轉換為網絡字節序。inet_addr()
或inet_pton()
: 將點分十進制的 IP 地址字符串轉換為網絡字節序的二進制形式,并存儲到in_addr.s_addr
中。
? 所以,我們可能需要正確的轉換排序的大小端格式,這個時候,咱們的確需要做的就是htons或者是htonl。
? 剩下的參數填寫的是采用的結構體的大小,這個是純粹方便正確的轉換的,填寫sizeof的結果就完事了
題外話:避免“地址已使用”錯誤 (EADDRINUSE):
在服務器程序中,當程序崩潰或異常退出時,操作系統可能不會立即釋放綁定的端口,導致在短時間內重新啟動程序時報告“地址已使用”錯誤。為了避免這個問題,通常會在 bind() 之前設置套接字的SO_REUSEADDR 選項,允許重新使用處于 TIME_WAIT 狀態的本地地址。這通過 setsockopt() 函數實現。
int optval = 1; if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {perror("setsockopt SO_REUSEADDR failed");// 處理錯誤 }
這樣的話,我們就不會再次觸發端口已經綁定的問題。
bind的本質是為服務器指定監聽地址和端口:對于服務器程序而言,
bind()
是必不可少的一步。它告訴操作系統,這個套接字將監聽哪個 IP 地址上的哪個端口號。只有綁定了地址和端口,客戶端才能找到并連接到服務器。
- IP 地址:
- 如果您想讓服務器監聽所有可用的網絡接口(即所有本機 IP 地址),可以將
sin_addr.s_addr
設置為INADDR_ANY
(通常是0.0.0.0
,經過htonl()
轉換后)。- 如果您想讓服務器只監聽特定的一個 IP 地址,可以將其設置為該 IP 地址的
inet_addr()
轉換結果。- 端口號:
- 端口號范圍是
0
到65535
。0-1023
是知名端口,通常由系統服務占用,需要 root 權限才能綁定。1024-49151
是注冊端口。49152-65535
是動態/私有端口,通常用于客戶端的臨時端口。- 作為服務器,您通常會選擇一個大于
1023
的固定端口號。
listen:將通信激活到監聽狀態
? 我們知道,下一步就是驅動我們的網卡監聽外部信息,去嘗試捕捉潛在的客戶端的連接。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
? 這是我們的API接口,sockfd是已經通過 socket()
函數創建并通過 bind()
函數綁定了本地地址和端口號的套接字文件描述符。listen()
函數將使這個特定的套接字開始監聽網絡連接。
注意: 只有流式套接字(
SOCK_STREAM
)才需要調用listen()
。數據報套接字(SOCK_DGRAM
)是無連接的,因此不需要監聽。這個算是一個需要注意的點!
backlog
(待處理連接隊列的最大長度)
? 這個參數是 listen()
函數的核心,它指定了系統可以為這個套接字排隊等待接受的連接請求數量。為了理解 backlog
,我們需要知道 TCP 連接建立的三次握手過程。當一個客戶端發起連接請求時,它會向服務器發送一個 SYN
報文。此時,操作系統會創建一個連接,并將其放在一個半連接隊列(SYN queue
)中。當服務器收到客戶端的 ACK
報文,完成三次握手后,這個連接會被從半連接隊列移動到全連接隊列(accept queue
)。
backlog
參數的真正作用就是限制這個“全連接隊列”的最大長度。
- 如果全連接隊列已滿:當有新的客戶端連接完成三次握手時,操作系統會忽略其
ACK
報文,導致客戶端最終超時,認為連接失敗。 backlog
的值:- 歷史上,不同的操作系統對
backlog
的解釋和實現有所不同。在現代 Linux 系統中,backlog
參數主要控制的就是全連接隊列的最大長度。 - 如何選擇
backlog
的值? 應該根據你的服務器性能和預期的并發連接數來決定。如果你的服務器可能在短時間內收到大量的連接請求,一個較大的backlog
值可以防止新連接被拒絕,直到你的程序有時間調用accept()
來處理它們。 - 如果
backlog
值設置為0
,某些系統可能將其視為默認值,而另一些系統則可能導致無法接受任何連接。因此,最好設置一個合理的值,例如10
、128
或SOMAXCONN
(通常是系統定義的最大值)。
- 歷史上,不同的操作系統對
accept:接受一個客戶端的連接
在 Linux 網絡編程中,accept()
函數是服務器程序接收客戶端連接的“握手”操作。在 listen()
函數使套接字進入監聽狀態并準備好連接隊列后,accept()
的作用就是從這個隊列中取出最靠前的一個連接請求,并創建一個新的套接字專門用于與該客戶端進行通信。
accept()
函數的原型定義在 <sys/socket.h>
頭文件中:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept()
是一個阻塞函數。這意味著如果當前全連接隊列中沒有等待的連接,它會一直阻塞在那里,直到有新的客戶端連接完成三次握手。如果 sockfd
被設置為非阻塞模式,accept()
會立即返回 -1
,并設置 errno
為 EAGAIN
或 EWOULDBLOCK
。成功時,它返回一個新的套接字文件描述符,這個描述符專門用來與發起連接的客戶端進行數據傳輸。失敗時,它返回 -1
。
1. sockfd
(監聽套接字文件描述符)
這是通過 socket()
和 bind()
創建,并用 listen()
進入監聽狀態的服務器監聽套接字。accept()
不會在這個套接字上進行數據收發,它只是用它來接收連接請求。
2. addr
(客戶端地址結構體)
這是一個指向 struct sockaddr
類型結構體的指針。accept()
函數會填充這個結構體,存儲發起連接的客戶端的地址信息,包括其 IP 地址和端口號。
通常,您會聲明一個 struct sockaddr_in
(用于 IPv4)類型的變量,然后將其地址強制轉換為 struct sockaddr *
傳遞給 accept()
。
3. addrlen
(地址結構體長度)
這是一個指向 socklen_t
類型的指針。在調用 accept()
之前,您需要將 addrlen
指向的變量設置為 addr
結構體的初始大小。accept()
函數執行完畢后,它會更新 addrlen
指向的值,使其反映出 addr
結構體中實際存儲的有效字節數。
? 下面的代碼就是一個最簡單的服務器端的通信代碼:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>#define PORT 8080int main() {int server_fd, new_socket;struct sockaddr_in address;socklen_t addrlen = sizeof(address);char buffer[1024] = {0};// ... socket() 和 bind() 和 listen() ...if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {perror("socket failed");exit(EXIT_FAILURE);}// ... bind and listen ...if (listen(server_fd, 10) < 0) {perror("listen failed");exit(EXIT_FAILURE);}printf("Server listening on port %d\n", PORT);// 4. 持續接受連接while (1) {printf("Waiting for a connection...\n");// 接受一個連接,如果隊列為空則阻塞if ((new_socket = accept(server_fd, (struct sockaddr *)&address, &addrlen)) < 0) {perror("accept failed");// 可以選擇繼續循環或者退出continue;}// 打印客戶端信息printf("Connection accepted from %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));// 在這里,服務器可以使用 new_socket 與客戶端進行通信read(new_socket, buffer, 1024);printf("Client sent: %s\n", buffer);const char *hello = "Hello from server";send(new_socket, hello, strlen(hello), 0);printf("Hello message sent\n");// 通信結束后,關閉新套接字close(new_socket);printf("Connection closed.\n");}close(server_fd);return 0;
}
題外話:TCP 三次握手與 Linux Socket API
客戶端 (Client)
connect()
函數調用:當客戶端調用connect()
函數時,操作系統會發送第一個 SYN 包給服務器,請求建立連接。此時,客戶端進入SYN_SENT
狀態,等待服務器的回應。connect()
函數返回:客戶端的connect()
函數會阻塞(在默認情況下),直到收到服務器的 SYN-ACK 包和客戶端自己的 ACK 包成功發送后,也就是三次握手完成,connect()
函數才會返回。此時,連接已經建立,客戶端進入ESTABLISHED
狀態。
服務器端 (Server)
listen()
函數調用:listen()
函數本身只是告訴操作系統,這個套接字已準備好接受連接。它會設置一個待處理連接隊列,但并不會立即開始三次握手。- 接收 SYN 包:當客戶端調用
connect()
發送第一個 SYN 包后,服務器端的操作系統內核會被動地接收這個 SYN 包。內核會回應一個 SYN-ACK 包,并創建連接的半連接狀態,將其放入半連接隊列中。這個過程是由內核自動完成的,不涉及任何應用程序級別的 API 調用。 - 接收 ACK 包并移動到全連接隊列:當服務器收到客戶端發來的最后一個 ACK 包后,三次握手完成。操作系統會將該連接從半連接隊列中移動到全連接隊列(
backlog
)。這個過程也是由內核自動完成的。 accept()
函數調用:服務器調用accept()
函數時,它會從全連接隊列中取出一個已經完成三次握手的連接。如果隊列為空,accept()
會阻塞等待。當accept()
成功返回時,就意味著它已經拿到一個建立好的連接,可以開始進行數據通信了。
? 換而言之,所有的三次握手不發生在accept中,對于服務器端,他早在accept返回之前就完成了連接(因為如果沒有完成,他會等待直到連接完成后,才會取出來這個套接字返回進行IO操作,三次握手的主動方在客戶端的connect上,connect調用發起的時候進行第一次SYN握手,等待服務器完成接受SYN報文且回復ACK-SYN報文,connect接受到這個報文后再次回復好ACK才會跳出connect函數準備進行通信
總結
三次握手步驟 | 客戶端 API | 服務器端 API | 備注 |
---|---|---|---|
第一次握手 | connect() 內部發送 SYN 包 | 內核被動接收 | 客戶端發起連接請求 |
第二次握手 | 內核被動接收 SYN-ACK 包 | 內核被動發送 SYN-ACK 包 | 服務器確認收到,并回應確認和自己的連接請求 |
第三次握手 | 內核被動發送 ACK 包 connect() 返回 | 內核被動接收 ACK 包 連接進入全連接隊列 | 客戶端確認,連接建立,服務器端可被 accept() 接收 |
因此,三次握手這個復雜的協議過程,在 Linux C Socket 編程中,被巧妙地封裝在了 connect()
和 accept()
這兩個 API 的阻塞行為中。connect()
阻塞直到連接建立,而 accept()
阻塞直到有完成握手的連接可以被接受。
客戶端的connect函數
? 我們這里再把最后的connect函數說一下:
connect()
函數的原型定義在 <sys/socket.h>
頭文件中:
#include <sys/socket.h>int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect()
是一個阻塞函數(在默認情況下)。它會一直等待,直到連接建立成功或發生錯誤。成功時,它返回 0
;失敗時,返回 -1
,并設置全局變量 errno
。
1. sockfd
(套接字文件描述符)
這是通過 socket()
函數創建的套接字文件描述符。connect()
函數將使用這個套接字來發起連接請求。
2. addr
(服務器地址結構體)
這是一個指向 struct sockaddr
類型常量的指針。它包含了你想要連接的服務器的地址信息,包括其 IP 地址和端口號。
與 bind()
類似,實際使用中我們會用更具體的地址結構體,如 struct sockaddr_in
(用于 IPv4),并將其地址強制轉換為 struct sockaddr *
。
struct sockaddr_in
結構體示例:
struct sockaddr_in {sa_family_t sin_family; // 地址族,通常為 AF_INETin_port_t sin_port; // 服務器端口號,網絡字節序struct in_addr sin_addr; // 服務器IP地址,網絡字節序
};
關鍵點: 在填充這個結構體時,必須使用網絡字節序。你需要將服務器的端口號和 IP 地址從主機字節序轉換過來,通常使用 htons()
和 inet_addr()
/inet_pton()
函數。
3. addrlen
(地址結構體長度)
這個參數指定了 addr
指向的地址結構體的實際大小。通常使用 sizeof(struct sockaddr_in)
。
connect()
的作用和流程
當客戶端調用 connect()
函數時,會觸發以下一系列事件:
- 發送 SYN 包:
connect()
函數內部,操作系統會向服務器端的 IP 地址和端口號發送一個 TCPSYN
(同步)報文,發起三次握手。 - 等待 SYN-ACK:
connect()
函數會阻塞,等待服務器端的SYN-ACK
(同步-確認)報文。 - 發送 ACK 包:當收到
SYN-ACK
后,客戶端操作系統會發送一個ACK
(確認)報文給服務器。 - 連接建立:三次握手完成。此時,
connect()
函數返回0
,表示連接已成功建立。
如果 connect()
失敗了,通常會有以下原因:
ECONNREFUSED
:服務器端沒有監聽該端口(即沒有調用listen()
),或者該端口上有防火墻阻止連接。ETIMEDOUT
:連接超時。服務器可能因為網絡問題無法到達,或者服務器沒有回應。ENETUNREACH
:無法到達網絡。EADDRINUSE
:客戶端的本地地址或端口已被使用。
客戶端的 bind()
和 connect()
一個有趣的細節是,客戶端在調用 connect()
之前,通常不需要調用 bind()
。
- 如果未調用
bind()
:操作系統會在connect()
內部自動為客戶端套接字分配一個可用的臨時(匿名)端口號和本地 IP 地址。 - 如果調用了
bind()
:客戶端可以指定一個特定的本地 IP 地址和端口號來發起連接。這在某些特殊應用場景下可能有用,但大部分情況下并不需要。
? 客戶端經典的通信流程:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>#define SERVER_IP "127.0.0.1" // 服務器IP地址
#define PORT 8080 // 服務器端口號int main() {int client_fd;struct sockaddr_in server_addr;// 1. 創建套接字if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {perror("socket creation error");exit(EXIT_FAILURE);}// 2. 準備服務器地址結構體server_addr.sin_family = AF_INET;server_addr.sin_port = htons(PORT);// 將IP地址字符串轉換為網絡字節序if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {perror("invalid address/address not supported");close(client_fd);exit(EXIT_FAILURE);}// 3. 連接到服務器if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {perror("connection failed");close(client_fd);exit(EXIT_FAILURE);}printf("Successfully connected to server at %s:%d\n", SERVER_IP, PORT);// 4. 在這里進行數據通信(send/recv)...const char *message = "Hello from client";send(client_fd, message, strlen(message), 0);printf("Message sent to server\n");// 5. 關閉套接字close(client_fd);return 0;
}