继续来网管的自我修养之TCP协议,这可是除 IP 协议外另一个核心协议了。
TCP 协议是网络传输中至关重要的一个协议,它位于传输层。向上支持 FTP、TELNET、SMTP、DNS、HTTP等常见的应用层协议,向下要与网络层的 IP 协议相互配合,实现可靠的网络传输。
为了让全世界的计算机有效的互联起来,国际标准化组织提出了一种概念化的网络模型,开放式系统互联模型(Open System Interconnection Model),简称 OSI 模型。
自上而下依次为应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。
应用层提供为应用软件而设计的接口,以设置与另一应用软件之间的通信。例如:HTTP、HTTPS、FTP、Telnet、SSH、SMTP、POP3等。
表示层把数据转换为能与接收者的系统格式兼容并适合传输的格式。
会话层负责在数据传输中设置和维护计算机网络中两台计算机之间的通信连接。
传输层把传输表头(TH)加至数据以形成数据包。传输表头包含了所使用的协议等发送信息。例如:传输控制协议(TCP)等。
网络层决定数据的路径选择和转寄,将网络表头(NH)加至数据包,以形成分组。网络表头包含了网络资料。例如:互联网协议(IP)等。
数据链路层负责网络寻址、错误侦测和改错。当表头和表尾被加至数据包时,会形成信息框。数据链表头(DLH)是包含了物理地址和错误侦测及改错的方法。数据链表尾(DLT)是一串指示数据包末端的字符串。例如以太网、无线局域网(Wi-Fi)和通用分组无线服务(GPRS)等。
分为两个子层:逻辑链路控制(logical link control,LLC)子层和介质访问控制(Media access control,MAC)子层。
物理层在局部局域网上传送数据帧,它负责管理电脑通信设备和网络媒体之间的互通。包括了针脚、电压、线缆规范、集线器、中继器、网卡、主机接口卡等。
OSI 模型是国际标准模型,是指导互联网模型的概念标准。而在实际的设计实现过程中,最后形成了 TCP/IP 4层模型结构。
TCP/IP 模型实际上并不单单指 TCP 和 IP,实际上这一个协议簇,还包含了其他的一些协议,比如 UDP、ICMP、IGMP 等。
TCP/IP 模型是事实上的标准模型,在 7 层模型的基础上将最上面三层的应用层、表示层、会话层统一为应用层,将数据链路层和物理层统一为链路层或者叫网络接口层。
实际应用中还是以 4 层模型为准,毕竟这才是事实上的标准。还有一种 5 层模型的说法,实际上就是把 7 层中的应用层、表示层、会话层合并为应用层,其他层保持不变。
TCP/IP 模型每个层都有各自的功能和分工,当有用户数据想要发送给另一台设备的时候,数据自上而下,从应用层向链路层传递有一个复杂的过程。
以 Telnet 为例,Telnet 在传输层是使用 TCP 协议的。
数据从应用层进入,到达传输层,添加上 TCP首部,将数据加工成 TCP 段,称为 Segment。这是为了保证数据的可靠性。
接着数据到达网络层,在网络层使用 IP 协议,被添加上 IP 首部,将数据加工成 IP数据报,称为 datagram 。经过网络层 IP 协议的加工,指定目标地址和 MAC 地址,保证数据准确的发送到目标机器。
接着数据到达链路层,添加上以太网头部,将数据加工成以太网帧,称为 frame,包含了网卡等硬件相关的数据。
无论是 Telnet 还是 HTTP,都至少涉及到两台设备才能称之为网络互连,那发送方有一个数据自应用层向底层链路层的加工过程,对应的,在数据接收方,有一个数据从链路层向应用层解析的过程。这中间可能经历了漫长的传输介质,比如光纤,还可能有若干个中间设备,比如路由器、交换机等等。要保证数据在这么复杂的网络环境中可靠、准确的发送到目标机器,就是靠的 TCP、IP协议精巧的设计。
TCP,全称是 Transmission Control Protocol,传输控制协议。 是一种面向连接的、可靠的字节流服务协议,正因为它要保证可靠性,所以比起 UDP 协议要复杂的多,正是由于这种复杂性,导致它的性能比 UDP 差。
TCP 是 TCP/IP 模型中的传输层一个最核心的协议,不仅如此,在整个 4 层模型中,它都是核心的协议,要不然模型怎么会叫做 TCP/IP 模型呢。
它向下使用网络层的 IP 协议,向上为 FTP、SMTP、POP3、SSH、Telnet、HTTP 等应用层协议提供支持。其他的还有我们常用的 Redis 的 RESP 协议、MongoDB的网络协议,以及我们编程中用到的 Socket,都是 TCP 协议在背后提供支持的。
网络协议是通信计算机双方必须共同遵从的一组约定。如怎么样建立连接、怎么样互相识别等。只有遵守这个约定,计算机之间才能相互通信交流。它的三要素是:语法、语义、时序。
TCP首部 + 用户数据被称为TCP段,其中 TCP 首部就是这里要主要研究的 TCP 协议的核心所在,用户数据部分是 TCP 段的负载。
TCP 段的大小也是有限制的,最大是 1460 字节,这是怎么算出的呢?
最终由网卡发出去的数据包叫做以太网帧,以太网帧由以太网首部和负载构成。
以太网帧的负载就是一个 IP 数据报,IP数据报由IP首部和负载构成。
IP数据报的负载就是一个 TCP段。所以,TCP段所能搭载的最大数据量可以这样计算出来:
$TCP段搭载的数据大小 = 以太网帧大小-以太网首部-IP首部-TCP首部$
以太网帧的大小是固定的 1522字节,而IP首部和TCP首部的大小是不固定的,但是最少会各占20字节,所以最后算下来 TCP段搭载的数据大小最多为 1460字节。
$TCP段搭载的数据大小(最多1460) = 以太网帧大小(1522字节)-以太网首部(22字节)-IP首部(最少20字节)-TCP首部(最少20字节)$
下图是TCP协议的示意图,如果不算「可选项」部分的话,共占用32bit x 5 = 160bit,也就是20个字节。
源端口和目标端口分别占用 2个字节,共占用 4 字节,分别记录数据发送端的端口号和数据接收端的端口号,这两个标记和 IP 协议中记录的发送端 IP 和接收端 IP组合起来,便可确定一个唯一的 TCP 连接。
由于TCP段的大小有限制,当要传输的数据量大于这个限制的时候,就要对数据进行分段,一段一段的发送,既然发送方要分段,那接收方就要对分段进行重组,才能还原回原始数据。在重组的过程中,要保证各段间的先后顺序,序号正是起到保证重组顺序的作用。
序号占用 4 字节,32 位,它的范围是 [0,$2^{32}$]。TCP是字节流服务,会对每一个发送的字节进行编号。在建立连接的时候,系统会给定一个 ISN(初始序号),然后这个设备在当前连接中发送的第一个字节的序号就是 ISN+1,假设 ISN 初始为0,那第一个字节的序号就是 1。
举个例子,假设ISN为0,发送端第一次发送 100 字节的数据包,那这第一个 TCP段的序号就是1,下次再发送 100字节的数据包,那这第二个 TCP段的序号就是 101。
这样一来,最大可以一直标记 $2^{32}$个字节,也就是 4个G的数据。当达到最大值后,又会从 0 开始标记。
序号只有在下面两种情况下才有用:
当数据发送出去,接收方收到之后,会回复一个确认序号回复给发送方,这个确认序号表示接收方希望下次接收的序号。例如发送了序号为501的,长度为100的TCP段,那接收方收到后要回复 601的确认序号,表示【0-600】的字节已经接收,下次希望收到第 601个字节以后的数据。
为了提高效率,并不是每次接收到TCP段都会马上回复给发送方,而是采用累积确认的方式,即每传送多个连续 TCP 段,可以只对最后一个 TCP 段进行确认。
确认序号只有在 ACK 标志位被设置的时候才有效。
之所以需要首部长度,是因为可选项的大小是不固定的,如果没有可选项的话,那首部长度就是 20字节。这个标示部分占 4 bit,单位是4字节,4bit 可表示的最大值是 15,一个单位表示的长度是4字节,所以首部长度最大可以是15 x 4字节,也就是 60 字节。
顾名思义,是保留位,占用6个比特位,目前的值为 0。
协议中有 6 个比特标记位,可以理解为 TCP 段的类型。
URG
1个比特位,当被设置为1时,表明紧急指针字段有效,该报文段有紧急数据,应尽快发送。
ACK
当 ACK 设置为1时,确认号才有效,连接建立后,所有的报文段ACK都为 1。
PSH
当 PSH 设置为1时,接收方应该尽快将这个报文段交给应用层,而不再等待整个缓存填满再交付。
RST
当 RST 为1时,表示连接出现严重错误,必须重新建立连接。
SYN
在建立连接时用到。
当SYN=1,ACK=0时,表明这是一个连接请求报文段。
当SYN=1,ACK=1时,表明对方同意连接。
FIN
用来释放一个连接窗口。当FIN=1时,表明此报文段的发送方不再发送数据,请求释放单向连接。TCP断开连接用到。
大小为2个字节,表示发送方自己的接收窗口,用来告诉对方允许发送的数据量,最大为65535字节。
校验和是必需的,是一个端到端的校验和,由发送端计算,然后由接收端验证。其目的是为了发现TCP首部和数据在发送端到接收端之间发生的任何改动。如果接收方检测到校验和有差错,则TCP段会被直接丢弃。
占2字节,当URG=1时,紧急指针表示本报文段中的紧急数据的字节数,表示从这个 TCP段的序号开始的后的若干个字节是紧急数据,之后的就是普通数据。
假设此TCP段的序号为101,紧急指针为30,那就表示从 101开始,直到 131,【101,131】这个区间内为紧急数据。
数据要完成传输,必须要建立连接。由于建立TCP连接的过程需要来回3次,所以,将这个过程形象的叫做三次握手。
而连接断开的时候要经过四次数据传输,所以也被称为4次挥手。
啥都别说了,先看图吧。
结合上面的图来看更清楚。
先说三次握手吧,连接是后续数据传输的基础。就像我们打电话一样,必须保证我和对方都拿着电话在听,才能保证我们两个说的话对方能够接收到。
三次握手大概就是这个意思:
张三想跟李四聊聊天,于是张三拨通了李四的手机号,李四听到铃声响起,按了接听按钮。
张三:Hi,李四,是你吗?唠两块钱的呀!
李四:Hi,张三,是我,可以唠。
张三:好,我确定是你了,接下来我要开始和你唠了。
看上去多少有点儿死板,但程序上确实就是这样的。
1、第一次握手
首先客户端发起连接请求,向服务器发送 TCP段,段中包含了目标端口和本机端口,设置 SYN 标志位为1,序号为 x,也就是初始序号 ISN,如果是第一个连接,很有可能就是 0。当然,此时服务器对应的端口要处于监听状态。此时,客户端进入 SYNC_SENT 状态,等待服务器的确认。
2、第二次握手
服务端收到客户端发来的 SYN 段,对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1),这就是确认序号。同时,服务端还要发送 SYN 请求信息,将SYN位置为1,Sequence Number为 y(服务端的TCP段序号)。服务器端将上述所有信息放到一个TCP段(即SYN+ACK段)中,一并发送给客户端,此时服务器进入SYN_RECV状态。
3、第三次握手
客户端接收到服务端发来的 SYN+ACK 段后,发送一个 ACK 给服务端,将 Acknowledgment Number 设置为 y+1。此时客户端进入 ESTABLISHED(已连接)状态,服务端接收到此 TCP段,也将进入 ESTABLISHED 状态,也就标志着三次握手结束,连接成功建立。
三次握手完成之后,连接就建立了,之后就可以愉快的传输数据了。
一旦有了感情(连接),再分手就难了,难到需要四次挥手。不像 UDP 那样,没有连接,说分就分。
当客户端和服务端双方发送数据完成后,一般会由客户端主动发起断开连接的请求,当然,也有少数情况是服务端主动发起。
以最常见的客户端发起断开连接为例,说一下四次挥手的过程。
1、第一次挥手
客户端设置序号(Sequence Number)和确认序号(Acknowledgment Number),发送一个 FIN 段给服务器。这时,客户端进入 FIN_WAIT_1状态,意味着客户端没有数据要发送了。
2、第二次挥手
服务端收到 FIN 报文段,向客户端发送一个 ACK 段,客户端进入 FIN_WAIT_2 状态。表示服务端已同意连接关闭请求。
3、第三次挥手
服务端向客户端发送 FIN 段,请求关闭连接,同时服务端进入 LAST_ACK 状态。
4、第四次挥手
客户端收到服务端发来的 FIN 段,向服务端发送 ACK 段,之后客户端进入TIME_WAIT状态。服务端收到客户端的ACK 段以后,就关闭连接。
上面就是由客户端主动发起关闭连接的过程。
TCP 是一个全双工的字节流服务,意思就是说两个端点都可以同时发送和接收消息。
正常情况下需要四次挥手才能完成连接的完全断开。但是有一种情况是这样的,只主动关闭自己到对方的连接,但是对方还是可以给自己发送数据。
Wireshark 是帮助我们分析网络请求的利器,建议每个同学都装一个。我们先用 Wireshark 抓取一个完整的连接建立、发送数据、断开连接的过程。
我这儿只简单的介绍一下操作流程。
1、首先打开 Wireshark,在欢迎界面会列出当前机器上的所有网口、虚机网口等可以抓取的部件。
2、我接下来要用 Telnet 连接一个外网服务器,所以我选择第一个 WI-FI:en0,这样 Wireshark 就会捕获我连接的 wifi 上的网络传输。
3、我只想要抓一下最简单的 TCP 连接、发数据、断开的过程,所以要做一下抓取过滤。Wireshark 中的过滤器可以实现这样的需求。在下图红框部分可以选了一个过滤器。
4、因为当前没有直接可用的符合要求的过滤器,所以,需要自己写一个。点击前面的绿色书签图标,然后在弹出窗口中点击加号添加一个。
内容如下,语法就不解释了,一看就知道。
tcp and host 你的远程ip
5、选择好刚添加的这个过滤器,双击wifi这个 interface 进入就开始捕获了。
6、我用 telnet 连接这台服务器的 6379 端口telnet ip 6379,因为这台服务器上装着 redis,可以模拟发数据。
在控制台中连接到 6379 端口成功,然后在 Wireshark 上马上捕获到了。
这就是三次握手的过程。
7、然后直接关掉终端,这样会自动触发断开连接,并且发送最少的数据,方便我们观察。整个的过程都被 Wireshark 完整的捕捉到了。
第一部分是连接建立的三次握手,第二部分是发了长度为 1个字节的数据,第三步是客户端主动发起的断开连接的四次挥手过程。
有图先看图
概览信息
也就是图中最上面的红色框部分。这一次的连接建立和中断一共产生了来回 8 次的请求,每次请求会在列表上列出时间、源端IP、目的端IP、以太网帧长度以及概览信息,包括数据传输方向(源端口->目标端口)、标记情况、序号、确认序号、窗口大小等等。
以太网帧
在每次请求信息中,还包括以太网帧,因为信息最终都会通过帧的形式发送出去。
IP数据报
还有 IP 数据报内容,其中包含了源端 IP 和 目的端 IP 等信息。
TCP段
TCP 段当然是重点了,其中包含了 TCP 协议中的所有信息,包括端口号、
MTU是什么
MTU 全称是最大传输单元,一个在网络上传输的包不能无限大,MTU 一般是对于链路层而言的,拿以太网来说,在链路层允许发送的最大的以太网帧的数据部分就是 1500字节。注意是以太网帧的数据部分,再加上以太网帧的头部,会大于1500字节。
通过ifconfig(windows 系统是 ipconfig)可查看本机各个网络接口(网卡)的MTU 大小。
MSS是什么
MSS 指TCP最大报文长度,是TCP协议定义的一个选项,MSS选项用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度。还是用以太网为例,MTU是 1500字节,减去TCP头(20字节)和IP头(20字节),就是MSS 1460字节。
粘包就是将几个比较小的 TCP 包合并成一个包,这样就只发送一次就可以将多个小包发送出去。例如下面这样,一个TCP报文请求中,包含小包A、B、C,每一个小包原本都是一个TCP报文。
为什么要粘包呢?一个一个发送不行吗?
其实是可以的,只不过在多数情况下来一个包马上就发送可能会造成网络拥塞,一个TCP 报文传输到链路层的时候,会加上TCP头和IP头,占用40字节,如果发送的数据内容很小,比如只有1个字节,为了这一字节的内容,要有40倍的额外的信息被传输,是不是有点浪费。
为了减少这种浪费,TCP 协议就做了一些优化,比如 Nagle 算法:
由于现在的宽带和设备性能的提升,Nagle 算法其实可以关闭了,有些设备上默认就是关闭的,也可以在写 Socket 的代码的时候主动关闭掉,关闭之后呢,只要接收端处理能力够快,可以保证来一个包马上发送,对那些要求实时反馈的应用来说尤其重要。
那来一个包发一个包,是不是就不会有粘包的问题了?也不是,这就要看接收端的处理能力了,接收端会有一个接收缓冲区,来不及被应用程序处理的会暂时放到这里,如果应用程序处理能力较差,这里还是会出现粘包。
既然发生了粘包,就要把这些大包拆成小包。怎么拆分其实都是上层应用的事儿了,核心要点就是约定好分隔符。举个简单的例子,比如说将包A和包B用一个特殊字符$分隔开,那应用在拆包的时候就要根据这个特殊字符进行分隔。当然了,真实情况要比这个复杂的多,如果你用过 Netty,就会发现 Netty 提供了多种处理粘包拆包的方式。
粘包是为了将多个小包变成一个大包,而半包是把超大包拆成小包。比如下图,假设包B是一个很大的包,已经超过了MSS 了,单单发送它自己都发不过去了,所以只能将它拆开,一部分一部分的发送。
半包就没那么复杂了,纯粹是因为单独的包太大,协议不支持这么大的包,只能拆开。
这样一部分一部分的包,到了接收端之后就要将其合并为一个整体,合并也比较简单,就是如果这个部分包没有开始或没有结束标志,就表示它不是完整的,需要给其找到对应的其他部分。
接收方通告的窗口称为 offered window,意思就是说我这边可以接受的最大字节数为这么多。例如下图中的红框部分为 offered window, 大小为 6 字节,发送端最大一次只能发送 6 个字节,要不然接收方就没有能力接收了。
可用窗口= offered window - 已经发送但未被确认的字节大小,这个值由发送方自己计算。前面说了三次握手,发送方发出去包,接收方接到后会反回一个 ACK,发出去但未收到ACK的数据也会占用窗口,表明接收方正在处理,所以,可用窗口的大小是 offered window 减去未收到 ACK 的大小。
为什么叫滑动窗口呢,看上面的图,把一个个字节想象成排成一排的格子。
首先看时刻1:红色格子的部分就是offered window,大小为6字节,后面10、11、12字节因为没在窗口内,所以不能发送。已发送但未被确认的也占用窗口大小,所以最终可用窗口就是 7、8、9这三个字节。
再看时刻2:刚才未被确认的 4、5、6字节收到了 ACK,所以1-6都变成了过去式,然后窗口覆盖到了7、8、9、10、11、12 这6个字节,对比时刻1和时刻2,给我们的感觉就是窗口(红色格子)向右滑动了,这就是所谓的滑动窗口了。
还有,窗口两个边沿的相对运动增加或减少了窗口的大小。
在使用 TCP 传输的过程中,肯定是希望数据传送的越快越好,但是在实际使用场景中,由于发送端和接收端处理数据的速度不一致,或者由于中间路由器性能限制、带宽限制等原因,发送的速度越快,越有可能导致丢包的情况。比如一下子发送了10M的数据出来,但是中间路由器只能处理 5 M,很可能就会把一些包丢弃。
因而设计了慢启动和拥塞避免算法,这两个设计都是为了合理的匹配发端的发送速度与收端的处理速度。
在连接刚建立的时候,发送端也不知道应该按什么速度发比较合适,所以就采用了一种渐进式的方式,就是慢启动的方式。
前面说了 offered window 是接收端的,在发送端也有一个窗口,叫做拥塞窗口,记做 cwnd,拥塞窗口初始化为 1 ,表示 1个报文段,也就是允许发送1个报文段,之后每当每当收到接收端返回的 ACK 时,就将 cwnd 的值加1。第一次发送一个数据报,当收到 ACK 后,cwnd 变为2,然后下一次发送两个数据报,当收到这两个数据报的 ACK 时,cwnd 就变成 4 。以此类推,这个增长是呈指数级的。
但是,在这个过程中,也是有限制的,发送的数据报大小要在消息接收端返回的通告窗口大小和 cwnd 中取较小的那个值。假设一个报文大小为 1024 字节,当 cwnd 为2,通告窗口大小为 4096 字节时,那发送端你可以连着发送2个数据报,也就是取 cwnd 的值,当 cwnd 为8 时,通告窗口大小仍然为 4096 字节时,那发送端最多可连续发送 4 个数据报,也就是不能超过 4096 字节。
拥塞避免算法其实和慢启动是在一起使用的。在慢启动中除了有拥塞窗口外, 还有一个叫做启动门限(ssthresh)的参数。启动门限默认的是 65535 字节。
在慢启动中,cwnd 是呈指数级增长,但是这个增长速度太快了,所以,拥塞避免算法就是让这个增速减缓的方式。
当 cwnd < ssthresh 的时候,就使用慢启动。
当 cwnd > ssthresh 的时候,就启动拥塞避免算法。
拥塞避免算法保证当 cwnd 超过限制之后,每次收到一个确认时将 cwnd 增加 1/cwnd。
当拥塞发生时(超时或收到重复确认),ssthresh 被设置为当前窗口大小的一半(cwnd和接收方通告窗口大小的最小值,但最少为 2个报文段)。
用一张图来说明慢启动和拥塞避免算法
假定当 cwnd 为32个报文段时就会发生拥塞。于是设置 ssthresh 为1 6个报文段, 而 cwnd 为1个报文段。 在时刻 0发送了一个报文段, 并假定在时刻 1接收到它的 ACK,此时 cwnd 增加为2。接着发送了2个报文段,并假定在时刻 2接收到它们的 ACK,于是 cwnd 增加为4 (对每个 ACK 增加1次)。这种指数增加算法一直进行到在时刻 3和4之间收到8个A C K后 cwnd 等 于 ssthresh 时才停止,从该时刻起,cwnd 以线性方式增加,在每个往返时间内最多增加 1个报 文段。
正如我们在这个图中看到的那样, 术语“慢启动”并不完全正确。 它只是采用了比引起 拥塞更慢些的分组传输速率, 但在慢启动期间进入网络的分组数增加的速率仍然是在增加的。 只有在达到 ssthresh 拥塞避免算法起作用时,这种增加的速率才会慢下来。
什么情况下要重传,当发送端认为丢包了就要重传,有两种情况下发送端就认为丢包了,于是就会发起重传。
发送端在一段时间(超时时间)后没有收到发送端返回的 ACK ,就认为这个包丢了,这个超时时间并不是固定的。
这里面有两个概念,RTT 和 RTO。
接收端回复的 ACK 会带着包的序号,当接收端重复三次收到同一个序号的ACK时,就要重传这个包;
比如下面图中画的这样:
1、seq=1的包发过去,接收端ACK=2,表示期望下次出现的序号为2,然后发送端就发了 seq=2的包,接收端接到后回复 ACK=3,表示期望下次收到序号为3的包,这是发送端第一次收到 ACK=3;
2、发送端继续发送 seq=3 的包,但是这个包可能传输的比较慢(比如路由选择的不好),接收端一直没收到;
3、发送端先不管,继续发送 seq=4 的包,接收端收到后,回复ACK,正常情况下应该是 ACK=5,但是序号为3的包还没收到,所以再次回复ACK=3,这是第二次收到ACK=3;
4、发送端继续不管,接着发送 seq=5的包,接收端收到后,回复ACK,正常情况下应该是 ACK=6,但是序号为3的包还没收到,所以再次回复ACK=3,这是第三次收到ACK=3;
到目前为止,已经收到三次 ACK=3了,然后发送端就重新发送 seq=3的包,这时候就当做这个包已经丢了。这就是快速重传。