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

前言

在高性能服务端开发中,网络编程是永恒的核心话题。从底层的 I/O 模型(epoll、io_uring)到上层的压测工具实现,再到面试中常见的问题(TCP/UDP 区别、粘包处理、UDP 并发等),每一个环节都值得深入探究。

本文分为三大部分:

  1. io_uring 与 epoll 的全面对比:从设计原理、性能特点到适用场景。

  2. 手写一个 TCP QPS 压测工具:支持多线程、多连接、自定义请求量,统计 QPS 和失败率。

  3. 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 下有问题)。

  • 使用长连接减少短连接数量。

  • 客户端使用端口范围扩大可用端口。


四、总结

本文从三个角度深入探讨了高性能网络编程:

  1. io_uring 与 epoll:对比了两者的设计思想和适用场景,io_uring 在真正异步 I/O 上有突破,但 epoll 依然简单高效。

  2. 自研 QPS 压测工具:提供了一个完整的 TCP 客户端实现,支持多线程、多连接、请求分发和校验,是测试服务端性能的实用工具。

  3. 10 道网络面试题:涵盖了 TCP/UDP、粘包、心跳、并发等核心知识点,帮助巩固理论基础。

无论是实际开发还是准备面试,掌握这些内容都能让你在网络编程领域更加游刃有余。

相关推荐
沙雕不是雕又菜又爱玩2 小时前
leetcode第12、13、14、15题(C++)
c++·算法·leetcode
睡一觉就好了。2 小时前
C++多态
c++
啦啦啦!2 小时前
项目环境的搭建,项目的初步使用和deepseek的初步认识
开发语言·c++·人工智能·算法
曼巴UE52 小时前
Unlua 官方案例
c++·ue5·lua·ue
鲸渔2 小时前
【C++ 变量与常量】变量的定义、初始化、const 与 constexpr
java·开发语言·c++
John_ToDebug2 小时前
Chrome 首次启动引导页里触发 Pref 设置,为什么主进程收不到 IPC
c++·chrome
我头发多我先学2 小时前
C++ STL vector 原理到模拟实现
c++·算法
西西弟2 小时前
网络通信基础之UDP基本通信
网络·网络协议·tcp/ip·udp
鲸渔2 小时前
【C++ 入门】第一个程序:Hello World 与基本语法规则
开发语言·c++·算法