一、基本簡介
在計算機通信領域,socket 被翻譯為“套接字”,它是計算機之間進行通信的一種約定或一種方式。
通過 socket 這種約定,一台計算機可以接收其他計算機的數據,也可以向其他計算機發送數據。
socket 的典型應用就是 Web 服務器和瀏覽器:瀏覽器獲取用戶輸入的URL,向服務器發起請求,服務器分析接收到的URL,將對應的網頁內容返回給瀏覽器,瀏覽器再經過解析和渲染,就將文字、圖片、視頻等元素呈現給用戶。
學習 socket,也就是學習計算機之間如何通信,並編寫出實用的程序。
二、數據傳輸方式
計算機之間有很多數據傳輸方式,各有優缺點,常用的有兩種:SOCK_STREAM 和 SOCK_DGRAM。
QQ 視頻聊天和語音聊天就使用 SOCK_DGRAM 傳輸數據,因為首先要保證通信的效率,盡量減小延遲,而數據的正確性是次要的,即使丟失很小的一部分數據,視頻和音頻也可以正常解析,最多出現噪點或雜音,不會對通信質量有實質的影響。
三、代碼實例
Server:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(){
//創建套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// AF_INET : 表示使用 IPv4 地址 可選參數
// SOCK_STREAM 表示使用面向連接的數據傳輸方式,
// IPPROTO_TCP 表示使用 TCP 協議
//將套接字和IP、端口綁定
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); //每個字節都用0填充
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具體的IP地址
serv_addr.sin_port = htons(1234); //端口
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
//進入監聽狀態,等待用戶發起請求
listen(serv_sock, 20);
//接收客戶端請求
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
//向客戶端發送數據
char str[] = "Hello World!";
write(clnt_sock, str, sizeof(str));
//關閉套接字
close(clnt_sock);
close(serv_sock);
return 0;
}
Client:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main(){
//創建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0);
//向服務器(特定的IP和端口)發起請求
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); //每個字節都用0填充
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具體的IP地址
serv_addr.sin_port = htons(1234); //端口
connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
//讀取服務器傳回的數據
char buffer[40];
read(sock, buffer, sizeof(buffer)-1);
printf("Message form server: %s\n", buffer);
//關閉套接字
close(sock);
return 0;
}
四、Socket套接字
int socket(int af, int type, int protocol);
1. af
af 為地址族(Address Family),也就是 IP 地址類型,常用的有 AF_INET 和 AF_INET6。AF 是“Address Family”的簡寫,INET是“Inetnet”的簡寫。AF_INET 表示 IPv4 地址
2. type
type 為數據傳輸方式,常用的有 SOCK_STREAM 和 SOCK_DGRAM
1. SOCK_STREAM
表示面向連接的數據傳輸方式。數據可以准確無誤地到達另一台計算機,如果損壞或丟失,可以重新發送,但效率相對較慢。常見的 http 協議就使用 SOCK_STREAM 傳輸數據,因為要確保數據的正確性,否則網頁不能正常解析。
2. SOCK_DGRAM
表示無連接的數據傳輸方式。計算機只管傳輸數據,不作數據校驗,如果數據在傳輸中損壞,或者沒有到達另一台計算機,是沒有辦法補救的。也就是說,數據錯了就錯了,無法重傳。因為 SOCK_DGRAM 所做的校驗工作少,所以效率比 SOCK_STREAM 高。
3. protocal
protocol 表示傳輸協議,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分別表示 TCP 傳輸協議和 UDP 傳輸協議
五、bind()和connect()函數
服務器端要用 bind() 函數將套接字與特定的IP地址和端口綁定起來,只有這樣,流經該IP地址和端口的數據才能交給套接字處理;
而客戶端要用 connect() 函數建立連接。
0. sockaddr_in結構體
struct sockaddr_in{
sa_family_t sin_family; //地址族(Address Family),也就是地址類型
uint16_t sin_port; //16位的端口號
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用,一般用0填充
};
-
sin_family 和 socket() 的第一個參數的含義相同,取值也要保持一致。
-
sin_prot 是 端口號。uint16_t 的長度為兩個字節,理論上端口號的取值范圍為 0~65536,但 0~1023 的端口一般由系統分配給特定的服務程序,例如 Web 服務的端口號為 80,FTP 服務的端口號為 21,所以我們的程序要盡量在 1024~65536 之間分配端口號。
-
sin_addr 是 struct in_addr 結構體類型的變量,下面會詳細講解。
-
sin_zero[8] 是多余的8個字節,沒有用,一般使用 memset() 函數填充為 0。上面的代碼中,先用 memset() 將結構體的全部字節填充為 0,再給前3個成員賦值,剩下的 sin_zero 自然就是 0 了。
端口號需要用 htons() 函數轉換,后面會講解為什么。
1. in_addr結構體詳解
struct in_addr{
in_addr_t s_addr; //32位的IP地址
};
in_addr_t 在頭文件 <netinet/in.h> 中定義,等價於 unsigned long,長度為4個字節。
也就是說,s_addr 是一個整數,而IP地址是一個字符串,所以需要 inet_addr() 函數進行轉換
2. 為什么使用 sockaddr_in 而不使用 sockaddr
sockaddr_in的結構體如下
struct sockaddr{
sa_family_t sin_family; //地址族(Address Family),也就是地址類型
char sa_data[14]; //IP地址和端口號
};

sockaddr 和 sockaddr_in 的長度相同,都是16字節,只是將IP地址和端口號合並到一起,用一個成員 sa_data 表示。要想給 sa_data 賦值,必須同時指明IP地址和端口號,例如”127.0.0.1:80“,遺憾的是,沒有相關函數將這個字符串轉換成需要的形式,也就很難給 sockaddr 類型的變量賦值,所以使用 sockaddr_in 來代替。這兩個結構體的長度相同,強制轉換類型時不會丟失字節,也沒有多余的字節。
可以認為,sockaddr 是一種通用的結構體,可以用來保存多種類型的IP地址和端口號,而 sockaddr_in 是專門用來保存 IPv4 地址的結構體。
sockaddr_in6的結構體如下
struct sockaddr_in6 {
sa_family_t sin6_family; //(2)地址類型,取值為AF_INET6
in_port_t sin6_port; //(2)16位端口號
uint32_t sin6_flowinfo; //(4)IPv6流信息
struct in6_addr sin6_addr; //(4)具體的IPv6地址
uint32_t sin6_scope_id; //(4)接口范圍ID
};
1. bind()
int bind(int sock, struct sockaddr *addr, socklen_t addrlen); //Linux
int bind(SOCKET sock, const struct sockaddr *addr, int addrlen); //Windows
2. connect()
connect() 函數用來建立連接,它的原型為:
int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen); //Linux
int connect(SOCKET sock, const struct sockaddr *serv_addr, int addrlen); //Windows
六、 listen()和accept()函數
對於服務器端程序,使用 bind() 綁定套接字后,還需要使用 listen() 函數讓套接字進入被動監聽狀態,再調用 accept() 函數,就可以隨時響應客戶端的請求了。
1.listen() 函數
通過 listen() 函數可以讓套接字進入被動監聽狀態,它的原型為:
int listen(int sock, int backlog); //Linux
int listen(SOCKET sock, int backlog); //Windows
sock 為需要進入監聽狀態的套接字,backlog 為請求隊列的最大長度。
被動監聽 是指當沒有客戶端請求時,套接字處於“睡眠”狀態,只有當接收到客戶端請求時,套接字才會被“喚醒”來響應請求。
請求隊列
當套接字正在處理客戶端請求時,如果有新的請求進來,套接字是沒法處理的,只能把它放進緩沖區,待當前請求處理完畢后,再從緩沖區中讀取出來處理。如果不斷有新的請求進來,它們就按照先后順序在緩沖區中排隊,直到緩沖區滿。這個緩沖區,就稱為請求隊列(Request Queue)。
緩沖區的長度(能存放多少個客戶端請求)可以通過 listen() 函數的 backlog 參數指定,但究竟為多少並沒有什么標准,可以根據你的需求來定,並發量小的話可以是10或者20。
如果將 backlog 的值設置為 SOMAXCONN,就由系統來決定請求隊列長度,這個值一般比較大,可能是幾百,或者更多。
當請求隊列滿時,就不再接收新的請求,對於 Linux,客戶端會收到 ECONNREFUSED 錯誤,對於 Windows,客戶端會收到 WSAECONNREFUSED 錯誤。
注意:listen() 只是讓套接字處於監聽狀態,並沒有接收請求。接收請求需要使用 accept() 函數。
2.accept()函數
當套接字處於監聽狀態時,可以通過 accept() 函數來接收客戶端請求。它的原型為:
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen); //Linux
SOCKET accept(SOCKET sock, struct sockaddr *addr, int *addrlen); //Windows
它的參數與 listen() 和 connect() 是相同的:sock 為服務器端套接字,addr 為 sockaddr_in 結構體變量,addrlen 為參數 addr 的長度,可由 sizeof() 求得。
accept() 返回一個新的套接字來和客戶端通信,addr 保存了客戶端的IP地址和端口號,而 sock 是服務器端的套接字,大家注意區分。后面和客戶端通信時,要使用這個新生成的套接字,而不是原來服務器端的套接字。
最后需要說明的是:listen() 只是讓套接字進入監聽狀態,並沒有真正接收客戶端請求,listen() 后面的代碼會繼續執行,直到遇到 accept()。accept() 會阻塞程序執行(后面代碼不能被執行),直到有新的請求到來。
Linux下數據的接收和發送
Linux 不區分套接字文件和普通文件,使用 write() 可以向套接字中寫入數據,使用 read() 可以從套接字中讀取數據。
兩台計算機之間的通信相當於兩個套接字之間的通信,在服務器端用 write() 向套接字寫入數據,客戶端就能收到,然后再使用 read() 從套接字中讀取出來,就完成了一次通信。
write() 的原型為:
ssize_t write(int fd, const void *buf, size_t nbytes);
fd 為要寫入的文件的描述符,buf 為要寫入的數據的緩沖區地址,nbytes 為要寫入的數據的字節數。
size_t 是通過 typedef 聲明的 unsigned int 類型;ssize_t 在 "size_t" 前面加了一個"s",代表 signed,即 ssize_t 是通過 typedef 聲明的 signed int 類型。
write() 函數會將緩沖區 buf 中的 nbytes 個字節寫入文件 fd,成功則返回寫入的字節數,失敗則返回 -1。
read() 的原型為:
ssize_t read(int fd, void *buf, size_t nbytes);
fd 為要讀取的文件的描述符,buf 為要接收數據的緩沖區地址,nbytes 為要讀取的數據的字節數。
read() 函數會從 fd 文件中讀取 nbytes 個字節並保存到緩沖區 buf,成功則返回讀取到的字節數(但遇到文件結尾則返回0),失敗則返回 -1。
Windows下數據的接收和發送
Windows 和 Linux 不同,Windows 區分普通文件和套接字,並定義了專門的接收和發送的函數。
從服務器端發送數據使用 send() 函數,它的原型為:
int send(SOCKET sock, const char *buf, int len, int flags);
sock 為要發送數據的套接字,buf 為要發送的數據的緩沖區地址,len 為要發送的數據的字節數,flags 為發送數據時的選項。
返回值和前三個參數不再贅述,最后的 flags 參數一般設置為 0 或 NULL,初學者不必深究。
在客戶端接收數據使用 recv() 函數,它的原型為:
int recv(SOCKET sock, char *buf, int len, int flags);
