ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一文拆解TCP与UDP核心机制:三次握手、Dup Ack与工程实战

一文拆解TCP与UDP核心机制:三次握手、Dup Ack与工程实战 我们平时聊网络协议TCP和UDP就是绕不开的两个老伙计。做嵌入式、写上位机、调工业总线、搞服务器开发几乎每天都要跟它们打交道。很多人背过“TCP是可靠连接、UDP是不可靠无连接”这两句话但到了真刀真枪调问题的时候——为什么TCP会频繁重传为什么UDP打流跑不满带宽为什么Modbus TCP连上了又断开“背过概念”和“能解决问题”之间差着一大截实操经验。这篇文章不打算给你把RFC文档念一遍而是从实际工程视角出发带你重新拆解TCP与UDP协议解析这件事内容包括两者的核心机制、三次握手与四次挥手、Dup Ack重传、iperf3 UDP打流、UDP端口测试、Modbus TCP和RTU实操、CAN报文解析思路以及用asio库搭建TCP服务器的完整过程。不管是刚入门的初学者还是被线上问题折腾过的老手都能在里面找到能直接拿去用的东西。1. 两个协议的本质差异先搞清楚它们为谁而生1.1 没有“握手”的UDP真的就不可靠吗很多初学者会有个错觉TCP是“好”协议UDP是“差”协议。这个观念得先纠正。UDP不是“做不好可靠传输”而是它压根不去做——它把可靠性这个选择权交到了应用层手里。UDP的报文头只有8个字节包含源端口、目的端口、长度和校验和。发送方把数据塞进报文丢给IP层就算完事接收方收到就收收不到不会反馈顺序乱了也不管。这种设计带来的直接好处是低延迟、无连接、开销极小。你用DNS查询、DHCP租约、语音通话、视频直播基本都是UDP在背后支撑因为这些场景里丢掉一个包重发一遍比等一个包重传再排序更耽误事。那UDP“不可靠”的问题怎么解决答案是交给业务层。比如游戏领域常用的自定义可靠UDP协议会在应用层做序列号管理、ACK确认、超时重传。QUIC协议干脆在UDP之上重新实现了类似TCP的可靠传输和拥塞控制。所以选型的时候不要问“哪个更好”要问“哪个更合适”——实时性要求高、可以容忍少量丢包的场景UDP是第一选择数据不能丢、顺序必须保证的场景TCP才是正解。1.2 TCP和UDP的协议栈落地差异从协议栈层面看TCP和UDP的差异不只是“有没有握手”那么简单。TCP是一个“有状态”的协议。通信双方各自维护一套状态机连接建立时要走SYN_SENT、SYN_RCVD、ESTABLISHED这些状态断开时要走FIN_WAIT、TIME_WAIT、CLOSE_WAIT这些状态。内核里还挂着发送缓冲区、接收缓冲区、拥塞窗口、滑动窗口这些复杂的机制任何一个环节出了问题都会直接影响连接行为。UDP则是“无状态”的。内核和协议栈只需要按端口把报文分配到对应的socket上不记录双方的连接状态。这个特性使UDP非常轻量适合承载需要高吞吐、低延迟的场景。但代价是——网络中间设备比如NAT网关、防火墙很难长期保持对UDP流量的映射所以UDP穿透NAT时需要考虑打洞策略而TCP因为自带握手和保活机制穿透反而更稳定。还有一个很容易被忽略的点TCP的粘包和拆包问题。TCP是字节流协议应用层发过来的数据会被内核拆成若干分段接收方读到的并不是你发送时的完整消息。所以写TCP应用时十有八九要自己设计报文边界——最常见的就是“长度头消息体”的结构。UDP则是报文协议一次sendto对应一次recvfrom边界天然存在但要注意MTU限制数据超过MSS会被分片分片丢失会导致整个报文被丢弃。2. TCP核心机制拆解三次握手、四次挥手与可靠性背后的真相2.1 三次握手和四次挥手为什么是这个次数三次握手的本质是“双方都要确认自己和对方的收发能力都正常”。第一次握手客户端发SYN服务端从这一步知道自己能收到客户端的消息但此时服务端不确定自己发出去的消息客户端能不能收到。第二次握手服务端回SYNACK客户端收到后确认了自己发的消息服务端能收到也确认了自己能收到服务端的消息。第三次握手客户端回ACK服务端收到后才确认自己发的消息客户端能收到。这之后双方才进入数据传输阶段。三次是数学上的最小次数少于三次有一方无法确认对方具备完整的收发能力。多于三次纯属浪费。四次挥手则复杂一些因为TCP支持全双工通信两个方向的通道是相互独立的。主动断开方发FIN被动方回ACK这只是关闭了主动方到被动方的发送通道。被动方可能还有数据要发等它发完了再发自己的FIN对方回ACK整个连接才彻底关闭。所以是四次而不是两次或三次。这里有个实际工程里经常踩的坑TIME_WAIT状态。主动断开连接的一方在收到对方FIN并回复ACK之后会进入TIME_WAIT持续2MSL最大报文生存时间通常为1到2分钟。这段时间内四元组源IP、源端口、目的IP、目的端口被内核占用无法立刻被新的连接复用。很多高并发服务器频繁主动断开短连接时会积压大量TIME_WAIT导致“Address already in use”错误。后面第5章会具体说怎么排查。有经验的开发者会在设计协议时让服务端主动断开连接因为客户端可以随时更换本地端口重新发起连接而服务端的端口通常是固定的。这样把TIME_WAIT积压到客户端一侧服务端就不会被这个问题卡住。2.2 Dup Ack与乱序TCP的“快递小哥”如何避免重复派送Dup Ack重复确认机制是TCP可靠传输中容易被忽视、却发现性能问题时的核心排查点。当接收方收到一个乱序的TCP分段时它会用当前期待的下一个序列号重新发送ACK这个ACK和上一次发送的完全一样就叫Dup Ack。如果发送方连续收到3个Dup Ack会认为某个包丢了立刻重传这就是“快速重传”机制不需要等超时计时器到期。这个机制本意是好的能大幅减少丢包时的等待时间。但实际组网中如果链路存在大量乱序比如多链路负载均衡、网络设备转发延迟抖动过大接收方会因为乱序而产生大量Dup Ack发送方一看3个Dup Ack就重传重传的数据和后续数据又继续乱序形成恶性循环。我遇到过一例跨地域专线传输大文件速度上不去的问题抓包一看全是Dup Ack最后定位到是中间链路上两台设备的负载分担策略不一致导致数据走了不同路径。排查这类问题时别一上来就怀疑带宽不够。先看TCP重传率再看乱序报文的比例。如果Dup Ack频繁出现而真正丢包的记录不多八成是路径乱序。这种场景下可以尝试关闭或调整SACK选择性确认参数或者在多出口设备上让同一个数据流的报文固定走一条路径而不是哈希到不同链路上。2.3 端口、连接与状态TCP协议栈的“台账”管理每个TCP连接在协议栈里都是一条独立的记录用四元组唯一标识源IP、源端口、目的IP、目的端口。理解这一点很多问题就迎刃而解。假设一个客户端程序崩溃后立刻重启还想用原来的端口连同一个服务端大概率会报“Address already in use”。原因就是崩溃时连接没有正常关闭四元组还残留在内核里尤其是在TIME_WAIT状态下。Java的TCP客户端重连时尤其容易踩这个坑解决办法是设置SO_REUSEADDR选项允许重用处于TIME_WAIT状态下的端口。带宽高、连接数多的场景TCP协议栈的表单管理和锁竞争会成为性能瓶颈。Linux内核从2.6.32开始支持TCP多队列和拆锁但每个连接仍然要占用一定的内存接收缓冲、发送缓冲、内核socket结构体默认配置下单机几万连接没压力几十万连接就需要动用epoll的边缘触发、减少锁粒度、合理调大文件描述符上限这些办法了。3. UDP的实际玩法打流、端口探测与协议解析3.1 用iperf3做UDP打流为什么TCP打流测不出真实带宽上限iperf3是网络性能测试里使用频率最高的工具TCP打流大家都熟但很多人测UDP的时候容易忽略一些关键参数导致测试结果完全失真。用iperf3做UDP打流的典型命令是iperf3 -c 192.168.1.10 -u -b 500M -t 30这条命令的含义是以UDP模式向192.168.1.10发送数据目标带宽500Mbps持续30秒。-b参数指定的是“目标带宽”不是“实际带宽”。iperf3会按照这个值计算每秒发送多少报文然后统计丢包率、抖动和实际吞吐。如果你的目标带宽设得太低测出来的吞吐上限就是假的设得太高链路跑不满报文堆积在缓冲区里被丢弃丢包率飙升。正确做法是逐步增大-b值比如从100M、200M、500M、1G这样往上爬找到丢包率还在可接受范围内比如小于0.1%的最大吞吐值。那才是这条链路在UDP传输下的真实上限。还有几个值得注意的细节。第一UDP打流时-t测试时间不要太短建议至少30秒以上因为UDP不受拥塞控制约束瞬间突发流量不能反映稳态表现。第二-P参数可以指定并发连接数UDP模式下会创建多个socket同时发送能更充分地利用多核CPU的网卡队列能力。第三测试结束后面板里有个Jitter指标表示报文到达时间的离散程度对实时场景非常重要——即使丢包率很低抖动过大会语音通话和视频会议一样卡成幻灯片。3.2 UDP端口测试与网络调试的实用姿势UDP不像TCP那样自带握手所以“测试UDP端口是否开放”比TCP麻烦得多。TCP可以用nc -zv ip port快速探测对方会响应ACK或者RST。UDP没有这个机制另一端收到报文后如果不回显你是无法从外部判断端口到底是通的还是不通的。常用的UDP端口测试手段有几种最简单的思路是“回环验证”在目标主机上启动一个 UDP 应用或者用 socat 把 UDP 报文转成日志然后从源端发一条测试报文过去看对方能否收到。比如在目标机器上执行socat -v UDP-LISTEN:6000,fork EXEC:cat源端发一条UDP报文到IP:6000如果socat把报文的十六进制内容打出来就说明链路通。如果什么都收不到再用抓包工具确认报文是否真的送出了网卡。抓包是UDP调试的王道。但要注意在Linux本机上用tcpdump过滤UDP流量时如果发送方和接收方在同一个主机上报文可能不会经过真实的网卡而是走lo回环接口所以在源端抓包要记得在回环接口上同时抓不然你会“明明是通的抓包却抓不到”。还有一个高频场景是UDP MTU问题。很多UDP应用在局域网里跑得好好的一跨广域网就出现问题。原因往往是应用层发送的数据包较大加上IP头部和UDP头部后超过了路径MTU导致IP分片。分片报文在中间网络设备上处理优先级低一旦某一个分片丢失整个数据报就被丢弃而UDP不会重传。排查时用ping -M do -s 1472这类命令探测路径MTU把UDP应用数据拆小到合理范围问题往往就消失了。4. 具体落地从协议栈到应用实现的实操记录4.1 用asio库搭建一个可用的TCP服务器很多人在Windows上做上位机或边缘节点的网络服务会用Casio库是其中绕不开的一环。现网项目里用户量不大但需要稳定运行的本地服务场景用asio写一个TCP服务器比裸用socket API写起来舒服太多而且代码可读性和可维护性都更好。一个最简单的asio TCP服务器核心结构是这样的#include asio.hpp #include iostream #include string using asio::ip::tcp; class Session : public std::enable_shared_from_thisSession { public: Session(tcp::socket socket) : socket_(std::move(socket)) {} void start() { do_read(); } private: void do_read() { auto self(shared_from_this()); socket_.async_read_some(asio::buffer(data_, max_length), [this, self](std::error_code ec, std::size_t length) { if (!ec) { do_write(length); } }); } void do_write(std::size_t length) { auto self(shared_from_this()); asio::async_write(socket_, asio::buffer(data_, length), [this, self](std::error_code ec, std::size_t) { if (!ec) { do_read(); } }); } enum { max_length 1024 }; tcp::socket socket_; char data_[max_length]; }; class Server { public: Server(asio::io_context io_context, short port) : acceptor_(io_context, tcp::endpoint(tcp::v4(), port)) { do_accept(); } private: void do_accept() { acceptor_.async_accept( [this](std::error_code ec, tcp::socket socket) { if (!ec) { std::make_sharedSession(std::move(socket))-start(); } do_accept(); }); } tcp::acceptor acceptor_; };这个设计看起来很简单的但里面对生产环境至关重要的点不少。async_read_some每次回调拿到的length只是本次读到的大小不代表一整个请求。如果客户端一次发来了一段完整报文但TCP分段把它拆成了两次到达服务端第一次回调只处理了前半段。所以正式项目里数组缓冲要结合报文字节流解析来做不能简单认为“收一次就是一个消息”。而async_write则是保证把缓冲区里所有数据都写完才回调不会出现写了一半被打断的问题。实际项目中每个Session会持有一个独立的socket通过shared_from_this保证异步回调执行时对象还活着。这个模式叫“每个连接一个Session对象”配合asio的event loop单线程就能处理几千个并发连接在嵌入式设备和上位机场景够用了。如果你要做更极致的性能可以把线程数加到CPU核心数或者用io_context::executor_work_guard防止event loop空转退出。4.2 Modbus TCP与Modbus RTU从协议帧到数据解析Modbus在工业控制里的地位不用多说。FX5U、C#、LabVIEW这些词一出现基本就是自动化设备在打通PLC、IO模块、实时机之间的数据链路。Modbus RTU和Modbus TCP的报文结构差异非常清楚。RTU走串口报文没有额外头一帧结构是地址码1字节 功能码1字节 数据区变长 CRC16校验2字节。TCP则是把RTU的数据区原封不动搬到TCP负载里前面加了MBAP头7字节事务处理标识符2字节 协议标识符2字节 长度2字节 单元标识符1字节。Modbus TCP做“一主多从”时和RTU有个本质区别TCP的从站服务器本身是多端口监听或单端口多连接。主站在套接字层已经通过四元组区分不同设备了所以MBAP里的单元标识符更多是用于网关、串口适配器、管理型设备下面的串口子设备。很多人在PLC做主站、PC做从站的场景里发现PC上起的Modbus TCP服务器只有一条连接能通问题往往出在PC端软件只允许单个客户端连接或者主站把多个从站映射到了同一个设备ID上。Modbus RTU的报文解析难点在于CRC校验和超时控制。串口本身没有TCP那样的内建可靠机制一个半包或者多包粘在一起分帧就崩了。实际做法是状态机逐字节接收收到3.5个字符时间的静默间隔认为上一帧结束校验CRC必须通过才能发响应不然当成噪声丢弃。这个“一主多从的轮询节奏”在工程上也有讲究轮询超时时间要大于从站的典型响应时间轮询地址切换间隔不能太短否则从站来不及释放总线。4.3 CAN协议报文解析别把CAN报文当串口数据读CAN控制器局域网络报文在汽车电子、工控装备里到处都在用。CAN协议并不像TCP/UDP那样面向字节流它是一条一条报文标准帧结构是帧ID11位或29位 控制场 数据场最多8字节 CRC 结束位。CAN协议解析的核心思路是把“字节数组”翻译成“物理量”。报文里不会直接写“转速1200rpm”而是用信号值加缩放因子、偏移量表达。比如数据区两个字节的原始值是0x1234缩放因子是0.1偏移量为0转速就是466.0。解析时先按DBCCAN数据库文件把信号拆出来再套公式换算成工程值。我见过不少人把CAN报文转成UDP或者TCP包传到上位机然后在PC端解析。这种架构本身没问题但要注意端到端时序。CAN总线上报文是广播式广播的不同帧的发送频率差异极大——有的帧10ms一条有的帧1000ms才发一条。转发到TCP链路上时会面临Burst和空闲两种极端状态上位机解析时不能依赖“固定间隔”这个假设而是应该根据报文ID做调度分发。另外CAN报文没有源地址和目标地址的概念所有节点都挂在同一根总线上靠帧ID来决定优先级。解析时不能把ID当作“从哪来”而应该当作“这是什么信号”来理解。0x123可能是转速0x126可能是SG油门位置不同厂家有时还会在同一CAN总线上复用同一ID传输不同用途的数据这时就要以DBC为准纠结ID反而会把自己绕进去。5. 常见问题与排查技巧实录5.1 用抓包手段解决协议问题不管是TCP还是UDP出了问题不抓包就猜测原因基本就是在碰运气。抓包不是解决问题的万能药但它能让你把“我觉得”变成“我看到”。TCP场景里最重要抓包指标有四个重传率如果超过0.5%说明网络质量堪忧逐一排查丢包、拥塞、缓冲区溢出。Dup Ack数量乱序和网络中多径负载分担的直观信号。RTT波动曲线延迟抖动过大时先看是不是网络设备队列积压。窗口值如果对端通告窗口持续归零说明接收方处理不过来大概率是应用层读取太慢。UDP抓包则主要关注流方向、数据包长度分布和到达时间间隔。诊断UDP丢包时不只在接收端抓包发送端也得抓——很多“丢包”实际上是发送端的socket发送缓冲区已满应用层写入被内核拒绝但应用程序没感知到把日志打出来才发现“丢在出门之前”。5.2 经典问题速查表以下是我在多个项目里反复遇到、可以一键对照排查的高频问题现象可能原因排查与解决TCP连接建立后立刻断开服务端接收连接队列容量过小或者服务端应用层主动踢连接查看服务端backlog参数和accept调用确认是否达到连接数上限客户端重启报“Address already in use”TIME_WAIT四元组残留设置SO_REUSEADDR或调整tcp_tw_reuse/tcp_tw_recycle参数注意内核差异TCP传输速率上不去抓包看到重传中间链路乱序、缓冲区溢出或者带宽瓶颈先观察重传率再用iperf3同时测TCP和UDP判断瓶颈在哪一层UDP打流丢包率极高iperf3目标带宽超过链路能力或者接收端socket缓冲不足调低-b值逐步测试增大接收端net.core.rmem_max和socket接收缓冲UDP端口探测无响应防火墙过滤、应用未监听、报文被NAT丢弃tcpdump抓包确认报文是否到达目标主机在目标主机用socat接收验证Modbus TCP连接一个从站正常多个从站失败主站未区分单元标识符/设备ID或从站端只允许单连接检查MBAP中的单元标识符确认每个从站设备ID配置正确CAN报文解析后数值不对DBC文件出厂版本不匹配或者数据字节序解析错误核对原始字节序Intel/Motorola对照DBC定义确认位布局5.3 避坑技巧三个关于TCP和UDP的细节第一个细节是TCP的Nagle算法和延迟确认之间的相互作用。Nagle算法会把小包攒起来合并发送延迟确认则让接收方等一会再回ACK。两个机制凑在一块会导致应用层明明写入了数据对端却几十毫秒后才收到。解决方法是按业务场景关闭Nagle设置TCP_NODELAY或者把“多个小消息合并成一个包”这种策略用起来不要在应用层每操作一次就send一次。第二个细节是TCP keepalive的默认参数建议改掉。Linux默认keepalive探测间隔是2小时很多长连接断了自己还不知道。对于可靠性要求高的系统可以调整net.ipv4.tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes三项参数或者直接在应用层做心跳超时判断不要依赖内核保活机制。第三个细节是UDP接收缓冲的默认值。Linux默认rmem_max约212992字节也就是208KB左右。高速UDP打流的时候如果应用层消费速度跟不上缓冲区一满后续报文全部被内核丢弃。此时用ss -ump看到Recv-Q持续非零说明应用层没及时读取。解决方案是调大Socket接收缓冲必要时启用recvmmsg批量接收一次系统调用收多包大大减少用户态和内核态的切换开销。6. 写在最后几个亲测有效的实战认知写到这里想把我自己踩过的坑和沉淀下来的经验再啰嗦几句。第一协议解析时永远不要靠想象“对方发的是什么”先抓包、看原始报文、把字节和十六进制亮出来对一遍再写解析逻辑。很多诡异的解析错误最后都发现是对端文档版本和实际实现对不上。第二通用网络发生问题时的排查顺序永远是“先测链路再查协议最后查应用”。就算你确定代码逻辑没问题也要先ping、先iperf3双向打流把物理链路和路由状态确认一遍否则可能在错误的方向上浪费一整天。第三TCP和UDP不是非此即彼的答案。工业实时控制、音视频交互、游戏同步这些场景越来越多地倾向于“UDP为主、应用层补齐可靠性”而命令控制、文件传输、数据库交互还是TCP更顺手。协议选型时别背教条把延迟、丢包容忍度、顺序要求、数据大小、网络环境这五个维度列出来答案自然就浮出水面。最后再分享一个小技巧如果你在UDP协议栈之上设计可靠传输尽量把ACK和业务数据复用同一条UDP通道。这样既减少了报文字段的额外开销又不会因为NAT内网环境只允许单端口通信而导致可靠通道无法建立。能把这个思路用到位你在实战中的协议设计能力会和停留在一句“UDP不可靠”的开发者拉开明显差距。
返回列表