【Linux网络】TCP 协议(二):三次握手、四次挥手、滑动窗口与拥塞控制一网打尽
目录1 ~ 连接管理机制1.1 三次握手1.1.1 管理连接1.1.2 状态变化1.1.3 为什么要三次握手1.1.4 三次握手是不是真的三次握手1.2 四次挥手1.2.1 状态变化1.2.2 为什么是四次挥手三次行不行1.2.3 主动方都close了还怎么读 发1.2.4 被动方不断开连接会怎么样1.2.5 主动方为什么要进入TIME_WAIT状态?2 ~ 滑动窗口2.1 滑动窗口的移动2.2 丢包处理3 ~ 流量控制4 ~ 拥塞控制5 ~ TCP 收尾5.1 延迟应答5.2 粘包问题5.3 TCP 异常情况5.4 UDP 和TCP 区别5.5 底层TCP 协议栈数据结构6 总结在上一篇博客我们已经对TCP 协议格式与相关字段作了详细介绍今天我们继续深入底层探讨TCP 是如何在复杂的网络环境中兼顾可靠性与传输效率的。1 ~ 连接管理机制1.1 三次握手1.1.1 管理连接多个客户端连接服务端对于多个连接就需要服务端管理。先描述再组织当客户端connect客户端与服务端双方建立连接。在应用层看连接只是一个整数 fd文件描述符但在内核深处一个连接由两个核心数据结构共同“描述”套接字对象(struct socket)位于 VFS/接口层负责对上暴露出fd接口让应用程序能够调用read()、write()。TCP 控制块 TCB(struct tcp_sock / struct sock)位于传输层负责存储该连接的所有 TCP 动态状态包括滑动窗口大小 rwnd、拥塞窗口 cwnd、序列号/确认号、重传定时器以及接收/发送缓冲区队列。1.1.2 状态变化客户端CLOSED - SYN_SENT客户端调用connect发送同步报文段SYN_SENT - ESTABLISHEDconnect成功返回connect阻塞等待收到服务端SYNACK后向服务端应答。服务端CLOSED - LISTEN服务器端调用listen后进入LISTEN状态等待客户端连接LISTEN - SYN_RCVD一旦监听到连接请求(同步报文段)就将该连接放入内核等待队列中并向客户端发送SYN确认报文SYN_RCVD - ESTATABLISHED服务端一旦收到客户端的确认报文就进入ESTATABLISHED状态可以进行读写数据了。如上所有动态变化三次握手均由双方操作系统在客户端connect发起连接后自动完成。1.1.3 为什么要三次握手1以最短方式验证TCP 的全双工。本质探测双方网络是否畅通第一次握手客户端发送报文SYN- 服务端服务端接收后代表客户端可以发。第二次握手服务端发送报文SYN ACK- 客户端客户端收到后代表服务端可以发也可以收。第三次握手客户端发送应答 - 服务端服务端收到应答代表客户端可以收。结论双方通过最少次数3次验证了TCP 的全双工即双方网络通畅。2以最小成本100%确认双方的通信意愿。既然要双方通信就需要征求双方意愿。只有都同意了才能够通信。第一次握手客户端说我愿意。第二次握手服务端说我收到你的请求了我也愿意。第三次握手客户端确认。服务端对客户端请求无脑接收前两次不携带数据还未验证全双工。第三次握手客户端可以携带数据因为在客户端眼里服务端可以收也可以发。1.1.4 三次握手是不是真的三次握手第二次握手实际上是捎带应答。第二次握手ACK表示我同意建立连接。SYN我也想和你建立连接。如果不合并三次握手就会变成四次握手。但利用捎带应答机制合并发送就可以减少一次网络包的发送是一种精妙且高效的设计。1.2 四次挥手建立连接后双方就可以正常通信了。通常当客户端把全部数据发送完毕后就需要断开连接但也有可能服务端主动断开。以客户端主动断开为例1.2.1 状态变化第一次挥手主动方客户端closefd发起关闭ESTABLISHED - FIN_WAIT_1主动方 主动方应用程序调用关闭连接的 API向对端发送FIN报文后进入此状态。代表含义 “我已经没有数据要发给你了我要关闭通道正在等待你的确认。”第二次挥手被动方确认请求CLOSE_WAIT被动方 被动方的操作系统内核收到 FIN 后自动立刻回复 ACK 确认报文进入此状态。代表含义 “我收到你想关闭的请求了。但我可能还有数据没发完或者应用层的善后逻辑还没处理完请等我处理完毕。”FIN_WAIT_2主动方 主动方收到了被动方发来的 ACK 确认报文。代表含义 “我知道你收到我的关闭请求了。现在我进入半关闭状态安静地等你把剩下的事情处理完等你发 FIN 给我。”第三次挥手被动方处理完毕发起关闭LAST_ACK被动方 被动方的应用程序处理完了所有业务也发送了最后的数据于是主动调用close()发送 FIN 报文进入此状态。代表含义 “我的事情也全办完了数据发完了现在我也要关闭连接了正在等待你的最后一次确认。”第四次挥手主动方最终确认连接终止TIME_WAIT主动方 主动方收到对端的 FIN并回复最后一次 ACK 后进入此状态。代表含义 “我确认了你的关闭请求。”(注此时主动方并没有立刻死亡而是要在这个状态硬熬 2MSL两倍的最大报文段生存时间通常约 1~4 分钟。目的是为了防止最后的 ACK 丢失导致对端无法正常关闭并且让网络中残留的迟到报文自然消亡防止串扰下一个新连接。)CLOSED被动方先入主动方后入被动方只要收到了最后的 ACK立刻进入 CLOSED彻底释放资源。主动方在熬完 TIME_WAIT 的倒计时后也进入 CLOSED彻底释放资源。代表含义 “连接已完全释放尘归尘土归土。”1.2.2 为什么是四次挥手三次行不行四次挥手以最小次数验证双方断开连接的共识。四次握手之所以可以合并为三次握手是由于服务端ACK 与SYN可以用捎带应答的方式同时发送。但四次挥手时主动方数据发送完毕主动断开连接但被动方可能还有数据需要发送需要等到处理完毕后才能断开连接。所以四次挥手时ACK 与FIN不能合并。1.2.3 主动方都close了还怎么读 发1谁在真正执行“读”和“发”用户态 vs 内核态当主动方发起关闭时系统分为两个层面应用层用户态 应用程序调用 close(fd) 后操作系统会回收该进程对该 fd 的句柄。此时如果代码试图再次调用 read(fd) 或 write(fd)系统会直接报错。应用程序层面已经彻底无法进行任何读写了。网络栈内核态 内核依然保留着该连接的控制块如 Linux 内核中的 struct sock。当被动方发回 ACK 或 FIN 报文时是内核的 TCP 协议栈在自动接收并由内核自动回复最后的 ACK。结论你的代码并没有在被动方发 FIN 时去调用 read()也没有在回复 ACK 时去调用 write() —— 这些纯属操作系统的内核行为不需要应用层代码干预。2close()与shutdown()的关键区别全关闭 vs 半关闭如果你观察到主动方在发起关闭后还能继续读取被动方发来的业务数据那主动方调用的其实不是close()而是shutdown()// 局部关闭intshutdown(intsockfd,inthow);close(fd)全关闭 读写通道同时关闭。应用程序既不能读也不能写。shutdown(fd, SHUT_WR)半关闭 仅关闭写通道。内核会向对端发送FIN报文告知“我不会再给你发数据了”。但读通道依然保持开启。主动方的应用程序依然可以继续调用read()把被动方还没发完的数据全部读完直到被动方也发送 FIN此时 read() 返回 0。3如果已经close()被动方还发数据会怎样如果主动方调用的是完全关闭close(fd)而被动方此时没有发送FIN而是尝试继续发送新的业务数据主动方的内核发现该 Socket 已经被用户态程序彻底丢弃已经没有进程会来读取数据了就会直接丢弃该数据并向被动方回复一个 RST重置报文强制终止连接。结论在标准的close()流程中应用程序已经完全退场后续的ACK和FIN交互全由内核一手包办而如果应用程序还能“读数据”那它用的一定是半关闭shutdown()。1.2.4 被动方不断开连接会怎么样被动方资源泄漏与服务宕机卡住状态CLOSE_WAIT等待应用层调用 close()。核心危害 Socket 句柄fd和内核缓冲区持续占用无法释放。高并发下会快速耗尽系统的文件描述符抛出 Too many open files 错误导致服务崩溃。主动方内核超时自动兜底卡住状态FIN_WAIT_2等待被动方的 FIN。解决机制 主动方不会被无限期卡死。Linux 内核会在超时后直接强行销毁该 Socket 并回收资源。1.2.5 主动方为什么要进入TIME_WAIT状态?被动方在close(fd)时即向主动方发送FIN请求主动方接收到后进入TIME_WAIT状态并向被动方发送确认应答。为什么主动断开连接的一方为什么要进入TIME_WAIT 状态而不是直接进入CLOSED呢1保证最后一个 ACK 能够可靠到达可靠终止连接在四次挥手中第 4 步是主动方发给被动方的最终确认报文ACK。由于 IP 网络不可靠这个 ACK 可能会在半路丢包。如果丢包了 被动方因为没收到 ACK会触发超时重传重新发送第 3 步的 FIN 报文。如果主动方没有 TIME_WAIT 而是直接关闭 当收到重传的 FIN 时主动方的内核已经完全删除了该 Socket 状态只能回应一个 RST重置报文。这会让被动方以为发生了网络异常或崩溃而不是优雅关闭。如果主动方处于 TIME_WAIT 状态 主动方依然保留着连接的信息。一旦收到重传的 FIN就可以重新补发一次 ACK协助被动方平滑关闭。2防止“旧连接的延迟报文”污染新连接TCP 数据包在路由网络传输时可能会因为网络拥堵而出现严重延迟。假设主动方关闭连接后系统立即释放端口并且马上用相同的四元组源 IP、源端口、目的 IP、目的端口建立了一个全新的 TCP 连接。如果此时上一条旧连接里延迟在半路上的老数据包突然送达了新连接的 TCP 协议栈无法区分这是“上一代遗孤”还是“这一代的新数据”。协议栈可能会直接接收这些旧数据导致应用层数据错乱甚至损坏。3那么TIME_WAIT 是多长时间2个MSL。2MSL 时间的意义1 个 MSL 是报文在网络中的最大存活时间。等待 2MSL来回最长时间可以确保上一次连接在两个方向上的所有残余报文都已在网络中彻底自然消失。之后重新建立连接时网络就是绝对干净的。当服务端主动断开连接此时就会处于TIME_WAIT状态。在此期间内核默认不允许任何新的Socket绑定这个还在“扫尾”的端口号就会导致bind error。解决办法设置SO_REUSERADDR在调用 bind() 之前加上下面的代码告诉内核允许分用处于TIME_WAIT状态的端口号。内核会根据TCP 序号字段区分老旧报文。intopt1;setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));一句话总结TIME_WAIT 是主动方为了给被动方“垫后”处理 ACK 丢失后的重传以及给网络“扫尾”清理残留数据包而付出的必要时间代价。2 ~ 滑动窗口发送方每发送一个报文接收方就要确认应答ACK发送方收到ACK 后继续发送报文如果这样那么效率就会很低。如果发送方一次发送多个报文就会吧多个等待的时间重叠在一起那么效率就比较高了。窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。我们将发送缓冲区抽象为一个一维数组滑动窗口就是其中一段数据区通过两个数组下标就可以定位起始位置 偏移量。窗口的大小由start和end指针维护。发送窗口中四个段的时候不需要等待任何ACK直接发送收到第一个ACK后滑动窗口向后移动继续发送第五个段的数据依次类推操作系统内核为了维护这个滑动窗口需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答只有确认应答过的数据才能从缓冲区删掉窗口越大则网络的吞吐率就越高。start之前的数据已经确认送达了即start 就是确认序号下次发送数据从start 位置开始发。发送数据的量由对方接收缓冲区决定则end start win接收缓冲区剩余空间大小。2.1 滑动窗口的移动滑动窗口中的数据一次性发送当收到第一个段的ACK后start 指针移动到确认序号处。1窗口能不能向左移不能确认序号是递增的滑动窗口只能向右移动。2能不能变大变小不变为零可以由接收方接收缓冲区剩余空间大小决定。2.2 丢包处理根据数据段在滑动窗口的位置丢包有三种情况最左侧报文丢了中间报文丢了最右侧报文丢了但由于滑动窗口会向右移动所以最终都会转化为最左侧丢包的情况。而丢包又分为报文丢了和发送方没有收到应答已最左侧丢包为例1应答丢了一次发送多个报文第一个或者某个报文的应答丢了但由于接收方收到了该报文所以只要发送方收到该报文之后的某个报文的应答。该应答的确认序号就代表之前的的数据全部收到了。例如1001的这个应答丢了但发送方收到2001的应答就说明2001之前的数据收到了即使1001这个报文丢了也没事。2报文丢了发送了多个报文但第二个报文1001~2000丢了那么接收方收到后续报文后就会一直回复1001即 start 指针不会向右移动。当发送方收到应答发现应答中确认序号为1001就会意识到1001~2000 这个报文可能丢了。但此时并不会立即重新发送该报文具体有两种重传机制快重传机制当发送方收到三个相同的应答1001后就会重发。由于后续应答的确认序号都是1001滑动窗口起始位置指向1001即1001~2000的数据一直在发送缓冲区保证丢包后数据能找到。当接收方收到1001~2000的报文后直接回复7001滑动窗口start 一次性移动到7001下次数据从7001处开始发。超时重传机制丢包后的兜底措施发送方在一定时间内没有收到应答自动重传1001~2000这个报文。3 ~ 流量控制接收端处理数据的速度是有限的。如果发送端发的太快导致接收端的缓冲区被打满这个时候如果发送端继续发送就会造成丢包继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力来决定发送端的发送速度。这个机制就叫做流量控制(FlowControl)。滑动窗可以用来实现流量控制。接收端将自己可以接收的缓冲区剩余空间大小放入 TCP 首部中的 “窗口大小” 字段通过ACK端通知发送端窗口大小字段越大说明网络的吞吐量越高接收端一旦发现自己的缓冲区快满了就会将窗口大小设置成一个更小的值通知给发送端发送端接受到这个窗口之后就会减慢自己的发送速度如果接收端缓冲区满了就会将窗口置为0这时发送方不再发送数据但是需要定期发送一个窗口探测数据段使接收端把窗口大小告诉发送端。4 ~ 拥塞控制通过滑动窗口就可以一次发送多个报文大大提高了通信效率。但通信还会收到网络状态的影响如果当前的网络状态非常拥堵再向网络中发送大量的报文会使得网络更加拥堵导致大面积丢包。所以TCP 引入了拥塞控制。拥塞控制网络拥塞时大面积丢包不能立即重发而是采用慢启动先发少量的数据探探路摸清当前的网络拥堵状态再决定按照多大的速度传输数据。此处引入一个概念称为拥塞窗口发送开始的时候, 定义拥塞窗口大小为1每次收到一个ACK应答拥塞窗口加11 - 2 - 4 - 8即拥塞窗口大小以2的指数增长每次发送数据包的时候将拥塞窗口和接收端主机反馈的窗口大小做比较取较小的值作为实际发送的窗口即滑动窗口大小 minwin 拥塞窗口像上面这样的拥塞窗口增长速度是指数级别的。 慢启动 只是指初使时慢, 但是增长速度非常快为了不增长的那么快因此不能使拥塞窗口单纯的加倍引入一个叫做慢启动的阈值当拥塞窗口超过这个阈值的时候不再按照指数方式增长而是按照线性方式增长继续试探网络拥塞状况。当TCP开始启动的时候慢启动阈值等于窗口最大值在再次遇到网络拥塞的时候慢启动阈值会变成原来的一半同时拥塞窗口置回1慢启动少量的丢包我们仅仅是触发超时重传大量的丢包我们就认为网络拥塞当TCP通信开始后网络吞吐量会逐渐上升随着网络发生拥堵吞吐量会立刻下降。拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方但是又要避免给网络造成太大压力的折中方案。5 ~ TCP 收尾5.1 延迟应答如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.假设接收端缓冲区为1M 一次收到了500K的数据如果立刻应答返回的窗口就是500K但实际上可能处理端处理的速度很快10ms之内就把500K数据从缓冲区消费掉了在这种情况下接收端处理还远没有达到自己的极限即使窗口再放大一些也能处理过来如果接收端稍微等一会再应答比如等待200ms再应答那么这个时候返回的窗口大小就是1M一定要记得窗口越大网络吞吐量就越大传输效率就越高我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。那么所有的包都可以延迟应答么?肯定也不是数量限制每隔N个包就应答一次时间限制超过最大延迟时间就应答一次具体的数量和超时时间依操作系统不同也有差异一般N取2超时时间取200ms。5.2 粘包问题首先要明确粘包问题中的 “包”是指的应用层的数据包。站在传输层的角度TCP是一个一个报文过来的。按照序号排好序放在缓冲区中。站在应用层的角度看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据就不知道从哪个部分开始到哪个部分是一个完整的应用层数据包。那么如何避免粘包问题呢? 归根结底就是一句话明确两个包之间的边界。对于定长的包保证每次都按固定大小读取即可例如上面的Request结构是固定大小的那么就从缓冲区从头开始按sizeof(Request)依次读取即可对于变长的包可以在包头的位置约定一个包总长度的字段从而就知道了包的结束位置对于变长的包还可以在包和包之间使用明确的分隔符应用层协议是程序猿自己来定的只要保证分隔符不和正文冲突即可。5.3 TCP 异常情况进程终止OS自动清理文件描述符TCP 协议栈会自动向对端发送FIN包正常四次挥手。机器重启重启之前OS会自动关闭所有进程正常四次挥手。机器掉电/网线断开接收端认为连接还在 一旦接收端有写入操作接收端发现连接已经不在了就会进行reset。即使没有写入操作TCP自己也内置了一个保活定时器会定期询问对方是否还在。如果对方不在也会把连接释放。另外应用层的某些协议也有一些这样的检测机制。例如HTTP长连接中也会定期检测对方的状态。例如在QQ断线之后也会定期尝试重新连接。5.4 UDP 和TCP 区别UDP无连接面向数据报不可靠TCP有连接面向字节流可靠但并不能说UDP 不可靠就是其缺点这是UDP 设计的自身特点。UDP 和TCP 都有各自适用的场景如果某个场景更适用UDP 但又需要一定的可靠性那么我们就可以将TCP 的各种保证可靠性的机制引入到应用层引入序列号保证数据顺序引入确认应答保证对端收到报文引入超时重传保证不丢包…5.5 底层TCP 协议栈数据结构一张图看懂从用户空间的进程如何通过文件描述符最终关联到内核中具体的 TCP 协议栈数据结构。structsocket--BSD Socket 层对用户态暴露的通用接口 └─structsock--通用网络层套接字锁、接收/发送队列、引用计数 └─structinet_sock--INET 协议族套接字IP 协议相关源/目的 IP、端口、TTL └─structinet_connection_sock--面向连接的套接字握手、重传定时器、ACK 状态机 └─structtcp_sock--TCP 协议专属滑动窗口、SACK、SEQ 序号、拥塞控制①极致的代码复用按能力划分抽象层struct sock通用套接字抽出所有协议的共性。无论是 TCP、UDP还是 UNIX 本地套接字都需要收发队列、锁机制、引用计数这些全在 sock 里。struct inet_sockIP 协议族套接字抽出所有基于 IP 协议的共性。UDP 和 TCP 都需要源 IP、目的 IP、源端口、目的端口、TTL 等这些放在 inet_sock 中UDP 直接继承它即可。struct inet_connection_sock面向连接的套接字抽出所有“面向连接”协议的共性。TCP、SCTP、DCCP 都需要三次握手、连接队列全连接/半连接、重传定时器、ACK 状态机这些统一交由 icsk 处理。struct tcp_sockTCP 专属套接字只留 TCP 特有的属性。如滑动窗口大小snd_wnd、选择性确认SACK、拥塞控制算法参数cwnd、滑动序号等。②极度节省内存按需分配在并发量极高的服务器上系统内核中可能维持着上百万个 Socket如果不分层只设计一个包含所有功能的巨大全局结构体那么每一个简单的 UDP Socket 也要被迫背负 TCP 庞大的滑动窗口和拥塞控制字段。通过分层继承UDP 创建 Socket 时只需分配struct udp_sock的内存只有 TCP 才会分配全量的struct tcp_sock内存。在数百万连接的规模下这种设计节省了极为客观的物理内存。③零开销的“类型转换”与高性能多态在 C 等面向对象语言中多态通常依赖虚函数表vtable和额外的指针开销。而在 Linux 内核中子类结构体的第一个字段永远是父类结构体。这种内存布局保证了父类与子类的结构体首地址完全重合偏移量 Offset 为 0。内核在处理数据包时可以极其优雅且零性能损耗地把struct tcp_sock*强转为struct sock*传给通用逻辑需要使用 TCP 特性时再通过container_of宏或类型强转轻松变回struct tcp_sock*。6 总结到这里TCP 的学习就结束了因为TCP 要保证可靠性和高性能所以比UDP 要复杂得多。可靠性校验和序列号连接管理确认应答超时重传流量控制拥塞控制提高性能滑动窗口快重传延迟应答捎带应答