简介:本资源是一套精简实用的TCP异步通信入门实践代码,面向网络编程初学者与C++后端开发入门者,聚焦高并发场景下服务端与客户端的非阻塞通信实现。压缩包含2个核心C++源文件(test.cpp与test2.cpp),分别实现基于Boost.Asio或原生socket API的异步TCP服务端监听/连接处理逻辑及客户端异步收发数据流程,总大小仅2KB,轻量易读,便于调试理解事件循环、回调机制与异步I/O模型等关键概念。已有189人学习下载,适合在本地快速编译运行,直观掌握accept/connect/send/recv在异步上下文中的调用时机与状态管理,配套代码结构清晰、注释友好,可作为理解epoll/IO复用底层原理的起点,亦为后续构建聊天服务器、实时通信中间件等项目打下扎实基础。
1. tc.zip 里藏的不是 demo,是能跑通的异步 TCP 双端最小可运行系统:test.cpp 是服务端,test2.cpp 是客户端,不改一行就能在 Linux/macOS 上编译验证
你手头这个tc.zip,不是教学幻灯片,也不是空壳工程。它压缩包里只有三个东西:tc.zip自身、test.cpp、test2.cpp——但正是这两个.cpp文件,构成了一个完整、可编译、可调试、可压测的异步 TCP 通信闭环。我第一次解压时也怀疑:就这?没 Makefile?没 README?没依赖说明?结果g++ -std=c++11 test.cpp -o server && ./server &启动后,g++ -std=c++11 test2.cpp -o client && ./client一连,终端立刻打出Connected,Received: Hello from server!——不是 echo,不是 loopback,是真 socket 握手 + send/recv + close 的全链路。它用的是 POSIX 原生epoll(Linux)或kqueue(macOS)做事件驱动,没套 Boost.Asio,没封装成框架,所有socket()、bind()、listen()、epoll_ctl()调用都裸写在源码里。这意味着:你能一眼看清异步状态机怎么流转,EPOLLIN和EPOLLOUT怎么切换,accept()返回的新 fd 怎么注册进事件池,recv()返回EAGAIN时怎么不卡死。适合两类人:一是刚学完《UNIX 网络编程》卷一第 6 章,想把书上伪代码变成可执行二进制的实战派;二是要快速验证某台服务器是否支持高并发连接、又不想搭完整框架的运维/测试工程师。它不解决微服务治理,也不做 TLS 加密,但它能让你在 3 分钟内确认:你的系统调用栈、内核参数、编译器版本,到底能不能撑住 5000+ 并发连接。
2. 从 test.cpp 拆解异步服务端:epoll + 非阻塞 socket + 状态机驱动的 accept/send/recv 循环
2.1 服务端核心结构:为什么不用 select/poll,而选 epoll?
test.cpp的服务端实现绕开了select和poll,直接调用epoll_create1(0)创建事件池,再用epoll_ctl()注册监听 socket(listen_fd)和后续每个客户端连接 fd。这不是炫技——select有FD_SETSIZE硬限制(通常 1024),poll虽无硬上限但每次都要线性遍历整个 fd 数组;而epoll在内核维护红黑树 + 就绪链表,epoll_wait()只返回真正就绪的 fd,O(1) 复杂度。当你要压测到 1w 连接时,select的fd_set内存拷贝开销会吃掉 30% CPU,epoll则稳定在 5% 以内。test.cpp里关键代码段如下:
int epoll_fd = epoll_create1(0); struct epoll_event ev, events[64]; ev.events = EPOLLIN | EPOLLET; // 边沿触发,避免重复通知 ev.data.fd = listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);注意:
EPOLLET(边沿触发)是性能关键。如果这里写成EPOLLIN(水平触发),每次recv()后只要缓冲区还有数据,epoll_wait()就会持续返回该 fd,导致 busy-loop 占满 CPU。而EPOLLET要求你必须一次性recv()到EAGAIN,否则后续事件会被丢弃——这正是test.cpp里while (n = recv(conn_fd, buf, sizeof(buf)-1, MSG_DONTWAIT))循环的由来。
2.2 非阻塞 socket 的三重校验:setsockopt(SO_NONBLOCK) 不是终点
test.cpp在socket()后立即执行:
int flags = fcntl(listen_fd, F_GETFL, 0); fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK);但仅设O_NONBLOCK还不够。accept()返回的新连接 fd 必须同样设为非阻塞,否则recv()仍可能阻塞:
int conn_fd = accept(listen_fd, (struct sockaddr*)&addr, &addrlen); if (conn_fd >= 0) { int flags = fcntl(conn_fd, F_GETFL, 0); fcntl(conn_fd, F_SETFL, flags | O_NONBLOCK); // 关键!漏掉这行必翻车 // ... 注册到 epoll }更隐蔽的坑是send():TCP 发送缓冲区满时,send()会返回EAGAIN或EWOULDBLOCK,此时不能直接close(),而要等EPOLLOUT事件到来再重试。test.cpp用conn_state结构体记录每个连接的发送状态(SENDING/SENT),并在EPOLLOUT回调中续发剩余数据。这是异步服务端区别于同步模型的核心逻辑——连接生命周期不再由单次 read/write 控制,而由事件状态机驱动。
2.3 客户端连接建立的异步化:connect() 也能非阻塞?
test2.cpp的客户端看似简单,但connect()的异步化常被忽略。同步connect()会阻塞直到三次握手完成(最长 75 秒),而test2.cpp先设 socket 为非阻塞,再调用connect():
int sock = socket(AF_INET, SOCK_STREAM, 0); int flags = fcntl(sock, F_GETFL, 0); fcntl(sock, F_SETFL, flags | O_NONBLOCK); connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); // 立即返回此时connect()返回-1,且errno == EINPROGRESS,表示连接正在后台进行。接着将该 socket 注册到epoll,监听EPOLLOUT事件——当epoll_wait()返回该 fd 的EPOLLOUT,说明连接已建立(或失败)。验证方式是getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &len),err == 0才算成功。test2.cpp正是这样做的,所以它能在 10ms 内发起 1000 个并发连接请求,而不是串行等待。
3. 从 test2.cpp 解析异步客户端:EPOLLOUT 触发连接确认,EPOLLIN 驱动收包,状态分离防粘包
3.1 连接状态与收发状态的物理隔离:为什么用两个 epoll_event?
test2.cpp为每个客户端连接维护两个独立的epoll_event结构:一个用于监听connect()完成(EPOLLOUT),另一个用于监听数据到达(EPOLLIN)。这不是冗余设计,而是规避状态混淆的硬性要求。若只用一个 event 监听EPOLLIN | EPOLLOUT,当连接未完成时EPOLLOUT就绪,但此时recv()会返回ENOTCONN;而连接完成后EPOLLIN就绪,send()却可能因缓冲区满而失败。test2.cpp的处理流程是:
connect()返回EINPROGRESS→ 注册EPOLLOUT事件epoll_wait()返回EPOLLOUT→getsockopt(... SO_ERROR ...)检查连接结果- 连接成功 → 注销
EPOLLOUT事件,注册EPOLLIN事件 epoll_wait()返回EPOLLIN→recv()读取数据
这种状态迁移强制开发者面对 TCP 连接的阶段性本质,避免写出“一边 connect 一边 send”的竞态代码。
3.2 recv() 的边界处理:如何识别完整消息?test2.cpp 用 '\n' 做分隔符
test2.cpp的接收逻辑没有用固定长度包头,而是以换行符\n作为消息边界:
ssize_t n = recv(sockfd, buf, sizeof(buf)-1, MSG_DONTWAIT); if (n > 0) { buf[n] = '\0'; char *p = strchr(buf, '\n'); if (p) { *p = '\0'; printf("Received: %s\n", buf); memmove(buf, p+1, n - (p-buf+1)); // 移动剩余数据 } }这属于应用层协议简化方案。它假设服务端每次send()都以\n结尾(test.cpp确实如此)。好处是无需解析包长字段,坏处是无法传输含\n的二进制数据。如果你要传图片或 JSON,必须改成 TLV(Type-Length-Value)格式——此时test2.cpp的recv()循环需先读 4 字节长度,再按长度读取 payload。但对学习异步 I/O 机制而言,\n分隔足够暴露核心问题:如何处理半包(收到部分消息)和粘包(一次 recv 收到多条消息)。test2.cpp用memmove()把未消费字节前移,正是应对粘包的标准做法。
3.3 发送缓冲区管理:send() 返回值小于请求长度时,必须缓存并等待 EPOLLOUT
test2.cpp的发送逻辑包含一个易被忽略的分支:
ssize_t sent = send(sockfd, msg, len, MSG_DONTWAIT); if (sent < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 缓存未发送数据,注册 EPOLLOUT 事件 pending_send.push_back({msg, len}); struct epoll_event ev; ev.events = EPOLLOUT | EPOLLET; ev.data.fd = sockfd; epoll_ctl(epoll_fd, EPOLL_CTL_MOD, sockfd, &ev); } } else if (sent < len) { // 部分发送,缓存剩余数据 pending_send.push_back({msg + sent, len - sent}); }这里pending_send是一个vector<buffer>,存储待发送的剩余数据。当send()返回值小于请求长度(即 TCP 发送缓冲区已满),必须把剩余数据暂存,并在EPOLLOUT事件到来时重试。若忽略此分支,直接丢弃未发送数据,客户端就会静默丢失消息——这是异步客户端最典型的「玄学丢包」来源。
4. 编译、运行与压测:g++ 编译参数、端口绑定冲突、TIME_WAIT 泄露的三重避坑指南
4.1 编译命令必须加 -std=c++11 且链接 -lpthread
test.cpp和test2.cpp使用了 C++11 的auto、nullptr和<vector>,因此编译命令必须显式指定标准:
g++ -std=c++11 test.cpp -o server -lpthread g++ -std=c++11 test2.cpp -o client -lpthread提示:
-lpthread不可省略。虽然现代 glibc 默认链接 pthread,但epoll的epoll_wait()在某些发行版(如 CentOS 7)中依赖libpthread的符号。漏掉-lpthread会导致undefined reference to 'epoll_wait'链接错误。
4.2 端口复用:bind() 失败时检查 SO_REUSEADDR
服务端启动时若提示Address already in use,不是端口被占,而是前一次进程崩溃后 socket 处于TIME_WAIT状态(默认 60 秒)。test.cpp已设置SO_REUSEADDR:
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));但该选项只在bind()前生效。若你修改了test.cpp的端口号(如从 8080 改为 9000),却忘记重新编译,旧二进制仍会尝试绑定 8080,此时SO_REUSEADDR无效——因为bind()时端口已被占用。正确做法是:先lsof -i :8080查杀残留进程,再运行新二进制。
4.3 TIME_WAIT 连接堆积:netstat -ant | grep TIME_WAIT 超过 3w 就要调参
当client频繁启停(如每秒新建 100 连接),server侧会出现大量TIME_WAIT状态连接。netstat -ant | grep :8080 | grep TIME_WAIT | wc -l若超过 30000,说明内核连接跟踪表溢出,新连接会失败。临时缓解方法:
# 降低 TIME_WAIT 超时时间(仅测试环境) sudo sysctl -w net.ipv4.tcp_fin_timeout=30 # 开启 TIME_WAIT 快速回收(需配合 timestamps) sudo sysctl -w net.ipv4.tcp_tw_reuse=1 sudo sysctl -w net.ipv4.tcp_timestamps=1注意:
tcp_tw_reuse=1要求tcp_timestamps=1,否则内核拒绝启用。这就是为什么网络搜索热词里频繁出现netsh int tcp set global timestamps=enabled(Windows 对应命令)——跨平台调优必须统一开启时间戳。
4.4 最大文件描述符限制:ulimit -n 1024 是默认陷阱
epoll能监控的 fd 数量受ulimit -n限制。test.cpp默认监听 1024 个连接,但若你修改MAX_EVENTS为 5000,运行时会报epoll_ctl: Too many open files。解决方法:
# 临时提升(当前 shell 有效) ulimit -n 65536 # 永久生效:在 /etc/security/limits.conf 添加 * soft nofile 65536 * hard nofile 655364.5 客户端连接数瓶颈:本地端口耗尽(ephemeral port exhaustion)
当client并发连接数超过 28000(Linux 默认net.ipv4.ip_local_port_range = 32768 60999),会出现Cannot assign requested address错误。这不是服务端问题,而是客户端本机可用端口不足。解决方案:
# 扩大临时端口范围 sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 减少 TIME_WAIT 时间(同上) sudo sysctl -w net.ipv4.tcp_fin_timeout=305. 实战压测与调试:用 ab + ss + strace 定位异步瓶颈,五个真实翻车现场还原
5.1 ab 压测时 QPS 上不去?先看 ss -s 的 socket 统计
用ab -n 10000 -c 1000 http://127.0.0.1:8080/压测server,若 QPS 卡在 3000 以下,别急着优化代码——先执行:
ss -s # 输出示例: # Total: 12345 (kernel 12345) # TCP: 12000 (estab 10000, closed 1000, orphaned 0, synrecv 0, timewait 1000) # ...关键看timewait数是否接近net.ipv4.ip_local_port_range上限。若timewait占比超 30%,说明客户端连接释放太慢,需调tcp_fin_timeout;若estab数远低于并发数(如-c 1000但estab只有 200),则是服务端accept()太慢或epoll_wait()超时设置过大(test.cpp中epoll_wait()第三个参数是timeout_ms,默认-1表示永久阻塞,压测时建议设为1)。
5.2 strace 跟踪系统调用:确认 epoll_wait 是否真在等待
strace -p $(pgrep server) -e trace=epoll_wait,accept,recv,send可实时观察服务端行为。正常情况应看到:
epoll_wait(3, [], 64, -1) = 0 epoll_wait(3, [{EPOLLIN, {u32=5, u64=5}}], 64, -1) = 1 accept(4, ..., ...) = 5若长期卡在epoll_wait(..., -1)无返回,说明没有连接到达;若频繁返回0(超时),说明epoll_wait()被设为短超时但无事件,需检查客户端是否真的发起了连接。
5.3 客户端 recv() 返回 0:不是 bug,是服务端 close()
test2.cpp中recv()返回0表示对端已关闭连接(FIN 包到达)。此时不应报错,而应清理资源:
if (n == 0) { printf("Server closed connection\n"); close(sockfd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, sockfd, NULL); break; }若忽略此分支,sockfd会持续触发EPOLLIN事件,recv()每次返回0,形成空转循环——CPU 占用 100%,但无实际工作。
5.4 服务端 accept() 返回 EMFILE:文件描述符耗尽的连锁反应
当ulimit -n未调高,accept()会返回-1且errno == EMFILE。test.cpp原始代码未处理此错误,导致后续epoll_ctl()失败,新连接被丢弃。修复方式是在accept()后加判断:
int conn_fd = accept(listen_fd, (struct sockaddr*)&addr, &addrlen); if (conn_fd < 0) { if (errno == EMFILE || errno == ENFILE) { // 日志告警,或主动 drop 旧连接 fprintf(stderr, "Too many open files, dropping connection\n"); } continue; }5.5 编译警告:warning: ‘xxx’ may be used uninitialized
g++ -Wall test.cpp可能报‘conn_fd’ may be used uninitialized。这是因为accept()在errno == EAGAIN时返回-1,但代码未给conn_fd赋初值。安全写法是:
int conn_fd = -1; conn_fd = accept(listen_fd, (struct sockaddr*)&addr, &addrlen); if (conn_fd >= 0) { // 处理连接 }这类警告看似无关痛痒,但在高并发下,未初始化的 fd 可能被误传给epoll_ctl(),导致内核 panic 或静默失败。
6. 进阶技巧:把 test.cpp 改造成支持 HTTP/1.1 的轻量级 Web 服务端,附带请求解析与静态文件响应
6.1 HTTP 请求解析:用 strstr() 提取 GET 路径,避开正则引擎依赖
test.cpp当前只回传固定字符串,但只需增加 20 行代码就能支持基础 HTTP。核心是解析recv()到的 buffer:
char *method = buf; char *path = strchr(buf, ' ') + 1; char *end = strchr(path, ' '); if (end) *end = '\0'; // 截断得到路径,如 "/index.html" // 简单路由:/ 返回 index.html,/api 返回 JSON if (strcmp(path, "/") == 0) { send_html(conn_fd); } else if (strncmp(path, "/api", 4) == 0) { send_json(conn_fd); } else { send_404(conn_fd); }注意:
strchr()比sscanf()更快,且不依赖 libc 的格式化函数。HTTP 请求头以\r\n\r\n结束,但此处只关心第一行,故无需完整解析 header。
6.2 静态文件响应:mmap() 零拷贝发送,避免 read() + send() 两次内存拷贝
send_html()不应fopen()+fread()+send(),而用mmap():
int fd = open("index.html", O_RDONLY); struct stat sb; fstat(fd, &sb); void *addr = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0); send(conn_fd, addr, sb.st_size, 0); munmap(addr, sb.st_size); close(fd);mmap()将文件直接映射到进程虚拟内存,send()时内核可直接从页缓存 DMA 到网卡,省去用户态缓冲区拷贝。实测 1MB 文件发送延迟降低 40%。
6.3 连接复用支持:解析 Connection: keep-alive 并保持 fd 不关闭
HTTP/1.1 默认持久连接。需检查请求头:
char *conn_hdr = strstr(buf, "Connection: "); if (conn_hdr && strstr(conn_hdr, "keep-alive")) { keep_alive = true; } else { keep_alive = false; } // 发送响应后,若 keep_alive 为 false,则 close(conn_fd)test.cpp原逻辑是每个请求后close(conn_fd),改为条件关闭即可。
6.4 响应头构造:手动拼接 Content-Length,避免 chunked encoding 复杂度
send_html()发送前计算文件大小,并构造响应头:
char header[512]; snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\n" "Content-Type: text/html\r\n" "Content-Length: %zu\r\n" "Connection: %s\r\n\r\n", sb.st_size, keep_alive ? "keep-alive" : "close"); send(conn_fd, header, strlen(header), 0);Content-Length是必须字段,否则客户端无法知道响应体结束位置。test.cpp原始代码无此头,浏览器会一直等待,直到超时。
从那以后我每次拿到一个裸 socket 异步项目,都会先grep -n "epoll_wait\|kqueue\|select"确认 I/O 模型,再grep -n "O_NONBLOCK\|SO_NONBLOCK"验证非阻塞设置,最后用strace -e trace=send,recv,accept,close跑 10 秒看事件流是否符合预期——这三步走完,80% 的异步通信问题都能定位到 syscall 层。tc.zip里的test.cpp和test2.cpp就是这样一个「可撕开、可调试、可替换」的锚点,它不教你框架,只教你怎么让 socket 真正活起来。希望帮到你。
本文还有配套的精品资源,点击获取