TCP协议原理详解

在阅读之前,你需要了解网络协议的基本知识,这篇博文并不会具体介绍,只是粗浅的总结Tcp协议相关知识。


一.TCP的概念

TCP协议是建立在传输层上的协议。不同于它的兄弟udp协议,它是面向连接的协议,即:必须两方建立了连接之后才可以传输数据。
这里通过一个图片来描述应用程序是如何通过tcp/ip协议通信。
这里写图片描述

同为传输层协议的tcp协议相对于udp协议,它可以保证数据传输的完整性,弥补了ip协议的best-effort性。
具体来看tcp协议是如何工作的:

TCP协议的连接和释放
要讲清楚tcp协议是如何开始工作的,首先先看数据在两个应用之间是如何通过封装分用来协调各个协议进行通信的:
数据封装
这幅图来自 《tcp/ip协议 卷一》,从这幅图可以看到在传输层数据由tcp首部和应用数据组成。很明显tcp首部包含了一切tcp工作机制所需要的信息。
接下来来看一下tcp首部格式
tcp首部

主要组成:

  • 4个字节的源端口和目的端口
  • 4个字节的序号
  • 4个字节的确认号
  • 6bits标志位 urg,ack,psh,rst,syh,fin
  • 窗口

以上组成是接下来分析需要用到的。先来简单的说一下:

  1. tcp首部只包含源端口和目的端口,因为源ip和目的ip是放在ip首部里面的。
  2. 4个字节的序号,主要存放传输数据的第一个字节的序号。TCP规定,tcp连接上传输的数据流中每一个数据都要编上一个序号。
    (如何通过序号保证数据传输的顺序性呢?)
  3. 4个字节的确认号是和序号合作使用,来保证数据传输的正确性。
  4. 6个0/1标志位用来表示tcp报文段所属类别。
  5. 窗口,用来控制流量。

二.TCP的连接和释放

简单分析了tcp首部之后,开始分析tcp的连接,即:三次握手。
这里写图片描述
分析上图的过程:
1.客户端A和服务器B上的TCP都处于CLOSED状态。
2.一个客户端A主动进程发起连接请求(主动打开),这时本地TCP实体就创建传输控制快(TCB),发送一个发送一段 报文段1 给服务器B ,报文段1中SYN标志位为1,序号为seq=x;(一个 SYN将占用一个序号),客户端A状态变成SYN-SENT。(第一次握手)
3.服务器进程发出被动打开,进入监听状态LISTEN。服务器B收到A发送的syn报文段后,同样返回一个syn报文段2, 报文段2中,SYN=1,ACK=1,序号为seq=y; ackSeq(确认号)=x+1; 确认号表示,希望客户端下一次发送的seq。 发送之后,服务器B状态编程SYN-RCVD。 (第二次握手)
4.客户端A在收到服务器发回来的报文段2,发送一个ack报文段3,报文段3中,SYN=0,ACK=1, seq=x+1; ack=y+1;(注意,这里SYN=0,因此这里不占用一个序号,tcp连接建立之后发送数据序号还是从seq=x+1 开始)。在发送之后,客户端状态变成established,等到服务器端接受到报文段3之后,状态也变成established,tcp连接建立,可以开始数据传输了。(第三次握手)

Q1:为什么需要三次握手,如果确认两方连接,两次握手就可以保证了?
A1: 主要是为了防止已失效的连接请求报文段突然又传到了B,因而产生错误。假定出现一种异常情况,即A发出的第一个连接请求报文段并没有丢失,而是由于网络延迟长时间滞留了,一直延迟到连接释放以后的某个时间才到达B,本来这是一个早已失效的报文段。但B收到此失效的连接请求报文段后,就误认为是A又发出一次新的连接请求,于是就向A发出确认报文段,同意建立连接。假定不采用三次握手,那么只要B发出确认,新的连接就建立了,这样一直等待A发来数据,B的许多资源就这样白白浪费了。
Q2:如果第三次握手没有成功,服务端会发生什么?
A1:这要查看tcp状态机变化(在博文最后会给出状态变化),当失败时服务器会根据设定的重传定时器,重传ack报文,但是之后客户端的第三次握手的ack报文还是失败,超过了可允许重传的时间爱你,那么服务端就发送RTS报文段,自身进入CLOSED状态。当客户端接收到RTS报文段后,也由established状态进入closed状态。这样做的目的是为了防止SYN洪泛攻击。

继续分析TCP连接的终止,即 四次挥手:
四次挥手
分析上图过程:
1.客户端进程发起主动关闭,客户端A发送一个FIN报文段1,报文段1中 FIN=1 seq=u;同时客户端状态由established 变成 FIN-WAIT-1 ,此时客户端这边连接已经关闭,不会再传输数据。
2.服务器收到了报文段1,发送确认ack报文段2,报文段2的ACK=1,seq=v,ackSeq=u+1;并在此时通知服务器进程,同时进入close-wait状态。
3.客户端在收到报文段2之后,自身进入FIN-WAIT-2 状态。
4.服务器进程发送关闭指令给服务器后,服务器发送关闭FIN报文段3,报文段3中 FIN=1,ACK=1,seq=w,ackSeq=u+1; (这里要注意,报文段2的seq=v,而这里seq=w,而不是v+1,这是因为在close-wait状态的时候,服务器连接没有关闭仍可以向客户端发送数据)。在发完报文段3之后,服务器进入last-ack状态。
4.客户端在fin-wait-2状态的时候,收到fin+ack报文段后,发送ack报文段4,报文段4 ACK=1,seq=u+1,ackSeq=w+1 进入TIME-WAIT状态,在经过2MSL(最大报文段生存时间),进入closed状态。
**MSL:任何报文段被丢弃前在网络内的最长时间,一般设置为30s。
5.服务器端在收到确认ack报文段后,进入closed状态。

Q1:为什么要设置一个TIME-WAIT状态然后经过2MSL进入到closed状态呢?
A1:这是为了保证最后一个ack一定能传输到服务器。如果服务器没有收到ack,会重发一次报文段3给客户端,然后客户端在发一次报文段4给服务器。一来一回,两个报文段最大生存时间是2MSL。
Q2:如果服务器进程不发送关闭指令给服务器,那么tcp连接是否一直处于半关闭状态?
A2:是的,如果没有报文段3发送过来,确实一直处于半关闭状态,只有服务器会向客户端发送数据。
解决办法:如果执行主动关闭的应用层将进行全关闭,而不是半关闭来说明它还想接收数据,就设置一个定时器。时间到了,就进入到closed状态。
Q3:tcp连接释放一定是经过4次挥手吗?
A3:不一定,tcp连接的释放会由于各种异常的情况导致,不过都是通过RST报文段来实现的。(也可以在数据传输完成后,直接发送RST报文段来关闭tcp连接)

当然,TCP的四次挥手是一方主动关闭,一方被动关闭,有可能会出现两方都主动关闭的情景:
这里写图片描述
由图中可以看到状态fin-wait-1时候,如果收到ack报文段就会进入fin-wait-2状态,如果收到fin报文段就会进入closing状态。然后在收到ack报文段就会进入time-wait,最终进入closed,关闭连接。

三.TCP的数据传输

前面已经分析了tcp的连接和释放,那么下面来分析tcp的数据传输是如何保证数据的完整性和顺序性。
已经提到,tcp传输的数据流中每一个字节都有一个序号,而tcp首部中序号是指,这个报文段中传输数据的第一个字节的序号。明确了这个前提,我们来看一段tcp传输数据的实例:
这里写图片描述
前面三段指的是三次握手,从第四次开始客户端向服务器发送一个包含长度为1440字节流的报文段1。(注意传输数据的时候SYN=0,ACK=1,此时seq=1,ackSeq=1,),然后第四次又继续传输了一个1440的报文段2。(这里注意到,并没有在收到确认报文ack后才能继续发送,但是tcp中针对有的情况会要求需要发送ack才能继续发送报文段,后面会讲到),报文段2的seq=1+1440,ack=1。第5次,服务器发送了一个ack确认回来,这个确认报文段3,seq=1;ack=1441,说明服务器希望客户端再发送的数据是1441.后面就不具体分析了。
上面的传输是一个没有出现错误的传输。
但是,网络上会由于各种各样的原因,导致数据传送出现错误。那么tcp是怎么保证数据不会出现错误呢?
tcp中引入了超时重传机制和快速重传机制。
超时重传算法:
加入客户端发送报文段1 的时候,出现了问题,服务器没有收到。那么服务器不会发送ack,而是一直等3传输过来,等到超时还没发送过来,就会重传1,但是由于ack不能跳着确认,只能确认最大连续收到的包,因此后面2,3,4,根本不能确认,那么我们是否应该重传2,3,4呢?而且每收不到一个包就要等超时重传,这样导致效率非常的低。也许会想,把超时重传的时间阀设置的非常小,那么是不是效率就快呢?这有可能会导致,还没接收到就重发。
快速重传机制:
我们可以看到超时重传算法,有很大的不足,所以tcp在重传这件事上又引用了快速重传机制,这个机制说的是,当客户端发送报文段1,出现了问题,服务器没有收到,这时候服务器会有一个定时器,时间到了就会继续发送ack,当客户端连续收到3个相同的ack报文,就会触发快速重传机制,重新传输报文段1.
这里写图片描述
但这样仍有不足,就是重传的时候,我们仍不知道后面有几份收到了,所以仍然可能会重传后面很多份数据。快速重传相对于超时重传只是解决了重传快慢的效率问题,而重传后面的数据仍没有解决。
为了解决这个问题,tcp引入sack机制。这个问题,我感觉我讲不清楚。想详细了解的可以看这篇博客:
http://coolshell.cn/articles/11564.html
这篇博客在我看tcp/ip卷一的过程中给了我很多帮助。

四.TCP和网络的配合

在第三节,分析了tcp是如何保证数据的完整性的,这一节来分析TCP协议针对各种不同的网络环境是通过什么机制和算法来确定数据传输的速率。
在具体分析之前,先弄懂tcp传输的数据一般有两种:

  • 1.交互数据流
  • 2.成块数据流

交互数据流

指的是客户端输入一个指令,服务器根据得到的报文段相应一个指令,一般是起控制作用的数据,这种数据有一个特点就是字节很少,但是由于tcp本身首部就有20个字节,ip首部也有20个字节,那么一个数据报文段就是41个字节(假设发送的控制数据为一个字节),很明显,如果不停的发送这种只含一个这样数据的小报文段,那么很容易造成网络的拥塞。对于这种数据流,tcp的处理方式有两种:

  • Nagle算法:(应用在客户端)
  • ack延时机制(应用在服务端)

    1. Nagle算法的设计原则:

(1)如果包长度达到最大报文长度(MSS,Maximum Segment Size),则允许发送;
(2)如果该包含有FIN,则允许发送;
(3)设置了TCP_NODELAY选项,则允许发送;
(4)未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;
(5)上述条件都未满足,但发生了超时(一般为200ms),则立即发送。

Nagle算法的作用是,减少网络上小包的数量,它的机制就是,数据小包如果小于mss,那么不发送,等到把数据合在一起超过了mss就发送。在多个小包合在一起的时间内,如果收到了服务器端对先前发送数据的确认,那么立即发送刚合成好的数据包(即使它不大于mss);

Nagle的伪代码.
if there is new data to send
  if the window size >= MSS and available data is >= MSS
    send complete MSS segment now
  else
    if there is unconfirmed data still in the pipe
      enqueue data in the buffer until an acknowledge is received
    else
      send data immediately
    end if
  end if
end if

2. ack延时机制

同样是为了减少网络中数据小包数量,它的机制是,在收到客户端发送的数据,不立即发送ack确认报文段,而是在等待一段时间后(一般为200ms),希望应用程序会对刚刚收到的数据进行应答,这样就可以用新数据将ACK捎带过去。这样通过把对客户端相应的数据和ack确认数据合成一个报文段。这样就减少了tcp协议的开销。

Q1:这两种机制加在一起是否会产生1+1大于2的作用呢?
A1:并不会,甚至有可能会导致性能变差。这种典型场景就是发送端写-写-读。
即先向服务端发送一个写操作小包,在服务器中由于ack延时机制,必须等待200ms才能发送ack确认。而发送端由于发送的小包触发了nagle算法,因此第二个写操作必须等待ack确认到了才能继续发送。 写-写之间就花费了200ms+RRT,而读操作,是等待服务器发送数据过来,写-读的时间就看服务器相应的时间了。
这里写图片描述

成块数据流

和交互数据流相反,成块数据流是正常的一个tcp中带很多字节的报文段。传输这种数据的时候,如果一直传输而不考虑网速问题,那么就会导致传送数据失败,网络拥塞的问题。那么tcp是如何根据网速来决定它传输的速度呢?是如何控制自己的速度呢?
这利用了一个概念 滑动窗口。在前面介绍tcp首部的时候,有说到窗口这个东西。这个窗口就是滑动窗口,它会根据实际情况调整自己的大小,因此叫滑动窗口。
在tcp连接建立的时候,客户端和服务端会协商一个窗口的大小,但在通信过程中,接收端可根据自己的资源情况,随时动态的调整对方的发送窗口上限值。
什么是滑动端口呢?
这里写图片描述
黑框的就是滑动窗口的大小,这是一个抽象的概念,里面包含发送未确认的数据(#2),还可以发送的数据(#3)。当#2中,例如确认数据到了38,那么窗口的开始处就变成了39,窗口向右移动。
这里可以明白一点,窗口的左边是受确认序号控制的。而窗口的大小动态的受服务器返回的ack中窗口值来规定。
比如,原来窗口大小20个字节,但是由于服务器处理速度快,希望客户端多发一点数据,于是在ack报文段中,窗口设置为30个字节,那么滑动窗口变成30个字节(其实,并不是这么简单的就是30个字节,这里我们是假设不考虑拥塞窗口的问题,后面会继续分析)。
当然,更典型的是,服务端处理速度慢,发送的数据总是不能得到及时的处理,因此为了防止客户端一直发送数据,而服务器又不能处理,导致丢包重传,甚至网络拥塞,那么服务器就会把窗口设置到很小,以至于小到mss,触发nagle算法。

这个窗口的协商过程是什么呢?
发送端在确定发送报文段的速率时,既要根据接收端的接收能力,又要考虑网络拥塞问题。

min[rwnd,cwnd]

rwnd:接收窗口,也就是上面提到的ack报文段中的窗口。
cwnd:拥塞窗口,发送端根据自己估计的网络拥塞程度而设置的窗口值。
当rwnd小于cwnd时,接收端的接收能力限制了发送窗口的最大值。
当cwnd小于rwnd时,网络的拥塞限制了发送窗口的最大值。

因此tcp中有一系列针对网络拥塞的处理算法。

拥塞控制算法:

  1. 慢启动算法
  2. 拥塞避免算法
  3. 快重传(这里第三节里已经讲过)
  4. 快恢复(配合快重传使用)

首先要明确拥塞控制算法的本质都在于控制拥塞窗口的大小,至于接收窗口是由服务器决定的,而不是算法可以决定的。

1.慢启动
1.设定一个慢启动阀值:ssthresh
2.在刚加入网络开始传输数据的时候,cwnd=mss
3.每当收到一个新的ack确认报文段cwnd+=mss
4.每当过了一个RRT(发送接收的一个来回时间),cwnd=cwnd*2,成指数增长;
5.当cwnd超过ssthresh的时候,进入拥塞避免算法。
慢启动算法,可以和实际练习起来,比如用迅雷下载东西的时候,刚加入的下载资源的速度总是从 0k 开始增长到带宽最大的速度。即,刚加入网络的资源要一点点提速。
2.拥塞避免算法
1.cwnd按线性增长,每经过一个RTT,cwnd+=mss;

这里写图片描述
图中有一个地方,发生超时的时候,重传速率又是采用慢开始算法。那么每次重传都要用慢开始算法吗?

3.快恢复算法
在前面了解到不必等到重传计时器超时,只要连续收到三个相同的ack,就开始重传数据,即不必等到超时,也不必重新采用慢开始算法。而是采用快恢复算法。
当可以在超时时间内连续收到3个ack,那么说明网络不至于那么差。
1. 设置cwnd = sshthresh + 3 * MSS
2. 重传Duplicated ACKs指定的数据包
3. 如果再收到 duplicated Acks,那么cwnd = cwnd +1 (继续慢增长,等待是否有新的ack或者超时)
4. 如果收到了新的Ack,那么,cwnd= sshthresh ,然后就进入了拥塞避免的算法了。(说明已经进入网络正常阶段了)

至此,Tcp的基础算是分析的差不多了,至于更高明的处理算法,有兴趣可以详读tcp/ip协议。然后这里提一下,tcp中超时重传的时间(RTO)非常重要,以及tcp中有4个定时器,可以去查资料了解。因为没有什么分析的价值。
最后给一个tcp状态机的伪代码:
tcp状态机图片我就不贴了,好累呀。用了一个礼拜才完成这篇blog。果然认真写博客要命啊。

switch (状态){
   case closed状态 :
      if(收到“被动打开”报文)
         进入到 listen 报文;
      if (收到“主动打开”报文)
      {
         发送SYN报文段;
         进入SYN-SENT 状态;
      }
      if(收到任何报文段){
         发送RST报文段
      }
      if(收到其他报文段) {
         发出差错报文;
      }
      break;

   case LISTEN  状态:
      if(收到"发送数据"报文){
         发送SYN报文段;
         进入SYN-SENT状态;
      }
      if(收到任何SYN报文段) {
         发送SYN+ ACK 报文段
      }
      if(收到任何其他报文段或) {
         发出差错报文
      }
      break;
   case SYS-SENT 状态:
      if(超时)
         进入closed状态;
      if(收到SYN报文段) {
         发送SYN+ACK 报文段;
         进入SYN-RCVD状态;
      }
      if(收到SYN+ACK 报文段){
         发送ACK报文段;
         进入established状态;
      }
      if(收到其他报文段) {
         发出差错报文;
      }
      break;
   case SYN-RCVD 状态:
      if(收到ACK报文段)
         进入established状态;
      if(超时) {
         发送RST报文段;
         进入到closed状态;
      }
      if(收到"关闭报文"){
         发送FIN报文段;
         进入到FIN-WAIT-1 状态;
      }
      if(收到RTS报文段){
         进入到listen 状态;
      }
      if(收到其他报文段) {
         发出差错报文;
      }
      break;
   case ESTABLISHED 状态:
      if(收到FIN报文段) {
         发送FIN报文段;
         进入closed-wait 状态;
      }
      if(收到进程的关闭报文) {
         发送FIN报文段;
         进入FIN-WAIT-1 状态;
      }
      if(收到RTS或者SYN报文段) {
         发出差错报文;
      }
      if(收到数据或ACK报文段)
         调用输入模块;
      if(收到"发送"报文)
         调用输出模块块;
      if(收到其他报文段) {
         发出差错报文;
      }
      break;
   case FIN-WAIT-1 状态:
      if(收到FIN报文段){
         发送ACK报文段;
         进入closing状态;
      }
      if(收到FIN+ack 报文段){
         发送ack报文段;
         进入fin-wait状态;
      }
      if(收到ack报文段)
         进入fin-wait-2;
      break;

   case FIN-WAIT-2 状态:
      if(收到FIN报文段){
         发送ack报文段;
         进入time-wait状态;
      }
      if(收到其他报文段) {
         发出差错报文;
      }
      break;

   case closing 状态:
      if(收到ack报文段)
         进入time-wait状态;
      break;
   case time-wait 状态:
      if(超时2msl)
         进入closed状态;
      break;
   case closed-wait 状态:
      if(收到"关闭"报文){
         发送FIN报文段;
         进入last-ack状态;
      }
      break;

   case last-ack状态:
      if(收到ack报文段)
         进入closed状态;
      break;
   }

   }
   }
}

参考资料:
https://wenku.baidu.com/view/4d96cb718e9951e79b8927e9.html
http://coolshell.cn/articles/11609.html
http://blog.csdn.net/macrossdzh/article/details/5967676

猜你喜欢

转载自blog.csdn.net/mooneal/article/details/77073021