网络协议之靠谱邮差 - TCP

网络的信道是不稳定的,有时候施工队伍一不小就把光缆给挖断了,也有时候也有可能网络上连接太多了,导致弱网环境, 这些冰山一角的事情,充分说明了网络是不可靠的. TCP设计的初衷是保证数据能在网络上进行可靠的传输。 光缆都断了,数据还怎么可靠传输呢?
下面,开始我们的探索之旅.

  • 什么是TCP
  • TCP要解决什么问题
  • TCP是如何解决这些问题的
  • 拾遗

什么是TCP

TCP全称是Transmission control Protocal, 是一个工作在传输层上的协议. 它只关心数据如何可靠和有效的到达目的地,但不关心数据如何到底目的地。

TCP要解决什么问题

TCP要解决的问题是数据如何可靠和有效的到达目的地。

  • 可靠
    • 如果发送的包丢了,应该如何处理?
    • 数据包是有顺序的,如何保证接受方拿到的包也是有顺序的?
  • 有效
    • 如果服务端比较忙或者比较闲,客户端应该采取什么样的策略来发送包?

TCP就是为了解决这三大问题而存在。 但这三大问题会衍生一些子问题.

回到一开始的那个问题: 光缆都断了,数据还怎么可靠传输呢?
答案是光缆断了,数据没法可靠传输。
网络不可靠是一个事实和大前提, 在网络不可靠的大前提,TCP尽可能的保证可靠的数据传输。
一个不太恰当的比喻: 张三娶了一个漂亮老婆,这个老婆有点花心和不可靠。 但即使老婆花心和不可靠, 那日子是不是还得照样过?所以张三就想了各种办法,比如平时对老婆好点,让她收收心,平时也查查她的手机,看看是否有可疑的聊天记录。张三所做的一切就是让一个本性不靠谱的女人行为上稍微靠谱一点.

TCP是如何解决这些问题的

一个完成的TCP过程是建立连接,收发数据,释放连接.

前置知识

  • TCP的包是没有IP地址的,那么是IP层的事情.
  • TCP连接是点对点的,也就是说一个TCP连接对应的是两个端口.
  • ACKn代表着到目前为止,对方序号n-1的之前的数据都正常收到了,也代表期望对象下次发送序号为n的数据.

建立连接

建立连接是为了进行可靠的数据传输做保证。就好比去拜访一位朋友,得事先打个招呼告知一下.
TCP建立连接是通过三次握手的策略来保证的。三次握手保证了

  • 客户端知道自己和对方有接受和发送的能力
  • 服务端知道自己和对方有接受和发送的能力

第一次握手是保证了服务端知道自己有接受能力,对方有发送能力.
第二次握手是保证了客户端知道自己有发送和接受能力,服务端有发送和接受能力.
第三次握手是保证了服务端知道客户端自己有发送的能力,客户端有接受能力.

从这里可以看出,二次握手是不能保证服务端知道自己是否有发送的能力,客户端是否有接受的能力.
四次握手也可以,但画蛇添足了.

收发数据

客户端给服务端的发送数据包,数据包丢了怎么办?

如果快递员给别人送货,货物弄丢了,快递员的选择很简单重发一个或者赔偿, TCP也不例外, 这就是TCP的重传策略. 对于重传,我们需要考虑以下问题

  • 什么时候进行包重传?
  • 重传哪些包?
  • 如何进行重转

服务端返回 ACK 包的机制

  • 客户端发送了1, 2, 3, 4, 5 总共5个包,服务端收到了1, 2,会返回ACK3, 然后收到了4(此时没有收到 3), 那么服务端应该返回什么? 还是ACK3. ACKn的真正含义是服务端已经收到了n-1个连续的包, 并期望客户端发送第n个包.

假设存在一种上帝策略X, 这个策略可以让客户端及时的发送服务端所需要的包,且不重复.
围绕上面三个问题,看看下面四种重传策略是如何逼近这个上帝策略的.

  • 超时重传
    • 超时重传的思路是:设定一个定时器,如果在规定时间内没有响应,则重发数据包.
    • 超时重传的思路是朴素和直观的。但超时时间应该怎么定?如果时间间隔太长,则发包的效率太慢,如果时间间隔太短,会导致响应包还没收到,就重发了,就导致更多的重发。目前主流的策略是超时重传的时间(Retransmission Timeout)略大于包往返时间(Round-Trip Time)。
    • 实际情况是 ,RTT是动态的,所以相应的 RTO也是动态的. RTO的计算规则相对复杂,具体公式可参考RFC 6298. 这里顺便说一下,这个公式的一些参数是长期实践得到的,无法从逻辑上推导出来,有点类似于现在的机器学习现状,参数调一调,发现可以得到预期的结果,但没有办法解释为什么,简单的说就是两眼一抹黑,完全靠不断的尝试.
  • 快速重传
    • 快速重传的思路是从多次发送包的响应值中找规律。
    • 如果客户端发送1,2,3,4,5份数据, 1先到了,ack返回2,然后2因为某种原因没收到,3到了,ack返回的还是2,4,5也到了,ack还是2. 至此,客户端收到了三个ack=2的包,就知道2出问题了。于是重发2,因为3,4,5都收到了,ack返回的是6.
    • 上面这个例子是一个比较理想化的例子。我们需要面两个选择:究竟是重发2,还是后面的3,4,5也要重发?
      • 重发2 - 如果3,4,5里面没有丢包,重发2是最完美的选择。 但 3,4,5里面如果有丢包的,那么还得重发丢失的包
      • 重发2,3,4,5 - 如果3,4,5里面没有丢包,重发3, 4, 5是一种浪费。
    • 快速重传仅仅解决了定时器效率的问题,但没有解决什么时候应该发2,什么时候该发3, 4,5这个问题.
  • SACK - Selective Acknowledgment
    • SACK的思路是服务端告诉客户端一个大体的全貌,我当前已经收到了什么,我还没收到什么。所以这也是一个比较自然的思路. 具体细节可参考 RFC 2018
    • SACK需要客户端和服务端同时支持
    • SACK会占用发送方的资源。试想一下,如果黑客劫持了服务端,给客户端发送不正常的SACK包,那么客户端就会每次都要计算服务端哪些包收到了,哪些包没有收到.
  • D-SACK - Duplicate SACK
    • D-SACK的核心思路是告诉客户端哪些数据是被重复接受了。具体可见RFC 2833.
    • 通过D-SACK, 我们可以知道
      • 丢失的包是发送的包还是ACK包,如果丢失的包是ACK包,客户端就不要无脑重发服务端已经收到的包
      • 先发的包后到的情况.

客户端给服务端发送数据包是按顺序发送的,服务端是如何保证拿到的一些数据包是有序的?

客户端给服务端发送数据包的过快怎么办?

前置知识

  • 滑动窗口 - TCP是每发一个请求,就会有一个响应,如此循环。 这明显效率太低了。有没有可能我一次发送多个请求, 然后只需要一次响应就可以了?这个思路直觉上是可行的. TCP的滑动窗口是为了协调发送方和接收方的速度. 这本质上是个生产者-消费者模型。生产者发送速度过快,消费者接受不了怎么办?增加一个中间缓存带。滑动窗口其实就是一个缓存带.
    流量控制是TCP提供的一种机制,是为了匹配收发双方的速度.
    以服务端接受数据为过程为例
  • 网卡接受到的数据会放到内核缓冲区
  • 内核缓冲区会将相应的信息挪到某一个TCP连接的接受缓存区(接受窗口就是接受缓存区)
  • 然后应用程序会从接受缓冲区读取数据
    客户端发送数据如下
  • 应用程序将数据放到发送缓存区(发送窗口就是发送缓存区)
  • 将发送窗口的数据挪到内核缓冲区
  • 内核缓冲区的数据从网卡发送出去

拾遗

  • 发送缓冲区和接受缓存区是针对一个TCP连接的。整个内核缓冲区是针对整个操作系统的
  • TCP头里有一个字段叫 Window代表窗口大小
  • 窗口的大小是有接受方的窗口大小决定的.
  • 如果客户端到的TCP window的值为0, 那么意味着服务端处在水深火热当中,没能力处理数据了。那么客户端就不会发送数据了。但是万一过会,服务端又复活了呢? TCP是用Zero Window Probe技术,发zwp包给服务端。
  • 窗口为0会引起死锁
  • 糊涂窗口综合症(Silly Window Syndrome) - 如果接受方的可用窗口太小,只能容纳几个字节,发送方还在为了发几个字节需要带上很多的附加信息,显得很耗带宽资源,得不偿失。有点像服务端对客户端说我的仓库没空间了,你不要大老远过来送一些牙膏牙膏牙刷了,等我空间大了,你再送些大件过来.
    • 如果是服务端导致的糊涂窗口综合症,那就关闭窗口
    • 如果是客户端导致的糊涂窗口综合症, 使用Nagels算法。核心思路是等可用的窗口变大了再发数据
    • Nagle算法默认是打开的,对telnet或ssh交互性比较强的程序,需要关闭这个算法。
  • 流量控制和用塞控制的区别是什么?
    • 流量控制是针对发送者和接受者之间的策略,侧重于微观,并不知道网络的整体情况。
    • 拥塞控制是为了从宏观保证整个网络畅通的
    • 流量控制为拥塞控制做了一小部分铺垫,但这还不够,拥塞控制还需要额外的策略.

关闭连接

直观上说,建立连接是三次握手,关闭连接应该是更简单,直接一句GoodBye就完事。
但事实并非如此,根本原因TCP是全双工。TCP建立连接之后,双方是可以同时收发数据的,那么就意味着连接需要两个通道. 所以关闭连接就变成了如何处理两个通道.
两个通道都没有数据收发才是最关闭连接的标志. 关闭的真正的含义是要关闭两个通道一起关闭,但前提是两个通道都要确认没有收据收发.
释放连接可以用客户端和服务端任意一方发起。
下面是客户端是发起释放连接的过程

  • 客户端发送FIN,代表要释放发送通道
  • 服务端收到客户端的FIN, 知道客户端没有数据要发送了. 发送ACK给客户端, 代表我同意你的请求. 但此时服务端还不能关闭连接,因为服务端可能还有数据要发送给客户端.
  • 服务端觉得直接没有数据要发送了,就发送一个FIN请求关闭连接
  • 客户端发送ACK给服务端,同意服务端的关闭连接请求.服务端收到ACK之后就关闭连接,客户端在等待2MSL时间之后,没有收到回复,就关闭连接.

整个网络发生了阻塞怎么办?

想象一下每次过节开车回家,怎么知道回家的路是一路通畅的呢?看地图上面的交通线路状况是不是绿色,如果是红色,则代表路况拥堵. 整个网络也是出现拥堵和通畅两种情况。
在节假日的时候,交警应对这种情况有好几种方法

  • 车主上高速的时候,收费站限流,只开几个闸口
  • 车主上高速的时候,收费站完全关闭,让车主改选国道
  • 已经在高速上的车流,交警会尽可能的疏导让它们快速的去目的地.

网络拥塞发生的时候,有如下策略

  • 慢启动
    • 慢启动的核心原理是第一次发包的时候发一个包,第二次翻倍,以此类推。发包的数量是指数级增长。当发包的数量超过某个阀值的时候,采用拥塞避免算法。
    • 可以看出慢启动的策略还是有点粗鲁,不够灵活。
  • 拥塞避免
    • 拥塞避免和慢启动有类似之处。当一次发送包的数量超过某个阀值的时候,那么下次发包的数量,就呈现线性增长。相比于指数增长,这个增长很慢了。
  • 拥塞发生
    • 当网络发生阻塞的时候,会发生丢包,既然发生了丢包,那么就要重传
    • 发生超时重传的时候,会导致又要重新进入慢启动的过程
    • 发生快速重传的时候,TCP认为你还能收到三个ACK包,说明网络还可以啊。于是将发包的数量降到一半,而不是像超时重传,将发包的数据量降到1. 然后进入快速恢复算法
  • 快速恢复
    • 快速恢复的核心是用另外一种策略来控制发生包的大小。 这个策略先不详细展开。

至此,介绍了拥塞控制的四个算法。这四个算法的都遵循的原则是

  • 尽量的发包,而不是不发.
  • 这个四个算法的思路都是围绕着“什么时候发多少包”展开的.这一点很像高速收费站,什么时候允许多少车辆进去.

拾遗

TCP粘包

客户端在发送包D的时候,服务的滑动窗口容纳不下这个包,所以只能接受一部分包D1,一个包就被拆开了。
客户端在发送D1和D2两个包,服务端接受的时候,接受到一个包,包含D1和D2, 服务端无法鉴别 D1和D2.
上面的这种现象是由TCP的特性决定的。 我们可以给这些现象命名。但这些是问题吗?如果是问题,TCP应该背这个锅吗?
答案是:TCP不背这个锅,TCP不解决这个问题。如果这是个问题,应该由应用层去解决.

TCP存在的问题

TCP设计的时候有个大前提:网络的包的丢失是因为网络堵塞引起的. 这个大前提直接决定了TCP的设计方向.
但其实网络包的丢失可能是

  • 移动基站的弱网环境
  • 防火墙针对性的丢包