1. 这不是教科书里的“socket”,而是你明天就要调试的那行代码
“Linux socket网络编程基础知识”——看到这个标题,很多人第一反应是:又来了,又是那种讲完socket()、bind()、listen()三板斧就收工的教程。但现实根本不是这样。我带过不少刚从学校出来的开发者,他们能背出TCP三次握手的每个SYN/ACK标志位,可一碰到connect()返回EINPROGRESS就卡住,翻遍文档也找不到为什么非阻塞模式下select()要配合getsockopt(SO_ERROR)才能判断连接是否真成功;有人在send()里传了2048字节却只发出去376字,死盯着errno是0,就是想不通数据去哪儿了;还有人把SO_REUSEADDR当成万能膏药,结果在服务重启时发现旧连接残留的TIME_WAIT状态反而被新进程抢走端口,导致客户端收到RST包直接断连。
这些不是“基础不牢”的问题,而是教材和实战之间存在一道真实的鸿沟:基础不是指API函数的调用顺序,而是理解内核如何调度套接字、协议栈如何拆解数据、用户态缓冲区与内核缓冲区如何协同、以及每一个返回值背后隐藏的系统状态。这篇内容,就是为跨过这道鸿沟写的。它不讲抽象模型,只讲你在strace里看到的真实系统调用流;不堆砌RFC定义,只解释man 7 socket里那几行容易被忽略的备注意味着什么;不假设你已掌握epoll,而是从select的fd_set大小限制开始,带你算清楚为什么FD_SETSIZE默认是1024、改大它会踩进什么内存陷阱。适合正在写第一个网络服务、调试一个卡死的长连接、或者准备面试中被问到“close()后还能send()吗”这类问题的任何人。你不需要提前读完《UNIX网络编程》,只需要知道gcc怎么编译、gdb怎么打断点,就能跟着把整套机制摸透。
2. 整体设计思路:从“一次HTTP请求”倒推到底层套接字行为
2.1 为什么不用“先学理论再写代码”的老路?
我试过按传统路径教学:先讲OSI七层模型,再画TCP状态机图,最后贴一段echo server代码。结果呢?学员写完代码跑起来,一加日志就崩溃,一换网络环境就超时,问“为什么accept()返回的sockfd和listenfd不一样”,答“因为它们代表不同连接”,这等于没答。问题出在:网络编程的本质不是记住协议规则,而是理解数据在用户空间与内核空间之间搬运的完整生命周期。所以这次,我们反着来——以一个最朴素的场景切入:用curl http://localhost:8080发起一次请求,然后一层层剥开,看这个请求如何触发socket()、connect()、send()、recv()这一系列调用,每一步内核做了什么,用户态程序又该做什么响应。
这个思路带来三个关键转变:
- 目标驱动:所有知识点都绑定到具体动作上。比如讲
SO_LINGER,不是先定义“控制关闭时的行为”,而是直接演示:当服务器进程在close()前未处理完接收缓冲区数据,客户端read()会收到多少字节、errno是什么、抓包看到FIN还是RST。 - 状态可见:每一步操作后,用
ss -tuln、netstat -s、cat /proc/net/snmp等命令观察套接字状态变化,让抽象概念变成终端里可验证的输出。例如ss -i能直接看到当前连接的RTT、cwnd、ssthresh值,比任何文字描述都直观。 - 错误前置:不等你写完50行代码再报错,而是在
socket()调用后立刻检查errno == EMFILE(文件描述符耗尽),告诉你ulimit -n该设多少才够用;在bind()前先ss -tuln | grep :8080确认端口空闲,避免EADDRINUSE这种低级但高频的坑。
2.2 方案选型:为什么坚持用原生C+系统调用,而不是libevent或Boost.Asio?
现在主流方案早就不手写select了,用epoll、kqueue甚至异步IO库是常态。但如果你跳过原生接口直接学封装层,就像学开车先背ECU固件手册——你永远不知道epoll_ctl(EPOLL_CTL_ADD)失败时,errno == EPERM意味着什么(答案:目标fd不支持epoll,比如管道或普通文件)。所以我坚持用最原始的socket()/connect()/send()/recv()组合,原因很实在:
- 调试链路最短:
strace -e trace=socket,connect,send,recv,close ./a.out能100%覆盖所有关键路径,没有中间层干扰; - 错误归因最准:
send()返回-1且errno == EAGAIN,说明内核发送缓冲区满,而不是你的业务逻辑错了; - 性能边界最清:当你发现
send()单次最多发212992字节(Linux默认sk->sk_sndbuf的一半),你就明白为什么大文件传输必须循环调用,而不是怪“网络慢”。
当然,这不是否定高级库的价值。恰恰相反,只有亲手填过select()的fd_set位图、算过FD_SETSIZE对栈空间的消耗,你才会真正欣赏epoll的O(1)事件通知有多珍贵。所以本文后面会专门对比select/poll/epoll的底层差异,但前提是——你得先知道select()内部到底在干什么。
2.3 架构分层:四层穿透式解析,每一层都解决一个真实痛点
我们把整个知识体系拆成四个物理层级,对应你在调试时最常卡住的位置:
| 层级 | 名称 | 核心痛点 | 解决什么问题 |
|---|---|---|---|
| L1 | 用户态API层 | send()返回值小于请求长度怎么办?recv()为什么有时只收一半数据? | 理解TCP流式传输本质,掌握MSG_WAITALL、MSG_DONTWAIT等标志的实际效果 |
| L2 | 内核协议栈层 | ss -i显示cwnd:10但ping延迟只有1ms,为什么吞吐上不去?/proc/sys/net/ipv4/tcp_slow_start_after_idle设成0有啥副作用? | 揭示拥塞控制算法如何影响你的应用,避免盲目调优tcp_rmem |
| L3 | 套接字状态机层 | close()后ss -tuln还显示FIN_WAIT2,客户端却收不到响应?TIME_WAIT状态到底占什么资源? | 搞懂TCP状态迁移条件,正确设置SO_LINGER和net.ipv4.tcp_fin_timeout |
| L4 | 系统资源管理层 | 启动1000个连接后socket()返回-1,errno == ENFILE,ulimit -n已经调到65535,问题出在哪? | 定位文件描述符、内存页、连接跟踪表(conntrack)三重瓶颈 |
这四层不是并列关系,而是递进依赖:L1的问题,往往根子在L2的拥塞窗口;L2的异常,可能由L3的状态机卡死引发;而L3的堆积,最终会压垮L4的系统资源。所以我们的实操过程,就是沿着这个链条一级级下钻。
3. 核心细节解析:从socket()调用开始的每一行代码背后的真相
3.1socket():你以为只是分配一个fd,其实它在初始化整个协议栈上下文
很多人以为socket()就是向内核申请一个文件描述符,类似open()。错。socket()真正的动作是:在内核中创建一个struct socket结构体,并关联对应的struct sock(即sk),同时根据地址族(AF_INET)、类型(SOCK_STREAM)、协议(IPPROTO_TCP)初始化协议栈处理函数指针。这意味着,哪怕你还没bind(),内核已经为你预置了完整的TCP处理逻辑。
我们来看一段实测代码:
#include <sys/socket.h> #include <stdio.h> #include <errno.h> int main() { int sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sockfd == -1) { perror("socket"); printf("errno: %d\n", errno); // 可能是EMFILE、ENFILE、ENOMEM return 1; } printf("socket fd: %d\n", sockfd); return 0; }运行后strace输出关键片段:
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3注意这个返回值3——它不是随机的。Linux内核为每个进程维护一个struct files_struct,其中fd_array[]数组索引就是fd号。stdin/stdout/stderr固定占0/1/2,所以第一个socket()必然返回3。但如果你之前打开了10个文件,socket()就可能返回13。这就是为什么ulimit -n限制的是整个进程能打开的文件描述符总数,包括普通文件、管道、socket等所有类型。
提示:
errno == EMFILE表示当前进程打开的fd数已达上限;errno == ENFILE表示整个系统的fd总数已达上限(/proc/sys/fs/file-max)。前者调ulimit -n即可,后者需改系统参数,且会影响所有进程。
更关键的是socket()的第三个参数protocol。虽然TCP通常填IPPROTO_TCP,但你可以填0,内核会自动匹配。不过,当使用AF_INET6时,填0可能导致IPv6 socket无法处理IPv4映射地址(IPv4-mapped IPv6 address),这是很多双栈服务出问题的根源。实测验证:
// 错误写法:IPv6 socket填0,可能无法接受IPv4连接 int sockfd6 = socket(AF_INET6, SOCK_STREAM, 0); // 正确写法:显式指定IPPROTO_TCP,确保协议栈正确初始化 int sockfd6 = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);3.2bind():端口抢占战背后的net.ipv4.ip_local_port_range与SO_REUSEADDR
bind()看似简单:把socket和IP:PORT绑定。但实际中,bind()失败的errno == EADDRINUSE是最让人抓狂的错误之一。你以为端口被占,netstat -tuln | grep :8080却显示空空如也。真相是:端口处于TIME_WAIT状态,而你的新socket没设置SO_REUSEADDR。
我们来拆解bind()的完整流程:
- 内核检查目标
{ip, port}是否已被其他ESTABLISHED或LISTEN状态的socket占用; - 若未被占用,再检查是否有
TIME_WAIT状态的socket占用同一{ip, port}; - 如果有
TIME_WAIT且新socket未设置SO_REUSEADDR,则bind()失败; - 如果设置了
SO_REUSEADDR,则允许绑定,但新socket不能listen()(监听),只能作为客户端connect()。
验证实验:
# 启动一个server,用curl访问后关闭 $ ./server & $ curl http://localhost:8080 $ kill %1 # 立即启动新server(未设SO_REUSEADDR) $ ./server # 失败:Address already in use # 修改server代码,在socket()后添加: int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); # 再次启动,成功 $ ./server # 成功但SO_REUSEADDR不是银弹。它解决的是TIME_WAIT端口复用,却不管ip_local_port_range。Linux默认动态端口范围是32768-60999(共28232个),如果短连接并发量极大,可能耗尽端口。查证命令:
$ cat /proc/sys/net/ipv4/ip_local_port_range 32768 60999计算最大并发连接数:60999 - 32768 + 1 = 28232。如果你的应用每秒新建1000个连接,平均存活20秒,那么峰值连接数就是20000,接近上限。此时要么扩大端口范围:
# 临时生效 $ echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range # 永久生效,写入/etc/sysctl.conf net.ipv4.ip_local_port_range = 1024 65535要么优化连接复用——这才是治本之策。
3.3connect():阻塞与非阻塞模式下,你必须知道的三次握手“黑箱”
connect()是网络编程中最易被误解的函数。教材说“它发起TCP三次握手”,但没告诉你:在阻塞模式下,connect()会一直等到三次握手完成(或超时)才返回;而在非阻塞模式下,它立即返回-1且errno == EINPROGRESS,此时连接仍在后台进行。
我们用strace看阻塞模式下的真实行为:
int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serv_addr = {0}; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(80); inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr); connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); // 阻塞在此strace输出:
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("127.0.0.1")}, 16) = 0返回0,表示连接成功。但如果目标端口无服务,你会看到:
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 ECONNREFUSED (Connection refused)而非阻塞模式下,connect()行为完全不同:
int flags = fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); // 立即返回 // 此时 errno == EINPROGRESSstrace输出:
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress)接下来,你必须用select()或poll()等待socket变为可写(writeable),然后调用getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &err, &len)检查err是否为0。为什么是“可写”而不是“可读”?因为TCP连接建立成功后,socket的写缓冲区就变为可写状态。这是绝大多数教程漏掉的关键点。
实测验证脚本:
// 非阻塞connect后,用select等待 fd_set writefds; FD_ZERO(&writefds); FD_SET(sockfd, &writefds); struct timeval tv = {5, 0}; // 5秒超时 int ret = select(sockfd + 1, NULL, &writefds, NULL, &tv); if (ret > 0 && FD_ISSET(sockfd, &writefds)) { int err = 0; socklen_t len = sizeof(err); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &err, &len); if (err == 0) { printf("connect success!\n"); } else { printf("connect failed: %s\n", strerror(err)); } }注意:
select()返回可写,不代表连接一定成功!必须getsockopt(SO_ERROR)二次确认。否则,当目标主机存在但端口关闭时,select()仍会返回可写,但SO_ERROR是ECONNREFUSED。
3.4send()与recv():流式协议下的“粘包”与“半包”真相
TCP是字节流协议,没有消息边界。send()和recv()的返回值与调用次数,完全由内核缓冲区状态和网络状况决定。这是“粘包”、“半包”问题的根源。
先看send()的典型陷阱:
char buf[1024] = "HELLO"; int sent = send(sockfd, buf, 1024, 0); printf("sent %d bytes\n", sent); // 可能输出5,也可能输出1024,甚至0send()返回值含义:
> 0:成功发送的字节数(可能小于请求长度);0:对端已关闭连接(shutdown()或close());-1:出错,errno指示原因(EAGAIN/EWOULDBLOCK表示发送缓冲区满,EPIPE表示对端已关闭且你尝试写入)。
关键点:send()成功返回,并不保证数据已到达对端,甚至不保证已进入网卡队列,它只表示数据已拷贝到内核发送缓冲区。所以,send()后立即close(),可能导致缓冲区数据丢失。正确做法是shutdown(sockfd, SHUT_WR),等待对端recv()返回0后再close()。
recv()同理:
char buf[1024]; int n = recv(sockfd, buf, 1024, 0); printf("recv %d bytes\n", n); // 可能是1024,也可能是1,也可能是0(对端关闭)recv()返回值含义:
> 0:接收到的字节数;0:对端已关闭连接(shutdown()或close());-1:出错,errno == EAGAIN/EWOULDBLOCK表示接收缓冲区为空(非阻塞模式)。
“粘包”问题:客户端连续send("A"); send("B");,服务端recv()可能一次收到"AB"。“半包”问题:客户端send("HELLO WORLD"),服务端第一次recv()只收到"HELLO",第二次才收到" WORLD"。
解决方案不是“让recv()一次收完”,而是应用层定义协议边界。最常用两种:
- 定长包头:前4字节存消息长度,先
recv()4字节,再按长度recv(); - 分隔符:用
\n或\0分隔,循环recv()直到遇到分隔符。
实测对比:用nc -l 8080启动监听,客户端执行:
$ echo -n "ABC" | nc localhost 8080 $ echo -n "DEF" | nc localhost 8080服务端nc -l 8080会显示ABCDEF连在一起,这就是粘包。而用telnet localhost 8080手动输入ABC回车,再输入DEF回车,则会分行显示,因为telnet自动加了\n。
4. 实操过程:从零实现一个抗压HTTP Echo Server,全程调试记录
4.1 目标设定:不只是“回显”,而是验证所有核心机制
我们要实现的不是一个玩具server,而是一个能承受1000并发连接、支持长连接、正确处理TIME_WAIT、并在strace下清晰可见每一步系统调用的实用demo。功能需求:
- 监听
0.0.0.0:8080,支持IPv4; - 接收HTTP GET请求,返回
HTTP/1.1 200 OK\r\n\r\nHello from Echo Server; - 支持
Connection: keep-alive,单连接处理多个请求; - 每个连接超时30秒自动关闭;
- 编译后体积小于100KB,无外部依赖。
为什么选HTTP?因为它是真实世界最常用的协议,其头部格式(Content-Length、Connection)能完美暴露recv()的半包问题,而keep-alive则直击TIME_WAIT管理痛点。
4.2 代码实现:逐行注释关键决策点
#include <sys/socket.h> #include <sys/epoll.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <errno.h> #include <time.h> #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 #define TIMEOUT_SECONDS 30 // 全局epoll fd int epoll_fd; // 连接结构体,存储每个client的状态 struct client { int fd; char buffer[BUFFER_SIZE]; size_t buf_len; // 当前buffer中有效字节数 time_t last_active; // 最后活跃时间,用于超时检测 }; // 初始化socket,设置非阻塞、REUSEADDR int init_server_socket(int port) { int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (sockfd == -1) { perror("socket"); return -1; } // 设置SO_REUSEADDR,避免TIME_WAIT端口占用 int opt = 1; if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) { perror("setsockopt SO_REUSEADDR"); close(sockfd); return -1; } // 设置非阻塞,避免accept阻塞 int flags = fcntl(sockfd, F_GETFL, 0); if (flags == -1 || fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) == -1) { perror("fcntl O_NONBLOCK"); close(sockfd); return -1; } struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = INADDR_ANY; if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { perror("bind"); close(sockfd); return -1; } if (listen(sockfd, SOMAXCONN) == -1) { perror("listen"); close(sockfd); return -1; } return sockfd; } // 将socket加入epoll监控 int add_to_epoll(int epoll_fd, int fd, uint32_t events) { struct epoll_event ev; ev.events = events; ev.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev) == -1) { perror("epoll_ctl ADD"); return -1; } return 0; } // 处理客户端recv,解析HTTP请求 int handle_client_recv(struct client *cli) { ssize_t n; while ((n = recv(cli->fd, cli->buffer + cli->buf_len, BUFFER_SIZE - cli->buf_len, MSG_DONTWAIT)) > 0) { cli->buf_len += n; cli->last_active = time(NULL); // 检查是否收到完整HTTP请求(有\r\n\r\n) char *pos = memmem(cli->buffer, cli->buf_len, "\r\n\r\n", 4); if (pos != NULL) { // 解析HTTP方法和路径(简化版) if (strncmp(cli->buffer, "GET ", 4) == 0) { // 找到第一个空格后的路径 char *path_start = cli->buffer + 4; char *path_end = memchr(path_start, ' ', cli->buf_len - 4); if (path_end != NULL) { // 发送HTTP响应 const char *response = "HTTP/1.1 200 OK\r\n" "Content-Type: text/plain\r\n" "Content-Length: 24\r\n" "Connection: keep-alive\r\n" "\r\n" "Hello from Echo Server"; send(cli->fd, response, strlen(response), MSG_NOSIGNAL); } } // 移除已处理的部分,剩余数据留在buffer中(处理粘包) size_t processed = pos - cli->buffer + 4; // +4 for \r\n\r\n if (processed < cli->buf_len) { memmove(cli->buffer, cli->buffer + processed, cli->buf_len - processed); cli->buf_len -= processed; } else { cli->buf_len = 0; } } // 如果buffer满了,丢弃(防止OOM) if (cli->buf_len >= BUFFER_SIZE - 1) { cli->buf_len = 0; } } if (n == 0) { // 对端关闭连接 printf("Client %d closed connection\n", cli->fd); return -1; } else if (n == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 非阻塞,无数据可读 return 0; } else { perror("recv"); return -1; } } return 0; } // 主循环 int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "Usage: %s <port>\n", argv[0]); return 1; } int port = atoi(argv[1]); int listen_fd = init_server_socket(port); if (listen_fd == -1) return 1; // 创建epoll实例 epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1"); close(listen_fd); return 1; } // 监听listen_fd上的EPOLLIN事件 if (add_to_epoll(epoll_fd, listen_fd, EPOLLIN) == -1) { close(listen_fd); close(epoll_fd); return 1; } struct epoll_event events[MAX_EVENTS]; struct client clients[MAX_EVENTS]; int client_count = 0; printf("Echo Server listening on port %d...\n", port); while (1) { int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, 1000); // 1秒超时,用于超时检测 if (nfds == -1) { if (errno == EINTR) continue; perror("epoll_wait"); break; } // 处理就绪事件 for (int i = 0; i < nfds; i++) { int fd = events[i].data.fd; uint32_t events_mask = events[i].events; if (fd == listen_fd && (events_mask & EPOLLIN)) { // 新连接 struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int client_fd = accept4(listen_fd, (struct sockaddr*)&client_addr, &client_len, SOCK_NONBLOCK | SOCK_CLOEXEC); if (client_fd == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { continue; // 无新连接 } else { perror("accept4"); continue; } } printf("New connection from %s:%d, fd=%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd); // 加入epoll监控 if (add_to_epoll(epoll_fd, client_fd, EPOLLIN | EPOLLET) == -1) { close(client_fd); continue; } // 初始化client结构 if (client_count < MAX_EVENTS) { clients[client_count].fd = client_fd; clients[client_count].buf_len = 0; clients[client_count].last_active = time(NULL); client_count++; } else { // 连接数超限,关闭新连接 close(client_fd); printf("Too many connections, rejected new client\n"); } } else if (events_mask & EPOLLIN) { // 已有连接可读 int idx = -1; for (int j = 0; j < client_count; j++) { if (clients[j].fd == fd) { idx = j; break; } } if (idx != -1) { if (handle_client_recv(&clients[idx]) == -1) { // 连接关闭,从epoll移除 epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); // 从clients数组中移除 if (idx < client_count - 1) { memmove(&clients[idx], &clients[idx + 1], (client_count - idx - 1) * sizeof(struct client)); } client_count--; } } } } // 超时检测:遍历所有client,关闭超时连接 time_t now = time(NULL); for (int i = 0; i < client_count; ) { if (now - clients[i].last_active > TIMEOUT_SECONDS) { printf("Client %d timeout, closing\n", clients[i].fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, clients[i].fd, NULL); close(clients[i].fd); if (i < client_count - 1) { memmove(&clients[i], &clients[i + 1], (client_count - i - 1) * sizeof(struct client)); } client_count--; } else { i++; } } } close(listen_fd); close(epoll_fd); return 0; }4.3 编译与调试:用strace和ss验证每一步
编译命令(静态链接,减小体积):
$ gcc -static -O2 -o echo_server echo_server.c -lpthread $ ls -lh echo_server -rwxr-xr-x 1 user user 852K ... echo_server启动server:
$ ./echo_server 8080 Echo Server listening on port 8080...用strace监控系统调用(只关注socket相关):
$ strace -e trace=socket,bind,listen,accept4,connect,send,recv,epoll_ctl,epoll_wait -p $(pgrep echo_server) 2>&1 | grep -E "(socket|bind|listen|accept4|epoll)"你会看到:
socket(...)→bind(...)→listen(...)→epoll_ctl(ADD)初始化;- 每次
curl触发accept4(...)→epoll_ctl(ADD)新client; epoll_wait()周期性唤醒,处理读写事件。
用ss观察连接状态:
# 启动server后 $ ss -tuln | grep :8080 LISTEN 0 128 *:8080 *:* # curl一次后 $ ss -tun state established '( dport = :8080 )' ESTAB 0 0 127.0.0.1:8080 127.0.0.1:54321 # 查看详细信息,含cwnd、rtt $ ss -ti 'dport = :8080' ESTAB 0 0 127.0.0.1:8080 127.0.0.1:54321 cwnd:10 rtt:0.123/0.012 mss:1448 pmtu:1500压力测试(用ab模拟1000并发):
$ ab -n 10000 -c 1000 http://localhost:8080/观察ss连接数变化:
$ watch -n 1 'ss -tun state established "( dport = :8080 )" | wc -l'正常应稳定在1000左右(ab默认keep-alive),不会出现TIME_WAIT堆积——因为我们设置了SO_REUSEADDR且正确处理了连接关闭。
4.4 性能调优:从/proc/sys/net/core/somaxconn到tcp_tw_reuse
实测中发现,当并发连接数超过128时,ab报告大量apr_socket_recv: Connection reset by peer。查ss -s:
Total: 1280 (kernel 1292) TCP: 1285 (estab 1000, closed 280, orphaned 0, synrecv 0, timewait 280/0), ports 0timewait 280/0表示有280个TIME_WAIT连接。问题出在SOMAXCONN(全连接队列长度)默认是128。当新连接洪峰到来,队列满,内核会丢弃SYN包,客户端重传后超时,最终reset by peer。
解决方案:
# 临时提高全连接队列 $ echo 1024 > /proc/sys/net/core/somaxconn # 永久生效 $ echo "net.core.somaxconn = 1024" >> /etc/sysctl.conf # 同时调整半连接队列(syncookies) $ echo 1 > /proc/sys/net/ipv4/tcp_syncookies另一个瓶颈是TIME_WAIT连接过多。Linux提供net.ipv4.tcp_tw_reuse,允许将TIME_WAITsocket用于新的OUTGOING连接(客户端),但对LISTEN状态的server无效。所以我们的server不能靠它,但可以调小tcp_fin_timeout:
# 默认60秒