一、学习目标#
这部分学习主要围绕两个目标展开:
- 编写一个 TCP 客户端测试工具,对比 epoll 和 io_uring 服务端的性能。
- 整理网络相关面试题,重点理解 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 模型:
- 客户端向服务端发送一段数据。
- 服务端将数据原样返回。
- 客户端比较发送和接收的数据。
- 数据一致则请求成功,否则失败。
- 最后统计总耗时和 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
);
}重复次数与数据长度的关系如下:
| 重复次数 | 数据长度 |
|---|---|
| 1 | 64 字节 |
| 2 | 128 字节 |
| 4 | 256 字节 |
| 8 | 512 字节 |
| 16 | 1024 字节 |
测试大包时,需要关闭服务端和客户端的数据打印,否则终端输出本身会严重影响测试结果。
四、64 bytes 测试#
测试 100 万次、每次发送 64 bytes 时,结果如下:
| 数据长度 | 服务端模型 | 成功 | 失败 | 耗时 | QPS |
|---|---|---|---|---|---|
| 64 bytes | epoll | 1000000 | 0 | 5132 ms | 194855 |
| 64 bytes | io_uring | 1000000 | 0 | 4400 ms | 227272 |
在 64 bytes 测试中:
epoll QPS:194855
io_uring QPS:227272io_uring 的 QPS 比 epoll 高约 16.6%。
五、不同数据长度下的测试#
1. io_uring 服务端#
| 数据长度 | 成功 | 失败 | 耗时 | QPS |
|---|---|---|---|---|
| 128 bytes | 1000000 | 0 | 4337 ms | 230574 |
| 256 bytes | 1000000 | 0 | 4268 ms | 234301 |
| 512 bytes | 1000000 | 0 | 4307 ms | 232180 |
| 1024 bytes | 1000000 | 0 | 4390 ms | 227790 |
从结果来看,在 128~1024 bytes 范围内,io_uring 的 QPS 比较稳定,基本维持在 22 万~23 万。
1024 bytes 测试时,笔记中怀疑可能出现“粘包”。
更准确地说,TCP 本身是字节流,没有一条 send() 必须对应一条 recv() 的保证。一次发送的 1024 bytes,可能需要多次 recv() 才能完整读取,因此客户端必须实现循环接收。
2. epoll 服务端——阻塞 I/O#
| 数据长度 | 成功 | 失败 | 耗时 | QPS |
|---|---|---|---|---|
| 128 bytes | 1000000 | 0 | 74935 ms | 13344 |
| 256 bytes | 1000000 | 0 | 147749 ms | 6768 |
| 512 bytes | 1000000 | 0 | 296207 ms | 3376 |
| 1024 bytes | 1000000 | 0 | 721163 ms | 1386 |
这组结果明显异常:数据长度每增加一倍,耗时几乎也增加一倍,QPS 则接近减半。
问题在于 epoll 服务端使用了阻塞 socket。
epoll 的作用是通知程序某个 fd 当前可读或者可写。但即使 fd 已经可读,也不代表接下来所有的 recv() 都不会阻塞。
尤其是在循环读取时,如果已经把当前到达的数据读完,又调用了一次阻塞式 recv(),整个事件循环就可能停在某一个连接上,导致其他连接无法及时处理。
所以 epoll 一般需要与非阻塞 socket 配合:
epoll 通知 fd 可读
↓
循环调用 recv
↓
读取当前已经到达的数据
↓
recv 返回 EAGAIN
↓
处理下一个就绪事件3. epoll 服务端——非阻塞 I/O#
将 socket 设置成非阻塞后重新测试:
| 数据长度 | 成功 | 失败 | 耗时 | QPS |
|---|---|---|---|---|
| 128 bytes | 1000000 | 0 | 5198 ms | 192381 |
| 256 bytes | 1000000 | 0 | 5144 ms | 194401 |
| 512 bytes | 1000000 | 0 | 5141 ms | 194514 |
| 1024 bytes | 1000000 | 0 | 5216 ms | 191717 |
修改后,epoll 的 QPS 恢复到约 19 万,并且不再随数据长度增加而大幅下降。
这说明前一组数据反映的主要不是 epoll 本身的性能,而是“epoll 配合阻塞 I/O”造成的异常。
六、epoll 与 io_uring 的完整对比#
| 数据长度 | io_uring QPS | epoll QPS |
|---|---|---|
| 64 bytes | 227272 | 194855 |
| 128 bytes | 230574 | 192381 |
| 256 bytes | 234301 | 194401 |
| 512 bytes | 232180 | 194514 |
| 1024 bytes | 227790 | 191717 |
从测试结果来看:
- 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 测试百万并发,包括:
- 建立相同数量连接所需要的时间;
- 相同并发连接数下的 QPS;
- 百万连接下的内存占用;
- 建链事件的处理能力;
- 断链事件的处理能力。
断开连接涉及 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 2048TEST_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个连接,并在这些连接之间分配请求。
十、当前代码需要优先处理的问题#
按照对测试准确性的影响排序:
-c参数没有生效,每个线程实际上只建立一个连接。send()没有循环处理短写。recv()没有循环处理短读。- 请求数不能整除线程数时,实际请求数量少于
-n。 failed++存在多线程数据竞争。- 工作线程结束后没有执行
close(connfd)。 - QPS 使用配置的请求数计算,而不是实际完成数。
- 没有检查
socket()、malloc()和pthread_create()的返回值。 connect()失败后没有关闭已经创建的 socket。- 工作线程中的
exit(1)会让一个线程的网络错误终止整个压测进程。 - 如果源代码确实包含
test\_context\_t,需要删除反斜杠。 - 如果源代码第一行确实是
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 的管理方式:
- 客户端第一次向服务端发送握手包;
- 服务端得到客户端的 IP 和端口;
- 服务端为这个客户端建立逻辑会话;
- 后续通过会话信息管理双方的数据;
- 如果需要分散压力,可以使用多个服务端端口或者多个 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 管理大量连接 fdTCP 协议栈已经维护了每条连接的状态,因此应用层可以通过不同 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
↓
业务协议
解决消息边界、心跳、会话和错误处理本轮实验可以得到三个明确认识:
- epoll 应当与非阻塞 I/O 配合,否则单个连接可能阻塞整个事件循环。
- 当前测试中,io_uring 的 QPS 全面高于非阻塞 epoll,整体优势约为 17%~21%。
- 客户端的连接模型、收发方式和计数准确性,会直接影响测试结果。当前
-c参数没有生效,循环收发也尚未实现。
十三、面试复盘#
面试需要不断复盘。
简历中能够被问到的内容是有限的,每次面试后都应该记录问题,并重新整理问题背后的知识。
原笔记中的经验数据为:
| 面试时间 | 个人估计的通过概率 |
|---|---|
| 30 分钟 | 5% |
| 45 分钟 | 30% |
| 60 分钟 | 80% |
求职过程可以通过两个比例进行观察:
邀面数量 / 投递数量
→ 反映简历的匹配度和成功度Offer 数量 / 邀面数量
→ 反映技术能力和面试表现