↓ 跳过正文
  1. Posts/
  2. 读书与资料/

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

··8597 字·18 分钟
目录

一、学习目标
#

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

  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

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

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

比较数据
#

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 数量 / 邀面数量
→ 反映技术能力和面试表现

相关文章