一、TCP协议原理(二)
1.解决TIME_WAIT状态引起的bind失败的方法
我们之前实现tcp服务器的时候发现,服务器经常有时候断开可以立即重启,有时候断开必须换端口号才能重新启动,这实际上就是因为服务器是先断开连接的一方,最后的状态是TIME_WAIT状态,这个时候服务器并没有真的关闭,还会等待一段时间确认另一方没有因为丢包等问题而重新向服务器发送FIN报文,服务器等待的时间是2*MSL(报文从一端到另一端的时间),在这期间内连接和端口依旧存在,端口依旧被占用所以导致我们无法绑定成功,下面我们可以看看绑定不成功的样例:
首先建立连接,然后我们让服务端直接关闭,然后我们就发现重启服务器这个8080端口号不能用了。在生活中一般出现这样的情景都是服务器压力过大,由于客户端连接的太多,最后导致服务器崩溃退出,这个时候退出服务器就会维持大量的TIME_WAIT,如果这个时候服务器无法立即重启就会导致公司造成大量损失,要解决这个问题实际上很简单,只需要调用系统接口setsockopt:
下面我们解决这个问题:
下面我们运行起来试一下:
可以看到当我们将服务器退出后,立即重启服务器不会像之前那样bind error了。
2.流量控制
接收端处理数据的速度是有限的
.
如果发送端发的太快
,
导致接收端的缓冲区被打满
,
这个时候如果发送端继续发送
, 就会造成丢包,
继而引起丢包重传等等一系列连锁反应
.
因此
TCP
支持根据接收端的处理能力
,
来决定发送端的发送速度
.
这个机制就叫做
流量控制
(Flow Control)
;
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
窗口大小字段越大, 说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
发送端接受到这个窗口之后, 就会减慢自己的发送速度;
如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端。
接收端如何把窗口大小告诉发送端呢
?
回忆我们的
TCP
首部中
,
有一个
16
位窗口字段
,
就是存放了窗口大小信息
; 那么问题来了, 16
位数字最大表示
65535,
那么
TCP
窗口最大就是
65535
字节么
?
实际上
, TCP
首部
40
字节选项中还包含了一个窗口扩大因子
M,
实际窗口大小是 窗口字段的值左移
M
位
;
3.滑动窗口
如果我们发送数据,没有收到应答之前,为了支持超时重传,我们必须将自己的已经发送的数据暂时保存起来,那么应该保存在哪里呢?实际上这部分数据会保存在发送缓冲区当中。
我们将发送缓冲区分为已发送并且收到应答,已发送,但是没有收到应答,数据尚未发送,没有数据只有空间这些区域,而实际上应用层拷贝数据到发送缓冲区时,会把数据拷贝到没有数据,只有空间这部分区域,其中我们把已经发送,但是没有收到应答这个区域叫做滑动窗口。
1.滑动窗口一定会向右滑动吗?会向左滑动吗?
2.窗口一定会一直不变吗?会变大吗?会变小吗?为什么?变的依据是什么?
3.收到应答确认的时候,如果不是最左侧发送的报文的确认,而是中间的,结尾的等怎么办?要滑动吗?
4.滑动窗口必须要滑动吗?会不会不动了?或者变为0了?
下面我们解决以上4个问题:
实际上滑动窗口滑动的原理就是下标的移动,而滑动窗口的大小和对方的接收能力有关,并且我们的滑动窗口是肯定不会向左滑动的,因为新数据存放的位置一定是在已发送但是没有应答区域的右边。那么一定会向右滑动吗?不一定,可能会保持不变。当对方的接收缓冲区越来越小时,我们要让win_start指针向右移动缩小滑动窗口,但是win_end指针不变所以这个时候窗口并没有滑动。
当需要更新滑动窗口的时候,我们假设win_start的报文对方已经收到并且应答了,这个时候我们只需要让win_start变成ACK_SEQ(确认报文的序号),win_end指向win_end+对方接受能力大小。当收到确认应答的时候如果是滑动窗口中间的或者结尾的,我们只需要让win_start下标移动到确认应答报文的序号位置即可,因为序号表示这个序号前面所有的报文都收到了,所以可以从序号位置开始。如果出现丢包问题,那么分为两种情况,1.数据没丢,应答丢了,这个情况不需要管,下次的确认应答收到后会直接移动到下次报文序号的位置。2.数据丢了,那么win_start下标必须移动到丢了的数据报文序号位置。
实际上滑动窗口并不是线性结构,是一个环形结构,所以可以一直滑动。
下面我们用图理解一下滑动窗口中的两种情况:
1.数据包没丢,ack丢了
2.数据包丢了
当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001" 一样;
如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中
这种机制被称为 "高速重发控制"(也叫 "快重传").
4.拥塞控制
虽然
TCP
有了滑动窗口这个大杀器
,
能够高效可靠的发送大量的数据
.
但是如果在刚开始阶段就发送大量的数据
,
仍然可能引发问题.
因为网络上有很多的计算机
,
可能当前的网络状态就已经比较拥堵
.
在不清楚当前网络状态下
,
贸然发送大量的数据
,是很有可能引起雪上加霜的.
TCP
引入
慢启动
机制
,
先发少量的数据
,
探探路
,
摸清当前的网络拥堵状态
,
再决定按照多大的速度传输数据
;
注意:在理解TCP拥塞控制这个特性的时候,一定要以多个计算机为例,在一个网络中可能有成前上万台计算机,一旦出现网络拥堵,而这个时候如果这些计算机都选择超时重传的话,将之前没有收到应答的消息又发送一次就会继续加重网络的故障问题。
慢启动机制就像试探一样,先发少量的报文探探路,如果没有回应说明网络还是堵塞状态。如果对方给了回应,那么说明当前网络状况有可能恢复,我们继续发少量报文每次比之前多一点,这样的机制是网络中所有计算机都需要遵守的。下面我们引入拥塞窗口的概念,这个拥塞窗口说白了就是给我们发送方定的一个数字,一旦我们发送的报文超过了这个窗口的大小就可能发生网络拥塞问题。这个拥塞窗口表示的就是网络的吞吐能力。
发送开始的时候, 定义拥塞窗口大小为1;
每次收到一个ACK应答, 拥塞窗口加1;
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗
口;
前面我们讲TCP报头的时候看到过里面有16位窗口大小,这个16位窗口大小实际上就是拥塞窗口。而我们在网络拥塞中每次发送数据包的时候,实际上是取对方接受能力大小和拥塞窗口之前的较小值,这样既不会超过窗口值引起网络拥塞,也不会影响发送的效率。
像上面这样的拥塞窗口增长速度
,
是指数级别的
. "
慢启动
"
只是指初使时慢
,
但是增长速度非常快
.
为了不增长的那么快
,
因此不能使拥塞窗口单纯的加倍
.
此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候
,
不再按照指数方式增长
,
而是按照线性方式增长
为什么要引入慢启动的阈值呢?因为当网络逐步恢复,窗口大小再继续以指数级增长就没有意义了,网络恢复后我们只需要看对方接受缓冲区大小是多少我们就能最大发送多少,即使窗口再大也还是受对方缓冲区大小的限制。所以一旦拥塞窗口大小增加到慢启动阈值的时候我们就开始线性增长,不再进行指数增长了。
当TCP开始启动的时候,慢启动阈值等于窗口最大值。在每次超时重发的时候,慢启动阈值会减小到原来的一半,同时拥塞窗口置为1.
下面我们讲解一下上图:横坐标是传输轮次,纵坐标是我们发送方的拥塞窗口大小。第一次我们的网络拥塞发生在发送24个报文的情况下,然后我们开始慢启动,先出两个报文开始,然后4个报文,指数增长到16个报文后受到慢启动阈值限制开始线性增长,当我们增长到24个报文的时候又一次发生了网络拥塞,这个时候重新慢启动,阈值进行乘法减小,拥塞窗口置为1,然后从1个报文开始指数增长,到达新的阈值后开始线性增长。在网络中我们可能会多次遇到网络拥塞情况,所以每次都会重复慢启动的步骤。
少量的丢包
,
我们仅仅是触发超时重传
;
大量的丢包
,
我们就认为网络拥塞
;
当
TCP
通信开始后
,
网络吞吐量会逐渐上升
;
随着网络发生拥堵
,
吞吐量会立刻下降
;
拥塞控制
,
归根结底是
TCP
协议想尽可能快的把数据传输给对方
,
但是又要避免给网络造成太大压力的折中方案
.
5.延迟应答
如果接收数据的主机立刻返回
ACK
应答
,
这时候返回的窗口可能比较小。我们举个例子:首先主机B的接收缓冲区是1M,比如主机A给主机B发送了500K的数据,主机B接收后立刻应答,那么返回的窗口就是500K,但实际上处理端处理的速度很快,10ms内就把500K的数据从缓冲区消费掉了,在这种情况下接收端处理还远没有达到自己的极限,即使窗口再大一些,也能处理的过来。这个时候如果主机B收到500K数据不要立即应答,等待10ms处理完成直接返回1M的窗口,那么我们的传输效率就会增大。
一定要记得
,
窗口越大
,
网络吞吐量就越大
,
传输效率就越高
.
我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么
? 肯定也不是。
数量限制: 每隔N个包就应答一次;
时间限制: 超过最大延迟时间就应答一次
注意:延迟应答时间不可以超过超时重传时间
具体的数量和超时时间
,
依操作系统不同也有差异
;
一般
N
取
2,
超时时间取
200ms;
6.捎带应答
在延迟应答的基础上
,
我们发现
,
很多情况下
,
客户端服务器在应用层也是
"
一发一收
"
的
.
意味着客户端给服务器说
了
"How are you",
服务器也会给客户端回一个
"Fine, thank you";
那么这个时候
ACK
就可以搭顺风车
,
和服务器回应的
"Fine, thank you"
一起回给客户端
7.面向字节流
下面我们重新理解一下TCP的面向字节流特性:
创建一个
TCP
的
socket,
同时在内核中创建一个
发送缓冲区
和一个
接收缓冲区:
调用write时, 数据会先写入发送缓冲区中;
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用read从接收缓冲区拿数据;
另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在
, TCP
程序的读和写不需要一一匹配
,
例如
:
写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次; 这里和UDP面向数据报做一个区分,如果是UDP那么读100个字节的数据只能一次读完或者一个不读。
8.粘包问题
首先我们要明确一点,粘包问题中的包是指应用层的数据包。
在TCP协议的头中,没有如同UDP一样的"报文长度"这样的字段,但是有一个序号这样的字段。站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
在应用层的角度,看到的只是一串连续的字节数据。
那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分到哪个部分是一个完整的应用层数据包。
所以粘包问题实际上是报文边界不明确,那么如何明确报文边界呢?
对于定长的包,只需要每次读取固定的大小。比如request请求结构,只需要在缓冲区从头开始依次读取sizeof(request)即可
对于变长的包,我们可以约定在包头的位置放一个包总长度的字段,从而就知道了包的结束位置。
对于变长的包,也可以在包和包之间放明确的分隔符(应用层协议,是程序员自己来定的,只要保证分隔符不和正文冲突即可)
那么对于udp协议来说,是否也会有粘包问题呢?
答案是并不会。如果还没有向上层交付数据,UDP的报文长度依然在,同时,UDP是一个一个把数据交付给应用层,就有很明确的数据边界。
站在应用层的角度,使用udp的时候要么收到完整的数据报文,要么不收。
9.TCP异常情况
进程终止
:
进程终止会释放文件描述符
,
仍然可以发送
FIN.
和正常关闭没有什么区别
.
:实际上当一个进程直接挂掉,我们的操作系统会帮我们释放掉文件描述符,所以进程挂掉操作系统是会帮我们回收当时创建的连接的。
机器重启
:
和进程终止的情况相同
:我们关闭计算机时,如果有没有关闭的程序系统会提醒有没有关闭的程序会提示用户自己关闭,如果用户不关闭系统也会帮忙杀掉没关闭的进程,所以和进程终止一样和正常关闭没区别。
机器掉电
/
网线断开
:
接收端认为连接还在
,
一旦接收端有写入操作
,
接收端发现连接已经不在了
,
就会进行
reset.
即使没有写入操作, TCP
自己也内置了一个保活定时器
,
会定期询问对方是否还在
.
如果对方不在
,
也会把连接释放。
10.TCP总结
为什么TCP这么复杂?因为既保证了可靠性同时又尽可能的提高性能。
可靠性:
校验和,序列号(按序到达),确认应答,超时重传,连接管理,流量控制,拥塞控制
提高性能:
滑动窗口,快速重传,延迟应答,捎带应答
基于TCP的应用层协议:
http , https ,SSH , Telnet ,FTP, SMTP
当然
,
也包括你自己写
TCP
程序时自定义的应用层协议。
11.TCP/UDP对比
我们说了
TCP
是可靠连接
,
那么是不是
TCP
一定就优于
UDP
呢
? TCP
和
UDP
之间的优点和缺点
,
不能简单
,
绝对的进行比较
TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播;
归根结底
, TCP
和
UDP
都是程序员的工具
,
什么时机用
,
具体怎么用
,
还是要根据具体的需求场景去判定。
12.经典面试题:UDP如何实现可靠传输
1.引入序列号(按序到达),保证数据顺序。
2.引入确认应答,确保对端收到了数据。
3.引入超时重传,如果隔一段时间没响应就重传。
13.理解listen的第二个参数
Linux
内核协议栈为一个
tcp
连接管理使用两个队列
:
1.
半链接队列(用来保存处于
SYN_SENT
和
SYN_RECV
状态的请求)
2.
全连接队列(
accpetd
队列)(用来保存处于
established
状态,但是应用层没有调用
accept
取走的请求) 而全连接队列的长度会受到 listen
第二个参数的影响
.
全连接队列满了的时候
,
就无法继续让当前连接的状态进入
established
状态了
.
这个队列的长度
是
listen
的第二个参数
+ 1.
总结
TCP的可靠性是通过各种优秀的机制实现的,重要的保证可靠性的机制有:序列号(按序到达),校验和,确认应答,超时重传,拥塞控制,连接管理,流量控制。
提升性能的机制有:滑动窗口,捎带应答,延迟应答,快速重传。
tcp的粘包问题主要是没有明确报文边界:
在传输层的角度:tcp就是一个一个的报文,按照序号存放在缓冲区中。
在应用层的角度:看到的是一串连续的字节数据,所以需要定制协议确认每一个数据包。
明确报文边界的方法:
对于定长的包,保证每次读取固定大小即可。
对于变长的包,约定在包头添加包总长度的字段,这样就明确包的结束部分。
对于变长的包,可以在包和包之间使用明确的分隔符。(只要保证分隔符不和正文冲突即可)