如果你在Linux服务器上写程序,不管是做后端服务、嵌入式应用还是运维脚本,网络通信这一关迟早要正面撞上。而Linux网络通信里最基础、也最容易让新人卡壳的,就是UDP和TCP这两类Socket编程。我见过不少同事能把“TCP三次握手”倒背如流,一碰到bind失败、连接超时、数据粘包照样手足无措。这篇就把Linux下的UDP与TCP Socket从原理、API、代码骨架到排障经验完整串一遍,适合刚接触网络编程的开发者,也适合那些想系统梳理一下底细的嵌入式、运维转开发的朋友。读完你至少能明白:什么时候该用TCP,什么时候该用UDP,以及出问题时该往哪个方向排查。
1. 先把TCP和UDP的底细摸清楚
1.1 同样是发数据,差别为什么这么大
先看两者最本质的区别:TCP是有连接、面向字节流的可靠传输协议,UDP是无连接、面向数据报的不可靠传输协议。
什么叫“面向字节流”?你可以把TCP想象成一条水管,你往里面倒多少水,水龙头那边就放多少水,中间没有“包”的概念。你调一次send发了100字节,对端一次recv可能只收到40字节,下次recv又收到剩下的60字节。这对应用层是透明的,但接收方自己必须处理“数据被切开又合起来”的问题。
UDP就完全相反。它像寄明信片,每一张都是完整独立的,投递出去就不管了。你调一次sendto发出一个数据报,对端一次recvfrom如果缓冲区足够大,收到的就是你发出的那个完整报文。没人保证它一定能到,也没人保证到达顺序和你发的顺序一致,更没人帮你重传。
这个区别直接决定了Socket编程的编码模型完全不同。TCP写起来重,重在对端状态的管理;UDP写起来轻,轻在“发就完了”,但这不代表UDP程序好写,因为丢包、乱序、重复到达这些事全得靠上层应用自己兜底。
1.2 一张表看清TCP和UDP的核心差异
下面这个表是我平时给团队做培训时必放的,基本覆盖了面试和实际选型最常问的点:
| 维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,需要建立和断开连接 | 无连接,直接发包 |
| 可靠性 | 可靠,有确认、重传、去重 | 不可靠,不确认、不重传 |
| 数据边界 | 字节流,无消息边界 | 数据报,有消息边界 |
| 顺序 | 保证到达顺序 | 不保证顺序 |
| 开销 | 高,头部20字节起,握手增加时延 | 低,头部8字节,无握手过程 |
| 传输速率 | 受流量控制和拥塞控制影响 | 没有拥塞控制,能打多快打多快 |
| 编程模型 | listen/accept/connect流程完整 | bind后直接sendto/recvfrom |
| 典型场景 | HTTP、数据库、文件传输 | 音视频、实时游戏、DNS查询、日志采集 |
实际项目里怎么选?我有一个比较朴素的判断方法:如果丢一个包会导致业务不可接受,比如转账、下单、配置下发,那就老老实实上TCP;如果业务本身能容忍丢包,或者要的是低延迟优先,比如通话、视频、传感器数据流,那就用UDP。还有一个折中思路,比如QUIC,本质上是基于UDP重新实现了可靠性,属于“UDP骨架、TCP灵魂”,但那是另一个话题了。
1.3 被问烂了的“三次握手”到底在握什么
既然标题里带着TCP,那三次握手必须讲透。面试里背答案谁都会,但很多人并不知道它解决的是什么实际问题。
三次握手的本质是:让通信双方确认“你能收到我的数据,我也能收到你的数据”,同时协商初始序列号。为什么需要三次?因为网络是不可靠的,存在乱序和重复。如果只握两次,服务端无法确认客户端是否已经准备好接收数据。经典场景是:客户端发的连接请求在网络里滞留了很久,超时后客户端重发并成功建立连接,结果那个滞留的旧请求又到了服务端,如果只握两次,服务端就会误认为这是一个新连接,白白建立一条废弃连接还占用资源。三次握手加上序列号的机制,就是为了处理这种“旧重复连接请求”。
要理解这个机制,最直观的办法是让抓包工具把真实过程拉出来看。后面第3部分我会给实际的tcpdump验证方式,这里先记住三个参与方:SYN、SYN-ACK、ACK。整个流程用一句话概括:客户端先问“你听得到吗”,服务端回答“我听得到,你听得到我吗”,客户端再答“听得到”,然后双方正式进入数据传输状态。
2. Linux下Socket API全家桶:从创建到关闭
2.1 核心API一览:这是你天天要打交道的几个函数
Linux下的Socket编程接口是典型的POSIX风格,数量不多,但每个函数的边界条件都要心里有数:
| 函数 | 主要用途 | 关键坑点 |
|---|---|---|
| socket() | 创建套接字 | 协议族和类型要匹配,SOCK_STREAM配TCP,SOCK_DGRAM配UDP |
| bind() | 绑定本地地址和端口 | port=0时内核随机分配;不bind也能发数据,但收不到数据 |
| listen() | 将TCP套接字置为监听状态 | backlog参数不是最大连接数,需要单独理解 |
| accept() | 从已完成连接队列中取出一个连接 | 返回的是新fd,监听fd要保留继续accept |
| connect() | 客户端发起连接 | 默认阻塞,遇到对端不可达会卡很久,需要设置超时 |
| send()/recv() | TCP收发数据 | 返回值和请求字节数不一定相等;对端关闭时recv返回0 |
| sendto()/recvfrom() | UDP收发数据报 | recvfrom能拿到发送端地址,这是UDP做应答的关键 |
| shutdown()/close() | 关闭连接 | close只是减引用计数,shutdown才能主动断掉数据收发方向 |
这里额外强调一下listen的backlog参数。在Linux内核里,TCP监听套接字包含两个队列:半连接队列(SYN队列)和全连接队列(accept队列)。backlog主要控制的是全连接队列的大小,也就是已经完成握手、等待应用层调accept取走的连接数量上限。很多人以为把它设成1000就能支持1000并发,实际上如果应用层不及时accept,队列满了以后内核会直接丢弃新连接,客户端那边表现就是连接超时或连接被重置。
2.2 TCP客户端与服务端的标准骨架
先看服务端流程,通常固定是socket -> bind -> listen -> accept -> recv/send -> close。下面这段代码是一个最简单的TCP回显服务端,监听8899端口,把收到什么原样返回给客户端:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 8899 #define BACKLOG 128 int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); } int reuse = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); 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) { perror("bind"); exit(1); } if (listen(listen_fd, BACKLOG) < 0) { perror("listen"); exit(1); } printf("server listening on port %d\n", PORT); while (1) { struct sockaddr_in client; socklen_t len = sizeof(client); int conn_fd = accept(listen_fd, (struct sockaddr *)&client, &len); if (conn_fd < 0) { perror("accept"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip)); printf("client %s:%d connected\n", ip, ntohs(client.sin_port)); char buf[1024]; int n = recv(conn_fd, buf, sizeof(buf), 0); if (n > 0) { send(conn_fd, buf, n, MSG_NOSIGNAL); } close(conn_fd); } close(listen_fd); return 0; }代码里有几个细节值得单独说。
SO_REUSEADDR不是可有可无的。服务端程序如果崩溃重启,或者主动关闭后立刻重新bind,你会频繁撞上“Address already in use”。原因在于TCP连接关闭后会进入TIME_WAIT状态,端口还被内核占着。设置SO_REUSEADDR之后,允许在TIME_WAIT状态下重新绑定同一个端口,这是Linux服务端程序的标配操作。我见过很多人第一版代码不写这行,测试时ctrl+c重启就报错,基本每两次必现一次,现场很尴尬。
send加MSG_NOSIGNAL,对付的是“对端已经关闭连接但你还在往里写数据”的场景。如果客户端先断开,服务端继续send,默认情况下内核会给进程发SIGPIPE信号,而SIGPIPE的默认动作是终止进程。你的服务端进程可能就这样无声无息地死掉了。加上MSG_NOSIGNAL,send会直接返回-1,由你代码里自己处理错误,进程不会莫名其妙被杀。
客户端那边也顺手给出来,结构是socket -> connect -> send/recv -> close:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 8899 int main(int argc, char *argv[]) { if (argc < 2) { printf("usage: %s <server_ip>\n", argv[0]); exit(1); } int fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); inet_pton(AF_INET, argv[1], &addr.sin_addr); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); exit(1); } const char *msg = "hello socket"; send(fd, msg, strlen(msg), 0); char buf[1024] = {0}; int n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { printf("recv: %s\n", buf); } close(fd); return 0; }这里最烦人的是connect默认阻塞超时时间很长。如果目标IP不可达,或者对端防火墙把包丢了,connect可能卡住一两分钟才返回错误。后面第4部分我会专门讲怎么用非阻塞方式把这个超时压到秒级。
2.3 UDP的Socket编程套路:比TCP简单不少
UDP服务端不需要listen和accept,流程压缩成socket -> bind -> recvfrom -> sendto -> close。发数据时用sendto,收数据用recvfrom。下面这段是UDP回显服务端的完整代码,监听9900端口:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 9900 int main() { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { perror("socket"); return 1; } 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(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } char buf[1024]; struct sockaddr_in client; socklen_t len = sizeof(client); int n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&client, &len); printf("recv %d bytes from %s:%d\n", n, inet_ntoa(client.sin_addr), ntohs(client.sin_port)); sendto(fd, buf, n, 0, (struct sockaddr *)&client, len); close(fd); return 0; }注意recvfrom的倒数第二个参数,传进去的是指向struct sockaddr_in的指针,函数执行完后里面填的是发送方的地址信息。UDP是无连接的,同一服务端可能同时收到来自多个客户端的数据报,你要回发给对方,就必须拿到这个来源地址。这个设计是UDP Socket编程的精髓,也是新手比较容易漏掉的地方。
UDP客户端的代码更简单,不需要连接,直接sendto,如果想要对方回包,再调一次recvfrom等待响应:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 9900 int main(int argc, char *argv[]) { if (argc < 2) return 1; int fd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); inet_pton(AF_INET, argv[1], &addr.sin_addr); const char *msg = "udp ping"; sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)&addr, sizeof(addr)); printf("sendto done\n"); char buf[128]; struct sockaddr_in from; socklen_t len = sizeof(from); int n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&from, &len); if (n > 0) { buf[n] = 0; printf("recv: %s\n", buf); } close(fd); return 0; }UDP有一个好处很多人没意识到:同一个socket可以给任意多个目标地址发数据,改一下sendto的参数就行,不需要像TCP那样为每个对端维护一条连接。DNS查询就是最典型的例子,客户端一个socket可以向多个DNS服务器发查询。
2.4 非阻塞、超时和优雅关闭:进阶必备
默认创建的socket都是阻塞模式。阻塞模式的问题在于:accept没有新连接时卡住、recv没有数据时卡住、connect连不上时卡住。在简单的demo里没问题,但真实的服务端程序没人敢这么写,因为一个客户端不发包,整个进程就被block住了,其他客户端全部卡死。
解决方案有三个层次:多进程、多线程、I/O多路复用。多进程最简单,accept到一个连接就fork一个子进程处理,代价是进程切换开销大;多线程同理,适合在线程模型成熟的开发环境;真正的主流方案是select/poll/epoll,让一个进程管理几千几万个连接。实际工作中,如果只是自己写工具,阻塞socket加一个超时控制就够了;如果要写服务器,第一件事就是换非阻塞加epoll。
关于超时,我在这里给一个非常实用的非阻塞connect模板。思路是:先设置O_NONBLOCK,再调connect。如果返回0,说明连接立即建立成功;如果返回-1且errno == EINPROGRESS,说明连接正在后台进行中,这时候调用select等待该fd变成可写,并设置超时时间:
int fd = socket(AF_INET, SOCK_STREAM, 0); int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); if (ret < 0 && errno != EINPROGRESS) { perror("connect"); close(fd); return -1; } if (ret == 0) { printf("connected immediately\n"); return 0; } fd_set wset; FD_ZERO(&wset); FD_SET(fd, &wset); struct timeval tv = {3, 0}; // 3秒超时 ret = select(fd + 1, NULL, &wset, NULL, &tv); if (ret <= 0) { printf("connect timeout\n"); close(fd); return -1; } int err = 0; socklen_t err_len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &err_len); if (err != 0) { errno = err; perror("connect failed"); close(fd); return -1; } printf("connected\n");这里有个新手必踩的坑:select返回该fd可写,只说明连接过程有“动静”了,不一定代表成功。如果对端拒绝连接,fd也会变得可写,因为错误通知也算事件。所以必须用getsockopt(fd, SOL_SOCKET, SO_ERROR, ...)把内核记录的套接字错误取出来,才是连接成功的最终结论。
3. 实操:跑通一个完整的Socket程序
3.1 环境准备与编译
这部分不需要任何第三方依赖,一台装Linux的机器就行,Ubuntu、Debian、CentOS、国产发行版都没问题,Socket API是POSIX标准的一部分,所有发行版用法完全一样。只需要一个gcc:
gcc -o tcp_server tcp_server.c gcc -o tcp_client tcp_client.c gcc -o udp_server udp_server.c gcc -o udp_client udp_client.c编译时不需要加-lpthread,因为这些程序还没用到多线程。如果看到warning: implicit declaration of function,一般是头文件漏了,检查arpa/inet.h和sys/socket.h是否都在。编译零警告是我自己始终保留的习惯,宁可多敲几个头文件,也不给后续排查埋雷。
3.2 TCP回显服务端的运行效果
开一个终端启动服务端:
./tcp_server再开一个终端运行客户端,参数填服务端IP。如果是本机测,用127.0.0.1:
./tcp_client 127.0.0.1服务端终端会打印一行客户端连接信息,客户端终端会打印回显的hello socket。整个过程看起来平淡,但这里面发生了完整的TCP三次握手、数据发送、数据接收、四次挥手。我推荐你在运行的同时拉一个tcpdump,亲眼确认一下:
tcpdump -i lo port 8899 -nn -t如果用的是本机回环地址,网卡要选lo。抓到的包里应该能看到熟悉的SYN、SYN-ACK、ACK三个包。第一次自己抓到三次握手的时候,那种感觉就像以前只在课本上见过的东西突然被自己亲手复现了一样,挺奇妙的。
3.3 UDP打流测试与验证
UDP的验证更直接,因为发包就是一瞬间的事。先开UDP服务端:
./udp_server它是单次收发的,跑完就退出。所以另一个终端执行客户端时,服务端要处于运行状态:
./udp_client 127.0.0.1客户端打印sendto done,服务端打印recv 9 bytes from 127.0.0.1:xxxxx,然后客户端收到回显字符串。注意UDP的客户端如果不预先调一次recvfrom,那么sendto之后程序直接退出,回显数据可能还没到就没了。真实项目中做UDP请求应答,回包等待逻辑是必须的,而且一般会加超时重试,不能傻等。
3.4 用nc和tcpdump验证网络行为
其实很多时候你不必写客户端,系统自带的nc(netcat)就能完成大部分网络调试工作。TCP测试用它最省事:
nc -vz 127.0.0.1 8899-vz表示测试端口是否开放并显示详细信息。nc还能直接当客户端收发文本数据:
echo "hello" | nc 127.0.0.1 8899UDP测试用-u参数:
nc -u 127.0.0.1 9900进入交互模式,输入一行文字就直接发出去了。这里提醒一下:UDP的nc无法判断对端是否真的收到,因为UDP本身没有ACK,你只能靠服务端的日志来确认。
tcpdump是排障时最重要的工具,多看两次抓包比背十遍协议文档有用。几个高频用法:
tcpdump -i eth0 port 8899 -nn tcpdump -i eth0 host 192.168.1.10 -nn tcpdump -i any udp port 9900 -nn -v-nn表示不做域名和端口反向解析,抓包速度快,输出也干净;-v能看到更多头部字段信息。配合-w参数还能把包保存成pcap文件,方便后续用Wireshark慢慢看。
4. 常见问题与排查技巧实录
4.1 bind失败:“Address already in use”的完整解法
这个报错在真实项目里出现频率极高,几乎每个写过服务端的人都撞过。原因通常是端口还处于TIME_WAIT状态。TCP四元组中的连接关闭后,主动关闭方要停留一个TIME_WAIT周期(Linux默认60秒),确保最后的ACK能被对端收到,防止旧连接的延迟报文干扰新连接。
解决办法按优先级排列:
- 服务端监听socket加上
SO_REUSEADDR,这是我给出的服务端代码里必带的那行; - 如果上一步加了还不行,用
ss -tunap | grep <端口>看当前到底是谁占着端口; - 不要随便调内核参数
net.ipv4.tcp_tw_reuse,那个参数只对主动连接方也就是客户端生效,对服务端监听端口不起作用,网上很多文章这点都没说清楚。
下面是排查端口占用的命令:
ss -tunap | grep 8899输出里能看到这条连接的本地地址、远端地址、状态和进程信息。如果状态是TIME_WAIT,不用慌,这是正常现象,加了SO_REUSEADDR就能继续bind。如果状态是LISTEN,说明另一个进程正在监听这个端口,要么换端口,要么先确认那个进程是什么。
4.2 粘包、拆包与UDP丢包,到底谁的问题
先说结论:TCP本身没有“粘包”这个概念,它是字节流,不存在消息边界。所谓粘包和拆包,是应用层把“多条消息”塞进同一个字节流后产生的问题。比如你连续调两次send各发10字节,对端一次recv可能收到完整的20字节,看起来像两条消息“粘”在一起了。这不是协议的问题,是你的应用协议没有定义消息边界。
惯用解法有三种:
- 固定长度:每条消息定长N字节,收满N再解析,适合结构固定的数据;
- 长度前缀:每个消息前面加4字节整型表示长度,这是目前最通用的做法;
- 分隔符:消息之间用
\n或特定字节分割,适合文本协议,但要注意内容里出现分隔符时要转义。
UDP丢包则是另一回事。UDP报文到达接收端后,如果接收缓冲区满了,内核直接丢弃报文。丢包原因常见的有:接收方处理慢、缓冲区太小、网络中间设备拥塞。可以通过调整接收缓冲区来缓解:
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));但注意,SO_RCVBUF设太大会导致内核内存占用上升,设太小又容易丢包,需要按实际流量压测。UDP没有内核级别自动重传,应用层设计时必须自己想好:哪些包丢了可以忍,哪些必须重传,用什么机制检测丢失。
4.3 connect卡住很久才超时,怎么处理
默认的TCP connect超时时间通常长达一两分钟,对端IP不可达时体验极差。有些运维同学反映脚本里nc连一个不通的IP要等半天,原理就在这里。解决办法就是我第2.4节给的非阻塞connect方案,把超时时间定在3秒或更短。另外提一下系统级参数:
sysctl net.ipv4.tcp_syn_retries这个值控制SYN重传次数,默认通常为6,一次连接超时最坏情况是1s + 2s + 4s + 8s + 16s + 32s + 64s这样的退避序列,所以才会让人觉得“卡死”了。调小能缩短失败时间,但会影响公网不稳定场景下的连接成功率。
4.4 用ss和tcpdump形成排障闭环
最后分享一个我自己的排障习惯。遇到网络程序连不上、丢失连接、性能异常时,我基本按这个顺序走:
ss -tunap先看本机所有连接状态。重点关注ESTABLISHED、SYN_SENT、TIME_WAIT这几类。SYN_SENT积压说明发出的连接请求没人应答,多半是防火墙拦截或对端宕机;ESTABLISHED数量远低于预期,可能服务没起来或者端口错误。
tcpdump -i eth0 port <服务端口> -nn再看实际网卡上的流量。这里有一个特别容易发现的假象:程序日志显示一直在发数据,但tcpdump里根本抓不到包,说明数据卡在了应用层队列或者路由配置有问题;反过来,tcpdump能抓到发出的包但收不到应答,说明包已经出网了,问题在对端或中间链路。
再配合一层最简单的连通性测试:
nc -vz <目标IP> <端口>这样一层层剥下来,基本能把问题定位到“本机配置、网络路径、对端程序”三个层面中的某一个。我一直觉得,网络编程能力的分水岭不在于背了几个API,而在于出问题时能不能用工具快速准确地定位到环节。
我个人在实际操作中养成了一个固执的习惯:所有socket相关的收发代码,一律写日志,每次send、recv、sendto、recvfrom都要把返回值、错误码、字节数记录清楚。这个习惯一开始看起来有点繁琐,等到线上对接第三方系统、对方不承认自己没发包的时候,你手上那份日志就是最硬的证据。另一个小技巧是,应用层协议设计时一定要带上一个递增的序号字段,排查乱序和重复时它会帮你节省好几天的脑细胞。希望这篇能把你在Linux UDP与TCP Socket路上遇到的坑,提前都替你踩一遍。