
如果你在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的核心差异下面这个表是我平时给团队做培训时必放的基本覆盖了面试和实际选型最常问的点维度TCPUDP连接状态面向连接需要建立和断开连接无连接直接发包可靠性可靠有确认、重传、去重不可靠不确认、不重传数据边界字节流无消息边界数据报有消息边界顺序保证到达顺序不保证顺序开销高头部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配TCPSOCK_DGRAM配UDPbind()绑定本地地址和端口port0时内核随机分配不bind也能发数据但收不到数据listen()将TCP套接字置为监听状态backlog参数不是最大连接数需要单独理解accept()从已完成连接队列中取出一个连接返回的是新fd监听fd要保留继续acceptconnect()客户端发起连接默认阻塞遇到对端不可达会卡很久需要设置超时send()/recv()TCP收发数据返回值和请求字节数不一定相等对端关闭时recv返回0sendto()/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服务端程序的标配操作。我见过很多人第一版代码不写这行测试时ctrlc重启就报错基本每两次必现一次现场很尴尬。send加MSG_NOSIGNAL对付的是“对端已经关闭连接但你还在往里写数据”的场景。如果客户端先断开服务端继续send默认情况下内核会给进程发SIGPIPE信号而SIGPIPE的默认动作是终止进程。你的服务端进程可能就这样无声无息地死掉了。加上MSG_NOSIGNALsend会直接返回-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标准的一部分所有发行版用法完全一样。只需要一个gccgcc -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验证网络行为其实很多时候你不必写客户端系统自带的ncnetcat就能完成大部分网络调试工作。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路上遇到的坑提前都替你踩一遍。