更好閱讀體驗:《理解 TCP 和 UDP》— By Gitbook
一切皆 Socket
我們已經知道網路中的程式是通過 socket 來通訊的,那什麼是 socket 呢?
socket 起源於 UNIX,而 UNIX/Linux 基本哲學之一就是「一切皆檔案」,都可以用「open → write/read → close」模式來操作。
socket 其實就是該模式的一個實現,socket 即是一種特殊的檔案,一些 socket 函式就是對其進行的操作。
使用 TCP/IP 協議的應用程式通常採用系統提供的程式設計介面:UNIX BSD 的套接字介面(Socket Interfaces)
以此來實現網路程式之間的通訊。
就目前而言,幾乎所有的應用程式都是採用 socket,所以說現在的網路時代,網路中程式通訊是無處不在,一切皆 socket
套接字介面 Socket Interfaces
套接字介面是一組函式,由作業系統提供,用以建立網路應用。
大多數現代作業系統都實現了套接字介面,包括所有 Unix 變種,Windows 和 Macintosh 系統。
套接字介面的起源
套接字介面是加州大學伯克利分校的研究人員在 20 世紀 80 年代早起提出的。
伯克利的研究者使得套接字介面適用於任何底層的協議,第一個實現就是針對 TCP/IP 協議,他們把它包括在 Unix 4.2 BSD 的核心裡,並且分發給許多學校和實驗室。
這在因特網的歷史成為了一個重大事件。
—— 《深入理解計算機系統》
從 Linux 核心的角度來看,一個套接字就是通訊的一個端點。
從 Linux 程式的角度來看,套接字是一個有相應描述符的檔案。
普通檔案的開啟操作返回一個檔案描述字,而 socket() 用於建立一個 socket 描述符,唯一標識一個 socket。
這個 socket 描述字跟檔案描述字一樣,後續的操作都有用到它,把它作為引數,通過它來進行一些操作。
常用的函式有:
- socket()
- bind()
- listen()
- connect()
- accept()
- write()
- read()
- close()
Socket 的互動流程
圖中展示了 TCP 協議的 socket 互動流程,描述如下:
- 伺服器根據地址型別、socket 型別、以及協議來建立 socket。
- 伺服器為 socket 繫結 IP 地址和埠號。
- 伺服器 socket 監聽埠號請求,隨時準備接收客戶端發來的連線,這時候伺服器的 socket 並沒有全部開啟。
- 客戶端建立 socket。
- 客戶端開啟 socket,根據伺服器 IP 地址和埠號試圖連線伺服器 socket。
- 伺服器 socket 接收到客戶端 socket 請求,被動開啟,開始接收客戶端請求,知道客戶端返回連線資訊。這時候 socket 進入阻塞狀態,阻塞是由於 accept() 方法會一直等到客戶端返回連線資訊後才返回,然後開始連線下一個客戶端的連線請求。
- 客戶端連線成功,向伺服器傳送連線狀態資訊。
- 伺服器 accept() 方法返回,連線成功。
- 伺服器和客戶端通過網路 I/O 函式進行資料的傳輸。
- 客戶端關閉 socket。
- 伺服器關閉 socket。
這個過程中,伺服器和客戶端建立連線的部分,就體現了 TCP 三次握手的原理。
下面詳細講一下 socket 的各函式。
Socket 介面
socket 是系統提供的介面,而作業系統大多數都是用 C/C++ 開發的,自然函式庫也是 C/C++ 程式碼。
socket 函式
該函式會返回一個套接字描述符(socket descriptor),但是該描述符僅是部分開啟的,還不能用於讀寫。
如何完成開啟套接字的工作,取決於我們是客戶端還是伺服器。
函式原型
#include <sys/socket.h>
int socket(int domain, int type, int protocol);複製程式碼
引數說明
domain:
協議域,決定了 socket 的地質型別,在通訊中必須採用對應的地址。
常用的協議族有:AF_INET
(ipv4地址與埠號的組合)、AF_INET6
(ipv6地址與埠號的組合)、AF_LOCAL
(絕對路徑名作為地址)。
該值的常量定義在 sys/socket.h
檔案中。
type:
指定 socket 型別。
常用的型別有:SOCK_STREAM
、SOCK_DGRAM
、SOCK_RAW
、SOCK_PACKET
、SOCK_SEQPACKET
等。
其中 SOCK_STREAM
表示提供面向連線的穩定資料傳輸,即 TCP 協議。
該值的常量定義在 sys/socket.h
檔案中。
protocol:
指定協議。
常用的協議有:IPPROTO_TCP
(TCP協議)、IPPTOTO_UDP
(UDP協議)、IPPROTO_SCTP
(STCP協議)。
當值位 0 時,會自動選擇 type
型別對應的預設協議。
bind 函式
由服務端呼叫,把一個地址族中的特定地址和 socket 聯絡起來。
函式原型
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);複製程式碼
引數說明
sockfd:
即 socket 描述字,由 socket() 函式建立。
*addr:
一個 const struct sockaddr
指標,指向要繫結給 sockfd
的協議地址。
這個地址結構根據地址建立 socket 時的地址協議族不同而不同,例如 ipv4 對應 sockaddr_in
,ipv6 對應 sockaddr_in6
.
這幾個結構體在使用的時候,都可以強制轉換成 sockaddr
。
下面是這幾個結構體對應的所在的標頭檔案:
sockaddr
:sys/socket.h
sockaddr_in
:netinet/in.h
sockaddr_in6
:netinet6/in.h
_in 字尾意義:網際網路絡(internet)的縮寫,而不是輸入(input)的縮寫。
listen 函式
伺服器呼叫,將 socket 從一個主動套接字轉化為一個監聽套接字(listening socket), 該套接字可以接收來自客戶端的連線請求。
在預設情況下,作業系統核心會認為 socket 函式建立的描述符對應於主動套接字(active socket)。
函式原型
#include <sys/socket.h>
int listen(int sockfd, int backlog);複製程式碼
引數說明
sockfd:
即 socket 描述字,由 socket() 函式建立。
backlog:
指定在請求佇列中的最大請求數,進入的連線請求將在佇列中等待 accept() 它們。
connect 函式
由客戶端呼叫,與目的伺服器的套接字建立一個連線。
函式原型
#include <sys/socket.h>
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);複製程式碼
引數說明
clientfd:
目的伺服器的 socket 描述符
*addr:
一個 const struct sockaddr
指標,包含了目的伺服器 IP 和埠。
addrlen:
協議地址的長度,如果是 ipv4 的 TCP 連線,一般為 sizeof(sockaddr_in)
;
accept 函式
伺服器呼叫,等待來自客戶端的連線請求。
當客戶端連線,accept 函式會在 addr
中會填充上客戶端的套接字地址,並且返回一個已連線描述符(connected descriptor),這個描述符可以用來利用 Unix I/O 函式與客戶端通訊。
函式原型
#indclude <sys/socket.h>
int accept(int listenfd, struct sockaddr *addr, int *addrlen);複製程式碼
引數說明
listenfd:
伺服器的 socket 描述字,由 socket() 函式建立。
*addr:
一個 const struct sockaddr
指標,用來存放提出連線請求客戶端的主機的資訊
*addrlen:
協議地址的長度,如果是 ipv4 的 TCP 連線,一般為 sizeof(sockaddr_in)
。
close 函式
在資料傳輸完成之後,手動關閉連線。
函式原型
#include <sys/socket.h>
#include <unistd.h>
int close(int fd);複製程式碼
引數說明
fd:
需要關閉的連線 socket 描述符
網路 I/O 函式
當客戶端和伺服器建立連線後,可以使用網路 I/O 進行讀寫操作。
網路 I/O 操作有下面幾組:
- read()/write()
- recv()/send()
- readv()/writev()
- recvmsg()/sendmsg()
- recvfrom()/sendto()
最常用的是 read()/write()
他們的原型是:
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);複製程式碼
鑑於該文是側重於描述 socket 的工作原理,就不再詳細描述這些函式了。
實現一個簡單 TCP 互動
服務端
// socket_server.cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define MAXLINE 4096 // 4 * 1024
int main(int argc, char **argv)
{
int listenfd, // 監聽埠的 socket 描述符
connfd; // 連線端 socket 描述符
struct sockaddr_in servaddr;
char buff[MAXLINE];
int n;
// 建立 socket,並且進行錯誤處理
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
// 初始化 sockaddr_in 資料結構
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);
// 繫結 socket 和 埠
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
{
printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
// 監聽連線
if (listen(listenfd, 10) == -1)
{
printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
printf("====== Waiting for client's request======\n");
// 持續接收客戶端的連線請求
while (true)
{
if ((connfd = accept(listenfd, (struct sockaddr *)NULL, NULL) == -1))
{
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
continue;
}
n = recv(connfd, buff, MAXLINE, 0);
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
close(connfd);
}
close(listenfd);
return 0;
}複製程式碼
客戶端
// socket_client.cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define MAXLINE 4096
int main(int argc, char **argv)
{
int sockfd, n;
char recvline[4096], sendline[4096];
struct sockaddr_in servaddr;
if (argc != 2)
{
printf("usage: ./client <ipaddress>\n");
return 0;
}
// 建立 socket 描述符
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
// 初始化目標伺服器資料結構
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(6666);
// 從引數中讀取 IP 地址
if (inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0)
{
printf("inet_pton error for %s\n", argv[1]);
return 0;
}
// 連線目標伺服器,並和 sockfd 聯絡起來。
if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
{
printf("connect error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
printf("send msg to server: \n");
// 從標準輸入流中讀取資訊
fgets(sendline, 4096, stdin);
// 通過 sockfd,向目標伺服器傳送資訊
if (send(sockfd, sendline, strlen(sendline), 0) < 0)
{
printf("send msg error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
// 資料傳輸完畢,關閉 socket 連線
close(sockfd);
return 0;
}複製程式碼
Run
首先建立 makefile
檔案
all:server client
server:socket_server.o
g++ -g -o socket_server socket_server.o
client:socket_client.o
g++ -g -o socket_client socket_client.o
socket_server.o:socket_server.cpp
g++ -g -c socket_server.cpp
socket_client.o:socket_client.cpp
g++ -g -c socket_client.cpp
clean:all
rm all複製程式碼
然後使用命令:
$ make複製程式碼
會生成兩個可執行檔案:
socket_server
socket_client
分別開啟兩個終端,執行:
./socket_server
./socket_client 127.0.0.1
然後在 socket_client
中鍵入傳送內容,可以再 socket_server
接收到同樣的資訊。