
大家好,我是蟹老板~
在 Linux 网络编程之中,Socket 乃是一个极为重要的概念。
我们平时访问网页、连接数据库、使用 SSH 登录服务器、调用 HTTP 接口、运行 Redis、Nginx、MQTT、WebSocket 以及各种 RPC 框架的时候,底层几乎都离不开 Socket。
可是对于刚刚开始学习网络编程的人来说,Socket 往往会给人一种比较割裂的感觉。
服务端为什么需要依次调用:
socket();bind();listen();accept();recv();send();客户端为什么通常只有:
socket();connect();send();recv();accept() 为什么又返回一个新的文件描述符?
一次 send() 为什么不能保证对端一次 recv() 就能够完整读取?
程序明明只是调用了一次 send(),数据为什么最终能够从网卡发送到另外一台计算机?
服务器连接数量越来越多以后,为什么又会出现 select、poll 和 epoll?
这些问题如果只停留在 API 记忆层面,是比较难真正串联起来的。
因此,理解 Linux Socket 最重要的并不是记住几十个函数,而是建立一条完整的数据流:
应用程序 ↓Socket API ↓文件描述符 ↓内核 Socket ↓TCP/UDP ↓IP ↓网卡驱动 ↓物理网络 ↓对端网卡 ↓对端协议栈 ↓对端 Socket ↓对端应用程序只要把这一条链路真正理解清楚,Socket 编程里面很多看起来零散的问题,其实都能够顺理成章地推导出来。
假设现在存在两台计算机:
计算机 A:192.168.1.10计算机 B:192.168.1.20A 想给 B 发送一句:
Hello从应用程序角度来看,可能只是:
send(fd, "Hello", 5, 0);但是计算机并不真正理解“Hello”这个业务含义。
字符串在内存里面最终是:
'H' = 0x48'e' = 0x65'l' = 0x6C'l' = 0x6C'o' = 0x6F这些数据需要经过网络协议栈不断封装。
例如 TCP 通信可以高度简化成为:
应用数据 ↓TCP Segment ↓IP Packet ↓Ethernet Frame ↓网卡发送到达对端以后则反过来:
Ethernet Frame ↓IP Packet ↓TCP Segment ↓Socket接收队列 ↓应用程序因此网络通信本质上就是:
把一台计算机进程内存中的数据,经过协议以及物理网络,可靠或者尽力地转移到另外一台计算机的进程中。

例如:
send(sockfd, buf, len, 0);一个非常粗略的发送过程可以表示成为:
用户空间 buf ↓send() ↓系统调用进入内核 ↓Socket发送缓冲区 ↓TCP协议处理 ↓IP协议处理 ↓邻居/路由处理 ↓网络设备发送队列 ↓网卡驱动 ↓DMA ↓网卡 ↓网络需要特别注意:
send();成功返回,并不等价于:
数据已经到达对端应用程序。
很多情况下,它只是表示:
内核已经接受了这些数据,并将其放入发送路径中。
真正什么时候进入网卡、什么时候抵达对端、什么时候被对端应用程序读取,是后续发生的事情。

Socket 可以理解成为:
Linux 内核提供给应用程序使用网络协议栈的一种通信端点。
应用程序并不需要自己手动构造:
TCP HeaderIP HeaderEthernet Header也不需要自己直接控制网卡发送每一个数据包。
应用只需要:
socket();connect();send();recv();内核负责把这些抽象操作转换成为真正的网络协议操作。
因此 Socket 如同应用程序和内核网络协议栈之间的一扇门。

全文会始终围绕下面这一条主线展开:
服务端创建Socket ↓bind ↓listen ↓客户端connect ↓TCP三次握手 ↓服务端accept ↓客户端send ↓内核TCP/IP ↓网络 ↓服务端recv ↓服务端send ↓客户端recv ↓close然后在这个基础之上继续解决真实工程问题:
一条连接 ↓多条连接 ↓阻塞问题 ↓非阻塞 ↓IO多路复用 ↓epoll ↓消息协议 ↓异常处理 ↓高并发服务器
Linux 中经常说:
一切皆文件。
这个说法并不是说所有对象真的都存放在磁盘文件里面,而是说很多不同类型的资源,都尽量使用统一的文件操作模型进行管理。
例如:
普通文件字符设备管道Socketeventfdtimerfd都可以通过文件描述符访问。
因此:
int fd = socket(AF_INET, SOCK_STREAM, 0);返回的也是一个整数文件描述符。
后续:
read(fd, ...);write(fd, ...);close(fd);和普通文件的操作形式非常相似。
但是 Socket 文件描述符背后对应的并不是磁盘 inode 数据,而是网络协议相关的内核对象。

应用程序执行:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);以后,会得到:
sockfd = 3这个 3 并不是 Socket 本身。
它只是当前进程文件描述符表中的一个索引。
关系可以高度简化成为:
用户空间sockfd = 3 ↓进程文件描述符表 ↓struct file ↓struct socket ↓struct sock ↓TCP/UDP协议控制结构因此:
close(sockfd);本质上是在释放当前进程对这个 Socket 文件对象的引用。
对于 TCP 来说,协议层还可能继续在内核里面维护 FIN、重传以及 TIME_WAIT 等状态。

假设服务器:
IP = 192.168.1.20Port = 8080IP 地址解决的是:
网络中要找到哪一台主机。
端口解决的是:
到达主机以后,要交给哪个网络程序。
例如同一台机器上可能同时运行:
22 SSH80 HTTP443 HTTPS3306 MySQL6379 Redis因此:
IP + Port共同定位某一个网络服务端点。
一条 TCP 连接通常由四元组区分:
源IP源端口目标IP目标端口例如:
192.168.1.10:52314 ↓192.168.1.20:8080
sockaddr 地址结构IPv4 常使用:
struct sockaddr_in { sa_family_t sin_family; in_port_t sin_port; struct in_addr sin_addr;};例如:
struct sockaddr_in addr;memset(&addr, 0, sizeof(addr));addr.sin_family = AF_INET;addr.sin_port = htons(8080);inet_pton(AF_INET, "192.168.1.20", &addr.sin_addr);IPv6 对应:
struct sockaddr_in6 { sa_family_t sin6_family; in_port_t sin6_port; uint32_t sin6_flowinfo; struct in6_addr sin6_addr; uint32_t sin6_scope_id;};但是:
bind();connect();accept();为了能够兼容不同协议族,参数通常使用通用的:
struct sockaddr *因此调用时经常需要转换:
bind(fd, (struct sockaddr *)&addr, sizeof(addr));如果程序需要同时处理 IPv4、IPv6 或其他地址族,可以使用:
struct sockaddr_storage;保存足够大的通用地址结构。

创建 TCP Socket:
socket(AF_INET, SOCK_STREAM, 0);这里:
SOCK_STREAM意味着面向字节流的 Socket。
典型底层协议是 TCP。
UDP:
socket(AF_INET, SOCK_DGRAM, 0);其中:
SOCK_DGRAM代表数据报。
它们最根本的区别可以先简单理解成为:
TCP
先建立连接再连续传输字节流提供可靠、有序传输UDP
无需建立TCP式连接直接发送一份份独立Datagram不保证可靠、有序、必达这会导致二者的编程模型存在明显区别。

TCP 所谓的“连接”,并不是两台机器之间真的拉出一根独占物理线。
TCP 连接实际上是通信双方内核分别维护的一组状态。
例如需要记录:
源IP目标IP源端口目标端口发送序列号接收序列号发送窗口接收窗口重传状态拥塞窗口TCP状态因此 TCP 在正常发送数据以前,需要先建立双方的协议状态。
这就是三次握手存在的重要原因。

客户端发送:
SYNSEQ = X服务端返回:
SYN + ACKSEQ = YACK = X + 1客户端再返回:
ACKACK = Y + 1完成以后,双方进入:
ESTABLISHED这个过程至少完成:
因此三次握手建立的并不只是一个“连接标志”,而是一套 TCP 传输所需要的上下文状态。

TCP 是全双工的。
也就是说,一条连接里面同时存在:
Client → Server和:
Server → Client两个独立方向。
因此连接建立以后:
send();recv();双方都可以执行。
例如:
Client Server send("hello") ─────────────────→ send("world") ←─────────────────两个方向甚至可以同时发生。
这也是为什么 TCP 关闭连接通常不是单纯一次“断开”,而需要分别关闭两个发送方向。

这是 Socket 编程里面最重要的知识点之一。
TCP 提供的是:
可靠、有序的字节流。
它不保留应用程序每次 send() 的消息边界。
假设客户端:
send(fd, "ABC", 3, 0);send(fd, "DEF", 3, 0);服务端并不一定:
recv(fd, buf, 3, 0);刚好读到:
ABC下一次刚好:
DEF实际可能是:
第一次 recv:AB第二次 recv:CDEF也可能:
第一次 recv:ABCDEF因此:
send次数和:
recv次数没有一一对应关系。

假设客户端主动关闭。
典型流程:
Client ServerFIN──────────────────────────→ ACK←────────────────────────── FIN←──────────────────────────ACK──────────────────────────→为什么通常需要四次?
因为 TCP 是全双工。
客户端发送 FIN 只表示:
Client 已经没有数据再发送。
并不表示 Server 也立即发送完了。
服务端可以先:
ACK Client的FIN继续发送剩余数据。
等服务端自己也完成发送,再发 FIN。

主动关闭的一方通常可能经历:
ESTABLISHED ↓FIN_WAIT_1 ↓FIN_WAIT_2 ↓TIME_WAIT ↓CLOSED被动关闭的一方:
ESTABLISHED ↓CLOSE_WAIT ↓LAST_ACK ↓CLOSEDTIME_WAIT
主要作用包括:
因此看到 TIME_WAIT 并不等于程序泄漏。
CLOSE_WAIT
表示:
对端已经关闭发送方向,本地也收到了 FIN,但是本地应用程序还没有把这个 Socket 正常关闭。
大量 CLOSE_WAIT 更值得检查应用程序是否:
忘记close线程阻塞连接对象泄漏错误路径没有释放Socket
socket():创建通信端点TCP 服务端第一步:
int listen_fd;listen_fd = socket(AF_INET, SOCK_STREAM, 0);if (listen_fd < 0) { perror("socket"); return -1;}这里创建的是一个 TCP Socket。
但是刚刚创建的时候:
它还没有端口没有进入监听状态也没有连接任何客户端可以把它理解为:
先创建了一个通信对象,但是还没有说明它要扮演什么角色。

bind():给服务器绑定 IP 和端口服务器通常需要固定端口,否则客户端不知道应该连接哪里。
例如:
struct sockaddr_in addr;memset(&addr, 0, sizeof(addr));addr.sin_family = AF_INET;addr.sin_port = htons(8080);addr.sin_addr.s_addr = htonl(INADDR_ANY);if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return -1;}其中:
INADDR_ANY表示绑定本机所有合适的 IPv4 本地地址。
如果服务器只希望监听:
127.0.0.1则只接受本机连接。
如果绑定:
192.168.1.20则主要监听该本地地址。
因此 bind() 的核心意义是:
为 Socket 确定本地地址身份。

listen():从普通 Socket 变成监听 Socket调用:
listen(listen_fd, 128);以后,这个 Socket 被用于被动等待连接。
可以理解为从:
普通TCP Socket变成:
Listening Socket它不再用于直接和某一个具体客户端交换业务数据,而是负责:
接收新的连接请求。
此时客户端可以向:
Server IP:8080发起 TCP 三次握手。
需要注意,Linux 内核中的监听连接管理比简单的“一个 backlog 队列”更加复杂。
从逻辑上可以理解为存在:
尚未完成握手的连接请求和:
已经完成握手、等待accept的连接两类状态。

accept():为什么会返回一个新的 Socket服务器:
int client_fd;client_fd = accept(listen_fd, NULL, NULL);很多初学者最容易疑惑:
已经有一个 listen_fd 了,为什么 accept 还要返回 client_fd?
因为它们承担的职责完全不同。
listen_fd:
负责继续监听8080端口client_fd:
专门代表某一个已经建立的TCP连接例如:
listen_fd │ ├── client_fd_1 │ 192.168.1.10:50001 │ ↕ │ 192.168.1.20:8080 │ ├── client_fd_2 │ 192.168.1.11:50002 │ ↕ │ 192.168.1.20:8080 │ └── client_fd_3服务器必须保留 listen_fd,继续接受后续新客户端。
每一个 accept() 返回的连接 Socket,则用于服务对应客户端。

recv()/read():接收客户端数据char buf[1024];ssize_t n = recv(client_fd, buf, sizeof(buf), 0);返回值极为重要。
n > 0
成功读取:
n Byten == 0
对端已经进行了正常关闭,当前 TCP 字节流到达 EOF。
通常需要:
close(client_fd);n < 0
发生错误。
需要检查:
errno例如:
EINTREAGAINECONNRESET
send()/write():向客户端发送数据例如 Echo Server:
send(client_fd, buf, n, 0);但是必须注意:
send();的返回值表示实际被接受的字节数。
不能永远假定:
send(fd, buf, 1000, 0);一定返回:
1000尤其是非阻塞 Socket、大量数据发送或者被信号打断时,需要正确处理 Partial Write。
通常会编写:
ssize_t send_all(int fd, const void *buf, size_t len){ const char *p = buf; size_t total = 0; while (total < len) { ssize_t n = send(fd, p + total, len - total, MSG_NOSIGNAL); if (n > 0) { total += n; continue; } if (n < 0 && errno == EINTR) continue; return -1; } return (ssize_t)total;}
close():关闭连接close(client_fd);表示应用程序释放这个文件描述符。
对于 TCP Socket,内核会根据连接状态进行后续协议关闭处理。
但是:
close();返回并不代表四次挥手已经在网络上全部完成。
内核可能继续维护:
FIN发送FIN重传ACKTIME_WAIT等状态。

最基础 TCP Server:
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <arpa/inet.h>#include <sys/socket.h>#define SERVER_PORT 8080int main(void){ int listen_fd; int client_fd; struct sockaddr_in server_addr; char buf[1024]; listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(SERVER_PORT); server_addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(listen_fd); return 1; } if (listen(listen_fd, 128) < 0) { perror("listen"); close(listen_fd); return 1; } printf("server listening on %d\n", SERVER_PORT); for (;;) { client_fd = accept(listen_fd, NULL, NULL); if (client_fd < 0) { perror("accept"); continue; } for (;;) { ssize_t n; n = recv(client_fd, buf, sizeof(buf), 0); if (n > 0) { send(client_fd, buf, n, MSG_NOSIGNAL); } else { break; } } close(client_fd); } close(listen_fd); return 0;}这个程序能够工作,但是它存在一个明显问题:
同一时刻只能认真服务一个客户端。
这会推动我们后面进入多进程、多线程以及 epoll。

服务端需要固定端口:
8080因为所有客户端都需要知道到哪里连接。
客户端通常不需要固定本地端口。
例如:
socket();connect();在 connect() 过程中,Linux 可以自动选择:
本地源IP临时源端口形成:
192.168.1.10:52731 ↓192.168.1.20:8080其中:
52731通常由内核从临时端口范围中选择。
客户端当然也可以主动 bind(),例如需要:
但一般普通客户端不需要。

socket() 创建客户端 Socketint fd;fd = socket(AF_INET, SOCK_STREAM, 0);此时只是创建 TCP Socket。
真正确定对端是在:
connect();阶段。
connect() 与 TCP 三次握手例如:
struct sockaddr_in server;server.sin_family = AF_INET;server.sin_port = htons(8080);inet_pton(AF_INET, "192.168.1.20", &server.sin_addr);connect(fd, (struct sockaddr *)&server, sizeof(server));阻塞 Socket 中,connect() 通常会等待连接建立成功或者失败。
底层发生:
客户端选择本地IP和临时端口 ↓发送SYN ↓等待SYN-ACK ↓发送ACK ↓进入ESTABLISHED ↓connect返回如果目标主机对应端口没有程序监听,可能收到 RST,然后:
connect();返回:
ECONNREFUSED如果网络路径直接丢弃 SYN,则可能经历 SYN 重试,最终连接超时。

连接建立以后:
send(fd, "hello", 5, 0);然后:
recv(fd, buf, sizeof(buf), 0);需要注意,TCP 并没有规定:
客户端必须先send服务器才能send只要连接已经 ESTABLISHED,双方都可以根据应用协议决定收发时机。

客户端调用:
close(fd);通常会成为主动关闭方。
因此客户端可能进入:
FIN_WAIT_1FIN_WAIT_2TIME_WAIT如果一个客户端程序每秒大量建立和主动关闭短连接,就可能看到许多 TIME_WAIT。
这并不一定是服务器问题。

#include <stdio.h>#include <string.h>#include <unistd.h>#include <arpa/inet.h>#include <sys/socket.h>int main(void){ int fd; struct sockaddr_in server; char send_buf[] = "hello"; char recv_buf[1024]; fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { perror("socket"); return 1; } memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(8080); if (inet_pton(AF_INET, "127.0.0.1", &server.sin_addr) != 1) { perror("inet_pton"); close(fd); return 1; } if (connect(fd, (struct sockaddr *)&server, sizeof(server)) < 0) { perror("connect"); close(fd); return 1; } if (send(fd, send_buf, strlen(send_buf), MSG_NOSIGNAL) < 0) { perror("send"); close(fd); return 1; } ssize_t n = recv(fd, recv_buf, sizeof(recv_buf) - 1, 0); if (n > 0) { recv_buf[n] = '\0'; printf("recv: %s\n", recv_buf); } close(fd); return 0;}
服务端:
socket ↓bind :8080 ↓listen内核中建立监听状态。
此时执行:
ss -lnt可能看到:
LISTEN0.0.0.0:8080
客户端:
connect();导致内核发出:
SYN服务端监听 Socket 收到以后返回:
SYN-ACK客户端返回:
ACK三次握手完成。

握手完成以后,服务端内核已经拥有一个已建立连接对象。
它进入等待应用取走的连接队列。
服务端调用:
accept();从中取出一个连接并创建新的文件描述符:
listen_fd │ └── accept ↓ client_fd因此严格来说:
accept()并不是三次握手的发起者。
在正常情况下,三次握手主要由内核协议栈完成。
accept() 是应用程序从内核取得一条已经准备好的连接。

客户端:
send(fd, buf, len, 0);数据大致经过:
用户态buf ↓系统调用 ↓内核Socket ↓Socket发送缓存 ↓TCP发送队列如果发送缓冲区有空间,send() 可以较快返回。
如果发送缓存已满,阻塞 Socket 可能等待空间。
非阻塞 Socket 则可能返回:
EAGAINEWOULDBLOCK
之后 TCP 会综合考虑:
MSS发送窗口接收窗口拥塞窗口Nagle重传状态决定什么时候发送多少数据。
然后:
TCP ↓IP ↓路由 ↓邻居解析 ↓Qdisc ↓网卡驱动 ↓NIC TX Ring ↓DMA ↓网卡网卡最终把数据转换成为物理信号发送出去。

服务端网卡接收到帧:
网卡RX ↓DMA写入内存 ↓驱动/NAPI ↓Ethernet ↓IP ↓TCP ↓查找对应Socket ↓接收队列 ↓唤醒阻塞recv ↓用户程序得到数据因此:
recv();不是直接从网卡读数据。
它读取的是:
当前已经被 Linux TCP 协议栈接收并放入该 Socket 接收队列的数据。


UDP 没有 TCP 那种连接状态。
服务端不需要等待:
SYNSYN-ACKACK因此也不需要:
listen();accept();一个 UDP Socket 绑定端口以后,就可以接收来自多个对端的数据报。
Client A ─── Datagram ───┐ │Client B ─── Datagram ───┼→ UDP Server Socket │Client C ─── Datagram ───┘
sendto() 与 recvfrom()UDP 发送时需要知道:
这一个 Datagram 发给谁。
因此:
sendto(fd, buf, len, 0, (struct sockaddr *)&peer, sizeof(peer));接收:
recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&peer, &peer_len);可以同时得到:
数据发送者地址
典型流程:
socket(SOCK_DGRAM) ↓bind ↓recvfrom ↓sendto例如:
int fd = socket(AF_INET, SOCK_DGRAM, 0);bind(fd, (struct sockaddr *)&addr, sizeof(addr));for (;;) { struct sockaddr_in peer; socklen_t peer_len = sizeof(peer); ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&peer, &peer_len); if (n > 0) { sendto(fd, buf, n, 0, (struct sockaddr *)&peer, peer_len); }}
客户端可以直接:
socket ↓sendto ↓recvfrom普通 UDP 通信甚至可以不调用 connect()。
不过 UDP Socket 也允许调用:
connect();这里不会建立 TCP 三次握手,而是给 Socket 设置默认对端,使应用可以使用:
send();recv();并让内核过滤其他对端的数据报。

TCP:
send("ABC")send("DEF")接收端看到的是连续字节流:ABCDEFUDP:
sendto("ABC")sendto("DEF")会形成两个独立 Datagram。
接收端:
第一个recvfrom→ ABC第二个recvfrom→ DEFUDP 保留数据报边界。
但是如果接收缓冲区太小:
recvfrom(fd, buf, 2, ...);去接收:
ABC剩余部分一般不会像 TCP 一样留给下一次读取。
因此 UDP 应用必须预留足够的 Datagram 接收空间。

TCP 更适合:
UDP 更适合:
不能简单认为:
TCP慢UDP快真正应该判断的是:
业务需要什么语义?
如果最终需要在 UDP 上自行实现:
序号ACK重传拥塞控制重排序系统复杂度也会迅速增加。

每一个 TCP Socket 都存在发送和接收相关缓存。
可以高度抽象成为:
应用程序 │ send() ↓┌───────────────┐│ Socket Send ││ Buffer │└───────────────┘ ↓ TCP ↓ 网络 ↓ TCP ↓┌───────────────┐│ Socket Receive││ Buffer │└───────────────┘ ↓ recv() ↓ 应用程序这两个 Buffer 把:
应用程序处理速度和:
网络发送接收速度进行了一定程度的解耦。

send() 到底把数据发送到了哪里例如:
ret = send(fd, data, 4096, 0);如果返回:
4096更加准确的理解是:
Linux Socket 层已经接受了这 4096 Byte,后续由内核负责发送。
它不代表:
对端recv已经读到4096 Byte甚至也不严格代表:
网卡已经把4096 Byte全部发出这就是为什么应用层协议如果需要知道:
对端业务真的处理成功了吗?
必须自己设计应用层 ACK。

recv() 为什么可能只读到部分数据假设对端发送:
1000 Byte你的:
recv(fd, buf, 1000, 0);可能只返回:
200为什么?
因为此时内核接收队列里面可能只有 200 Byte 可以立即交付。
剩余数据:
TCP 保证的是:
字节顺序不是:
一次recv把你想要的所有数据全读完
假设:
send(fd, "ABC", 3, 0);send(fd, "123", 3, 0);TCP 内核关注的是连续序列空间:
A B C 1 2 3它不会记录:
前三字节来自send1后三字节来自send2因此 TCP 没有应用消息边界。
这是协议设计者必须自己解决的问题。

所谓“粘包”:
发送端:
Packet APacket B接收端一次:
recv();读到了:
Packet A + Packet B所谓“拆包”或者“半包”:
发送端一个 Packet:
ABCDEFGH接收:
recv1 → ABCrecv2 → DEFGH这些并不代表 TCP 错误。
它们只是:
TCP 字节流与应用消息之间缺少边界定义。

固定长度
规定:
每帧64 Byte优点简单。
缺点浪费空间,并且不适合数据长度变化大的业务。
分隔符
例如文本协议:
hello\r\nworld\r\n适合:
但是 Payload 中出现分隔符时需要转义或者编码。
Length + Body
更加通用:
+--------+--------+| Length | Body |+--------+--------+| 4 Byte | N Byte |+--------+--------+例如:
00 00 00 0548 65 6C 6C 6F接收端先读取 4 Byte Length:
5然后等待 5 Byte Body。
这是很多二进制协议常见的设计。

推荐使用接收 Buffer 加解析循环。
例如:
Socket recv ↓追加到应用层buffer ↓buffer中是否有完整Header? ↓解析Length ↓是否有完整Packet? ├── 否 → 等下一次recv └── 是 ↓ 取出Packet ↓ 继续检查buffer中是否还有下一包伪代码:
while (1) { n = recv(fd, temp, sizeof(temp), 0); if (n <= 0) break; buffer_append(&conn->rxbuf, temp, n); while (1) { if (buffer_size(&conn->rxbuf) < HEADER_SIZE) break; uint32_t body_len = parse_length(&conn->rxbuf); size_t packet_len = HEADER_SIZE + body_len; if (buffer_size(&conn->rxbuf) < packet_len) break; handle_packet(buffer_data(&conn->rxbuf), packet_len); buffer_consume(&conn->rxbuf, packet_len); }}这才是 TCP 应用层正确解析方式。

默认创建的 Socket 通常工作在阻塞模式。
所谓阻塞,是指:
当前操作暂时无法完成时,调用线程进入睡眠等待。
例如:
recv(fd, buf, 1024, 0);当前没有任何数据。
那么线程可能暂停执行,直到:
有数据到达对端关闭发生错误收到信号
accept()
当:
没有已经完成并等待应用取走的新连接时阻塞。
recv()
当:
当前没有可读取数据连接也没有关闭时阻塞。
send()
很多人认为 send() 永远不会阻塞,这是错误的。
如果:
Socket发送缓冲区已经很满而网络又暂时无法继续发送数据,阻塞模式的 send() 可能等待缓存空间。

fcntl() 设置非阻塞模式获取原 Flag:
int flags = fcntl(fd, F_GETFL, 0);设置:
fcntl(fd, F_SETFL, flags | O_NONBLOCK);可以封装:
int set_nonblocking(int fd){ int flags; flags = fcntl(fd, F_GETFL, 0); if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK);}
EAGAIN/EWOULDBLOCK 到底意味着什么非阻塞模式调用:
recv();当前没有数据时,不会让线程睡眠。
而是立即返回:
-1并设置:
errno = EAGAIN或者:
EWOULDBLOCK它的含义不是:
Socket 坏了。
而是:
现在暂时做不了,请以后再试。
同样,非阻塞 send() 如果发送缓存暂时没有空间,也可能返回 EAGAIN。

这是非常重要的区别。
非阻塞:
操作不能立即完成→ 函数直接返回→ 你以后自己再尝试它仍然是:
你的线程主动调用read/send异步则更强调:
提交操作 ↓系统在后续完成 ↓完成时通知应用因此:
O_NONBLOCK并不等价于真正意义上的异步 IO。
epoll 本身也主要是一种:
事件就绪通知机制它告诉你:
现在某个 fd 很可能可以读或者写了。
真正的数据传输仍然通常由应用调用 recv()、send() 完成。

少量连接、程序简单:
阻塞IO往往更加容易实现。
例如:
大量连接、事件驱动:
非阻塞 + epoll通常更加合适。
关键不在于:
非阻塞一定高级而是:
并发模型和业务规模是否需要。

最简单服务器:
accept Client A ↓recv A ↓处理A ↓send A ↓继续recv A如果 A 一直不发送数据:
recv(A);会阻塞。
那么:
Client BClient CClient D即使已经连接,也得不到及时处理。
因此一个阻塞线程很难直接同时管理大量独立连接。

一种经典做法:
Main Process │ ├─ accept Client A → fork Process A ├─ accept Client B → fork Process B └─ accept Client C → fork Process C优点:
缺点:
适合连接数不是极大的传统服务器模型。

Main Thread │ ├── Thread A → Client A ├── Thread B → Client B └── Thread C → Client C优点:
缺点:
一万个连接就创建一万个线程,通常并不是理想方案。

更加合理的设计:
Accept Thread ↓Task Queue ↓┌──────────────┐│ Worker Thread││ Worker Thread││ Worker Thread│└──────────────┘线程数量固定。
任务动态分配给工作线程。
避免:
每个请求都创建和销毁线程但是如果每一个工作线程仍然在一个慢 Socket 上长期阻塞,线程池容量同样可能被占满。

于是出现另一种思路:
不给每一个连接单独创建一个线程,而是让一个线程同时等待很多 fd。
例如:
fd 10fd 11fd 12fd 13...交给:
selectpollepoll等待。
当某几个真正有事件时:
fd 11 readablefd 13 readable应用只处理它们。
这就是 IO 多路复用。

Reactor 的基本思想:
事件源 ↓事件多路复用器 ↓事件循环 ↓Handler例如:
epoll_wait() ↓有新连接? ↓accept某连接可读? ↓recv + parse某连接可写? ↓flush send buffer一个事件循环能够管理大量连接。
这也是高性能网络程序中极其常见的结构。


所谓“复用”,并不是:
多个连接共用同一个 Socket。
而是:
一个线程使用同一个等待机制,同时观察多个 fd 的 IO 状态。
例如:
Thread │ └── epoll_wait │ ├── fd3 ├── fd7 ├── fd11 ├── fd25 └── fd1000哪个 fd 就绪,就处理哪个。

select() 的工作原理基本接口:
select(maxfd + 1, &readfds, &writefds, &exceptfds, &timeout);应用准备:
fd_set readfds;FD_ZERO(&readfds);FD_SET(listen_fd, &readfds);FD_SET(client1, &readfds);FD_SET(client2, &readfds);调用 select()。
返回以后,通过:
FD_ISSET(fd, &readfds)判断哪个 fd 就绪。

select() 使用固定大小:
fd_set表示 fd 集合。
在很多 Linux 用户空间环境中:
FD_SETSIZE典型值是:
1024如果文件描述符数值超过这个范围,就不能直接安全地放入默认 fd_set。
除此之外,select() 每次调用:
大量 fd 时效率会逐渐下降。

poll() 做了哪些改进poll() 使用数组:
struct pollfd { int fd; short events; short revents;};调用:
poll(fds, nfds, timeout);它不再依赖固定大小的 fd_set,因此不受 FD_SETSIZE 这种固定集合表示的直接限制。
但是:
大量fd时,应用和内核仍然需要处理整个 pollfd 数组。
即使只有少量连接发生事件,也需要在很多项中寻找。

epoll_create/epoll_ctl/epoll_wait创建 epoll:
int epfd = epoll_create1(EPOLL_CLOEXEC);添加 fd:
struct epoll_event ev;ev.events = EPOLLIN;ev.data.fd = listen_fd;epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);等待:
struct epoll_event events[128];int n = epoll_wait(epfd, events, 128, -1);返回以后:
for (int i = 0; i < n; i++) { int fd = events[i].data.fd; ...}修改:
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);删除:
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
epoll 的重要设计特点之一是:
关注的 fd 集合长期保存在内核中。
应用不需要像 select() 那样每次重新提交整个 fd 集合。
同时 epoll 会维护:
Interest ListReady List当 fd 状态发生变化并满足事件条件时,相关就绪信息进入 epoll 的就绪管理结构。
应用调用:
epoll_wait();主要获得的是:
当前已经就绪的事件而不是每次从头检查全部连接。
因此在:
连接数量很多真正活跃连接只占少数的典型高并发服务器场景中,epoll 很有优势。
需要注意:
epoll 并不是说所有操作都是绝对 O(1),也不是只要换成 epoll 程序就一定快。
真正性能仍然受到:
等因素影响。

LT:Level Triggered
只要 fd 当前仍然满足条件:
还有数据没读完下一次 epoll_wait() 仍然可以继续报告可读。
例如:
Socket有1000Byte应用只读100Byte剩余:
900Byte仍然可读。
下一次 epoll 会继续通知。
LT 更容易编程,也是 epoll 默认模式。
ET:Edge Triggered
更加关注:
状态从“不就绪”变成“就绪”的边沿。
假设数据到达触发一次事件。
应用如果只读取一部分,然后停止,剩余数据可能不会因为“它一直都已经是可读状态”而再次产生同样的边沿提醒。
所以 ET 下必须一次尽量把数据读干净。

ET 常见读取模式:
for (;;) { ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { handle(buf, n); continue; } if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { break; } ...}也就是:
一直读到 EAGAIN。
如果 fd 是阻塞模式:
当前所有数据读完以后再调用一次 recv(),线程可能直接阻塞在那里。
那么整个事件循环就卡死了。
因此 ET 模式通常必须和:
O_NONBLOCK结合。


setsockopt() 到底在设置什么Socket 本身拥有大量可配置属性。
接口:
setsockopt(fd, level, optname, &value, sizeof(value));例如:
SOL_SOCKET表示 Socket 通用层。
IPPROTO_TCP表示 TCP 协议层。
因此不同 Option 实际对应不同层次。

SO_REUSEADDR:端口为什么无法立即重新绑定典型设置:
int on = 1;setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));它允许在 Linux 规定的地址复用条件下更加灵活地重新绑定本地地址,常用于服务器重启等场景。
但必须注意:
SO_REUSEADDR并不等于“无条件忽略所有端口占用”。
例如另一个正常运行的进程已经以冲突方式绑定同一个地址和端口,不能认为设置它以后就必然能够抢占。
此外:
TIME_WAIT和:
listen端口不能bind之间的关系也没有网上某些简单说法那么直接。
更加正确的理解是:
SO_REUSEADDR是本地地址绑定策略的一部分,而不是一个“强行清除TIME_WAIT”的开关。

SO_KEEPALIVE:如何检测失效连接TCP 连接可能出现:
网线被拔掉路由器断电对端系统突然崩溃但是本地如果一直没有发送任何数据,可能无法立即知道。
开启:
int on = 1;setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));允许 TCP 在连接长时间空闲以后发送 Keepalive Probe。
Linux 还可以调整:
TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT不过默认 Keepalive 时间通常比较长。
因此对于:
秒级故障检测很多应用会自己设计心跳,而不是完全依赖系统 TCP Keepalive。

SO_RCVBUF/SO_SNDBUF:调整收发缓冲区发送 Buffer:
int size = 1024 * 1024;setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));接收:
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));缓冲区大小会影响:
但是不能简单认为:
缓冲区越大性能越好过大的缓存可能:
Linux TCP 还具备自动缓冲区调优机制,因此真实项目应该结合测量结果进行调整。

TCP_NODELAY 与 Nagle 算法Nagle 算法主要用于减少大量小 TCP Segment。
如果存在尚未确认的小数据,新的小写入可能暂时等待合并。
实时交互业务可能希望关闭 Nagle:
int on = 1;setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));典型适用:
但是开启 TCP_NODELAY 并不能解决应用自己一字节一字节低效调用 send() 的全部问题。
合理批量发送仍然非常重要。

SO_RCVTIMEO/SO_SNDTIMEO 超时控制设置接收超时:
struct timeval tv;tv.tv_sec = 5;tv.tv_usec = 0;setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));阻塞接收在超时后通常返回错误,并可能表现为:
EAGAINEWOULDBLOCK发送超时:
SO_SNDTIMEO主要影响阻塞型发送操作等待的时间。
需要注意:
Socket IO 超时并不等价于完整业务请求超时。
例如业务规定:
一个请求总共最多允许3秒则更加可靠的方式是使用统一 Deadline,而不是每一次 recv() 都重新获得 3 秒。

shutdown() 和 close() 有什么区别close(fd):
释放应用对整个 Socket 文件描述符的引用。
shutdown() 可以只关闭某个通信方向:
shutdown(fd, SHUT_RD);shutdown(fd, SHUT_WR);shutdown(fd, SHUT_RDWR);例如:
shutdown(fd, SHUT_WR);表示:
本地以后不再发送数据,但是仍然允许接收。
内核通常会在 TCP 发送方向产生 FIN。
这种方式称为:
Half-Close例如客户端:
上传数据完成 ↓shutdown(SHUT_WR) ↓告诉服务端“请求体已经结束” ↓继续recv服务端最终响应
假设:
uint32_t value = 0x12345678;四个 Byte:
12 34 56 78大端
低地址保存高位 Byte:
低地址 ↓12 34 56 78小端
低地址保存低位 Byte:
低地址 ↓78 56 34 12不同 CPU 架构可能采用不同字节序。

如果通信双方 CPU 字节序不同:
机器A:小端机器B:大端直接传递多字节整数,就可能得到完全不同的值。
因此 TCP/IP 协议规定多字节协议字段使用统一:
Network Byte Order传统网络字节序为大端。

htons/htonlHost To Network Short:
uint16_t port = htons(8080);Host To Network Long:
uint32_t value = htonl(0x12345678);
ntohs/ntohlNetwork To Host Short:
uint16_t host_port = ntohs(network_port);Network To Host Long:
uint32_t host_value = ntohl(network_value);这也是为什么 Socket 地址中:
addr.sin_port = htons(8080);不能直接:
addr.sin_port = 8080;
inet_pton/inet_ntop字符串:
192.168.1.100转换为网络地址结构:
struct in_addr addr;inet_pton(AF_INET, "192.168.1.100", &addr);反过来:
char str[INET_ADDRSTRLEN];inet_ntop(AF_INET, &addr, str, sizeof(str));对于 IPv6:
INET6_ADDRSTRLEN以及:
AF_INET6现代程序应该优先使用:
inet_ptoninet_ntop而不是依赖一些只适合 IPv4 或语义容易混淆的老接口。

对于 TCP:
ssize_t n = recv(fd, buf, sizeof(buf), 0);n > 0
成功读取:
n Byten == 0
对端已经进行了正常的发送方向关闭,数据流到达 EOF。
应该进入连接关闭处理。
n == -1
检查:
errno不能看到 -1 就一律关闭连接。
例如:
EINTR可以重新调用。
EAGAIN在非阻塞模式下只是当前没有更多数据。

例如:
ssize_t n = send(fd, buf, 10000, 0);可能:
n = 4096剩余:
5904 Byte需要应用后续继续发送。
非阻塞服务器通常为每条连接维护:
Output Buffer例如:
Application Message ↓append send buffer ↓Socket可写 ↓send as much as possible ↓如果还有剩余注册EPOLLOUT ↓下一次继续发送而不是在事件循环里面死等全部数据发送完成。

SIGPIPE 为什么会导致程序退出假设对端已经关闭连接。
本地继续:
send(fd, buf, len, 0);某些情况下内核会产生:
SIGPIPE默认处理动作可能导致进程退出。
服务器程序必须正确处理。
常见方式:
send(fd, buf, len, MSG_NOSIGNAL);或者进程级忽略:
signal(SIGPIPE, SIG_IGN);前者通常粒度更加明确。

常见:
ECONNRESET意味着 TCP 收到了连接复位 RST,或者连接被协议栈判定为异常复位。
可能场景:
因此:
Connection reset by peer不是普通 FIN 关闭。
它代表连接异常终止。

如果本地继续向一个已经无法正常发送的连接写数据,可能:
send()→ EPIPE错误文字常表现:
Broken pipe同时还可能产生 SIGPIPE。
因此真实服务器发送失败处理必须同时考虑:
返回值errnoSIGPIPE
客户端:
connect();如果目标主机可达,但是目标 TCP 端口没有监听服务,主机通常可能直接返回:
RST客户端得到:
ECONNREFUSED这和:
连接超时不同。
Refused
通常说明:
目标主机能回应但该端口不存在接受连接的服务Timeout
可能说明:

EINTR
系统调用被信号中断。
例如:
if (n < 0 && errno == EINTR) continue;EAGAIN / EWOULDBLOCK
非阻塞 IO:
现在暂时无法完成以后再试不是连接异常。
ECONNRESET
对端复位连接。
通常需要关闭。
EPIPE
连接发送方向已不可用。
ETIMEDOUT
操作或者 TCP 连接超时。
ENOTCONN
Socket 当前未处于可以执行该操作的连接状态。

事件驱动服务器关闭连接时,不应只:
close(fd);而忘记其他资源。
通常需要:
从epoll移除 ↓停止定时器 ↓取消业务请求 ↓释放接收Buffer ↓释放发送Buffer ↓删除连接表记录 ↓close fd特别要防止:
fd 已经被关闭并重新分配给其他连接,但是旧异步任务仍然拿着原来的 fd 数字进行操作。
工程上通常需要使用:
Connection对象GenerationReference CountUnique Connection ID等方式避免生命周期错误。

ping:先判断网络是否可达ping 192.168.1.20可以帮助判断:
但是:
ping 通不代表 TCP 端口一定正常。
因为 Ping 使用 ICMP,而应用可能使用 TCP。
同样:
ping 不通也不一定代表应用 TCP 一定不通。
某些防火墙会专门禁 ICMP。
ss/netstat:查看端口与TCP连接状态查看监听:
ss -lntp查看全部 TCP:
ss -antp查看指定端口:
ss -antp | grep ':8080'查看状态:
ss -ant state establishedss -ant state time-waitss -ant state close-wait例如服务端声称:
我已经监听8080第一步就应该验证:
ss -lntp | grep 8080而不是立即抓包。
lsof:到底哪个进程占用了端口lsof -iTCP:8080 -sTCP:LISTEN可以看到:
COMMANDPIDUSERFD也可以:
lsof -i :8080快速查找占用。
nc:快速测试TCP/UDP服务器监听 TCP:
nc -l 8080连接:
nc 127.0.0.1 8080如果自己的客户端连接 nc 成功,而自己的服务器失败,就可以快速缩小问题范围。
UDP:
nc -u 192.168.1.20 9000curl:测试应用层协议HTTP:
curl -v http://127.0.0.1:8080/HTTPS:
curl -vk https://example.com/-v 可以观察:
如果 TCP 已经正常,但 HTTP 返回错误,就应该把排查重点从 Socket 层转到应用协议层。
tcpdump:从数据包定位问题抓取 8080:
tcpdump -i eth0 \ -nn \ -s 0 \ port 8080保存:
tcpdump -i eth0 \ -nn \ -s 0 \ -w socket.pcap \ port 8080只看 TCP SYN:
tcpdump -i eth0 \ -nn \ 'tcp[tcpflags] & tcp-syn != 0'抓包可以回答:
SYN到底发出去了吗?服务端收到SYN了吗?SYN-ACK返回了吗?谁先发FIN?有没有RST?有没有重传?Wireshark 可以更加直观地分析:
SYNSYN-ACKACKTCP PayloadWindowRetransmissionDuplicate ACKFINRSTFollow TCP Stream 可以把一条连接的应用层字节流重新组合起来。
但需要注意:
抓包工具看到的是抓包点观察到的数据,不一定等于整个网络的绝对事实。
例如:
都可能影响观察结果。

核心:
accept ↓recv ↓send原数据也就是:
Client:hello ↓Server ↓Client:hello这是测试 Socket 最经典的程序。
最初可以使用线程:
void *client_thread(void *arg){ int fd = *(int *)arg; for (;;) { char buf[1024]; ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n <= 0) break; send(fd, buf, n, MSG_NOSIGNAL); } close(fd); return NULL;}每次 accept() 后创建线程。
这种方法适合理解并发,但不适合作为极高连接数服务器最终结构。
监听 Socket:
set_nonblocking(listen_fd);客户端 Socket 同样:
set_nonblocking(client_fd);然后所有 IO 都必须正确处理:
EAGAINEWOULDBLOCK例如:
for (;;) { ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { ... continue; } if (n == 0) { close_connection(fd); break; } if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) break; close_connection(fd); break;}事件循环:
epoll_wait ↓listen_fd readable? ├─是→accept所有可接受连接 │ └─client_fd readable? ↓ recv直到EAGAIN ↓ 解析消息监听 Socket 在 ET 下同样应该不断:
accept();直到:
EAGAIN因为一次通知可能对应多个已经排队的新连接。
定义协议:
+------------+-------------+| Length | Payload |+------------+-------------+| uint32_t | N Byte |+------------+-------------+Length 使用网络字节序。
发送:
uint32_t len_net = htonl(payload_len);send_buffer_append(conn, &len_net, sizeof(len_net));send_buffer_append(conn, payload, payload_len);接收时:
Buffer < 4 Byte→ 等待Buffer >= 4 Byte→ 解析LengthBuffer < 4 + Length→ 等待Buffer >= 完整Packet→ 取出并处理必须限制:
MAX_PACKET_SIZE防止恶意客户端声明:
Length = 4GB导致服务器分配巨量内存。
需要处理:
recv == 0ECONNRESETEPIPEEPOLLERREPOLLHUPEPOLLRDHUP例如注册:
ev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;收到关闭事件时,仍应根据程序设计把当前能读取的数据处理完整,然后释放连接。
下面给出一个精简但可以体现核心结构的示例:
#define _GNU_SOURCE#include <stdio.h>#include <stdlib.h>#include <string.h>#include <errno.h>#include <unistd.h>#include <fcntl.h>#include <arpa/inet.h>#include <sys/socket.h>#include <sys/epoll.h>#define PORT 8080#define MAX_EVENTS 128#define BUF_SIZE 4096static int set_nonblocking(int fd){ int flags = fcntl(fd, F_GETFL, 0); if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK);}static void close_client(int epfd, int fd){ epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd);}int main(void){ int listen_fd; int epfd; struct sockaddr_in addr; struct epoll_event ev; struct epoll_event events[MAX_EVENTS]; listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (listen_fd < 0) { perror("socket"); return 1; } int reuse = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(listen_fd, 128) < 0) { perror("listen"); return 1; } epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); return 1; } memset(&ev, 0, sizeof(ev)); ev.events = EPOLLIN | EPOLLET; ev.data.fd = listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { perror("epoll_ctl listen"); return 1; } printf("server listening on %d\n", PORT); for (;;) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < n; i++) { int fd = events[i].data.fd; uint32_t e = events[i].events; if (fd == listen_fd) { for (;;) { struct sockaddr_in peer; socklen_t peer_len = sizeof(peer); int client_fd = accept4( listen_fd, (struct sockaddr *)&peer, &peer_len, SOCK_NONBLOCK | SOCK_CLOEXEC); if (client_fd < 0) { if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) break; perror("accept4"); break; } struct epoll_event cev; memset(&cev, 0, sizeof(cev)); cev.events = EPOLLIN | EPOLLRDHUP | EPOLLET; cev.data.fd = client_fd; if (epoll_ctl( epfd, EPOLL_CTL_ADD, client_fd, &cev) < 0) { perror("epoll_ctl client"); close(client_fd); } } continue; } if (e & (EPOLLERR | EPOLLHUP)) { close_client(epfd, fd); continue; } if (e & EPOLLIN) { int closed = 0; for (;;) { char buf[BUF_SIZE]; ssize_t r = recv(fd, buf, sizeof(buf), 0); if (r > 0) { size_t sent = 0; while (sent < (size_t)r) { ssize_t s = send( fd, buf + sent, r - sent, MSG_NOSIGNAL); if (s > 0) { sent += s; continue; } if (s < 0 && errno == EINTR) continue; if (s < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { /* * 真正生产实现这里不能 * 丢弃剩余数据,应放入 * Connection发送Buffer, * 并注册EPOLLOUT。 */ break; } closed = 1; break; } if (closed) break; continue; } if (r == 0) { closed = 1; break; } if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) break; closed = 1; break; } if (closed) { close_client(epfd, fd); continue; } } if (e & EPOLLRDHUP) { close_client(epfd, fd); } } } close(epfd); close(listen_fd); return 0;}需要特别说明:
这个示例主要展示:
non-blockingaccept4epollETrecv直到EAGAIN异常关闭真正生产级服务器还必须进一步设计:
Connection对象输入Buffer输出BufferEPOLLOUT协议解析流控超时管理连接限额业务线程池内存池日志TLS安全限制因此:
会写一个 epoll Echo Server,只是进入高性能网络编程的起点,而不是终点。
HTTP 是应用层协议。
经典 HTTP/1.1 和 HTTP/2 通常运行在 TCP 之上。
因此底层仍然需要:
socket ↓connect ↓TCP ↓发送HTTP数据HTTPS 则是:
HTTP ↓TLS ↓TCP ↓Socket所以框架虽然把 Socket API 隐藏起来了,底层网络通信并没有消失。
例如浏览器访问:
http://example.com/可以高度简化成为:
DNS解析 ↓得到Server IP ↓socket() ↓connect(Server:80) ↓TCP三次握手 ↓send HTTP Request ↓GET / HTTP/1.1Host: example.com... ↓服务器recv ↓HTTP解析 ↓服务器send Response ↓客户端recvHTTP Keep-Alive 允许多个 HTTP 请求复用同一条 TCP 连接。
这能够减少:
重复三次握手TCP慢启动连接创建和销毁的成本。
WebSocket 并不是绕过 TCP 的特殊连接。
典型流程:
TCP连接 ↓HTTP Upgrade握手 ↓协议升级成功 ↓双方在同一条TCP连接上持续传输WebSocket Frame因此 WebSocket 同样面对:
TCP字节流连接断开Keepalive缓冲区事件驱动等问题。
只是 WebSocket 自己定义了:
FrameOpcodePayload LengthMaskPing/Pong等更高层协议。
一个 RPC 框架可以把:
get_user(123);变成:
Method IDRequest IDLengthSerialized Payload例如:
+--------+--------+---------+---------+|Length |Req ID |Method |Payload |+--------+--------+---------+---------+然后:
序列化 ↓Socket发送 ↓Server解析 ↓执行函数 ↓生成Response ↓Socket返回因此 RPC 做的事情本质上是:
在 Socket 字节流之上建立更加高级的请求响应语义。
这类服务器可能同时维护大量:
Keep-Alive连接空闲连接长连接如果:
一个连接 = 一个线程线程数量会非常庞大。
事件驱动模型则可以:
少量事件线程 ↓管理大量非阻塞Socket ↓只有真正活跃的连接才消耗处理时间因此:
epollReactorEvent Loop成为高性能网络程序中的重要基础。
不过实际高性能程序还会继续考虑:
可以把学习路线理解成为:
阻塞Socket ↓多进程/多线程 ↓非阻塞Socket ↓select/poll ↓epoll ↓Reactor ↓Event Loop ↓网络框架框架最终把复杂细节封装成为:
on_connect()on_message()on_write_complete()on_close()但是理解框架之前掌握 Socket,仍然非常重要。
因为当出现:
连接泄漏半包发送阻塞大量TIME_WAITCLOSE_WAITEPOLLOUT持续触发CPU空转Connection Reset这些问题的时候,最终仍然必须回到底层 Socket 和 TCP 行为分析。
学习 Socket 最容易陷入的误区,就是把下面这些函数当成独立知识点:
socketbindlistenacceptconnectsendrecvclose实际上它们共同描述的是一条通信链路的生命周期。
服务端:
socket ↓bind ↓listen ↓accept ↓recv/send ↓close客户端:
socket ↓connect ↓send/recv ↓closeAPI 只是表象。
它们背后真正运行的是:
Socket对象TCP状态机协议缓冲区网络协议栈网卡一次最普通的:
send(fd, data, len, 0);背后可以展开成为:
用户程序 ↓send() ↓系统调用 ↓Socket发送缓存 ↓TCP字节流 ↓TCP分段 ↓IP封装 ↓路由 ↓邻居子系统 ↓Qdisc ↓网卡驱动 ↓DMA ↓NIC ↓物理网络对端:
NIC ↓DMA ↓网卡驱动 ↓NAPI ↓IP ↓TCP ↓Socket接收队列 ↓recv() ↓应用程序当这条链真正建立以后,很多问题就不再神秘。
例如:
send成功但对端没收到并不矛盾。
因为 send() 只是整条链路中的前面一步。
可以把 Socket 网络编程整理成为下面这张知识图谱:
Linux Socket │ ┌────────────────┼────────────────┐ │ │ │ 地址 传输 IO模型 │ │ │ IP / Port TCP / UDP Blocking sockaddr │ Nonblocking IPv4/IPv6 │ │ │ select/poll/epoll │ ┌──────────┼──────────┐ │ │ │ 建连 收发数据 关闭 │ │ │ Handshake Buffer FIN/RST accept Stream TIME_WAIT connect Packet CLOSE_WAIT │ 应用层协议 │ Header / Length / CRC / ID │ 并发模型 │ Process / Thread / Reactor │ 工程调试 │ ss / tcpdump / Wireshark真正需要掌握的不是某一个函数,而是这些知识之间的关系。
当最基础的 Socket 模型已经掌握以后,下一步就可以继续研究:
epoll内部机制Reactor与Proactorio_uringsendfilesplice零拷贝TCP性能调优SO_REUSEPORT多核网络服务器RSS/RPS/RFSNAPIXDPAF_XDPDPDKTLSHTTP/2HTTP/3QUIC但是这些更高级的技术依然没有脱离本文最开始的主线:
应用产生数据 ↓交给内核 ↓协议栈组织数据 ↓网卡发送 ↓对端协议栈接收 ↓交给对端应用所以,真正理解 Linux Socket 网络编程,不应该停留在:
我会调用
socket()、bind()、listen()和accept()。
而应该能够回答:
为什么服务端需要两个Socket?connect到底在等待什么?send的数据先去了哪里?为什么一次recv拿不到完整消息?为什么非阻塞会返回EAGAIN?epoll到底在解决什么问题?为什么ET模式必须一直读到EAGAIN?TCP关闭以后为什么还有TIME_WAIT?Connection Reset和正常FIN有什么区别?程序出问题以后应该从应用、Socket还是网络哪一层查?当这些问题能够被连接成为一套完整的数据流模型以后,就已经从“会写 Socket API”,逐渐进入了“真正理解 Linux 网络程序如何运行”的阶段。
而这套思维,也是后续学习 Nginx、Redis、高性能 RPC、Linux TCP 内核、epoll、io_uring、DPDK 以及现代网络框架最重要的基础。