运输层提供的服务
运输层位于应用层和网络层之间, 旨在为应用层提供直接的数据通信服务, 并隐藏传输过程的实现细节. 目前广泛使用的运输层协议包括尽力交付的用户数据报协议(User Datagram Protocol, UDP), 以及提供可靠数据传输的传输控制协议(Transmission Control Protocol, TCP).
网络层负责将数据分组从一个主机传递到另一个主机, 而运输层则更专注于端口之间的通信. 运输层提供的是端口与端口之间的逻辑通信链路.
运输层协议本质是作用在主机上, 实际执行在操作系统内核态. 在发送主机中, 运输层协议负责接收进程(端口)的请求, 按照一定规则处理后将其提交给网络层. 目标主机接收到网络层发送的数据分组后, 目标主机的运输层协议负责将数据分组按照与发送主机约定好的规则处理后, 传递给对应的进程(端口).
网络层的数据通信协议都是不可靠的, 例如广泛使用的IP协议, 既不确保数据分组是否能够交付, 也无法确定数据分组的交付顺序, 更不保证已交付数据分组的完整性.
此外, 传输层还可以在主机层面(一般是在OS内核中)实现对网络层不可靠传输模式的修复. TCP和UDP都通过在报文首部提供差错检查字段来对接收的报文进行完整性校验. 传输层协议必须支持进程到进程的数据交付以及差错检查, 这是最低限度的要求, 事实上UDP也仅仅满足这两点. 而TCP则在此基础上提供了可靠数据传输以及拥塞控制, 但这些复杂的功能也显著提升了TCP的传输成本.
运输层和网络层分别负责不同的功能。在TCP/IP协议族中,网络层IP提供的是一种不可靠的服务。也就是说,它只是尽可能快地把分组从源结点送到目的结点,但是并不提供任何可靠性保证。而另一方面,TCP在不可靠的IP层上提供了一个可靠的运输层。为了提供这种可靠的服务,TCP采用了超时重传、发送和接收端到端的确认分组等机制。
复用与分用
尽力交付
由RFC 768定义的用户数据报协议(User Datagram Protocol, UDP)是最基础的传输层协议模型. 除了具备简单的数据校验功能之外, UDP仅提供了用于在网络层与正确的应用进程之间传递数据的复用与分解服务. 从应用角度看, UDP的功能几乎完全等价于网络层的网际协议(Internet Protocol, IP):
- UDP从应用进程得到数据, 附加上用于多路复用与分解服务的源和目的端口号字段以及少量其他信息, 然后就将形成的报文段交给网络
- 该报文在网络层添加IP首部包装之后就会被发送到目标主机
- 到达目标主机后, UDP对报文进行分用, 转发给占用目标端口的进程
应用层的DNS, DHCP, SNMP, RIP, NTP等协议都使用UDP传输数据. 以DNS查询为例: 域名的查询者会向DNS服务器发送UDP报文, DNS服务器处理接收到的报文并返回DNS查询结果到原始主机. 如果原始主机没有收到DNS服务器的回复报文, 则说明请求报文或响应报文在传输过程中丢失, 这需要在应用层面进行处理. 可以重新向该服务器或其他服务器发送查询请求, 也可以设置超时时间, 超过阈值还没有得到回复就按照DNS查询失败进行错误处理.
UDP无连接无状态. 在进行端口级别的数据通信时不需要事先建立连接;协议本身也不会记录所传递报文的状态信息, 即UDP不会记录谁给谁发过报文. UDP的特点与应用场景描述如下:
- 能够更精细地控制分组的发送时间: 在要求低延迟的视频直播、语音通信、游戏加速器等应用场景中, 使用带有拥塞控制机制的TCP作为传输层协议, 将在网络环境较差的情况下导致难以接受的延迟, 因为TCP只负责可靠交付, 而不在乎实现可靠交付的代价. 在能够容忍一定分组丢失的情况下(视频流只要I帧不丢失就可以成功解码), 使用UDP可以提供更低的延迟并占用更少的资源. 此外, 在UDP的基础上可以实现更加个性化的可靠连接, 例如传输层的QUIC协议就是在UDP的基础上实现了TCP的拥塞控制、丢失重传, 并且针对HTTP协议做了特殊的握手优化、连接复用. 总的来讲, UDP更灵活, TCP更稳定.
- 不用建立连接: TCP需要通过三次握手建立连接, 这会产生2个RTT的额外时延;更常见的HTTPS和HTTP2不仅需要TCP握手, 还需要TLS握手, 这就会产生3个RTT时延;在大量短连接的场景下, 由于频繁建立连接而产生的资源消耗以及延迟是不可忽视的. 例如: DNS协议为了降低延迟而选择不可靠的UDP协议来传输报文. 尽管HTTP在设计之初为了保证承载的超文本能够被可靠地传输而选择了TCP, 但在最新的HTTP3标准中却采用建立在UDP基础上的QUIC作为传输层协议.
- 无连接状态: TCP的拥塞控制、流量控制、差错恢复等功能都是在OS内核中实现的, 这就要求内核额外维护每个TCP连接的状态, 例如: 分组到达的顺序、编号、确认号等, 这显然会带来额外的开销. 而UDP是尽力交付协议, 不需要保证可靠连接, 因此不需要维护连接状态.
- 分组首部开销小: UDP首部仅需要8字节, 而TCP则需要20个字节. UDP的首部只有4个字段: 源端口号、目的端口号、长度、校验和, 每个字段占2个字节. 在RFC 1071中描述了UDP校验和的实现细节. 值得注意的是, IP和ICMP以及IGMP的校验和只针对首部, 而TCP和UDP的校验和却是由首部以及所承载的数据共同计算而来.
值得注意的是, TCP/IP协议族使用(源端口, 目标端口, 源IP, 目的IP, 协议类型)来判断分组是否被正确地传输到指定主机的指定端口中, 因此在传输层计算校验和时, 除了计算所承载的数据之外, 还需要额外计算这5个字段. 但由于TCP和UDP报文首部只包含源端口与目标端口, 在计算校验和时就需要使用IP首部中的源IP、目的IP、协议类型字段, 最终为了方便计算而额外定义了存储这些字段的伪首部(pseudo header).
然而, 伪首部最初并不是为了计算校验和而设计的. 根据TCP规范早期制定者, MIT教授David P. Reed在邮件讨论中所说, 伪首部数据结构最初是为了对TCP/IP协议族中的关键信息进行加密而设计的, 但后来由于效果不好而取消了这一方案, 但伪首部的结构得以保留, 并最终被用于计算校验和.
可靠的数据传输
由于网络层是不可靠的, 可靠数据传输是在传输层或应用层对发送或接收数据进行检测与干预, 所触发的系统调用流程如下:
- 发送数据时: 应用层通过
rdt_send()系统调用将数据转移到传输层, 传输层通过udt_send()将数据转移到网路层. 其中rdt和udt分别表示可靠传输和不可靠传输. - 接收数据时: 传输层通过
rdt_rcv()从网路层获取数据, 然后通过deliver_data()将经过可靠处理的数据传递给应用层.
网络层的通信信道不可靠, 因此需要在运输层做可靠传输. 如果网路层是完全可靠的, 则传输模型就可以简化为rdt1.0:
- 发送端: 简化为单状态的有限状态机(Finite-State Machine, FSM). 状态转移的触发条件是应用层通过
rdt_send()系统调用将数据转移到传输层. 状态转移过程中, 传输层需要将应用层传来的数据打包, 然后通过udt_send()将数据转移到网路层. - 接收端: 同样简化为单状态的FSM. 状态转移的触发条件是网络层向传输层
rdt_rcv()所监听的缓冲区中写入数据. 状态转移过程中, 传输层需要将网路层传来的数据解包, 然后通过deliver_data()将数据递交给应用层.
rdt1.0模型假定发送发和接收方的速率一致, 因此不需要拥塞控制. 此外, 由于假设信道完全可靠, 传输层也不需要额外记录连接状态信息, 更不需要进行比特差错校验.
然而在实际传输过程中, 经由物理设备传输的分组数据可能由于信号干扰等外部因素导致出现比特差错. 可靠传输协议不能像UDP一样直接丢弃出错的数据分组, 而需要让发送方重新传输, 这就是TCP中实现的自动重传请求(Automatic Repeat reQuest, ARQ)协议.
ARQ的主要工作在于: 差错检测、接收方反馈、分组重传. 现在假设所有的数据分组能够按照发送顺序到达接收端, 那么在rdt1.0的基础上添加ARQ协议将其升级为rdt2.0:
- 发送端: 带有两个状态的FSM. 相比于1.0版本, 在发送数据后会等待接收端回传发送结果, 如果收到ACK就回到初始状态, 等待新数据发送;如果收到NAK或等待超时, 就在当前状态重新发送当前分组. 这就是最简单的停止等待ARQ协议.
- 接收端: 仍然是单状态FSM. 但是在状态转移过程中会额外添加数据校验以及返回值, 如果数据受损就返回NAK, 让发送端重发, 数据完好就反馈ACK.
rdt2.0协议的致命缺陷是没有考虑ACK和NAK是否能被发送端成功接收, 即发送端是否能及时获得反馈结果. 此外, ACK和NAK的数据完整性也需要纳入考量.
TCP在接收到受损的ACK或NAK分组时都会重新发送当前分组, 即在信道中引入冗余分组(duplicate packet). 但冗余分组的问题在于, 接收方无法判断这个分组是新的分组还是重发的分组, 因此需要对分组标注序号(sequence number).
真实网络环境除了会发生比特受损外, 还有可能直接丢失分组(例如: 路由器的发送队列满了). 通过给分组标注序号可以很容易地检测出当前传输过程是否发生丢包, 丢包后按照ARQ协议重发分组即可. ARQ协议不仅能解决由比特差错导致的分组错误, 也能修复由丢包导致的分组丢失.
更复杂的ARQ协议
使用停止等待ARQ协议的rdt2.0版本存在严重的性能问题, 信道的大部分时间被用于无意义的等待. 通过采用流水线方式控制分组发送与接收可以显著提升信道利用率.
常见的ARQ协议包括停止等待、后退N帧、选择重传, 它们本质上都可以归纳为滑动窗口算法:
- 停止等待(Stop and Wait, SW)协议:发送窗口是1, 接收窗口是1. 由于发送窗口被限制, 无法以流水线方式发送分组, 因此效率较差, 但是实现最简单.
- 后退N帧(Go-Back-N, GBN)协议:发送窗口是N, 接收窗口是1. 值得注意的是, GBN会丢弃所有不按顺序到达的分组, 尽管这样做很浪费, 但是配合累积确认机制可以保证向发送方返回编号为n的ACK时, 之前的所有分组都已经被正确接收. 例如: 主机A向主机B发送两个序号分别为92和100的分组, 在主机A的超时间隔内, 92的ACK丢失而100的ACK正确到达, 那么主机A就不会重传任何分组, 因为100的ACK正确到达说明之前的所有分组都已经被正确接收.
- 选择重传(Selective Repeat, SR)协议:发送窗口是N, 接受窗口也是N. 如果在规定时间内仍未收到带有某个序号的ACK, 就重发该序号分组. 如果收到序号在窗口内的ACK, 则发送方将该分组标记为已接收, 如果该分组序号恰好为窗口内最小, 则向右移动窗口直到左边界遇到第一个尚未被标记为已接收的分组. 窗口尺寸N必须小于等于序号空间尺寸的一半, 从而防止混淆重发内容与新发内容. TCP选择重传方式传递数据时, 双方会采用选择确认(selective acknowledgment)的方式标识已接收的数据分组
面向连接的传输控制协议
TCP发送和接收都经过缓冲区. 应用进程通过套接字将数据写入TCP缓冲区, 然后TCP会在其方便的时候从缓冲区中取出一块数据并传递给网路层.
TCP可以从缓冲区中取出并放入报文段中的数据数量受限于最大报文段长度(Maximum Segment Size, MSS). MSS通常根据本地发送主机的最大链路层帧长度, 即最大传输单元(Maximum Transmission Unit, MTU)来设置. MTU一般为1500, 减去默认的TCP/IP首部长度40, MSS的典型值为1460字节.
TCP报文段结构:
- 源端口号、目的端口号
- 序号. TCP将数据看成无结构、有序的字节流. 因此报文段的序号取决于其首个字节在整条连接流中的编号. 假设MSS为1000字节, 那么第一个报文段的序号是0, 第二个是1000, 第三个是2000, 以此类推.
- 确认号
- 首部长度(TCP报文段首部可以包含数量不固定的选项, 因此需要显式指定首部长度, 如果没有任何选项则首部长度的值为20个字节)、保留段、报文段类型(ACK、SYN、FIN等)、接收窗口(用于流量控制, 指示接收方愿意接受的字节数量)
- 因特网校验和、紧急数据指针
- 长度不固定的选项
- 数据
为什么学这个? 因为理解原理能更好地解决问题, 详见真实案例:
大约是15年前, 那时候kernel版本还是2.4, windows服务器还是windows nt 4. 我作为某一厂家的支持人员到了一电信客户的现场解决问题, 其中有一个问题是客户的suse linux服务器和windows nt服务器总是无法建立网络连接, 而windows nt和windows nt之间则可以. 记得当时有个intel的大牛在现场, 他先用tcpdump抓了windows nt和linux服务器之间的通讯包, 把tcp header头解析开, 对比windows nt之间的通讯包的tcp header, 逐字段的分析, 结果发现当时suse linux里某一保留字段被置为1, 而windows里则为0, 从而导致windows和linux无法连接. 然后他通过rfc定位这一字段的含义, 在系统/proc/sys/net里找到对应的标志位, 把它置为0, 然后问题就解决了.
差错恢复
TCP会定期对分组的往返时间进行采样, 记录为${RTT}_{Sample}$. 然而${RTT}_{Sample}$会显著受到网络环境的影响, 因此其波动范围比较大. 为了更好的表述整个TCP流中的往返时间, 可以对${RTT}_{Sample}$进行指数加权移动平均, 计算出预估往返时间${RTT}_{Estimated}$:
\[{RTT}_{Estimated} = (1 - \alpha) \cdot {RTT}_{Estimated} + \alpha \cdot {RTT}_{Sample}\]${RTT}_{Dev}$用于估算${RTT}_{Sample}$偏离${RTT}_{Estimated}$的程度, $\beta$一般取值0.25:
\[{RTT}_{Dev} = (1 - \beta) \cdot {RTT}_{Dev} + \beta \cdot |{RTT}_{Sample} - {RTT}_{Estimated}|\]根据${RTT}_{Estimated}$和${RTT}_{Dev}$动态设置TCP的超时重传时间间隔$TimeoutInterval$:
\[TimeoutInterval = {RTT}_{Estimated} + 4 \cdot {RTT}_{Dev}\]RFC 6298中推荐的初始超时间隔为1秒. 每当出现超时, $TimeoutInterval$的值将暂时加倍, 以免即将被确认的后继报文段过早出现超时. 当TCP重新搜集${RTT}_{Sample}$后, $TimeoutInterval$的值将会被更新.
超时重传可能会显著提升端到端传输的时延. 为此, RFC 5681中规定了接收方生成冗余ACK的策略. 当接收方发现接收的数据分组存在序号间隔, 就会向发送方发送已接收的、连续的、最小编号ACK, 一旦发送方接收到3个冗余ACK就会执行快速重传(fast retransmit)
TCP所采用的ARQ策略混合了GBN与SR的优势. 典型的SR协议要求发送方维护当前窗口内已经被接收的分组, 在恰当的时候(例如: 超时后)将所有未确认的分组重新发送给接收方;而TCP要求发送方仅仅需要维护最小确认序号SendBase以及下一个要发送的分组序号NextSeqNum, 这类似于GBN协议. 但GBN在重传时会将SendBase及之后的所有分组都重新发送, 而TCP则只会发送序号最小的分组. 当接收方收到空缺分组后会继续返回已接收的连续分组的右边界, 这样能够让发送端重发所有的空缺分组, 这又类似于SR协议.
流量控制
TCP连接的每一侧都有缓冲区, 应用层传来的数据不会被立即发送, 因为内核可能在处理其他进程的请求;同样的, 接收方的缓冲区可以暂时存储网络层提交的数据分组, 在内核空闲时才会取出数据并转交给对应的应用.
但缓冲区的大小受制于主机的内存容量, 如果服务器特别繁忙, 大量客户端都向服务器发送分组, 服务器的缓冲区就会溢出, 因此需要对发送方的发送频率进行控制, 这就是TCP提供的流量控制服务(flow-control service)的应用场景.
尽管名称与之相类似的拥塞控制(congestion control)也是通过向发送方发送消息来抑制发送速率, 但拥塞控制与流量控制的应用场景有显著区别. 拥塞控制更关心的是到达接收方的链路是否拥挤, 会不会因为路由器的等待队列满了而发生丢包. 而流量控制关心的是接收方主机是否会发生缓存溢出. TCP同时启用了这两个服务.
全双工TCP通过让发送方与接收方分别维护名为接收窗口(receive window)的变量来记录对方缓冲区剩余的空间容量, 从而实现流量控制. 假设主机A通过一条TCP连接向主机B发送大文件, 主机B为TCP连接分配了大小为$B_{recv}$的缓冲区. 令${Read}_{last}$表示主机B上的应用从缓冲区读出的数据流的最后一个字节编号, ${Receive}_{last}$表示已经到达主机B并被放入缓冲区的最新的字节编号. 只要满足以下等式就不会发生缓冲区溢出:
\[{Receive}_{last} - {Read}_{last} \leq B_{recv}\]主机B当前可用的接收窗口大小$B_{window}$可以表示为:
\[B_{window} = B_{recv} - ({Receive}_{last} - {Read}_{last})\]$B_{window}$的值会在主机B中更新, 并随着ACK发送到主机A上. 主机A需要额外维护$Send_{last}$和$Acked_{last}$变量, 分别用于记录已经发送的和已经被主机B成功接收的最后一个字节编号. 为了实现流量控制, 主机A需要保证以下等式始终成立:
\[Send_{last} - Acked_{last} \leq B_{window}\]当主机B的缓冲区满时, $B_{window}$的值会变为0, 这会阻塞主机A的发送请求. 更严重的是, 由于TCP的ACK发送很大程度上依赖于正确到达的分组数据, 当主机A因$B_{window}$的值为0而持续阻塞时, 主机B由于无法收到新分组而不会给主机A发送新的ACK, 即使后来主机B的缓冲区空闲, 也无法有效告知主机A, 导致$B_{window}$的值无法更新, 从而陷入死循环. RFC 5681中规定, 当主机B的接受窗口为0时, 主机A需要继续发送只有一个字节的报文段, 主机B会持续接受这些报文段并返回新的$B_{window}$, 当该值大于0时, 主机A就会重新正常发送分组.
UDP不提供流量控制服务, 当大量数据报发送到UDP接收端时, 如果主机的读取速度跟不上接收速度就会导致缓冲区溢出. 而在直播型应用中, 主播的网络环境往往是不可靠的, 数据分组从主机到流媒体服务器之间可能经过多重路由转发, 其中某条链路拥塞可能会导致分组堆积. 在某一时刻, 这些堆积的UDP报文将全部涌向服务器, 可能导致宕机. 此外, 流媒体服务器还需要向观看直播的用户分发稳定的视频流, 因此希望在UDP的基础上, 实现应用级别的流量控制, 具体要实现以下功能:
- 控制分组传输的平均速率: 进行帧率恒定的视频编码和解码需要提供稳定的数据流. 客户端调整清晰度的本质就是调整视频码率, 因此流量控制应当提供调节分组传输平均速率的功能.
- 限制分组传输的峰值速率: 由于UDP尽力交付的性质, 流媒体服务器可能在某个瞬间接收到大量数据分组. 流量控制系统应当能对流量进行整形和削峰, 分散短时间内的超负荷数据压力.
- 能够弹性处理短暂的突发流量: 在直播秒开问题中, 需要服务器在建立连接后立即向客户端传递大量数据分组, 降低播放等待时间. 因此流量控制系统也应当在可接受的峰值范围内, 具备突发流量的快速传输功能.
早期的流量控制系统采用漏桶算法. 令漏桶$b$为能够以恒定速率$v_{out}$输出分组, 且可以容纳$c$个分组的缓冲区. 假设数据分组以动态速率$v_{in}$写入漏桶$b$中. 显然, 通过调节$v_{out}$就能够实现控制平均传输速率的功能, 而缓冲区(漏桶)的存在, 也让对流量峰值的限制成为了可能, 整个系统的流量峰值就是$v_{out}$, 过大的$v_{in}$只会导致缓冲区满而丢弃分组. 但漏桶算法的问题在于无法有效地处理突发流量. 短时间内大量的数据分组要么被存储在漏桶中, 要么因为容量已满而被丢弃, 而输出速率却不会也不应该随着漏桶容量的变化而调整, 换句话说, 由于整个系统的峰值速率就是平局速率, 要想实现对突发流量的快速传输就需要手动调整系统的平均速率, 这在实际应用中是不可接受的.
而作为漏桶算法改进的令牌桶算法则实现了对突发流量的处理. 令牌桶是存储令牌的缓冲区, 令牌以速率$v_{token}$持续生成, 并且限定了令牌能够被取出的最高速率$v_{max}$. 分组数据在经过流量控制系统时会先尝试从令牌桶中取出令牌, 如果没有令牌则丢弃该分组, 否则交给应用去处理. 系统的平均传输速率就是令牌的生产速率$v_{token}$, 而系统的峰值速率就是令牌的最大漏出速率$v_{max}$. 此外, 只要令牌桶中存有足量的令牌, 就可以处理特定数量的突发数据分组, 并且处理速率为系统的峰值速率$v_{max}$.
连接管理
TCP需要提供全双工的可靠数据传输, 因此在传输数据之前需要先确认连接双方都能够成功地将数据发送到对方主机, 并且能正确地接收到对方主机发来的数据分组. TCP连接的建立过程如下:
- 第一步: 客户端向服务端发送SYN请求, 即报文首部的SYN标志位被置为1. 此外, 客户端会随机生成序号$client\_isn$并将其放入报文首部. 客户端此时不会申请任何资源, 但连接状态会变为SYN_SENT, 表示构建当前连接的SYN已经发送.
- 第二步: 服务端处于LISTEN状态的某个端口在接收到客户端发来的SYN后, 会为该TCP连接分配缓存和变量, 将该连接在服务端的状态修改为SYN_RECV, 最后将该socket放入半连接队列(sync queue). 半连接队列可容纳的连接数量为$\max(64, backlog)$. 服务端返回的报文首部包含大量信息, 首先SYN比特被置为1; 其次, 首部确认号的值被置为$client\_isn + 1$;最后, 服务器选择自己的初始序号$server\_isn$. 这个报文段既有SYN的性质, 又有ACK的性质, 因此被称为SYNACK报文段.
- 第三步: 客户端收到SYNACK后会为该连接分配缓冲区和变量, 然后该连接在客户端变为ESTABLISHED. 客户端还需要给服务端发送ACK来表明自己已经完成了TCP连接所需的全部工作, 该报文首部中确认号的值为$server\_isn + 1$, 并且可以在报文体中携带数据. 服务端在接收到该ACK后也会变为ESTABLISHED状态, 并将尚未被应用通过accept系统调用取走的socket放入全连接队列(accept queue).
服务器在第二步就为TCP连接分配缓存和变量可以保证客户端能够在第三步发送ACK时顺便能携带分组数据, 可以节约半个RTT. 但是这种便利的方法却存在安全漏洞, 即SYN洪泛攻击(SYN Flood Attack). 当大量恶意用户仅仅只发送SYN分组时, 服务器也需要为这些请求分配内存资源以及连接句柄, 这是典型的拒绝服务攻击(DoS). CloudFlare提供了以下几种解决办法:
- 设置SYN超时时间: SYN洪泛攻击的本质就是通过将大量已经申请资源的socket放入半连接队列来消耗服务器的资源. 只要保证新的连接请求可以放入队列就可以抵消SYN洪泛攻击的后果, 因此可以给队列中的元素设置过期时间, 超时就出队. 尽管服务器默认设置为在大约一分钟之后主动回收这些无效的SYN请求, 但是如果请求数量大到能够占满内存, 这种简单的防御方式将毫无作用.
- 半连接socket重用: 相比于超时后直接删除, 让新的TCP连接请求重用队列中的半连接socket不失为一种可选的方案. 触发重用的条件为队列满或元素超时.
- SYN cookies: RFC 4987给出的解决方法, 目前常用的OS内核都支持这种防御手段. 该方法的本质是将服务端的资源申请阶段放到第三步进行, 也就是说, 在服务端不会存在半连接状态的socket, 而是直接在established状态下为成功连接的TCP创建资源. 传统方式需要在第二步申请资源, 其中包括记录了SYNACK序号$seq$的变量, 在第三步接受客户端的ACK时需要判断序号是否等于$seq+1$. 如果不在第二步申请资源, 服务端就无法通过上述方式判断客户端传来的ACK序号是否合法, 因此需要用TCP连接双方都有的信息来做验证, 即SYN cookies. cookie在SYN到达后由服务端计算, 然后随着SYNACK传递给客户端, 客户端解码cookie得到值并加1后传递给服务端进行验证. 即使请求发起者在发送SYN之后就放弃连接也不会对接受者造成伤害, 因为接受者仅仅通过一次hash计算出SYN cookies, 而没有申请额外的资源. 常见的cookie计算方式如下:
释放连接的过程要稍微复杂. 假设客户端主动断开连接, 那么TCP连接释放的过程如下:
- 第一步: 客户端向服务端发送首部FIN比特置为1的报文, 表明要主动断开连接, 该连接在客户端进入FIN_WAIT_1状态.
- 第二步: 服务端接收到客户端发来的FIN报文, 立即回复一个ACK, 并进入CLOSE_WAIT状态. 而接收到该ACK的客户端会进入FIN_WAIT_2状态, 不再主动发送数据分组, 只会被动地回复ACK以及重传丢失的分组. 服务端在该阶段会等待客户端已经发送的数据完全被接受完毕, 同时也可以给客户端发送分组来完成收尾工作, 服务端自身也可以在该状态下进行数据记录等工作, 这部分的逻辑取决于应用层的设置.
- 第三步: 当服务端处理完与该连接相关的业务逻辑后, 就会给客户端发送FIN报文告知其服务端已准备断开. 服务端会进入LAST_ACK状态, 等待客户端发送最后一个ACK.
- 第四步: 客户端接收到服务端发来的FIN报文后立即回复一个ACK并进入TIME_WAIT状态, 该状态需要至少持续两个TTL, 以防止客户端最后传递的ACK丢失导致服务端重发FIN报文. TIME_WAIT状态结束后会进入CLOSED状态, 释放所有资源. 与此同时, 接收到最后一个ACK的服务端也会从LAST_ACK状态进入CLOSED状态, 释放所有资源.
由于主动释放连接的一方最终会进入TIME_WAIT状态, 为可能发生的ACK丢失保留两个报文段最大生存时间(Maximum Segment Lifetime, MSL)的socket资源(约1分钟左右), 因此建议尽量让客户端主动断开连接. 服务端没有接收到最后的ACK时可能会向客户端重新发送数据与FIN报文, 如果恰好客户端发生故障重启并重新与服务器建立连接, 这些在路上的报文会被当作第二次连接过程中传递的数据, 可能会造成意想不到的后果. 为了防止这种情况, RFC 793指出TCP在重启动后的MSL秒内不能建立任何连接. 这就称为平静时间(quiet time). 只有极少的实现版遵守这一原则, 因为大多数主机重启动的时间都比MSL秒要长.
在需要维护大量连接的服务端, 如果存在规模庞大的处于TIME_WAIT状态的socket, 则会产生不必要的内存和CPU性能损耗, 端口号也存在用尽的风险 . 此外, 内核需要为每个socket维护文件描述符, 即使处于TIME_WAIT状态也需要占用文件描述符, 文件描述符用尽会导致操作系统无法正常使用. 如果连接双方同时发起或关闭连接将导致更为复杂的情况, 具体参见TCP连接的同时打开与关闭.
- 同时打开: 尽管发生的概率很小, 但TCP仍然对同时打开连接的情况做了优化, 双方同时打开的连接最终会经历四次握手而变为一条TCP连接, 并且双方的状态变化都是从SYN_SENT到SYN_RCVD再到ESTABLISHED.
- 同时关闭: 当连接双方几乎同时发送FIN报文时就会导致同时关闭. 双方的状态变化都是从FIN_WAIT_1到CLOSING再到TIME_WAIT.
TCP根据报文中的源IP和目的端口号来进行分用, 从而将缓冲区中的数据分组转发给正确的应用. 如果在建立连接时或由于某些特殊情况导致TCP报文首部的目的端口指向了接收方未开放的端口, 那么接收方会返回一个RST置为1的特殊分组, 提示发送方的请求不合法. 而对UDP来说, 发送到主机未开放端口的报文会导致主机返回一个特殊的ICMP数据报.
端口扫描工具nmap就是根据上述原理获取目标机器上目前开放的端口号. nmap会尝试与目标主机上的所有端口建立连接, 如果端口返回SYNACK则说明端口开放, 如果返回RST则说明端口关闭或无法访问, 如果没有任何反应则大概率已经被防火墙拦截.
此外, RST报文也可以被用于主动终止连接. 在已经建立TCP连接的双方通信的某一方由于断电断网运行时错误等因素已经异常断开, 另一方会全然不知, 这种连接被称为半打开连接. 在发生运行时错误时主动发送RST报文可以告知另一方当前主机出现了问题, 请求重置连接. 而断电断网则更难处理, 为了防止大量半打开连接浪费服务器资源, 需要用keepalive选项进行TCP保活.
拥塞控制
与拥塞控制不同的是, 流量控制是接收方根据自己的处理能力, 主动与发送方协商数据传输速率的底层服务. 而由于网络层无法检测出当前信道是否出现了拥塞(有些路由器会主动向发送方告知队列已满), 需要在传输层或应用层通过对丢包与时延状况进行分析才能估计出是由于信道拥塞而导致数据传输不通畅, 因此拥塞控制更倾向于被动地根据网络状况协商数据传输速率.
TCP采用的是端到端的拥塞控制方式, 因此丢包(超时或3次冗余确认)会被认为是网络拥塞的迹象, TCP会适当减小窗口长度. 令拥塞窗口的容量为$C_{window}$, 以及拥塞阈值为$C_{threshold}$:
-
慢启动: 拥塞窗口的初始值为1个报文段, 每当有1个非冗余ACK被成功回传, 拥塞窗口的容量就加1. 如果没有发生丢包, 拥塞窗口的容量就将以指数级别增长. 当发生丢包或检测到3个冗余ACK时, 拥塞窗口的容量将减半, 并进入拥塞避免模式.
-
拥塞避免: 在拥塞避免阶段, 只有当前发送窗口中的所有报文都成功被服务器接受时(例如: 发送4个报文并得到4个非冗余ACK), 拥塞窗口的容量才会加1. 这使得$C_{window}$能够以更平稳的方式增长, 一旦发生丢包就将$C_{window}$的值置为1, 并重新进入慢启动状态. 而如果出现3个冗余ACK就会根据当前TCP的版本进入快速重传或快速恢复状态, 无论如何, 只要在拥塞避免模式下发生发生改变就会重新计算$C_{threshold} = 0.5 \cdot C_{window}$.
-
快速重传: 早期的Tahoe版本TCP采用快速重传机制来恢复乱序到达的报文. 当出现3个冗余ACK时, TCP会忽略超时定时器而直接重新发送报文, 并将$C_{window}$的值置为1, 进入慢启动状态. 显然, 这种做法只是在进入慢启动阶段前重发最后一个出问题的报文而已, 无法有效地提升传输速率的恢复速度.
-
快速恢复: 后来推出的Reno版本TCP中采用的是快速恢复机制, 具体做法是在快速重传的基础上, 将$C_{window}$的值置为调整后的$C_{threshold} + 3$(因为有3个冗余ACK说明有三个分组已经到达), 然后重新进入拥塞避免阶段. 尽管Reno版本的快速恢复算法能够防止一次偶然的乱序到达而导致系统整体状态退化为慢启动, 但是乱序报文一般都是连续出现的. Reno只负责处理第一个乱序报文, 连续出现的乱序报文会持续触发快速恢复状态, $C_{window}$的值将持续缩小, 最终退化为接近慢启动的状态. 因此后来提出的NewReno版本将在处理完整个窗口内的乱序报文之后才会退出快速恢复状态.
除了上述经典的拥塞控制算法之外, Linux 5.0 内核中还提供了SACK, TCP Vegas, BIC, CUBIC.
由于UDP不具有拥塞控制服务, 基于视频流的应用一般采用UDP来防止因拥塞控制而导致延迟上升. 但信道中充斥的大量UDP会占用其他报文的资源, 因此在RFC 4340中提出了一种能够阻止UDP流量不断压制其他报文导致资源过度倾斜的现象.
案例1: 提高TCP的网络利用率
Nagle算法: 即使发送端当前有应当发送的数据, 但如果这部分数据很少, 就延迟发送. 具体来说, 就是在满足下列任意条件时立即发送数据, 否则就等待计时器.
- 已发送的数据都已经收到确认应答时
- 滞留的数据长度大于等于MSS时
延迟确认应答: TCP默认采用累积应答技术, 即返回编号为N的ACK就说明N之前的所有分组都已经被成功接受. 在实际应用中, TCP一般会在成功接收两个包或收到长度等于两个MSS的数据之后返回累积的ACK, 如果两个分组的间隔大于0.2则会立即返回当前分组的ACK.
稍带应答: 像HTTP这种基于TCP的请求响应协议, 本身在应用层就要求接收到请求之后必须返回一个响应, 因此可以直接将ACK稍带在响应数据中. SMTP, POP, FTP等也存在类似的机制.
案例2: TCP连接保活
保活定时器
保活功能是否应当在运输层实现是存在争议的.