概述

TCP/IP(Transmission Control Protocol/Internet Protocol)是一种可靠的网络数据传输控制协议。定义了主机如何连入因特网以及数据如何在他们之间传输的标准。

特点

  • 面向连接
  • 可靠的数据流
  • 全双工通信
    • 单工:数据传输只支持数据在一个方向上传输
    • 半双工:数据传输允许数据在两个方向上传输,但是在某一时刻,只允许在一个方向上传输,实际上有点像切换方向的单工通信
    • 全双工:数据通信允许数据同时在两个方向上传图片输,因此全双工是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接

TCP协议

TCP报文头

分布式通信-TCP协议 - 图2
我们来分析分析每部分的含义和作用

  • 源端口号/目的端口号: 表示数据从哪个进程来,到哪个进程去.
  • 32位序号:
  • 4位首部长度: 表示该tcp报头有多少个4字节(32个bit)
  • 6位保留: 顾名思义,先保留着,以防万一
  • 6位标志位

16位源端口号与16位目的端口号。
32位序号:在建立连接(或者关闭)的过程,这个序号是用来做占位,当 A 发送连接请求到 B,这个时候会带上一个序号(随机值,称为 ISN),而 B 确认连接后,会把这个序号 +1 返回,同时带上自己的充号。当建立连接后,该序号为生成的随机值 ISN 加上该段报文段所携带的数据的第一个字节在整个字节流中的偏移量。比如,某个 TCP 报文段发送的数据是字节流中的第 100 ~ 200 字节,那该序号为 ISN + 100。所以总结起来说明建立连接(或者关闭)时,序号的作用是为了占位,而连接后,是为了标记当前数据流的第一个字节。
4位头部长度:标识 TCP 头部有多少个 32 bit 字,因为是 4位,即 TCP 头部最大能表示 15,即最长是 60 字节。即它是用来记录头部的最大长度。
6位标志位,包括:
URG 标志:表示紧急指针是否有效。
ACK 标志:确认标志。通常称携带 ACK 标志的 TCP 报文段为确认报文段。
PSH 标志:提示接收端应该程序应该立即从 TCP 接收缓冲区中读走数据,为接收后续数据腾出空间(如果不读走,数据就会一直在缓冲区内)。
RST 标志:表示要求对方重新建立连接。通常称携带 RST 标志的 TCP 报文段为复位报文段
SYN 标志:表示请求建立一个连接。通常称携带 SYN 标志的 TCP 报文段称为同步报文段
FIN 标志:关闭标志,通常称携带 FIN 标志的 TCP 报文段为结束报文段
这些标志位说明了当前请求的目的,即要干什么。
16 位窗口大小:表示当前 TCP 接收缓冲区还能容纳多少字节的数据,这样发送方就可以控制发送数据的速度,它是 TCP 流量控制的一个手段。
16 位校验和:验证数据是否损坏,通过 CRC 算法检验。这个校验不仅包括 TCP 头部,也包括数据部分。
16 位紧急指针:正的偏移量,它和序号字段的值相加表示最后一个紧急数据的下一字节的序号。TCP 的紧急指针是发送端向接收端发送紧急数据的方法。
TCP 头部选项:可变长的可选信息,这部分最多包含 40 字节,因为 TCP 头部最长是 60 字节,所以固定部分占 20 字节。

三次握手

先来解释三次握手过程:
1、发送端发送连接请求,6位标志为 SYN,同时带上自己的序号(此时由于不传输数据,所以不表示字节的偏移量,只是占位),比如是 223。
2、接收端接到请求,表示同意连接,发送同意响应,带上 SYN + ACK 标志位,同时将确认序号为 224(发送端序号加1),并带上自己的序号(此时同样由于不传输数据,所以不表示字节的偏移量,只是占位),比如是 521。
3、发送端接收到确认信息,再发回给接收端,表示我已接受到你的确认信息,此时标志仍为 ACK,确认序号为 522。
涉及到的问题:为什么是三次握手,而不是四次或者两次?
首先解释为什么不是四次。四次的过程是这样的:
发送方:我要连你了。
接收方:好的。
接收方:我准备好了,你连吧。
发送方:好的。
显然接收方准备好连接并同意连接是可以合并的,这样可以提高连接的效率。
再来,我们解释为什么不是两次。其实也比较好理解,我们知道 TCP 是全双工通信的,同时也是可靠的,连接和关闭都是两边都要执行才算真正的完成,同时还需要确保两端都已经执行了连接或者关闭。如果只有两次,过程是这样的:
发送方:我要连你了。
接收方:好的。
很明显,接收方并不知道也不能保证发送方一定接收到 “好的” 这条信息,一旦接收方真的没有收到这条信息,就会出现接收收“单方面连接”的情况,这个时候发送方就会一直重试发送连接请求,直到真正收到 “好的” 这条信息之后才算连接完成。而对于三次,如果发送方没有等待到你回复确认,它是不会真正处于连接状态的,它会重试确认请求。

四次挥手

接着我们来看看四次挥手过程:
1、发送方发送关闭请求,标志位为:FIN,同时也会带上自己的序号(此时同样由于不传输数据,所以不表示字节的偏移量,只是占位)。
2、接收方接到请求后,回复确认:ACK,同时确认序号为请求序号加1。
3、接收方也决定关闭连接,发送关闭通知,标志位为 FIN,同时还会带上第2步中的确认信息,即 ACK,以及确认序号和自己的序号。
4、发送方回复确认信息:ACK,接收方序号加1。
涉及到的问题:为什么需要四次握手,不是三次?
三次的过程是这样的:
发送方:我不再给你发送数据了。
接收方:好的,我也不给你发了。
发送方:好的,拜拜。
这是因为当接收方收到关闭请求后,它能立马响应的就是确认关闭,它这里确认的是接收方的关闭,即发送方不再发数据给接收方了,但他还是可以接收接收方发给他的数据。而接收方是否需要关闭“发送数据给发送方”这条通道,取决于操作系统。操作系统也有可能 sleep 个几秒再关闭,如果合并成三次,就可能造成接收方不能及时收到确认请求,可能造成超时重试等情况。因此需要四次。

什么是 TIME_WAIT 状态

当一方断开连接后,它并没有直接进入 CLOSED 状态,而是转移到 TIME_WAIT 状态,在这个状态,需要等待 2MSL(Maximum Segment Life,报文段最大生存时间)的时间,才能完全关闭。
涉及问题:
1、为什么需要有 TIME_WAIT 状态存在?
简单来说有两点原因如下:
a. 当最后发送方发出确认信息后,仍然不能保证接收方能收到信息,万一没收到,那接收方就会重试,而此时发送方已经真正关闭了,就接受不到请求了。
b. 如果发送方在发出确认信息后就关闭了,在接收方接到确认信息的过程中,发送方是有可能再次发出连接请求的,那这个时候就乱套了。刚连接完,又收到确认关闭的信息。
2、为什么时长是 2MSL 呢?
这个其实也比较好理解,所以我发送确认信息,到达最长时间是 MSL,而你如果没接受到,再重试,时间最长也是 MSL,那我等 2MSL,如果还没收到请求,证明你真的已经正常收到了。
正因为我们有这个 TIME_WAIT 状态,所以通常我们说是客户端先关闭,一般不会让服务器端先关闭,可以设置关闭时端口可复用。

使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符.

确认应答机制(ACK机制)

分布式通信-TCP协议 - 图3
TCP将每个字节的数据都进行了编号, 即为序列号。
分布式通信-TCP协议 - 图4
每一个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据;下一次你要从哪里开始发。
比如,客户端向服务器发送了1005字节的数据,服务器返回给客户端的确认序号是1003,那么说明服务器只收到了1-1002的数据,1003、1004、1005都没收到,此时客户端就会从1003开始重发。

超时重传机制

分布式通信-TCP协议 - 图5
主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B。如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发,但是主机A没收到确认应答也可能是ACK丢失了。
分布式通信-TCP协议 - 图6
这种情况下,主机B会收到很多重复数据,那么TCP协议需要识别出哪些包是重复的,并且把重复的丢弃.
这时候利用前面提到的序列号,就可以很容易做到去重。
超时时间如何确定?
最理想的情况下, 找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”。
但是这个时间的长短,随着网络环境的不同,是有差异的。
如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包。
TCP为了保证任何环境下都能保持较高性能的通信,因此会动态计算这个最大超时时间。

  • Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
  • 如果重发一次之后,仍然得不到应答,等待 2500ms 后再进行重传。如果仍然得不到应答,等待 4500ms 进行重传。
  • 依次类推,以指数形式递增,累计到一定的重传次数,TCP认为网络异常或者对端主机出现异常,强制关闭连接。

    滑动窗口

    刚才我们讨论了确认应答机制,对每一个发送的数据段,都要给一个ACK确认应答,收到ACK后再发送下一个数据段。
    这样做有一个比较大的缺点,就是性能较差,尤其是数据往返时间较长的时候。
    那么我们可不可以一次发送多个数据段呢?例如这样:
    分布式通信-TCP协议 - 图7
    窗口大小指的是无需等待确认应答就可以继续发送数据的最大值。上图的窗口大小就是4000个字节 (四个段)。
    发送前四个段的时候,不需要等待任何ACK,直接发送。收到第一个ACK确认应答后,窗口向后移动,继续发送第五六七八段的数据…
    因为这个窗口不断向后滑动,所以叫做滑动窗口。
    操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答,只有ACK确认应答过的数据,才能从缓冲区删掉。
    分布式通信-TCP协议 - 图8
    如果出现了丢包,那么该如何进行重传呢?
    此时分两种情况讨论:
    1,数据包已经收到,但确认应答ACK丢了。
    分布式通信-TCP协议 - 图9
    这种情况下,部分ACK丢失并无大碍,因为还可以通过后续的ACK来确认对方已经收到了哪些数据包。
    2,数据包丢失
    分布式通信-TCP协议 - 图10
    当某一段报文丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 “我想要的是 1001” 。
    如果发送端主机连续三次收到了同样一个 “1001” 这样的应答,就会将对应的数据 1001 - 2000 重新发送,
    这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了。
    因为2001 - 7000接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。
    这种机制被称为 “高速重发控制” ( 也叫 “快重传” )

    流量控制

    接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被填满,这个时候如果发送端继续发送,就会造成丢包,进而引起丢包重传等一系列连锁反应。
    因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做 流量控制(Flow Control)。
    接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK通知发送端;窗口大小越大,说明网络的吞吐量越高;接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口大小的通知之后,就会减慢自己的发送速度;如果接收端缓冲区满, 就会将窗口置为0;
    这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,让接收端把窗口大小再告诉发送端。

    分布式通信-TCP协议 - 图11

    那么接收端如何把窗口大小告诉发送端呢?
    我们的TCP首部中,有一个16位窗口大小字段,就存放了窗口大小的信息;
    16位数字最大表示65536,那么TCP窗口最大就是65536字节么?
    实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M 位(左移一位相当于乘以2)。

    拥塞控制

    虽然TCP有了滑动窗口这个大杀器,能够高效可靠地发送大量数据。但是如果在刚开始就发送大量的数据,仍然可能引发一些问题。
    因为网络上有很多计算机,可能当前的网络状态已经比较拥堵.
    在不清楚当前网络状态的情况下,贸然发送大量数据,很有可能雪上加霜.
    因此,TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态以后,再决定按照多大的速度传输数据.
    分布式通信-TCP协议 - 图12
    在此引入一个概念 拥塞窗口
    发送开始的时候,定义拥塞窗口大小为1;每次收到一个ACK应答,拥塞窗口加1;每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。
    像上面这样的拥塞窗口增长速度,是指数级别的。
    “慢启动” 只是指初使时慢,但是增长速度非常快。为了不增长得那么快,此处引入一个名词叫做慢启动的阈值,当拥塞窗口的大小超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
    分布式通信-TCP协议 - 图13
    当TCP开始启动的时候,慢启动阈值等于窗口最大值。
    在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1。
    少量的丢包,我们仅仅是触发超时重传; 大量的丢包,我们就认为是网络拥塞; 当TCP通信开始后,网络吞吐量会逐渐上升; 随着网络发生拥堵,吞吐量会立刻下降。
    拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

    延迟应答

    如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
    假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答,返回的窗口大小就是500K; 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了; 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来; 如果接收端稍微等一会儿再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M
    窗口越大,网络吞吐量就越大,传输效率就越高。TCP的目标是在保证网络不拥堵的情况下尽量提高传输效率;那么所有的数据包都可以延迟应答么? 肯定也不是,有两个限制:

  • 数量限制: 每隔N个包就应答一次

  • 时间限制: 超过最大延迟时间就应答一次。具体的数量N和最大延迟时间,依操作系统不同也有差异,一般 N 取2,最大延迟时间取200ms。

    捎带应答

    在延迟应答的基础上,我们发现,很多情况下,客户端和服务器在应用层也是 “一发一收” 的,意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine,thank you” ,那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine,thank you” 一起发送给客户端。
    分布式通信-TCP协议 - 图14

    面向字节流

    创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区; 调用write时,数据会先写入发送缓冲区中; 如果发送的字节数太大,会被拆分成多个TCP的数据包发出; 如果发送的字节数太小,就会先在缓冲区里等待,等到缓冲区大小差不多了,或者到了其他合适的时机再发送出去; 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区; 然后应用程序可以调用read从接收缓冲区拿数据; 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据,这个概念叫做 全双工
    由于缓冲区的存在,所以TCP程序的读和写不需要一一匹配
    例如:

  • 写100个字节的数据,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节;

  • 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次;

    粘包问题

    首先要明确,粘包问题中的 “包”,是指应用层的数据包。在TCP的协议头中,没有如同UDP一样的 “报文长度” 字段,但是有一个序号字段。
    站在传输层的角度,TCP是一个一个报文传过来的. 按照序号排好序放在缓冲区中。
    站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这一连串的字节数据,就不知道从哪个部分开始到哪个部分是一个完整的应用层数据包。此时数据之间就没有了边界,就产生了粘包问题
    那么如何避免粘包问题呢? 归根结底就是一句话,明确两个包之间的边界。
    对于定长的包

  • 保证每次都按固定大小读取即可
    例如上面的Request结构,是固定大小的,那么就从缓冲区从头开始按sizeof(Request)依次读取即可

对于变长的包

  • 可以在数据包的头部,约定一个数据包总长度的字段,从而就知道了包的结束位置
    还可以在包和包之间使用明确的分隔符来作为边界(应用层协议,是程序员自己来定的,只要保证分隔符不和正文冲突即可)

对于UDP协议来说,是否也存在 “粘包问题” 呢?

对于UDP,如果还没有向上层交付数据,UDP的报文长度仍然存在. 同时,UDP是一个一个把数据交付给应用层的,就有很明确的数据边界. 站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收. 不会出现收到 “半个” 的情况.

TCP 异常情况

进程终止: 进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。
机器重启: 和进程终止的情况相同。
机器掉电/网线断开: 接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行 reset. 即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在. 如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制。
例如:HTTP长连接中,也会定期检测对方的状态。
例如QQ,在QQ断线之后,也会定期尝试重新连接.

总结

为什么TCP这么复杂?因为既要保证可靠性,同时又要尽可能提高性能。
保证可靠性的机制:

  • 校验和
  • 序列号(按序到达)
  • 确认应答
  • 超时重传
  • 连接管理
  • 流量控制
  • 拥塞控制

提高性能的机制:

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答

定时器:

  • 超时重传定时器
  • 保活定时器
  • TIME_WAIT定时器