高性能网络编程:io_uring vs epoll、QPS测试工具实现与10道网络面试题解析

张开发
2026/4/20 15:20:54 15 分钟阅读

分享文章

高性能网络编程:io_uring vs epoll、QPS测试工具实现与10道网络面试题解析
前言在高性能服务端开发中网络编程是永恒的核心话题。从底层的 I/O 模型epoll、io_uring到上层的压测工具实现再到面试中常见的问题TCP/UDP 区别、粘包处理、UDP 并发等每一个环节都值得深入探究。本文分为三大部分io_uring 与 epoll 的全面对比从设计原理、性能特点到适用场景。手写一个 TCP QPS 压测工具支持多线程、多连接、自定义请求量统计 QPS 和失败率。10 道高频网络面试题详解覆盖 TCP/UDP、三次握手、粘包、心跳、UDP 并发等。一、io_uring 与 epoll 的对比1.1 设计思想epollReactor 模式内核通知应用程序“I/O 就绪”应用程序仍需主动调用read/write完成实际读写是同步非阻塞模型。io_uringProactor 模式应用程序提交读写请求后内核异步完成数据拷贝完成后通过完成队列通知是真正异步 I/O模型。1.2 核心差异对比维度epollio_uring系统调用次数每次事件循环至少 1 次epoll_wait 每个读写操作至少 1 次read/write批量提交io_uring_submit和批量收割io_uring_peek_batch_cqe调用次数少数据拷贝epoll_wait返回事件数组需拷贝到用户态read/write需要将数据从内核拷贝到用户空间共享内存环形队列mmap无需拷贝事件注册 buffer 后可减少数据拷贝工作模式水平触发LT和边缘触发ET固定异步模式提交后内核自动处理适用场景海量连接、事件稀疏的网络服务如聊天室、网关高 IOPS 场景数据库、对象存储、需要零拷贝、大量读写操作的场景内核版本Linux 2.6Linux 5.1推荐 5.4编程复杂度较低成熟稳定稍高需要管理 SQE/CQE 生命周期1.3 性能对比总结在短连接、高频读写场景下io_uring 通过批量提交和减少系统调用性能通常优于 epoll。在长连接、低活跃度场景下两者的差距不明显epoll 的成熟度和易用性更占优势。对于磁盘 I/Oio_uring 相比传统libaio有巨大提升且支持缓存文件。二、自研 TCP QPS 压测工具为了测试服务端的性能我们常常需要编写一个简单的压测客户端。下面实现一个支持多线程、多连接、指定总请求数的 QPS 测试工具。2.1 需求定义通过命令行参数指定服务器 IP、端口、线程数、每个线程的连接数、总请求数。每个线程负责一定数量的连接和请求。每个请求发送固定测试消息并校验回显或响应是否正确。统计成功/失败次数计算总耗时和 QPS。2.2 核心数据结构typedef struct test_context { char server_ip[32]; int port; int thread_num; // 线程数 int conn_per_thread; // 每个线程的连接数每个连接串行发送请求 int total_req; // 总请求数所有线程之和 long success_count; // 成功次数原子操作 long fail_count; // 失败次数 long start_time; // 开始时间戳us long end_time; } test_context_t;2.3 完整实现代码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include pthread.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include time.h #include errno.h #define TEST_MESSAGE ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\r\n #define MSG_LEN (sizeof(TEST_MESSAGE) - 1) #define BUFFER_SIZE 2048 // 全局测试上下文 static test_context_t g_ctx; static pthread_mutex_t g_lock PTHREAD_MUTEX_INITIALIZER; // 原子增加操作简化使用互斥锁 static void atomic_inc(long *ptr) { pthread_mutex_lock(g_lock); (*ptr); pthread_mutex_unlock(g_lock); } // 连接 TCP 服务器 static int connect_tcp_server(const char *ip, int port) { int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { perror(socket); return -1; } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); if (inet_pton(AF_INET, ip, addr.sin_addr) 0) { perror(inet_pton); close(sockfd); return -1; } if (connect(sockfd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(connect); close(sockfd); return -1; } return sockfd; } // 发送并接收校验回显数据处理粘包/分包 static int send_recv_check(int fd) { // 发送测试消息 ssize_t sent send(fd, TEST_MESSAGE, MSG_LEN, 0); if (sent ! MSG_LEN) { return -1; } // 循环接收直到收到完整消息处理分包 char recv_buf[BUFFER_SIZE] {0}; size_t total_recv 0; while (total_recv MSG_LEN) { ssize_t n recv(fd, recv_buf total_recv, MSG_LEN - total_recv, 0); if (n 0) { return -1; } total_recv n; } // 校验消息内容 if (memcmp(recv_buf, TEST_MESSAGE, MSG_LEN) ! 0) { return -1; } return 0; } // 线程入口函数 static void* test_qps_entry(void *arg) { test_context_t *ctx (test_context_t*)arg; // 每个线程创建若干个连接 int *fds malloc(ctx-conn_per_thread * sizeof(int)); for (int i 0; i ctx-conn_per_thread; i) { fds[i] connect_tcp_server(ctx-server_ip, ctx-port); if (fds[i] 0) { fprintf(stderr, thread: connect failed\n); // 简化处理失败则该连接后续请求全部算失败 } } // 计算本线程应该发送的请求数均分总请求数 int req_per_thread ctx-total_req / ctx-thread_num; int remain ctx-total_req % ctx-thread_num; // 前 remain 个线程多承担一个请求 int my_req req_per_thread (pthread_self() % ctx-thread_num remain ? 1 : 0); for (int i 0; i my_req; i) { // 轮询使用连接 int idx i % ctx-conn_per_thread; int fd fds[idx]; if (fd 0) { atomic_inc(ctx-fail_count); continue; } int ret send_recv_check(fd); if (ret 0) { atomic_inc(ctx-success_count); } else { atomic_inc(ctx-fail_count); } } // 关闭所有连接 for (int i 0; i ctx-conn_per_thread; i) { if (fds[i] 0) close(fds[i]); } free(fds); return NULL; } // 获取当前时间微秒 static long get_usec() { struct timeval tv; gettimeofday(tv, NULL); return tv.tv_sec * 1000000L tv.tv_usec; } // 打印使用帮助 static void usage(const char *prog) { fprintf(stderr, Usage: %s -s server_ip -p port -t threads -c conn_per_thread -n total_requests\n, prog); exit(1); } int main(int argc, char *argv[]) { // 解析命令行参数 int opt; memset(g_ctx, 0, sizeof(g_ctx)); while ((opt getopt(argc, argv, s:p:t:c:n:h)) ! -1) { switch (opt) { case s: strncpy(g_ctx.server_ip, optarg, sizeof(g_ctx.server_ip)-1); break; case p: g_ctx.port atoi(optarg); break; case t: g_ctx.thread_num atoi(optarg); break; case c: g_ctx.conn_per_thread atoi(optarg); break; case n: g_ctx.total_req atoi(optarg); break; default: usage(argv[0]); } } if (g_ctx.server_ip[0] 0 || g_ctx.port 0 || g_ctx.thread_num 0 || g_ctx.conn_per_thread 0 || g_ctx.total_req 0) { usage(argv[0]); } printf(Test config: server%s:%d, threads%d, conn_per_thread%d, total_req%d\n, g_ctx.server_ip, g_ctx.port, g_ctx.thread_num, g_ctx.conn_per_thread, g_ctx.total_req); pthread_t *threads malloc(g_ctx.thread_num * sizeof(pthread_t)); g_ctx.start_time get_usec(); for (int i 0; i g_ctx.thread_num; i) { pthread_create(threads[i], NULL, test_qps_entry, g_ctx); } for (int i 0; i g_ctx.thread_num; i) { pthread_join(threads[i], NULL); } g_ctx.end_time get_usec(); double elapsed (g_ctx.end_time - g_ctx.start_time) / 1000000.0; double qps g_ctx.success_count / elapsed; printf( Result \n); printf(Total requests: %ld\n, g_ctx.success_count g_ctx.fail_count); printf(Success: %ld, Failed: %ld\n, g_ctx.success_count, g_ctx.fail_count); printf(Time elapsed: %.2f sec\n, elapsed); printf(QPS (success): %.2f\n, qps); printf(\n); free(threads); return 0; }2.4 编译与使用gcc -o qps_client qps_client.c -lpthread ./qps_client -s 127.0.0.1 -p 8080 -t 4 -c 10 -n 10000参数说明-s服务器 IP-p端口-t线程数-c每个线程创建的连接数连接复用-n总请求数所有线程发起的请求总数注意事项代码中处理了消息的分包问题循环 recv 直到收满。每个线程内轮询使用多个连接避免单连接瓶颈。使用互斥锁保证计数器线程安全生产环境可用原子操作。请求数可能无法被线程数整除通过余数分配保证了总请求数精确。2.5 测试建议测试不同并发连接数和请求量下的 QPS64、128、256、512 并发连接。观察服务端的 CPU 使用率、连接建立/断开频率建链/断链在协议栈完成测试工具应避免频繁建链因此本工具复用了连接。对于需要测试心跳包的场景可在send_recv_check中增加心跳逻辑。三、10 道高频网络面试题详解3.1 三次握手和四次挥手的过程三次握手SYN - SYNACK - ACK。目的是建立可靠连接双方确认收发能力。四次挥手FIN - ACK - FIN - ACK。因为 TCP 是全双工的双方需要独立关闭。3.2 TCP 和 UDP 的主要区别维度TCPUDP连接性面向连接需三次握手无连接可靠性可靠确认重传不可靠可能丢包乱序顺序保证顺序序号机制不保证顺序流量/拥塞控制有无首部开销20 字节8 字节适用场景HTTP、FTP、SSHDNS、RTC音视频、游戏3.3 TCP 粘包和分包问题及解决方案原因TCP 是流式协议发送端多次 write 可能被合并为一个数据段Nagle 算法或缓冲接收端一次 read 可能读到多个消息粘包或半个消息分包。解决方案固定长度每个消息固定大小不足补位。分隔符如使用\r\n作为消息边界。长度字段在消息头部增加 2/4 字节表示消息体长度先读长度再读数据。3.4 UDP 如何处理大量并发如 1 万个客户端同时向一个服务端发送数据问题UDP 无连接服务端只需要一个端口即可接收所有客户端的数据。但客户端的源端口是有限的16 位65535 个如果每个客户端需要独立会话需要更多端口。解决方案服务端使用一个端口监听通过recvfrom获取客户端 IP 和端口然后可以创建新的 UDP socket 与该客户端通信模拟 TCP 的“连接”从而突破端口限制。或者使用端口范围服务端绑定多个端口每个端口服务一定数量的客户端。在实际业务中UDP 通常用于短请求-响应如 DNS无需维护大量会话。3.5 为什么 UDP 更适合实时游戏和音视频低延迟无需确认重传数据报直接发送。容忍丢包少量丢包对体验影响小如画面偶尔马赛克。无头阻塞TCP 的丢包重传会导致后续数据延迟UDP 不会。3.6 什么是心跳包为什么需要定义定期发送的小数据包用于检测连接是否存活。作用检测死连接对端崩溃、网络中断及时释放资源。维持 NAT 映射防止中间设备回收端口映射。保活TCP 的 keepalive 默认 2 小时太慢。3.7 短连接和长连接的适用场景短连接每次请求建立连接完成后关闭。适合请求频率低、非频繁交互的场景如 HTTP 1.0。缺点建链开销大。长连接连接复用持续发送多个请求。适合高频率交互如数据库连接、WebSocket。缺点需要心跳保活资源占用稍高。3.8 epoll 的 LT 和 ET 模式区别LT水平触发只要 fd 上有未处理的事件每次epoll_wait都会返回。适合简单编程。ET边缘触发仅在状态变化时返回一次从无到有。必须循环读取直到 EAGAIN效率更高但编程复杂。3.9 io_uring 相比 epoll 的底层优势是什么共享内存SQ 和 CQ 通过 mmap 共享避免事件拷贝。批量系统调用一次enter可提交多个请求减少上下文切换。真正的异步内核完成读写应用程序不需要自己调用read/write。3.10 服务端处理大量 TIME_WAIT 的优化方法调整tcp_tw_reuse允许重用 TIME_WAIT 的端口。调整tcp_tw_recycle谨慎在 NAT 下有问题。使用长连接减少短连接数量。客户端使用端口范围扩大可用端口。四、总结本文从三个角度深入探讨了高性能网络编程io_uring 与 epoll对比了两者的设计思想和适用场景io_uring 在真正异步 I/O 上有突破但 epoll 依然简单高效。自研 QPS 压测工具提供了一个完整的 TCP 客户端实现支持多线程、多连接、请求分发和校验是测试服务端性能的实用工具。10 道网络面试题涵盖了 TCP/UDP、粘包、心跳、并发等核心知识点帮助巩固理论基础。无论是实际开发还是准备面试掌握这些内容都能让你在网络编程领域更加游刃有余。

更多文章