计算机网络(2)传输层TCP/UDP

334 阅读18分钟

运输层协议为运行在不同主机上的应用进程之间提供了逻辑通信。从应用层的角度看,通过逻辑通信,运行不同进程的主机好像直接相连一样,而无需考虑承载这些报文的物理设施的细节。

在与因特网有关的环境中,我们将运输层数据称为报文段(segment),协议分为TCP和UDP。

分层逻辑抽象与面向接口编程有着相同的精妙之处,上层不用在意接口的底层实现,而专注于完成自己在当前层的任务。

多路复用

在协议栈中,运输层刚好位于网络层之上,运输层为运行在不同主机上的进程之间提供了逻辑通信,而网络层则提供了主机之间的逻辑通信。UDP和TCP最基本的任务是,将两个端系统间IP的交付服务扩展为运行在两个端系统上的进程之间的交付服务,这被称为运输层的多路分解和多路复用。

进程有一个或多个套接字(socket),它相当于从网络向进程传递数据和从进程向网络传递数据的门户,接收主机中的运输层实际上并没有直接将数据交付给进程,而是通过一个中间的套接字来传递,由于在任何一个时刻接收主机上可能有多个套接字,所以每个套接字都有一个唯一的标识符,其格式取决于它是UDP套接字还是TCP套接字。

将运输层报文段中的数据交付到正确的套接字的工作称为多路分解,从源主机的不同套接字中收集数据块,并为每个数据块封装上首部信息(这将在多路分解时使用)从而生成报文段,然后将报文段传输到网络层的工作称为多路复用。

多路复用的纽带就在于端口,端口号是一个16bit的数字,其大小在0~65535之间。

多路复用与多路分解.jpg

多路复用和多路分解是相邻高低层次之间交互的常见形式,比如rpc框架使用serviceName和methodName区分不同服务不同方法的请求与响应,这些区分属性存储在低层并解析给高层。

UDP多路复用

主机上的每个套接字被分配一个端口号,当报文段到达主机时,运输层检查报文段中的目的端口号,并将其定向到相应的套接字,然后报文段中的数据通过套接字进入其所连接的进程,为什么不需要检查来源端口号呢?因为UDP是无连接的,两个进程间不维护连接关系,能保证被接收即可,此时目的套接字是被多个源套接字共享的。

然而实际的UDP报文段头却存储了源端口,这个其实是预留给目的进程的,如果想进行回包可以取源端口出来封包发回去。

UDP通信.jpg

TCP多路复用

TCP套接字是由一个四元组(源IP地址、源端口号、目的IP地址、目的端口号)来标识的。与UDP不同的是,两个具有不同源IP地址或源端口号的到达的TCP报文段将被定向到两个不同的套接字(没有规定一个端口只能分配一个套接字)。

    // 客户端
    Socket clientSocket = new Socket("serverHostName", 6789);
    // 服务端
    Socket connectSocket = welcomeSocket.accept();

笔者过去一直误把socket当成是对等连接,其实不是,客户端和服务端维护的是各自的socket,只不过它们拥有标识上的关联,以使数据流通来往。

TCP通信.jpg

UDP

UDP报文段

UDP仅提供了最基础的多路复用和差错检测功能。

UDP报文结构.jpg

UDP差错检测

UDP提供了校验和,如同许多链路层协议也提供差错检测一样,由于不能保证源和目的之间的所有链路都提供差错检测,当报文段存储在某台路由器的内存中,也可能引入比特差错,所以UDP必须在端对端基础上再运输层提供差错检测。

发送方的UDP对报文段中的所有16比特字的和进行反码运算,求和时遇到的任何溢出都被回卷,得到的结果放在UDP报文段中的检验和字段。

虽然UDP提供差错检测,但它并不能进行差错恢复。

基于消息体使用某种计算方式得到一个结果,再在对端进行比对的思路方法,也被应用于应用层数据校验,比如哈希签名。

UDP的应用

  • 应用层能更好地控制要发送的数据和发送时间。采用UDP时,只要应用进程将数据传递给UDP,UDP就会将此数据打包成UDP报文段并立即将其传递给网络层,而TCP使用了拥塞控制与确认重传机制,并不管可靠交付需要用多长时间,实时应用通常要求最快的发送速率,不想过分地延迟报文段的传送,且能容忍一些数据丢失。
  • 无需连接建立。TCP需要三次握手建立连接而UDP不需要,减少这部分时延是DNS运行在UDP而不是TCP之上的主要原因。另外一方面,UDP也无需维护连接的存储状态,比如发送缓存和接收缓存、拥塞控制参数、序号和确认号等参数。
  • 分组首部开销小,每个TCP报文段都有20字节的首部开销,而UDP仅有8字节的开销。 总而言之,就是牺牲可靠性与公平性,去实现更轻更快。

UDP由于缺乏拥塞控制,可能导致UDP发送方和接收方之间出现高丢包率,并挤垮了TCP会话。资源隔离保护的确是我们需要进行留意的地方,比如docker容器资源隔离,sentinel线程资源隔离,mq生产消费连接隔离。

TCP

TCP报文段

区别于UDP报文段,TCP首部包含了:

  • 6bit的标志字段,其中常见的是ACK、SYN、FIN、RST这4个,这些与连接状态维护有关
  • 32bit的序号和32bit的确认号,这些与数据可靠传输有关
  • 16bit的接收窗口字段,这些与流量控制有关,指示接收方愿意接受的字节数量

TCP报文结构.jpg

以上三部分,分别引入TCP的三个核心特点:面向连接、可靠传输、流量控制

面向连接

TCP是面向连接的,这是因为在一个应用进程可以开始向另一个应用进程发送数据之前,这两个进程必须先相互握手,即它们必须相互发送某些预备报文段,以建立确保数据传输所需的参数。这些参数是后续可靠传输和流量控制的重要支撑,可以说面向连接是为后两个特性服务的基础特性

由于TCP协议只在端系统中运行,而不在中间的网络元素(路由器和链路层交换机)中运行,所以中间元素对TCP连接完全不知情,它们看到的是数据报,而不是连接。笔者过去一直误以为TCP连接是全链路维护的,如上所言,通过socket套接字标识和连接参数,一条运行在运输层对端进程的虚拟的TCP连接便建立起来了。

具体TCP建立和关闭连接的流程被称为:三次握手和四次挥手

TCP面向连接.jpg

三次握手

序号seq:建立在传送的字节流上,而不是建立在传送的报文段的序列之上,一个报文段的序号是该报文段首字节的字节流编号,连接建立后双方交互起始seq。MSS(maximum segment size)限制了报文段数据的最大长度,当TCP发送一个大文件时,TCP通常是将文件划分成长度为MSS的若干块(最后一块除外,它通常小于MSS)。

确认号ack:主机期望从对方收到的下一字节的序号,也侧面证明了此序号前的字节流已正确接收,符合后面数据可靠传输的累计确认特性。

连接交互:三次握手原本是四次握手,即两对SYN+ACK,只不过中间两条可以合并就变成三次。

为什么需要第三次握手?

网络上大部分博客讲得云里雾里,笔者这里有一个通俗的case:面试与入职。

第一次握手:求职者对某公司感兴趣,进行简历投递与面试。

第二次握手:该公司经过考核通过了求职者,并发放offer。

第三次握手:求职者接受offer,并提交资料入职。公司核实offer落地,hc减1。

因为网络层本身是不可靠的,如果没有第三次握手,该公司无法确定求职者是否收到了offer通知书以及是否接受涨薪待遇等,这种情况下没有办理入职是不允许进行资源分配的。

SYN洪泛:攻击者仅发送第一次握手SYN,但恶意不发送第三次握手ACK,这种大量的攻击使得服务器维护的半连接队列溢出造成网络拥挤。一种解决方案是服务器生成一个初始TCP序列号,该序列号是SYN报文段的源和目的IP地址、端口号以及仅被该服务器所知的秘密数的一个散列函数计算的结果,这个序号被称为cookie放到第二次握手SYNACK发送出去,此时服务器不作任何cookie记忆与状态维护,而在收到第三次握手ACK后校验其确认号,通过才分配连接变量和缓存。

四次挥手

为什么断开连接,不像创建连接一样合并中间的报文段呢?

因为此时服务器socket可能还有数据未发送完,客户端socket发出FIN只代表客户端不再发送数据,但仍然可以从服务器socket接收最后的数据,等服务器ready后再进行反向FIN。

为什么客户端需要进行time_wait?又为什么是等待2MSL?

  1. 防止ACK丢失,有机会让服务器超时重传FIN,然后客户端再传ACK。
  2. MSL(Maximum Segment Lifetime)是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。每次客户端ACK发送出去,定时器重置2MSL,在此之间不允许两端重新建立一条标识符一样的TCP连接,而在当前TCP连接周期内发送的所有数据报文段和其ACK报文段无论怎么飘游都将过期,2MSL一过,新的TCP连接便不会被前面飘游的报文段干扰。

可靠传输

在报文段的传输、传播或缓存的过程中,底层信道中的比特可能受损,此时需要引入一种接收方确认机制,ACK使得接收方可以让发送方知道哪些内容被正确接收,哪些内容接收有误从而需要重传,基于这种重传机制的可靠数据传输协议称为自动重传请求(Automatic Repeat reQuest,ARQ)协议。

显然,ARQ协议的组成有两个部分:

  1. 接收方差错检测,并反馈
  2. 发送方重传

实现ARQ依赖三个问题:

  1. 接收方反馈,如何保证发送方能正确接收?
  2. 发送方重传,如何让接收方区分是正常报文段还是重传报文段?
  3. 网络传输中的任意报文段丢失了怎么办?

问题1,发送方可以对反馈本身进行差错检测,如果检测失败那也当成NAK处理,宁可错杀不可放过,发送方进行重传。

问题2,对报文段进行序号编号,序号向前推进过程如果收到更小序号的报文段,就知道是重传报文段了。

问题3,引入超时机制,超时即丢失,丢失即重传。

滑动窗口

如果发放方只能在接收方确认一个报文段后才能发送下一个报文段,这样一个停等协议的性能必然不能满足要求,为此我们需要允许发送方发送多个未确认的报文段,这就是滑动窗口。

接收方可以缓存已收到但失序的报文段,窗口尾部只要满足连贯就可以往前移动,并把连贯的部分提交给上层应用程序。

发送方可以缓存已发送但未确认的报文段,因为序号递增并搭配冗余ACK,发送方不使用连贯确认,而是使用累计确认推动窗口往前移动(累计确认的一大好处是如果中间的部分ACK丢失,后面的ACK到达可以避免不必要的重传)。定时器只监听尾部最后一个报文段,重传也仅重传这个,否则成本略高。

冗余ACK:如果收到受损或乱序的报文段,接收方不发送NAK,而是发送一个对按序接收到的最后一个报文段的ACK,发送方接收到对同一个报文段的多次ACK,就知道接收方没有正确接收到此报文段后面的报文段。延伸下去甚至被TCP引用为快速重传的判断依据!这个也算是对单定时器的补偿,妙哇花

因为滑动窗口本质是循环数组,所以窗口长度必须小于等于序号空间大小的一半,不然接收方窗口循环到下一轮后尾部与发送方窗口头部重叠,重叠的部分无法辨别报文段的序号以重传的算还是新的算。

滑动窗口.jpg

超时阈值

时延的估算需要与真实网络情况贴近,所以需要进行采样得到SampleRTT,不同采样数据采用指数加权移动平均EWMA,因为越新的采样参考性越高,所以对最新样本赋予的权重要大于对老样本赋予的权重,α一般取值0.125,得到EstimatedRTT:

EstimatedRTT=(1α)EstimatedRTT+αSampleRTTEstimatedRTT = (1 - α) * EstimatedRTT + α * SampleRTT

超时阈值应该大于等于EstimatedRTT,否则将造成不必要的重传,但是也不应该比EstimatedRTT高出太多,否则当报文段丢失时,TCP不能很快地重传该报文段,从而对上层应用带来很大的数据传输时延,因此需要添加一定的余量,当SampleRTT波动较大时,余量就越大,DevRTT本质是SampleRTT和EstimatedRTT之间差值的指数加权移动平均EWMA,β一般取值0.25:

DevRTT=(1β)DevRTT+βSampleRTTEstimatedRTTDevRTT = (1 - β) * DevRTT + β * |SampleRTT - EstimatedRTT|

最后用EstimatedRTT加上余量DevRTT得出TimeoutInterval:

TimeoutInterval=EstimatedRTT+4DevRTTTimeoutInterval = EstimatedRTT + 4 * DevRTT

然而,在实际发生超时事件时,意味着网络问题可能比较严重,所以TCP进行了超时加倍的调整,某种程度上超时拉长减少了重传发送频率,也算提供了拥塞控制。

这种超时阈值自适应估算,同样适用于rpc等负载均衡,以机房和网络类型计算初始权重,再使用运行时请求时延动态调节权重,负载均衡可以优先选择权重较高的节点。

流量控制

TCP为应用程序提供了流量控制服务,以消除发送方使接收方缓存溢出的可能性。TCP通过让发送方维护一个称为接收窗口的变量来提供流量控制,该值告诉发送方,接收方还有多少可用的缓存空间。

接收窗口的值是接收缓存减到停留在运输层还未提交给应用层的数据量:

RevWindow=RevBuffer(LastByteRecvLastByteRead)RevWindow = RevBuffer - (LastByteRecv - LastByteRead)

对于发送者,已发送未确认的数据量需要控制在RevWindow内:

RevWindow>=LastByteSentLastByteAckedRevWindow >= LastByteSent - LastByteAcked

将下游buffer消费不过来这个信息反馈给发送者,并限制发送者行为,是比Backpressure更加融洽的解决方式。

拥塞控制

分组丢失一般是在网络变得拥塞时由于路由器缓存溢出引起的,可以使用可靠传输服务进行重传,但是却不能解决网络拥塞问题,因此需要一些机制在面临网络拥塞时遏制发送方,这种形式的控制被称为拥塞控制。

流量控制和拥塞控制虽然都抑制了发送速率,但其控制目的不同。

我们可根据网络层是否为运输层拥塞控制提供了显式帮助来区分控制方法:

  • 端对端拥塞控制。网络层没有为运输层拥塞控制提供显式支持,即使网络中存在拥塞,端系统也必须通过对网络行为的观察(如分组丢失和时延)来推断。因为IP层不支持网络反馈的关系,TCP采用了此方法。
  • 网络辅助的拥塞控制。在网络辅助的拥塞控制中,网络层组件(即路由器)向发送方提供关于网络中拥塞状态的显式反馈信息,这种方式的通知通常采用一种阻塞分组的形式。ATM这种虚电路方案采用了此方法。

TCP采用了端对端拥塞控制,面临三个问题:

  1. 发送方如何感知从它到目的地之间的路径存在拥塞?
  2. 发送方如何调控它向其连接发送流量的速率?
  3. 发送方流量调控策略是什么?

问题1,要么出现超时,要么收到来自接收方的3个冗余ACK,发送方就能感知到拥塞。

问题2,TCP让连接端维护一个变量CongWin,表示拥塞窗口,通过维持调节CongWin,便可以控制发送速率。在一个发送方中未被确认的数据量不会超过CongWin与RcvWindow中的最小值:

LastByteSentLastByteAcked<=min(CongWin,RcvWindow)LastByteSent - LastByteAcked <= min(CongWin, RcvWindow)

问题3,就是问题1出现时,如何应用问题2的解决方式,此策略略微微妙:

  • 加性增
    • 如果没有检测到拥塞,则可能有可用的带宽可被该TCP连接使用,TCP缓慢地增加其拥塞窗口的长度,每过一个RTT将CongWin增加1个MSS,即每收到一个ACK则CongWin增加值MSS/CongWin。
  • 慢启动
    • 当一个TCP连接开始时,CongWin的初始值置为1个MSS,如果仅仅是加性增,在达到某个可观级别的发送速率之前将会经历很长的时间,所以TCP发送方在初始阶段是以指数的速度增加,每过一个RTT将CongWin翻倍,即每收到一个ACK则CongWin增加值1,直到发生一个丢包事件或3个冗余ACK为止。
  • 乘性减
    • 超时事件发生时,TCP发送方进入慢启动阶段,直到CongWin达到超时事件前的一半后改为加性增。
    • 收到3个冗余ACK时,将CongWin减半,然后加性增。与发生超时事件的情况不同,网络在表现它自己至少能交付一些报文段,即使其他报文段因拥塞而丢失了,这种在收到3个冗余ACK后取消慢启动阶段的行为称为快速恢复。

TCP通过维护一个称为Threshold的变量来管理这些较复杂的动态过程,它是用来确定慢启动将结束并且拥塞避免将开始的窗口长度,Threshold初始化时被设置为一个很大的值,每当发生一个丢包事件时,Threshold值会被设置为当前CongWin值的一半,然后窗口长度以指数速度增长,直到达到Threshold后变成线性增长。

拥塞控制.jpg

小结

TCP是本专栏的核心模块,我在温习时发觉其与应用编程上的方方面面有着如出一辙的精妙设计,当初首读时由于经验匮乏并没有get到,我们在进行应用设计时可以多多参考学习这些思路,达到融会贯通。本文也穿插了我的一些思考路径和梳理总结,希望朋友们理解更深,收获更多。