做 Linux 网络通信开发这些年,最绕不开的就是 UDP 和 TCP 这对 Socket 双子星。不管是写上位机、调嵌入式设备、做网关转发,还是面试被问三次握手,本质都在跟这两个协议打交道。这篇文章我会把 UDP 与 TCP 在 Linux 下的 Socket 编程完整梳理一遍——从协议差异到 API 调用,从三次握手到四次挥手,从代码示例到线上排错,所有内容都来自我实际调试过的场景和踩过的坑,想要系统搞懂 Socket 编程或者正在写相关代码的朋友,可以直接照着参考。
1. 理解 UDP 和 TCP:同是传输层,脾气完全不同
1.1 核心差异:打电话 vs 寄快递
TCP 和 UDP 都工作在传输层,但设计哲学完全相反。TCP 是面向连接的、可靠的字节流协议,你可以把它想象成打电话——先拨号接通(三次握手),然后双方都能确认对方在线,通话过程中说的一句话没听清可以要求对方重说(重传机制),挂电话时还要互相说再见(四次挥手)。UDP 则更像寄快递——你把包裹往快递点一扔,填个地址就完事,快递能不能送到、什么时候送到、甚至会不会丢件,寄件人一概不管,收件人拿到什么算什么。
这个差异决定了它们最根本的适用边界。TCP 保证数据有序、不丢失、不重复,代价是连接开销大、有确认和重传延迟;UDP 不保证任何可靠性,但头部开销小、无连接状态、发送延迟极低。我在实际项目里见过不少新手把游戏帧数据用 TCP 传,结果网络抖动一次,整条链路都在重传,画面卡成 PPT,这就是协议选型没想清楚。
1.2 可靠性机制:TCP 的重传与流量控制
TCP 的可靠性不是白来的,它靠的是一整套机制协同工作:校验和保证数据完整,序列号保证有序拼接,确认应答(ACK)告诉发送方“我收到了”,超时重传处理丢包,滑动窗口实现流量控制,拥塞控制避免把网络打爆。
这里有个新手容易忽略的点:TCP 的 ACK 是累积确认的,也就是说接收方回复一个 ACK 序号 N,代表序号 N 之前的所有字节都收到了。这带来一个实际影响——TCP 的“可靠”是端到端的,但对应用层来说,你调用 send() 成功只代表数据进了内核发送缓冲区,并不代表对端应用已经 recv() 到。很多刚入行的同事因此踩过坑,以为 send() 返回了就万事大吉,实际上对端进程早就崩溃了,数据只是在内核里兜了一圈。
1.3 选型方法论:什么场景用哪个协议
我总结了一套比较实用的选型逻辑,基本就是问三个问题:第一,数据丢了行不行?第二,实时性要求多高?第三,连接数量级多大?
- 选 UDP 的场景:实时音视频、游戏状态同步、DNS 查询、SNMP 监控、工业现场的传感器采集。这类场景允许少量丢包,但对延迟非常敏感,宁可用最新数据覆盖旧数据,也不要卡在重传上。
- 选 TCP 的场景:文件传输、数据库事务、HTTP API、消息队列、远程控制指令。这些场景数据必须完整到达,缺一个字节都可能出大问题。
- 混合使用的场景:比如视频通话的媒体流走 UDP,信令控制走 TCP;或者像 QUIC 那样在 UDP 之上自己实现可靠层,那是进阶玩法,暂不展开。
还有一个从实测中得到的经验:内网环境下 UDP 和 TCP 的延迟差距可能只有几毫秒,但跨公网、跨运营商时差距会被放大到几十甚至上百毫秒。做选型时不要只看实验室数据,要到真实网络环境去验证。
2. Socket API 全景:从 socket() 到 close() 的完整链路
2.1 socket() 与 bind():创建套接字与绑定地址
Linux 下一切网络通信的起点就是 socket() 系统调用。它的三个参数分别是协议族(AF_INET 表示 IPv4)、类型(SOCK_STREAM 是 TCP,SOCK_DGRAM 是 UDP)、协议号(通常填 0 让内核自动推断)。下面这段是 UDP 套接字的创建:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int udp_socket; udp_socket = socket(AF_INET, SOCK_DGRAM, 0); if (udp_socket < 0) { perror("socket"); exit(1); }创建完套接字后,服务端需要 bind() 到固定端口,客户端通常不需要显式绑定(内核会临时分配一个随机端口)。但有个细节值得注意:bind() 前最好设置 SO_REUSEADDR,否则服务端程序重启时会因为 TIME_WAIT 状态报“Address already in use”,这在后面章节我会详细讲。
struct sockaddr_in server_addr; int opt = 1; setsockopt(udp_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网卡 server_addr.sin_port = htons(8888); if (bind(udp_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(udp_socket); exit(1); }2.2 listen() / connect() / accept():TCP 建立连接的固定动作
TCP 服务端的套路是固定的:socket() 创建套接字,bind() 绑定地址,listen() 进入监听状态,然后循环 accept() 接收连接。listen() 的第二个参数 backlog 指定的是内核维护的已完成连接队列长度,注意不是最大连接数,有些资料在这里会说错。
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(9999); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 128) < 0) { // 128 是 backlog perror("listen"); exit(1); }客户端的动作更简单:socket() 创建后,直接 connect() 指定服务端 IP 和端口。connect() 对 TCP 来说会触发三次握手,对 UDP 来说只是记录一下默认对端地址,不会真的发起通信。很多人刚接触时不知道这一点,以为 UDP 调了 connect() 就建立了连接,其实只是内核帮你记住了地址,之后 send() 可以直接发。
2.3 sendto/recvfrom 与 send/recv:数据收发的两条路线
UDP 的数据收发必须用 sendto()/recvfrom(),因为它们每次都要携带对端地址信息,这是无连接协议的特性决定的。而 TCP 建立连接后,双方地址已经固定,用 send()/recv() 就行,接口更简洁。
// UDP 发送 char buf[1024] = "hello from udp client"; struct sockaddr_in server_addr; socklen_t addr_len = sizeof(server_addr); ssize_t n = sendto(udp_socket, buf, strlen(buf), 0, (struct sockaddr *)&server_addr, addr_len); // UDP 接收 char recv_buf[1024]; struct sockaddr_in peer_addr; socklen_t peer_len = sizeof(peer_addr); ssize_t rn = recvfrom(udp_socket, recv_buf, sizeof(recv_buf), 0, (struct sockaddr *)&peer_addr, &peer_len);一个实操细节:recvfrom()/recv() 的返回值是实际接收的字节数,如果你提供的缓冲区太小,多余的数据会被内核丢弃(UDP 不会像 TCP 那样粘连或拆分,一个 datagram 要么完整收到,要么收不到)。这就是 UDP 的“报文边界”,也解释了它不会出现 TCP 粘包问题的原因。
3. 完整可运行示例:UDP 收发与 TCP 并发服务
3.1 UDP 服务端 + 客户端:一个最简单的丢包实验
下面这个 UDP 服务端例子我测试过很多次,它接收客户端消息后原样回送(echo),同时打印客户端地址。代码逻辑很简单,但它是做 UDP 调试的基础模板。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8888 #define BUF_SIZE 1024 int main() { int sock_fd; struct sockaddr_in server_addr, client_addr; char buf[BUF_SIZE]; socklen_t addr_len = sizeof(client_addr); sock_fd = socket(AF_INET, SOCK_DGRAM, 0); if (sock_fd < 0) { perror("socket"); return 1; } int opt = 1; setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(sock_fd); return 1; } printf("UDP echo server listening on port %d\n", PORT); while (1) { ssize_t n = recvfrom(sock_fd, buf, BUF_SIZE, 0, (struct sockaddr *)&client_addr, &addr_len); if (n < 0) { perror("recvfrom"); continue; } buf[n] = '\0'; printf("recv %zd bytes from %s:%d: %s\n", n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buf); sendto(sock_fd, buf, n, 0, (struct sockaddr *)&client_addr, addr_len); } close(sock_fd); return 0; }客户端代码也一并给出,你会发现 UDP 客户端几行就能跑起来,没有任何连接步骤:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8888 int main(int argc, char *argv[]) { if (argc < 3) { printf("usage: %s <server_ip> <message>\n", argv[0]); return 1; } int sock_fd = socket(AF_INET, SOCK_DGRAM, 0); if (sock_fd < 0) { perror("socket"); return 1; } struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); inet_pton(AF_INET, argv[1], &server_addr.sin_addr); sendto(sock_fd, argv[2], strlen(argv[2]), 0, (struct sockaddr *)&server_addr, sizeof(server_addr)); char buf[1024] = {0}; socklen_t addr_len = sizeof(server_addr); ssize_t n = recvfrom(sock_fd, buf, sizeof(buf), 0, (struct sockaddr *)&server_addr, &addr_len); if (n > 0) { buf[n] = '\0'; printf("echo: %s\n", buf); } close(sock_fd); return 0; }3.2 TCP 服务端:多进程与 epoll 两种写法
TCP 服务端相比 UDP 复杂不少,核心在于并发处理多个连接。我最早用的方案是多进程,每来一个连接就 fork() 一个子进程处理,逻辑简单,但连接多了进程开销很大。后来换成 epoll 事件驱动,单线程就能扛住上万连接。
先看多进程版本,适合连接数少、逻辑独立的场景:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <signal.h> #include <sys/wait.h> #define PORT 9999 #define BUF_SIZE 1024 void handle_client(int client_fd) { char buf[BUF_SIZE]; ssize_t n; while ((n = recv(client_fd, buf, BUF_SIZE, 0)) > 0) { send(client_fd, buf, n, 0); // echo } close(client_fd); } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0 || listen(listen_fd, 128) < 0) { perror("bind/listen"); return 1; } signal(SIGCHLD, SIG_IGN); // 自动回收子进程,避免僵尸 printf("TCP server listening on port %d\n", PORT); while (1) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len); if (client_fd < 0) { perror("accept"); continue; } pid_t pid = fork(); if (pid == 0) { close(listen_fd); handle_client(client_fd); exit(0); } else if (pid > 0) { close(client_fd); } } close(listen_fd); return 0; }再看 epoll 版本的骨架。epoll 的核心思路是把所有关心的 fd 注册进内核事件表,然后阻塞等待就绪事件,每来一个事件就处理一个连接,不需要为每个连接创建线程或进程。这套在做网关服务时实测非常稳,2 万连接下 CPU 占用依然很低。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <sys/epoll.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 9999 #define MAX_EVENTS 1024 #define BUF_SIZE 4096 int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(PORT); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); int epoll_fd = epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[MAX_EVENTS]; char buf[BUF_SIZE]; printf("epoll TCP server listening on port %d\n", PORT); while (1) { int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i = 0; i < nfds; i++) { if (events[i].data.fd == listen_fd) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len); ev.events = EPOLLIN; ev.data.fd = client_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev); printf("new connection from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); } else { int fd = events[i].data.fd; ssize_t n = recv(fd, buf, BUF_SIZE, 0); if (n > 0) { send(fd, buf, n, 0); // echo } else { epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } close(listen_fd); return 0; }使用 epoll 时有个坑:EPOLLIN 触发是水平模式(LT),缓冲区里只要有数据就会一直通知;如果用边缘模式(ET),必须一次性把数据读完,否则会丢事件。我建议新手先用默认的 LT,等彻底理解了非阻塞 IO 再玩 ET,否则很容易出诡异 bug。
3.3 参数确认:缓冲区、超时与 linger 设置
真正做稳定服务时,内核参数的微调比代码逻辑更能决定成败。几个我常用的关键配置:
- SO_SNDBUF / SO_RCVBUF:套接字发送/接收缓冲区大小。UDP 场景下如果瞬时数据量大,缓冲区太小会直接丢包;TCP 场景下缓冲区影响吞吐和窗口滑动效率,视频传输这类大流量服务通常要调大到 1MB 以上。
- SO_RCVTIMEO / SO_SNDTIMEO:收发超时。设置后 recv()/send() 不会无限阻塞,超时返回 -1 且 errno 为 EAGAIN/EWOULDBLOCK。嵌入式设备通信中这个必须设,否则对端断电不说再见,你的程序永远卡在读上面。
- SO_LINGER:控制 close() 的行为。默认 close() 会尽量把缓冲区的数据发完再关;设置 l_linger = 0 后 close() 立即发送 RST,丢弃所有未发送数据。这在某些业务场景能快速释放连接,但也会让对端读到连接异常重置错误。
下面这段是我常用的一套 TCP 参数初始化兜底代码,实测在 ARM 嵌入式 Linux 和高并发 x86 服务器上都跑过,稳定性不错:
int fd = socket(AF_INET, SOCK_STREAM, 0); int keepalive = 1; int keepidle = 60; int keepintvl = 10; int keepcnt = 3; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));这套配置的意思是:连接空闲 60 秒后开始探测,每 10 秒探一次,连续 3 次没响应就判定连接死亡。我在一个 IoT 网关项目里靠这个才及时清掉了几百个断网设备的僵尸连接,否则 fd 很快会被耗尽。
4. 三次握手与四次挥手:TCP 状态机的实战视角
4.1 三次握手:为什么核心是序列号同步
TCP 建立连接的三次握手,面试必问,但很多人只记住了“客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK”这个流程,没理解为什么要这样设计。
握手的本质是让双方确认三件事:双方的收发能力正常、双方初始序列号同步、避免历史重复连接干扰。客户端第一个 SYN 里携带自己的初始序列号 ISN,服务端回复 SYN+ACK 时既确认客户端的序列号(ack = ISN+1),也告知自己的初始序列号;最后客户端再回一个 ACK 确认服务端的序列号。从第三次握手之后,双方都有了对端序列号的明确认知,后续的可靠性机制才能展开。
第一次和第二次握手如果只有两次,服务端无法确认客户端有没有收到自己的 SYN+ACK,也就无法区分“这条连接是当前新发起的”还是“网络里延迟的旧连接重放”。只有等客户端真正回了一个 ACK,服务端才能确定双方意愿达成一致。
我在排查问题时习惯用 tcpdump 抓包检验三次握手,一个正常的建立流程应该是 SYN、SYN+ACK、ACK 三个包按顺序出现。如果只看到 SYN 重传,通常是服务端端口没监听或被防火墙丢弃;如果 SYN+ACK 发出去但客户端不回 ACK,可能是客户端被安全策略拦了,或者 SYN 包里的源 IP 是伪造的。
4.2 四次挥手与 TIME_WAIT 的坑
四次挥手比三次握手在实际运维中更让人头疼。主动关闭方先发 FIN,对端回 ACK,等对端应用层处理完数据后再回 FIN,最后主动方回 ACK,然后进入 TIME_WAIT 状态,等 2MSL(Maximum Segment Lifetime,报文最大生存时间)后才彻底关闭。
TIME_WAIT 是很多线上事故的根源。主动关闭的一方在 TIME_WAIT 期间,本地端口还占着,如果服务端自己主动断开大量连接(比如重启),会积压一堆 TIME_WAIT,导致新的监听端口被拒。比较典型的报错是 bind() 时提示“Address already in use”,或者“Cannot assign requested address”。
这里要分清两个层面:如果是 listen fd 要 bind 同样的端口,设置 SO_REUSEADDR 就能立刻跳过 TIME_WAIT 限制;如果是主动连接端要复用端口去 connect,Linux 下还需要设置 SO_REUSEPORT 或调整参数。我曾经在一个网关服务上因为没有处理 TIME_WAIT,压测到 5 万短连接后端口全部卡死,最后通过调低net.ipv4.tcp_fin_timeout和启用连接复用才解决。
生产环境里还要注意:如果服务端被大量 TIME_WAIT 拖住,直接echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse配合tcp_timestamps=1是业内常见手段,但 tcp_tw_reuse 只能用于主动连接方,不是万灵药;另一个tcp_tw_recycle在 NAT 环境下会误杀连接,NAT 场景千万别开,这个坑很多老手都踩过。
4.3 用 ss 与 netstat 观察连接状态
排查 TCP 状态最常用的还是 ss 命令(netstat 在新版系统里逐渐被替代,但思路一致)。我在现场调设备时,第一件事就是看连接状态分布,几秒钟就能定位问题方向:
ss -ant | awk '{print $1}' | sort | uniq -c这条命令统计所有 TCP 连接的状态分布。看到大量 SYN_SENT 说明连接迟迟建不上,可能是对端没监听或者网络不通;大量 ESTABLISHED 且空闲是正常现象;大量 TIME_WAIT 说明短连接频繁创建销毁;大量 CLOSE_WAIT 则说明对端关了连接,而你的程序还在傻等,这是应用层没处理 EOF 导致的,属于代码 bug。
CLOSE_WAIT 在排查中经常被忽略,但它往往是最要命的。正常流程里,对端发来 FIN 后,本端内核回 ACK 并通知应用 recv() 返回 0,这时应用应该主动 close() 释放 fd。如果应用代码没写好,没判断 recv 返回 0,连接就一直挂在 CLOSE_WAIT,fd 泄漏到一定数量,服务就彻底瘫了。这是我见过最多的线上故障类型。
5. 实战排错:从端口占用到协议栈异常
5.1 端口无法绑定与地址冲突
服务启动时报错Address already in use或Bind: Address already in use,第一步先查是谁占了端口。Linux 下用ss -lntp或者lsof -i:端口号,Windows 环境用netstat -ano | findstr 端口号,这个排查思路是通用的。
实践中有两类情况要区分:如果端口被一个还在正常运行的进程占用,要么换端口,要么先停掉那个进程;如果端口处于 TIME_WAIT 状态,多半是刚才服务自己关闭时留下的,这时只要在代码里给 listen fd 设置 SO_REUSEADDR,重启就能立刻绑定成功。
我在一个项目中遇到过更隐蔽的情况:两个程序通过 SO_REUSEPORT 共享同一端口(内核做负载均衡),结果其中一个程序异常退出,另一个程序收到大量连接被重置的错误。这类问题在容器环境里更常见,因为容器端口映射和宿主机 socket 的复用策略不同,排查定位要结合ss、iptables和容器网络模式逐层看。
5.2 收发异常:从缓冲区到协议栈参数
接收数据偶尔丢、收发延迟大、吞吐上不去,这类问题要分层排查。UDP 场景最容易先看丢包统计,netstat -su会显示 UDP 的 receive buffer errors,如果这个数字持续增长,说明用户态程序消费速度跟不上内核收包速度,要么调大rmem_max和rmem_default,要么在应用层优化处理逻辑。
TCP 场景下,ss -ext可以看到 send-q 和 recv-q 堆积。send-q 一直不为零,说明对端消费能力不行或者网络拥塞,TCP 自动做了流控;recv-q 一直堆积,说明你的应用读得太慢,多半是业务处理里有阻塞操作阻塞了 IO 循环。我在优化一个文件传输服务时,就是通过观察 recv-q 发现接收端磁盘 IO 成了瓶颈,后来把接收缓冲改成双 buffer 异步落盘才解决。
TCP 协议栈参数里,我实际调过比较出效果的有这几个:
net.ipv4.tcp_rmem/net.ipv4.tcp_wmem:接收/发送缓冲区自动调节范围。高速内网传输时适当调大,能让单条 TCP 流的吞吐提升明显。net.ipv4.tcp_congestion_control:拥塞控制算法。局域网用默认 cubic 就行,跨数据中心高延迟建议试试 bbr,需要内核支持并加载模块,实测在一些链路质量差的场景下 bbr 能把吞吐拉高好几倍。net.core.netdev_max_backlog:网卡接收队列长度。突发流量大而 CPU 处理不及时的时候,这个参数过小会直接丢包。
调协议栈参数没有通解,我建议所有改动基于监控数据来做,改完后用 iperf3 重新测,看吞吐、丢包率、延迟三个指标的变化,再决定保不保留。
5.3 性能验证:iperf3 打流的方法与判读
写完服务不确定性能上限时,iperf3 是我最常用的工具。它的用法很简单:服务端跑iperf3 -s -p 5201,客户端跑iperf3 -c 服务端IP -p 5201 -t 60,表示持续测试 60 秒。
UDP 打流相比 TCP 多一个带宽参数,因为 UDP 没有拥塞控制,不会自己跑满带宽,需要手动指定目标速率:iperf3 -u -c 服务端IP -b 100M -t 60。这里我建议把 -b 值从 10M 开始逐步往上加,每个档位跑 10 秒记录丢包率和抖动,找到这个网络链路和缓冲区配置下能稳定运行的临界速率。
判读结果时,重点看三个字段:Transfer 是传输总量,Bitrate 是平均吞吐,Loss 是丢包率。TCP 测试如果 Bitrate 远低于链路标称值,多半是接收窗口太小或者 CPU 瓶颈;UDP 测试如果 Loss 突然从 0 跳到很高,说明到达了缓冲上限,这时要回到上一档速率,或者去调net.core.rmem_max和套接字缓冲区。
有一次现场调一个摄像头视频传输项目,UDP 打流 30M 时丢包率只有 0.02%,看起来正常,但实际跑视频时画面花斑严重。后来才发现是程序里 recvfrom() 的循环没及时处理积压数据,iperf3 因为是纯收包所以看不出问题,换成真实负载立刻暴露。这提醒我:性能测试最好用接近业务形态的载荷,不能只信通用工具的指标。
6. 继续深入的方向与个人体会
写了这么多,最后聊一点经验和体会。Socket 编程看起来只是几个 API 的调用,但我带了几年项目后发现,最能拉开水平差距的往往不是语法,而是对数据流向和内核行为的理解。比如 TCP 粘包怎么处理、UDP 丢包怎么补偿、高并发下 fd 怎么管理、协议栈参数怎么调优,这些才是工程里真正决定系统稳定性的东西。
面试里问 UDP 和 TCP 的区别,看起来是送分题,但真要结合实际场景说清楚“为什么游戏帧同步选 UDP 而转账必须用 TCP”,很多人反而答不完整。这篇文章从协议原理到 API 使用,从握手挥手到排错实测,把这条主线完整走了一遍。后续我建议你可以再往两个方向深入:一是多路 IO 复用(epoll 的进阶用法和 Reator 模式),二是在 UDP 之上设计可靠的传输层协议(类似于 QUIC 的思路)。这两个方向我都在实践中有不少积累,后面有机会继续分享。