TCP协议

对于常见的网络协议来说,TCP是最为人知晓的,即便是一些业外人士只要对电脑网络有一定的了解也能说上几句,那么广为人知的TCP协议到底是什么呢,下边作为一个初学者的视角来浅谈一下

TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议 。常用于一次传输要交换大量报文的情形,如文件传输,远程登陆等。为了实现这种端到端的可靠传输,TCP必须规定传输层的连接建立与拆除的方式,数据传输格式,确认的方式,目标应用进程的识别以及差错控制和流量控制机制等。

TCP的协议数据单元被称为分段,TCP通过分段的交互来建立连接,传输数据,发出确认,进行差错控制,流量控制及关闭连接。

TCP分段格式

  • TCP协议段格式

TCP协议段格式

  • 源/目的端口号:表示数据从哪个进程来,到哪个进程去;

  • 32位序号:该分段在发送方的数据流中的位置,用来保证到达数据顺序的序号

  • 32位确认序号:下一个期望接收的分段序号,相当于是对方发送的并已经被本方正确接收的分段的确认

  • 4位首部长度:TCP头长,以32位字长为单位。表示该TCP头部有多少个4字节,所以TCP头部最大长度为15*4=60,实际相当于给出数据在数据段中的开始位置

  • 保留:占6比特,目前置为“0”

  • 六位标志位:

    URG:紧急指针是否有效,即解决报文插队的问题
    ACK:确认号是否有效
    PSH:提示接收端应用程序立刻从TCP缓冲区将数据读走(接收方的上层尽快取走)
    RST:对方要求重新建立连接(复位报文段)
    SYN:请求建立连接,与ACK合用以建立TCP连接
    FIN:通知对方,本端要关闭了。称携带FIN标识的为结束报文段
    
  • 16位窗口大小:可以理解成自己所能提供的缓冲区大小(填上自己的,给对方看,让对方根据这个数值来设置要发给自己的被滑动窗口传输的数据量)。由于窗口由16位bit所定义,所以接收端TCP 能最大提供65535个字节的缓冲

  • 16位校验和:CRC校验。接收端不通过则认为数据有问题。此处的校验和不光包括TCP首部,也包括TCP数据部分
  • 16位紧急指针:标识哪部分数据是紧急数据
  • 40字节头部选项:暂时忽略

连接管理机制

TCP连接包括建立和拆除两个过程。TCP使用3次握手协议来建立连接。连接可以由任何一方发起,也可以由双方同时发起。一旦一台主机上的TCP软件已经主动发起连接请求,运行在另一台主机上的TCP软件就被动地等待握手。

建立连接(三次握手)

设主机B运行一个服务器进程,它先发出一个被动打开命令,告诉它的TCP要准备接收客户进程的连续请求,然后服务进程就处于听的状态。不断检测是否有客户进程发起连续请求,如有,作出响应。 设客户进程运行在主机A中,他先向自己的TCP发出主动打开的命令,表明要向某个IP地址的某个端口建立运输连接,过程如下:

​ 1)主机A的TCP向主机B的TCP发出连接请求报文段,其首部中的同步比特SYN应置1,同时选择一个序号x,表明在后面传送数据时的第一个数据字节的序号是x。

​ 2)主机B的TCP收到连接请求报文段后,如同意,则发挥确认。在确认报文段中应将SYN置为1,确认号应为x+1,同时也为自己选择一个序号y

​ 3)主机A的TCP收到此报文段后,还要向B给出确认,其确认号为y+1

​ 4)主机A的TCP通知上层应用进程,连接已经建立,当主机B的TCP收到主机A的确认后,也通知上层应用进程,连接建立。

释放连接(四次挥手)

在数据传输完毕之后,通信双方都可以发出释放连接的请求。释放连接的过程为如上图所示:

​ 1)数据传输结束后,主机A的应用进程先向其TCP发出释放连接请求,不在发送数据。TCP通知对方要释放从A到B的连接,将发往主机B的TCP报文段首部的终止比特FIN置为1,序号u等于已传送数据的最后一个字节的序号加1。

​ 2)主机B的TCP收到释放连接通知后发出确认,其序号为u+1,同时通知应用进程,这样A到B的连接就释放了,连接处于半关闭状态。主机B不在接受主机A发来的数据;但主机B还向A发送数据,主机A若正确接收数据仍需要发送确认。

​ 3)在主机B向主机A的数据发送结束后,其应用进程就通知TCP释放连接。主机B发出的连接释放报文段必须将终止比特置为1,并使其序号w等于前面已经传送过的数据的最后一个字节的序号加 1,还必须重复上次已发送过的ACK=u+1。

​ 4)主机A对主机B的连接释放报文段发出确认,将ACK置为1。这样才把从B到A的反方向连接释放掉,主机A的TCP再向其应用进程报告,整个连接已经全部释放。

连接管理机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
可以根据上图看到客户端以及服务端状态变化:

客户端状态变化:
CLOSED->SYN_SENT:客户端调用connect,发送同步报文段
SYN_SENT->ESTABLISHED:connect调用成功,则进入ESTABLISHED状态,开始读写数据
ESTABLISHED->FIN_WAIT_1:客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1
FIN_WAIT_1->FIN_WAIT_2:客户端收到服务器对结束报文段的确认就进入FIN_WAIT_2,开始等待服务器的结束报文段
FIN_WAIT_2->TIME_WAIT:客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK
TIME_WAIT->CLOSED:客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间才进入CLOSE状态


服务端状态变化:
CLOSED->LISTEN:服务端调用listen后进入LISTEN状态,等待客户端连接
LISTEN->SYN_RCVD:一旦监听到客户端连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送 SYN确认报文
SYN_RCVD->ESTABLISHED:服务端一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了
ESTABLISHED->CLOSE_WAIT:当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文 段并进入CLOSE_WAIT
CLOSE_WAIT->LAST_ACK:进入CLOSE_WAIT后说明服务器准备关闭连接(处理完之前的数据);当服务端真正调用close 关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个 ACK是客户端确认收到了FIN)
LAST_ACK->CLOSED:服务器收到了对FIN的ACK,彻底关闭连接

服务拒绝式攻击

​ 在连接管理机制这里,我们可以发现如果想要黑掉一台服务器是很容易的。因为TCP是面向连接的,也就是说当服务器端与客户端要进行数据通信时首先要建立连接,而在建立连接时服务器端与客户端的关系本身是一对多的,当一个服务器被多个客户端访问时就有可能在服务器端建立很多的连接,此时我们首先需要明确一点——服务器端管理连接是要耗费成本的——空间成本和时间成本。那么也就是说服务器端操作系统通过先描述再组织的方式要把连接管理起来,即在系统层面上创建一个结构体对象,而创建一个结构体对象是需要成本的(包括内存资源),连接越多所需要的资源越多,换而言之,要想黑掉一个服务器只需要使服务器挂上大量不做任何操作的非法连接,这样就会导致正常客户想要和服务器无法正常通信——操作系统因为资源问题不可能使连接创建好。

为什么是三次握手?

​ 在了解了服务拒绝式攻击后,我们现在可以思考一个问题,建立连接为什么是三次握手而不是两次,抑或四次?

1
2
为什么不是两次握手?
两次握手会产生一个问题——最后一个响应报文(ACK)丢失,从而导致服务器端对客户端的请求进行回应(第二次握手)后,就会认为连接已经建立好。而如果客户端没有收到服务器端对其做出的回应呢?此时,客户端认为连接尚未建立,而服务器端会对已经建立的连接保存必要的资源,如果出现大量的这种情况,服务器端会崩溃。
1
2
3
4
为什么是三次握手?
我们看了上边的分析又会有一个疑问:既然没法确认第二次的握手,客户端是否可以收到,那么怎么确定第三次握手服务器端就可以收到呢?
这根本没法确定,因为完全可靠的通信协议是根本不存在的,我们任何的通信协议都是在接受这样的现实情况之上进行的。此时我们假设三次握手的最后一个ACK丢失,这时客户端认为连接已经建立好而服务器端认为没有,连接挂在了客户端上,服务器端就不用耗费资源去管理连接,有效的规避了服务器受到攻击的可能。
所以使用三次握手可以较为可靠的建立连接!

理解TIME_WAIT

释放连接时,提到一个名词–>TIME_WAIT,那么什么是TIME_WAIT?
首先我们可以通过如下测试:启动server,再启动一个client,然后使用Ctrl+C使server终止,这时立马运行server结果是:

TIME_WAIT

这是因为,虽然server的应用程序终止了,但是TCP协议层的连接还没有完全断开,因此不能监听到同样的server端口。

  • 1
    2
    TCP协议规定,主动关闭的一方要处于TIME_WAIT状态,等待两个MSL的时间才能回到CLOSED状态。
    我们使用Ctrl+C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。

TIME_WAIT为什么是2MSL呢

1
2
3
MSL是TCP报文的最大生存时间,因此TIME_WAIT持续在2MSL时间的话:
1.保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立即重启就会收到来自上一个进程迟到的数据,而这个数据可能是错误的)
2.在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失了,那么服务器会重发一个FIN。这时候虽然客户端进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK)

​ 此时会出现一个新的问题:如果服务器需要处理大量的客户端连接,但是这些连接的生存周期很短,这个时候如果由服务器来主动关闭连接清除不活跃连接就会导致服务器上产生大量TIME_WAIT连接,从而导致服务器的端口不够用无法处理新的连接。

1
2
3
4
5
6
7
如何解决这个问题呢?

在server代码的socket()和bind()调用之间插入如下代码:
int opt = 1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));

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

确认应答(ACK)机制

​ TCP将每一个字节的数据都进行了编号,即为序列号。而每一个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发。

确认应答机制

​ 由图分析:当主机1给主机2发送了1~1000这么多数据时,主机2如果收到了就会给主机1应答(ACK报文段,每一个ACK都带有对应的确认序列号),表示你给我发的1~1000的数据我已经全部收到了(收到哪些数据),下次你再给我发就给我从1001数据开始发(下次从哪里开始发)。那么主机1收到应答之后就知道对方已经收到了1~1000的全部数据,所以再一次发送数据的时候他就会从1001开始发,后面都是依此类推的情况。

​ 当然了,当我们的主机1给主机2发送了数据之后,经过一端时间主机1并没有收到主机2的应答的情况也是有的,所以这个时候为了确保数据的准确到达,TCP就有了超时重传机制。

超时重传机制

超时重传

​ 如图所示,主机A发送数据给主机B之后,可能因为网络拥堵等原因,数据无法到达主机B;那么主机A在一个特定的时间间隔内没有收到B发来的确认应答就会进行重发。

​ 但是,主机A未收到主机B发来的确认应答,也可能是因为ACK丢失了:

超时重传

因此主机B会收到很多重复数据,那么TCP协议需要能识别出哪些包是重复的包,并且把重复的丢弃掉,这时候我们可以利用前面提到的序列号就可以很容易做到去重的效果。

​ 那么,超时的时间如何确定呢?

​ 最理想的情况下,找到一个最小的时间,保证“确认应答一定能在这个时间内返回”,但是这个时间的长短随着网络环境的不同是有差异的。如果超时时间设的太长会影响整体的重传效率,如果超时时间设的太短有可能会频繁发送重复的包。

​ TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态的计算这个最大超时时间。

1
Linux下,超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次仍然得不到应答,那么等待2*500ms后再进行重传;如果还得不到应答,等待4*500ms进行重传···依次类推,以指数形式递增。在累计到一定的重传次数后,TCP会认为网络或者对端主机出现异常,强制关闭连接。

滑动窗口

​ 刚才我们讨论了确认应答机制,对每一个发送的数据段,都要给一个ACK确认应答,收到ACK后再发送下一个数据段。这样做有一个比较大的缺点:性能较差。尤其是数据往返时间较长时候。

滑动窗口

​ 这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。

滑动窗口

  • 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图窗口大小就是4000个字节(四个段)
  • 发送前四个段的时候,不需要等待任何ACK,直接可以发送
  • 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据,依次类推
  • 操作系统为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答,只有确认应答过的数据才能从缓冲区删掉
  • 窗口越大,则网络的吞吐率就越高

滑动窗口

1
2
3
注意:
1.凡是已经发送过的数据,在未收到确认之前都必须暂时保留,以便在超时重传时使用。
2.发送窗口前沿通常是不断向前移动的,但也有可能不动。这对于两种情况:一是没有收到新的确认,对方通知的窗口大小也不变;二是收到了新的确认但对方通知的窗口缩小了,使得发送窗口前沿正好不动。

​ 那么,如果出现了丢包,如何进行重传?这里分两种情况讨论。

情况一:数据包已经抵达,ACK被丢失了

滑动窗口

这种情况下,部分ACK丢失了并不要紧,因为可以通过后续的ACK进行确认。

滑动窗口

  • 当某一段报文段丢失之后,发送端会一直收到1001这样的ACK,就像是在提醒发送端“我想要的是1001”一样

  • 如果发送端主机连续三次收到了同样一个“1001”这样的应答,就会将对应的数据1001~2000重新发送

  • 这个时候接收端收到了1001之后,再次返回的ACK就是7001了(因为2000~7001接收端其实之前已经就收到了,被放到了接收端操作系统内核的接收缓冲区中)

这种机制也被叫做“高速重发控制”(“快重传”)

根据以上所讨论的,我们还要强调三点:

​ 第一,虽然A的发送窗口是根据B的接收窗口设置的,但在同一时刻,A的发送窗口并不总是和B的接收窗口一样大。这是因为通过网络传送窗口值需要经历一定的时间滞后(这个时间是不确定的)。另外,发送方A还要根据当时的拥塞情况适当的减小自己的发送窗口数值。

​ 第二,对于不按序到达的数据应如何处理,TCP标准并无明确的规定。如果接收方把不按序到达的数据一律丢弃,那么接收窗口的管理就会比较简单,但这样做对网络资源的利用不利(因为发送方会重复发送较多的数据)。因此TCP通常对不按序到达的数据是先临时存放在接收窗口中,等到字节流中所缺少的字节收到后,再按序交付给上层的应用进程。

​ 第三,TCP要求接受方必须有累计确认的功能,这样可以减少传输开销。接收方可以在合适的时候发送确认,也可以在自己有数据发送时把确认信息顺便捎带上。但请注意两点:第一,接收方不应过分推迟发送确认,否则会导致发送方不必要的重传,这反而浪费了网络资源。TCP标准规定,确认推迟的时间不应超过0.5秒。若收到一连串具有最大长度的报文段,则必须每隔一个报文段就要发送一个确认。第二,捎带确认实际并不经常发生,因为大多数应用程序不同时在两个方向上发送数据。

流量控制

​ 接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等一系列连锁反应。因此,TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制。

​ 下面通过图示例子说明入户利用滑动窗口机制进行流量控制。

​ 设A向B发送数据。在连接建立时,B告诉了A:“我的接收窗口rwnd = 400”(这里rwnd表示receiver window)。因此,发送方的发送窗口不能超过接收方给出的接收窗口的数值。请注意,TCP的窗口单位是字节,不是报文段。TCP连接建立时的窗口协商在图中没有显式出来。再设每一个报文段为100字节长,而数据报文段序号的初始值设为1.请注意,途中箭头上边大写ACK表示首部中的确认位ACK,小写ack表示确认字段的值。流量控制

​ 我们应注意到,接收方的主机B进行了三次流量控制。第一次把窗口减小到rwnd = 300,第二次又减到rwnd=100,最后减到rwnd=0,即不允许发送方再发送数据了。这种使发送方暂停发送的状态将持续到主机B重新发送一个新的窗口值为止。我们还注意到,B向A发送的三个报文段都设置了ACK=1,只有在ACK=1时确认号字段才有意义。

​ 现在我们考虑一种情况:B向A发送了零窗口的报文段后不久,B的接收缓存又有了一些存储空间。于是B向A发送了rwnd=400的报文段。然而这个报文段在传送过程中丢失了。A一直等待收到B发送的非零窗口的通知,而B也一直等待A发送的数据。如果没有其他措施,这种互相等待的死锁局面将一直持续下去。

​ 为了解决这个问题,TCP为每一个连接设有一个持续计时器。只要TCP连接的一方收到对方的零窗口通知,就启动持续计时器。若持续计时器设置的时间到期,就发送一个零窗口探测报文段(仅携带1字节的数据),而对方就在确认这个探测报文段时给出了现在的窗口值。如果窗口仍是零,那么收到这个报文段的一方就重新设置持续计时器。如果窗口不是零,那么死锁的僵局就可以打破。

​ 接收端如何把窗口大小告诉发送端呢?

​ 回忆我们的TCP首部中,有一个16位的窗口字段,就是存放了窗口大小信息;那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小时窗口字段的值左移M位。(扩大2的M次)

拥塞控制

​ 虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能雪上加霜的。

​ 发送方维持一个叫做拥塞窗口cwnd的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。发送方让自己的发送窗口等于拥塞窗口(如果再考虑到接收方的接受能力,那么发送窗口还可能小于拥塞窗口)。发送方控制拥塞窗口的原则是:只要网络没有出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去。但只要网络出现拥塞,拥塞窗口就减小一些,以减少注入到网络中的分组数。

​ TCP引入慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

​ 慢开始的思路是这样的:当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么就有可能引起网络拥塞,因为现在并不清楚网络的负荷情况。经验证明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是说,由小到大逐渐增大拥塞窗口数值。通常在刚刚开始发送报文段时,先把拥塞窗口cwnd设置为一个最大报文段MSS的数值。而在每收到一个对新的报文段的确认后,把拥塞窗口增加至多一个MSS的数值。 用这样的方法逐步增大发送方的拥塞窗口cwnd,可以使分组注入到网络的速率更加合理。

流量控制

  • 发送开始的时候,,定义拥塞窗口大小为1;

  • 每次收到一个ACK应答,拥塞窗口加1

  • 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口

像上面这样的拥塞窗口增长速度,是指数级别的。“慢启动”只是指初始时慢,但是增长速度非常快。

为了不增长的那么快,因此不能使拥塞窗口单纯的加倍

此处引入一个叫做慢启动的阈值

当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长

当TCP开始启动时 ,,慢启动阈值等于窗口最大值

在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置为一

​ 那么发送方又是如何知道网络发生了拥塞呢?我们知道,当网络发生拥塞时,路由器会丢弃分组。少量的丢包,我们仅仅是出发超时重传;大量的丢包,我们就认为网络拥塞。

延迟应答

如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小

1
假设接收端缓冲区为1M,一次收到了500K的数据;如果立即应答,返回的窗口就是500K,但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了,在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些也能处理过来,如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口就是1M

​ 一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率

​ 那么所有的 包都可以延迟应答吗?当然不是的

  • 数量限制:每隔N个包就应答一次
  • 时间限制:超过最大延迟时间就应答一次

具体的数量和超时时间,依操作系统不同也有差异:一般N取2,超时时间取200ms

捎带应答

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

面向字节流

创建一个TCP的socket,同时在内核中创建一个“发送缓冲区”和“接收缓冲区”

  • 调用send/write时,数据会先写入发送缓冲区中
  • 如果发送的字节数太长,会被拆分成多个TCP的数据包发出
  • 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去
  • 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区
  • 然后应用程序可以调用read从接收缓冲区拿数据
  • 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区。那么对于这一个连接,既可以读数据,也可以写数据。(全双工)

由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:

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

粘包问题

​ 首先明确一点,粘包问题中的“包”,是指应用层的数据包。在TCP的协议头中,没有如同UDP一样的“报文长度”这样的字段,但是有一个序号这样的字段。站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。站在应用层的角度,看到的只是一串连续的字节数据,那么应用程序看到了这么一串字节数据就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。

​ 那么如何避免粘包问题呢?明确两个包之间的边界!!!

  • 对于定长的包,保证每次都按固定大小读取即可
  • 对于变长的包,可以在包头位置,约定一个包总长度的字段,从而就知道了包的结束位置
  • 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序员自己定的)
0%