Featured image of post io_uring(二):QPS 实测,真的比 epoll 强吗?

io_uring(二):QPS 实测,真的比 epoll 强吗?

用 TCP Echo 压测工具对比 io_uring 与 epoll 在 64—1024 字节请求下的 QPS,并拆解阻塞 I/O、短读短写、连接数与统计偏差等压测陷阱

一、学习目标

这部分学习主要围绕两个目标展开:

  1. 编写一个 TCP 客户端测试工具,对比 epoll 和 io_uring 服务端的性能。
  2. 整理网络相关面试题,重点理解 TCP、UDP、建链、断链、并发及分包粘包问题。

二、QPS 测试工具需求

客户端需要支持以下参数:

-n request_num     请求总数
-t thread_num      线程数量
-c connection_num  连接数量
-s server_ip       服务端 IP
-p server_port     服务端端口

使用示例:

./test_qps_tcpclient \
  -s 172.16.145.129 \
  -p 2048 \
  -t 50 \
  -c 100 \
  -n 10000

测试采用 Echo 模型:

  1. 客户端向服务端发送一段数据。
  2. 服务端将数据原样返回。
  3. 客户端比较发送和接收的数据。
  4. 数据一致则请求成功,否则失败。
  5. 最后统计总耗时和 QPS。

QPS 的计算方式为:

QPS = 完成的请求数 ÷ 耗时(秒)

代码中使用毫秒计时,因此写成:

qps = request_num * 1000 / time_used_ms;

三、测试数据的构造

测试字符串为:

#define TEST_MESSAGE \
"ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyz\r\n"

这段字符串的长度正好是 64 字节:

26 个大写字母
+ 10 个数字
+ 26 个小写字母
+ \r\n 两个字符
= 64 字节

通过重复复制,可以构造不同大小的请求:

for (i = 0; i < 2; i++) {
    strcpy(
        wbuffer + i * strlen(TEST_MESSAGE),
        TEST_MESSAGE
    );
}

重复次数与数据长度的关系如下:

重复次数数据长度
164 字节
2128 字节
4256 字节
8512 字节
161024 字节

测试大包时,需要关闭服务端和客户端的数据打印,否则终端输出本身会严重影响测试结果。


四、64 bytes 测试

测试 100 万次、每次发送 64 bytes 时,结果如下:

数据长度服务端模型成功失败耗时QPS
64 bytesepoll100000005132 ms194855
64 bytesio_uring100000004400 ms227272

在 64 bytes 测试中:

epoll QPS:194855
io_uring QPS:227272

io_uring 的 QPS 比 epoll 高约 16.6%。


五、不同数据长度下的测试

1. io_uring 服务端

数据长度成功失败耗时QPS
128 bytes100000004337 ms230574
256 bytes100000004268 ms234301
512 bytes100000004307 ms232180
1024 bytes100000004390 ms227790

从结果来看,在 128~1024 bytes 范围内,io_uring 的 QPS 比较稳定,基本维持在 22 万~23 万。

1024 bytes 测试时,笔记中怀疑可能出现“粘包”。

更准确地说,TCP 本身是字节流,没有一条 send() 必须对应一条 recv() 的保证。一次发送的 1024 bytes,可能需要多次 recv() 才能完整读取,因此客户端必须实现循环接收。


2. epoll 服务端——阻塞 I/O

数据长度成功失败耗时QPS
128 bytes1000000074935 ms13344
256 bytes10000000147749 ms6768
512 bytes10000000296207 ms3376
1024 bytes10000000721163 ms1386

这组结果明显异常:数据长度每增加一倍,耗时几乎也增加一倍,QPS 则接近减半。

问题在于 epoll 服务端使用了阻塞 socket。

epoll 的作用是通知程序某个 fd 当前可读或者可写。但即使 fd 已经可读,也不代表接下来所有的 recv() 都不会阻塞。

尤其是在循环读取时,如果已经把当前到达的数据读完,又调用了一次阻塞式 recv(),整个事件循环就可能停在某一个连接上,导致其他连接无法及时处理。

所以 epoll 一般需要与非阻塞 socket 配合:

epoll 通知 fd 可读
        ↓
循环调用 recv
        ↓
读取当前已经到达的数据
        ↓
recv 返回 EAGAIN
        ↓
处理下一个就绪事件

3. epoll 服务端——非阻塞 I/O

将 socket 设置成非阻塞后重新测试:

数据长度成功失败耗时QPS
128 bytes100000005198 ms192381
256 bytes100000005144 ms194401
512 bytes100000005141 ms194514
1024 bytes100000005216 ms191717

修改后,epoll 的 QPS 恢复到约 19 万,并且不再随数据长度增加而大幅下降。

这说明前一组数据反映的主要不是 epoll 本身的性能,而是“epoll 配合阻塞 I/O”造成的异常。


六、epoll 与 io_uring 的完整对比

数据长度io_uring QPSepoll QPS
64 bytes227272194855
128 bytes230574192381
256 bytes234301194401
512 bytes232180194514
1024 bytes227790191717

从测试结果来看:

  • 64 bytes 时,io_uring 比 epoll 高约 16.6%;
  • 128~1024 bytes 时,io_uring 大约比 epoll 高 19%~21%;
  • 所有测试包大小下,io_uring 的 QPS 都高于 epoll;
  • 两种模型在改正 epoll 的阻塞 I/O 问题后,QPS 都没有随着数据大小增加而发生明显下降。

二者的处理思路不同。

epoll

epoll 是就绪通知模型:

先等待 fd 可读或可写
→ 得到就绪事件
→ 应用程序再调用 recv/send

也就是说,epoll 负责告诉应用程序“现在可以尝试进行 I/O”,真正的数据读写仍然由应用程序发起。

io_uring

io_uring 更接近完成通知模型:

应用程序先提交 recv/send 请求
→ 内核执行 I/O
→ I/O 完成后通知应用程序

应用程序可以提前向内核提交 I/O 请求,然后通过完成队列取得结果。

根据本次测试,可以得到以下结论:

在当前服务端实现、当前客户端模型和当前测试环境下,io_uring 的 QPS 全面高于非阻塞 epoll,整体高约 17%~21%。

这个结论应当限定在本次测试条件内,不能仅凭这一轮测试认为所有业务中 io_uring 都一定优于 epoll。


七、业务中的数据包大小

实际业务中可能同时存在小包和大包。

1. 心跳包

客户端和服务端之间通常会存在心跳包。

心跳包只用于检测连接是否仍然存活,因此数据量通常很小,可能只有几个字节。

这类场景更关注:

  • 大量连接的管理能力;
  • 小包的处理效率;
  • I/O 事件通知和调度开销;
  • 服务端是否能够及时发现断开的连接。

2. 视频和图片传输

视频或者较大的图片需要被拆分成多个数据块。

应用层可以将每一个业务包组织成约 1 KB,再通过协议头标识数据长度、类型和序号。

这类场景除了 QPS,还需要关注:

  • 吞吐量;
  • 单次发送的数据大小;
  • 分包和组包;
  • 内存复制;
  • 慢连接对事件循环的影响。

因此,也可以结合不同数据大小下的测试结果,理解 epoll 和 io_uring 的差别。

3. 百万并发测试

后续还可以使用 io_uring 和 epoll 测试百万并发,包括:

  1. 建立相同数量连接所需要的时间;
  2. 相同并发连接数下的 QPS;
  3. 百万连接下的内存占用;
  4. 建链事件的处理能力;
  5. 断链事件的处理能力。

断开连接涉及 TCP 协议栈中的状态转换,但应用层仍然需要正确执行 close()、移除连接状态,并释放自己管理的连接资源。


八、QPS 客户端代码详解

1. 测试上下文

struct test_context_s {
    char serverip[16];
    int port;
    int threadnum;
    int connection;
    int requestion;

#if 1
    int failed;
#endif
};

typedef struct test_context_s test_context_t;

这个结构保存测试参数:

  • serverip:服务端 IPv4 地址;
  • port:服务端端口;
  • threadnum:测试线程数;
  • connection:期望建立的连接数量;
  • requestion:请求总数;
  • failed:失败请求数。

requestion 想表达的应该是请求数量,也就是 request_count 或者 requests,只是命名不太准确,不影响程序运行。

serverip 的长度是 16,刚好可以保存最长的 IPv4 字符串和字符串结尾的 \0

255.255.255.255\0

不过,代码使用的是:

strcpy(ctx.serverip, optarg);

如果传入的参数超过数组长度,就会发生越界写入。


2. 建立 TCP 连接

int connect_tcpserver(
    const char *ip,
    unsigned short port
)

函数首先创建 TCP socket:

int connfd = socket(AF_INET, SOCK_STREAM, 0);

其中:

  • AF_INET 表示使用 IPv4;
  • SOCK_STREAM 表示使用 TCP;
  • 返回值 connfd 是连接对应的文件描述符。

然后构造服务端地址:

struct sockaddr_in tcpserver_addr;

memset(
    &tcpserver_addr,
    0,
    sizeof(struct sockaddr_in)
);

清零后设置地址类型:

tcpserver_addr.sin_family = AF_INET;

设置服务端 IP:

tcpserver_addr.sin_addr.s_addr = inet_addr(ip);

inet_addr() 将类似下面的 IPv4 字符串:

172.16.145.129

转换成网络地址。

设置端口:

tcpserver_addr.sin_port = htons(port);

htons() 将端口从主机字节序转换成网络字节序。

最后调用:

int ret = connect(
    connfd,
    (struct sockaddr *)&tcpserver_addr,
    sizeof(struct sockaddr_in)
);

如果连接成功,返回 connfd

return connfd;

如果连接失败,返回 -1

if (ret) {
    perror("connect\n");
    return -1;
}

当前代码没有检查 socket() 是否创建失败。

另外,如果 connect() 失败,已经创建的 connfd 没有关闭,会造成文件描述符泄漏。


3. 时间计算

#define TIME_SUB_MS(tv1, tv2) \
    ((tv1.tv_sec - tv2.tv_sec) * 1000 + \
    (tv1.tv_usec - tv2.tv_usec) / 1000)

这个宏用来计算两个 timeval 之间相差的毫秒数:

秒差 × 1000 + 微秒差 ÷ 1000

主函数在线程创建前记录开始时间:

struct timeval tv_begin;
gettimeofday(&tv_begin, NULL);

所有线程结束后记录结束时间:

struct timeval tv_end;
gettimeofday(&tv_end, NULL);

然后计算:

int time_used = TIME_SUB_MS(tv_end, tv_begin);

因此,当前统计结果包含:

  • 创建线程的时间;
  • 建立 TCP 连接的时间;
  • 发送和接收请求的时间;
  • 等待线程退出的时间。

所以当前 QPS 是一轮完整压测的整体 QPS,并不是纯粹的数据收发 QPS。

如果后续需要分别测试建链性能和请求处理性能,就需要把建立连接和发送请求分开计时。


4. 测试消息

#define TEST_MESSAGE \
"ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyz\r\n"

#define RBUFFER_LENGTH 2048
#define WBUFFER_LENGTH 2048

TEST_MESSAGE 是 64 bytes。

读缓冲区和写缓冲区都设置为 2048 bytes。

在当前代码中,WBUFFER_LENGTH 宏并没有被使用,因为写缓冲区直接写成了:

char wbuffer[2048] = {0};

5. 单次请求过程

int send_recv_tcppkt(int fd)

这个函数完成一次请求和响应。

构造发送数据

char wbuffer[2048] = {0};
int i = 0;

for (i = 0; i < 2; i++) {
    strcpy(
        wbuffer + i * strlen(TEST_MESSAGE),
        TEST_MESSAGE
    );
}

由于 TEST_MESSAGE 为 64 bytes,循环两次后,wbuffer 中保存了 128 bytes 数据。

第一次复制的位置是:

wbuffer + 0

第二次复制的位置是:

wbuffer + 64

因此,两段 64 bytes 的数据首尾相连,组成 128 bytes 的请求。

发送数据

int res = send(
    fd,
    wbuffer,
    strlen(wbuffer),
    0
);

如果发送失败:

if (res < 0) {
    exit(1);
}

这里的主要问题是,一次 send() 不保证发送完全部数据。

例如:

send(fd, buffer, 1024, 0);

返回值可能是:

1024:全部发送完成
512:只发送了 512 bytes
-1:发送失败

所以不能只判断 res < 0,还需要判断返回值是否等于预期发送长度。

正确的逻辑应当是:

记录总共需要发送的长度
        ↓
循环调用 send
        ↓
每次根据返回值移动缓冲区指针
        ↓
直到所有字节发送完成

接收数据

char rbuffer[RBUFFER_LENGTH] = {0};

res = recv(
    fd,
    rbuffer,
    RBUFFER_LENGTH,
    0
);

如果返回值小于或者等于 0:

if (res <= 0) {
    exit(1);
}

recv() 的返回值含义是:

大于 0:本次实际读取的字节数
等于 0:对端正常关闭连接
小于 0:读取失败

send() 一样,一次 recv() 也不保证读取到完整的响应。

服务端即使完整返回了 1024 bytes,客户端也可能出现:

第一次 recv:读取 300 bytes
第二次 recv:读取 500 bytes
第三次 recv:读取 224 bytes

因此,需要循环接收,直到读取到预期长度。

这正是笔记中“很有必要重新封装一个 recvsend”的原因。

比较数据

if (strcmp(rbuffer, wbuffer) != 0) {
    printf(
        "failed: '%s'!='%s'\n",
        rbuffer,
        wbuffer
    );
    return -1;
}

这里通过 strcmp() 比较接收数据和发送数据。

但网络数据本质上是一段带长度的二进制数据,使用 strcmp() 的前提是两个缓冲区都以 \0 结尾。

更稳定的比较方式是根据实际数据长度使用:

memcmp(rbuffer, wbuffer, expected_length);

如果一次 recv() 只读到部分数据,那么当前 strcmp() 会直接认为请求失败,即使剩余数据稍后还能继续收到。


6. 工作线程

static void *test_qps_entry(void *arg)

线程首先取得测试上下文:

test_context_t *pctx =
    (test_context_t *)arg;

原始笔记中出现了:

(test\_context\_t *)arg

如果实际代码中确实包含反斜杠,需要将反斜杠删除。它也可能只是复制代码时产生的 Markdown 转义。

计算每个线程的连接数

int conn_num =
    pctx->connection / pctx->threadnum;

这里理论上表示每个线程需要管理多少个连接。

例如:

连接数:100
线程数:50
每个线程应当管理:2 个连接

但是,conn_num 后面没有被使用。

实际代码只建立了一次连接:

int connfd = connect_tcpserver(
    pctx->serverip,
    pctx->port
);

因此,每个线程只建立一个连接。

这意味着:

-t 50 -c 100

实际建立的不是 100 个连接,而是 50 个连接。

当前代码中的 -c 参数实际上没有生效。

计算每个线程的请求数

int count =
    pctx->requestion / pctx->threadnum;

例如:

请求总数:10000
线程数:50
每个线程:200 次请求

每个线程执行:

while (i++ < count) {
    res = send_recv_tcppkt(connfd);

    if (res != 0) {
        printf("send_recv_tcppkt failed\n");
        pctx->failed++;
        continue;
    }
}

线程中的请求模型为:

发送一次请求
        ↓
阻塞等待响应
        ↓
收到完整响应
        ↓
再发送下一次请求

所以每个连接同一时间最多只有一个未完成请求。

整个客户端能够同时处理的请求数量,实际上接近线程数量,而不是 -c 指定的连接数量。

请求数量不能整除线程数

如果:

-n 10003
-t 50

则:

count = 10003 / 50;

整数除法结果为:

200

实际发送请求数为:

200 × 50 = 10000

剩余 3 次请求不会发送。

但是,程序最后仍然按照 10003 计算成功数量和 QPS,所以统计结果会不准确。


7. 失败请求统计

失败时执行:

pctx->failed++;

所有线程共享同一个 failed,但这里没有加锁,也没有使用原子变量。

多个线程可能同时执行:

pctx->failed++;

例如,两个线程同时读到:

failed = 10

两个线程都计算得到 11,再分别写回,最终结果可能仍然是 11,而不是正确的 12。

因此,当前失败数具有以下特点:

  • 可以粗略判断是否出现了失败;
  • 不能保证失败数量准确;
  • 多线程同时修改时存在数据竞争。

笔记中提到“数据没有那么重要,只需要知道有失败的可能性”,这种处理可以用于临时测试,但最终版本如果需要准确统计失败数,仍然需要使用锁或者原子操作。


8. 命令行参数解析

主函数通过 getopt() 解析参数:

while (
    (opt = getopt(
        argc,
        argv,
        "s:p:t:c:n:?"
    )) != -1
)

参数含义如下:

-s:服务端 IP
-p:服务端端口
-t:线程数量
-c:连接数量
-n:请求数量

例如:

case 's':
    strcpy(ctx.serverip, optarg);
    break;

读取 IP。

case 'p':
    ctx.port = atoi(optarg);
    break;

读取端口。

case 't':
    ctx.threadnum = atoi(optarg);
    break;

读取线程数量。

case 'c':
    ctx.connection = atoi(optarg);
    break;

读取连接数量。

case 'n':
    ctx.requestion = atoi(optarg);
    break;

读取请求总数。

getopt() 使用了一些全局状态,因此笔记中记录它不是线程安全函数。不过当前代码只在主线程、创建工作线程之前调用,所以不会产生并发调用问题。


9. 创建工作线程

程序首先根据线程数量申请线程 ID 数组:

pthread_t *ptid =
    malloc(
        ctx.threadnum * sizeof(pthread_t)
    );

然后记录开始时间:

struct timeval tv_begin;
gettimeofday(&tv_begin, NULL);

创建工作线程:

for (i = 0; i < ctx.threadnum; i++) {
    pthread_create(
        &ptid[i],
        NULL,
        test_qps_entry,
        &ctx
    );
}

所有线程都接收同一个 ctx 地址:

&ctx

其中:

  • IP、端口、线程数、连接数和请求数只读;
  • failed 会被所有线程共同修改。

创建完成后,主线程等待所有工作线程退出:

for (i = 0; i < ctx.threadnum; i++) {
    pthread_join(ptid[i], NULL);
}

只有全部线程执行完毕,主线程才会继续计算总耗时。


10. 统计 QPS

所有线程结束后,记录结束时间:

struct timeval tv_end;
gettimeofday(&tv_end, NULL);

计算总耗时:

int time_used =
    TIME_SUB_MS(tv_end, tv_begin);

最后输出:

printf(
    "success: %d,failed:%d,"
    "time_used:%d,qps:%d\n",
    ctx.requestion - ctx.failed,
    ctx.failed,
    time_used,
    ctx.requestion * 1000 / time_used
);

输出内容包括:

  • 成功请求数;
  • 失败请求数;
  • 总耗时;
  • QPS。

当前 QPS 使用的是:

ctx.requestion * 1000 / time_used

这里使用了配置的请求数,而不是实际完成的请求数。

如果请求数不能整除线程数,或者中间出现失败,QPS 就不能准确表示真正成功完成的请求数量。


11. 资源释放

主函数结束前释放线程数组:

free(ptid);

但工作线程建立的 TCP 连接没有关闭:

close(connfd);

在线程退出前,应当关闭自己创建的连接。

虽然进程退出时,操作系统最终会回收文件描述符,但如果后续让一个进程连续执行多轮测试,不关闭连接就会逐渐产生资源泄漏。


九、当前客户端的实际测试模型

虽然命令行参数中包含 -c,但当前代码的实际模型是:

主线程
  ├─ 工作线程 1
  │    └─ 一个 TCP 连接
  │          └─ 串行发送请求
  │
  ├─ 工作线程 2
  │    └─ 一个 TCP 连接
  │          └─ 串行发送请求
  │
  ├─ 工作线程 3
  │    └─ 一个 TCP 连接
  │          └─ 串行发送请求
  │
  └─ 其他工作线程
       └─ 每个线程一个 TCP 连接

因此:

实际连接数 = 线程数
每个连接同时只有一个请求
-c 参数没有生效

当前工具比较的是:

多线程、每个线程使用一个阻塞 TCP 连接、请求与响应严格交替时,epoll 和 io_uring 服务端的处理能力。

它还不能测试固定线程数管理大量连接的能力,也不能直接用于百万并发测试。

如果需要让 -c 生效,每个线程就要创建:

connection / threadnum

个连接,并在这些连接之间分配请求。


十、当前代码需要优先处理的问题

按照对测试准确性的影响排序:

  1. -c 参数没有生效,每个线程实际上只建立一个连接。
  2. send() 没有循环处理短写。
  3. recv() 没有循环处理短读。
  4. 请求数不能整除线程数时,实际请求数量少于 -n
  5. failed++ 存在多线程数据竞争。
  6. 工作线程结束后没有执行 close(connfd)
  7. QPS 使用配置的请求数计算,而不是实际完成数。
  8. 没有检查 socket()malloc()pthread_create() 的返回值。
  9. connect() 失败后没有关闭已经创建的 socket。
  10. 工作线程中的 exit(1) 会让一个线程的网络错误终止整个压测进程。
  11. 如果源代码确实包含 test\_context\_t,需要删除反斜杠。
  12. 如果源代码第一行确实是 include<stdio.h>,需要补上 #

十一、网络面试问题整理

原始笔记目前记录了四个核心问题,没有足够内容整理成十个,因此这里只整理已有内容,不额外补题。

1. TCP 三次握手与四次挥手

三次握手

客户端                       服务端

SYN
  ------------------------>

               SYN + ACK
  <------------------------

ACK
  ------------------------>

三次握手需要确认:

  • 客户端能够发送;
  • 服务端能够接收;
  • 服务端能够发送;
  • 客户端能够接收;
  • 双方同步初始序列号。

之所以不能只使用两次握手,是因为服务端发送 SYN + ACK 后,还需要知道客户端是否真正收到了自己的响应。

第三次 ACK 让服务端确认:

客户端已经收到服务端的 SYN
客户端也能够正常接收数据

2. 为什么建链是三次,断链通常是四次

建立连接时,服务端可以把两个操作合并:

确认客户端的 SYN
+
发送自己的 SYN

因此,服务端可以通过一个 SYN + ACK 同时完成这两个操作,最终形成三次握手。

断开连接时:

  • 收到 FIN 后必须先回复 ACK;
  • 但是本端可能还有数据没有发送完;
  • 等剩余数据发送完成后,才能发送自己的 FIN。

因此,ACK 和 FIN 通常不能立即合并,形成四次挥手。

四次挥手

主动关闭方                   被动关闭方

FIN
  ------------------------>

                    ACK
  <------------------------

                    FIN
  <------------------------

ACK
  ------------------------>

TCP 是全双工连接,两个方向需要分别关闭。

一方发送 FIN,只表示自己不再发送数据,并不表示对方也已经没有数据可发。

如果对方收到 FIN 时,刚好也没有剩余数据需要发送,那么 ACK 和 FIN 也可能合并。


3. UDP 的并发如何实现

UDP 没有 TCP 中的:

listen
accept
connection fd

客户端通过:

sendto()

向服务端指定的 IP 和端口发送数据。

服务端通过:

recvfrom()

接收数据,同时得到客户端的 IP 和端口。

假设有 100 万个客户端同时向同一个 UDP 端口发送数据,内核会按照数据报把数据放入 socket 的接收队列。

服务端每次调用 recvfrom() 时,可以获得:

  • 一条 UDP 数据报;
  • 发送方的 IP;
  • 发送方的端口。

因此,不会因为多个客户端共用一个服务端端口,就直接把不同客户端的数据拼接成一条“脏数据”。

应用层可以使用:

客户端 IP + 客户端端口

标识一个逻辑会话。

笔记中提出了一种模拟 TCP 的管理方式:

  1. 客户端第一次向服务端发送握手包;
  2. 服务端得到客户端的 IP 和端口;
  3. 服务端为这个客户端建立逻辑会话;
  4. 后续通过会话信息管理双方的数据;
  5. 如果需要分散压力,可以使用多个服务端端口或者多个 UDP socket。

例如:

2000 个端口
每个端口管理约 1000 个逻辑连接
总计约 200 万个逻辑连接

但是,不一定需要为每个 UDP 客户端单独分配一个端口。

UDP 的并发数量也不是直接受 65535 个端口限制。多个客户端可以通过不同的来源 IP 和来源端口访问同一个服务端端口。

这一部分需要继续整理成一篇独立的技术笔记:

UDP 的并发实现与应用层会话管理。


4. TCP 和 UDP 有哪些区别

连接方式

TCP 是面向连接的协议。

通信前需要建立连接:

客户端连接 fd ←→ 服务端连接 fd

服务端通过 accept() 为每个客户端创建连接 fd,因此每条 TCP 连接都有独立的连接状态。

UDP 是无连接协议。

客户端不需要先建立 TCP 意义上的连接,可以直接通过 sendto() 发包,服务端通过 recvfrom() 接收。


数据形式

TCP 是字节流协议。

它传输的是连续字节流,本身不保留应用层的消息边界。

UDP 是数据报协议。

每次 sendto() 发送一条 UDP 数据报,接收端通过 recvfrom() 接收一条数据报。UDP 会保留数据报边界,但如果接收缓冲区太小,超出的部分可能被截断。


可靠性和顺序

TCP 提供:

  • 有序传输;
  • 丢包重传;
  • 流量控制;
  • 拥塞控制。

先发送的数据,在连接正常的情况下,会先交付给接收方应用程序。

UDP 本身不保证:

  • 数据一定到达;
  • 数据按照发送顺序到达;
  • 数据不会重复;
  • 丢失的数据会自动重传。

如果业务需要这些能力,就需要在 UDP 的应用层加入:

  • 包 ID;
  • 数据序号;
  • ACK 确认;
  • 超时重传;
  • 去重;
  • 乱序重排。

例如,把一份大数据拆成多个 UDP 包时,可以在用户空间定义:

消息 ID
分片序号
总分片数量

接收端根据 ID 和序号判断当前收到的是第几个包,并将多个数据包重新组装。


5. TCP 的分包与粘包

TCP 是字节流。

应用层调用一次:

send(fd, buffer, 1024, 0);

并不代表接收端一定通过一次:

recv(fd, buffer, 1024, 0);

得到完整数据。

可能出现:

一次 send → 多次 recv
多次 send → 一次 recv

所以所谓的 TCP 分包和粘包,本质上是应用层没有明确消息边界。

常见解决方法有两种。

方法一:长度字段

在应用数据的头部记录消息体长度:

| 固定长度的消息头 | 消息体 |

例如使用前两个或者前四个字节表示长度:

| 2 bytes 长度 | 实际数据 |

接收端先读取固定长度的消息头:

先循环读取 2 bytes
        ↓
解析出消息体长度
        ↓
再循环 recv
        ↓
读取完整消息体

由于 TCP 保证字节流有序,因此接收端可以按照这个顺序解析数据。

方法二:分隔符

每条消息后面增加一个特殊分隔符,例如:

\r\n\r\n

接收端不断读取数据并放入缓冲区,然后查找分隔符:

收到数据
    ↓
加入接收缓冲区
    ↓
查找 \r\n\r\n
    ↓
找到后切出一条完整消息

如果消息正文中也可能出现相同分隔符,就需要进行转义,或者使用长度字段。


6. TCP 和 UDP 的并发区别

TCP 服务端通常采用:

listen socket
        ↓
accept
        ↓
每个客户端对应一个连接 fd
        ↓
使用 epoll 管理大量连接 fd

TCP 协议栈已经维护了每条连接的状态,因此应用层可以通过不同 fd 区分客户端。

UDP 没有为每个客户端创建独立的连接 fd。

服务端一般根据:

来源 IP
来源端口
应用层会话 ID

管理客户端状态。

因此,不是说 UDP 不能做高并发,而是 UDP 需要在应用层建立自己的会话管理方式。


7. UDP 的使用场景

UDP 更适合以下场景:

  • 实时音视频;
  • 对实时性要求较高的游戏数据;
  • DNS 等一次请求、一次响应的场景;
  • 能够容忍少量丢包的场景;
  • 由应用层自己控制可靠性的场景。

例如在游戏团战中,玩家位置、技能释放和实时状态对延迟比较敏感。

如果一个旧的位置数据由于重传很晚才到达,这个数据可能已经失去意义。因此,这类实时状态通常更偏向使用 UDP。

但是,“对战游戏没有使用 TCP”这个说法过于绝对。

更合适的理解是:

游戏中强调实时性的位置、动作等消息更倾向使用 UDP;登录、充值、聊天等要求可靠的数据仍然可能使用 TCP。


8. TCP 的使用场景

TCP 更适合:

  • 文件传输;
  • 需要完整交付的数据;
  • 需要严格有序的数据;
  • HTTP/1.1、HTTP/2 等基于 TCP 的协议;
  • 长连接通信。

文件下载需要保证最终文件完整,因此通常需要可靠传输。

UDP 本身没有拥塞控制和可靠重传。如果使用 UDP 传输文件,应用层就需要自己实现:

  • 丢包检测;
  • 重传;
  • 顺序恢复;
  • 流量控制;
  • 拥塞控制。

TCP 已经提供了这些能力,因此使用 TCP 开发更加直接。


9. 短通信与长连接

DNS 是典型的短请求、短响应场景:

发送一次查询
        ↓
服务端返回查询结果
        ↓
本次通信结束

UDP 不需要建立连接,适合这种简单的请求响应。

“UDP 做短连接”更准确的说法是:

UDP 没有 TCP 意义上的连接,适合短暂的一次性请求响应。

有些业务也会选择 TCP 完成短请求,因为:

  • TCP 相关框架成熟;
  • 开发更加方便;
  • 不需要自己实现可靠性;
  • 业务本身更重视开发成本和正确性。

TCP 则天然适合需要长期保持通信状态的长连接,但 TCP 并不是只能做长连接。


十二、阶段总结

从传统阻塞 I/O、epoll 再到 io_uring,容易产生一种混沌感。

当前最需要捋清的是三个层次之间的关系:

TCP
提供字节流、连接、可靠传输等协议能力
        ↓
epoll / io_uring
解决应用程序如何高效处理 I/O
        ↓
业务协议
解决消息边界、心跳、会话和错误处理

本轮实验可以得到三个明确认识:

  1. epoll 应当与非阻塞 I/O 配合,否则单个连接可能阻塞整个事件循环。
  2. 当前测试中,io_uring 的 QPS 全面高于非阻塞 epoll,整体优势约为 17%~21%。
  3. 客户端的连接模型、收发方式和计数准确性,会直接影响测试结果。当前 -c 参数没有生效,循环收发也尚未实现。

十三、面试复盘

面试需要不断复盘。

简历中能够被问到的内容是有限的,每次面试后都应该记录问题,并重新整理问题背后的知识。

原笔记中的经验数据为:

面试时间个人估计的通过概率
30 分钟5%
45 分钟30%
60 分钟80%

求职过程可以通过两个比例进行观察:

邀面数量 / 投递数量
→ 反映简历的匹配度和成功度
Offer 数量 / 邀面数量
→ 反映技术能力和面试表现