前言
在高性能服务端开发中,网络编程是永恒的核心话题。从底层的 I/O 模型(epoll、io_uring)到上层的压测工具实现,再到面试中常见的问题(TCP/UDP 区别、粘包处理、UDP 并发等),每一个环节都值得深入探究。
本文分为三大部分:
-
io_uring 与 epoll 的全面对比:从设计原理、性能特点到适用场景。
-
手写一个 TCP QPS 压测工具:支持多线程、多连接、自定义请求量,统计 QPS 和失败率。
-
10 道高频网络面试题详解:覆盖 TCP/UDP、三次握手、粘包、心跳、UDP 并发等。
一、io_uring 与 epoll 的对比
1.1 设计思想
-
epoll :Reactor 模式,内核通知应用程序"I/O 就绪",应用程序仍需主动调用
read/write完成实际读写,是同步非阻塞模型。 -
io_uring :Proactor 模式,应用程序提交读写请求后,内核异步完成数据拷贝,完成后通过完成队列通知,是真正异步 I/O 模型。
1.2 核心差异
| 对比维度 | epoll | io_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.6+ | Linux 5.1+(推荐 5.4+) |
| 编程复杂度 | 较低,成熟稳定 | 稍高,需要管理 SQE/CQE 生命周期 |
1.3 性能对比总结
-
在短连接、高频读写场景下,io_uring 通过批量提交和减少系统调用,性能通常优于 epoll。
-
在长连接、低活跃度场景下,两者的差距不明显,epoll 的成熟度和易用性更占优势。
-
对于磁盘 I/O ,io_uring 相比传统
libaio有巨大提升,且支持缓存文件。
二、自研 TCP QPS 压测工具
为了测试服务端的性能,我们常常需要编写一个简单的压测客户端。下面实现一个支持多线程、多连接、指定总请求数的 QPS 测试工具。
2.1 需求定义
-
通过命令行参数指定:服务器 IP、端口、线程数、每个线程的连接数、总请求数。
-
每个线程负责一定数量的连接和请求。
-
每个请求发送固定测试消息,并校验回显(或响应)是否正确。
-
统计成功/失败次数,计算总耗时和 QPS。
2.2 核心数据结构
cpp
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 完整实现代码
cpp
#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 编译与使用
bash
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 测试建议
-
测试不同并发(连接数)和请求量下的 QPS:64、128、256、512 并发连接。
-
观察服务端的 CPU 使用率、连接建立/断开频率(建链/断链在协议栈完成,测试工具应避免频繁建链,因此本工具复用了连接)。
-
对于需要测试心跳包的场景,可在
send_recv_check中增加心跳逻辑。
三、10 道高频网络面试题详解
3.1 三次握手和四次挥手的过程
-
三次握手:SYN -> SYN+ACK -> ACK。目的是建立可靠连接,双方确认收发能力。
-
四次挥手:FIN -> ACK -> FIN -> ACK。因为 TCP 是全双工的,双方需要独立关闭。
3.2 TCP 和 UDP 的主要区别
| 维度 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接(需三次握手) | 无连接 |
| 可靠性 | 可靠,确认重传 | 不可靠,可能丢包乱序 |
| 顺序 | 保证顺序(序号机制) | 不保证顺序 |
| 流量/拥塞控制 | 有 | 无 |
| 首部开销 | 20 字节 | 8 字节 |
| 适用场景 | HTTP、FTP、SSH | DNS、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、粘包、心跳、并发等核心知识点,帮助巩固理论基础。
无论是实际开发还是准备面试,掌握这些内容都能让你在网络编程领域更加游刃有余。