文章目录
- 1、TCP协议报文
- 1、报头和有效载荷的分离
- 2、TCP可靠性
- 3、序号和确认序号
- 4、16位窗口大小
- 5、6个标志位和紧急指针
- 2、TCP可靠性
- 1、应答机制
- 2、超时重传机制
- 3、连接管理机制
- 握手
- 挥手
- 3、流量控制
1、TCP协议报文
UDP属于TCP/IP协议族。
1、报头和有效载荷的分离
从头到选项上边这部分,总共是20个字节,5行,每行都是4个字节。源和目的端口号和UDP一样。数据就是TCP协议上层封装的信息,比如应用HTTP的响应,数据就是有效载荷。选项长度不定,选项里有一些保活用的东西,保证服务器客户端之间的链接不会一直没有信息来往。
选项可有可没有。读取前20个字节就是报头,如果没有选项,就和UDP一样的做法,如果有选项,就需要确定报头+选项的长度。4位首部长度占4个比特位,可选择的就是0000到1111,也就是最大为15,首部计算的基本单位是4字节,也就是最大为60,最小为0,这个数字表示的报头+选项的长度总共最大为60,报头占据20个字节,选项则最多占据40个字节。如果是标准报头,选项为0,报头为20,那么4位首部长度应当表示20个字节,也就是5 * 4,4是基本单位,那么它就表示为0101。
对于TCP协议,前20个字节必然是报头,提取后,用首部长度字段 * 4 - 20,等于0就直接拿有效载荷,如果不等于0,减20得到的数就是选项的字节数,那就继续读取选项,读完后剩下的就是有效载荷。
至于选项,首部长度是如何实现这种判断的,有很多种办法,其中一种就是柔性数组。柔性数组可以定义变长结构体,结构体中前面的这些部分都写好后,最后带一个柔性数组,当需要多少选项时,就多malloc多少,然后加上20个字节,填充进首部长度,这样就实现了报头 + 选项的结构体。
通过上图会发现,TCP中没有能够计算有效载荷长度的数,这是因为TCP面向字节流,在收到信息时,就已经知道总体的字节数了,所以有效载荷自然也就能知道了。
2、TCP可靠性
TCP可靠。不可靠的行为有少/大量丢包、乱序、重复、校验失败、发送太快/慢、网络出问题,这些问题的原因是通信双方距离变长。
可靠性的核心就是应答机制,无论是请求还是应答方,双方在进行交互时,发送的都是TCP报头 + 有效载荷。应答机制也对应丢包问题的解决。 丢包首先就要能够知道包已经被丢弃。
客户端C,服务端S,C对S发送报文,S必须发送应答,但应答也是报文,也有可能丢掉。发送并发送确认信息(应答)是必要的,C如果收到应答,那么就说明S一定收到了报文;如果收不到,C就认为,报文丢了。无论收到还是收不到,相互发送有时间成本,所以在一定的时间内,如果C没收到报文,就认为报文丢了。
通过以上得知,可靠性是通过收到应答保证的。C如果收到应答,就能够保证C发送给S的报文是一定被收到的,但不能保证S发出来的应答是否已经丢弃。这也就是局部可靠性。我们无法保证任何报文都可靠送达,只能保证局部可靠。
TCP中client和server的低位是对等的,S也是局部可靠性,S发送给C消息,C要给应答,同样地,S只能保证成功发送给C,但C的应答并不保证。
所以客户端保证发送到服务端的报文可靠,服务端保证发送到客户端的报文可靠。但以上的办法可以看出是串行的,效率低。
真实的情况:C并行发送多次报文,S也并行发送多次应答,这样时间重叠了,提高了效率。但多个报文经过网络,发送顺序并不一定是收到的顺序;客户端收到多个确认的时候,客户端并不知道是哪几个报文正确发送了。所以报文必须得带上序号,防止乱序情况,收到应答方也得确认序号。
3、序号和确认序号
上图中,报头中还有序号和确认序号,序号是客户端对自身报文的编号,服务端给的应答必须要有确认序号,C发送的序号是10,那么S发送的确认序号就得是11,S发送的序号是11,那么C的确认序号就得是12,序号按升序走,就能保证报文按序到达。
确认序号X的含义是X - 1之前的报文已经全部收到,下次发送请从X号开始发送。为什么序号是10,确认序号不能是10?假设C发送了10和11,S会回复11和12,如果11这个确认序号C没有收到,只收到了12,那么意思就是11之前的报文已经全部收到了,也就是10和11都收到了,下次发送C就会发12号报文,所以这里的确认序号允许少量的应答丢失,也能更细粒度地确认丢包的原因。
为什么序号和确认序号不用一个四字节来表示?TCP是全双工的,C和S都会同时发送和接收。以S来说,S发送的报文包括对C发送到S的报文的确认和S要发送给C的报文,两个报文可以分开发,但效率更低,所以要合并发,压缩成一个应答,这就是捎带应答。这样的情况,S必须要有序号和确认序号,两个序号干两个活,同时存在,所以需要分成两个序号。
4、16位窗口大小
传输层内部包含发送、接收缓冲区,传输层上面有系统调用层,再上面就是应用层。应用层也有缓冲区,使用send/write等系统接口的本质就是把应用层缓冲区的内容拷贝到传输层的发送缓冲区,然后继续向下发,通过网络发到另一个主机上的传输层的接收缓冲区,再通过read/recv等系统接口来到应用层的缓冲区。
发送的过程由传输层决定,发送多少,发送速度,也就是TCP协议决定,所以它是传输控制协议,应用层拷贝完就返回,全权交给网络自己去做。两方主机,如果对方主机TCP的接收缓冲区满了,那么我方的TCP再次发送时,报文到了那里就会被丢弃,因为缓冲区满了,但这些报文已经消耗了网络资源,所以这样就会出现低效的情况。相应的解决方案是流量控制,控制发送方发送的速度,让接收方有时间清理缓冲区。TCP之间有应答,发送方发送后,等到接收方给到应答再发送。因为有缓冲区的存在,且每个报文必须都有应答,就可以设置一个窗口大小来限制,窗口大小表示多少对方接收缓冲区剩余的空间,那么我方就发送少于这个空间的报文。一个接收方,窗口大小填自己的接收缓冲区的剩余空间大小。流量控制不仅限速,也加速(如果发送太慢了,或者接收方消化得很快,我方相对应慢)。
检验和就是用来检验报文是否合格的,成功就无事,失败就丢弃整个报文,这时候才是丢包,丢包之后不给应答,那么TCP的发送方就有超时重传机制。
保留的6位是留出来的位置,协议的定制者想好后再填,不填也行,就空着。
5、6个标志位和紧急指针
一个只占一个比特位。
假设客户端C,服务端S,一个服务端会收到不止一个服务端的请求。TCP是面向链接的,C会向S发送链接请求,然后进行正常通信,然后发送断开链接的请求等等。一个S,会在同一个时间段内收到很多各种各样的报文请求,即报文格式内容都不一样。报文类型不同,不同的报文也得给到不同的服务,报文的类型由标志位来标识。标志位总共6位,是哪一类型,哪个位置就是1,其它为0,比如00100,就是RST报文。
ACK:确认应答报文。如果携带数据就是捎带应答,不是就是纯ACK
SYN:连接请求报文。伴随3次握手
FIN:断开连接报文。伴随4次挥手
PSH。在接收端接收缓冲区满了之后,发送端得到报文说剩余空间为0,发送端就得等待,但等待多久?发送端每隔一段时间就发送不带数据只有报头的报文来找接收端,看看空间有没有留出来,如果多次发送,接收端的接收缓冲区剩余空间都是0,说明接收端的应用层读取数据太慢或者还没读取等问题,这时候发送端就发送一个PSH报文。提示接收端应用程序立刻从TCP缓冲区把数据读走,这就是PSH报文的作用。
RST。发送SYN报文后会发生3次握手,C向S发请求,S发应答,C再发请求,这个不一定能成功,如果成功,就建立了连接,这时候如果S释放了链接,但C不知道,C还会发送请求,还会维护链接,不过肯定没有S发送给C的任何报文,当S重新用这个链接时,C发过来了数据,但S会认为,不应当直接发送数据,应当先握手,此时S就知道C的这个链接是老的,是异常的,S就会发送一个RST报文,C得到RST报文就断掉现在的这个连接,重新跟S握手。RST就是reset,重置连接。
URG。紧急指针标志位。TCP报文都是按序处理的,根据序号来排队处理。有些报文需要紧急处理,就得插队,那么UGR报文就是用来表示紧急报文的,当报文是URG类型的,报头中的紧急指针也就有效。紧急指针是偏移量,表示紧急数据在有效载荷部分中的偏移量,这样接收方就可以直接找到紧急数据并处理。紧急数据的大小固定只有1个字节。URG报文发送方要特殊地发送,接收方要对应特殊地接收。发送法上传数据的中途取消上传或者暂停上传,它需要尽快实现这个行为,而不是和平常一样排队处理,这时候就可以发送URG报文,紧急数据这时候就是状态码,表示要取消还是暂停,紧急指针可以新开辟一个链路,只处理这个URG报文。紧急数据也叫带外数据。想查看一个服务器状态时也可以用这种报文,即使服务器忙,也可以开辟一个新链路去查看状态,返回状态码来得知,recv系统接口中有一个参数是flags,是一个标志位,其中有一个MSG_OOB,这个标志位就是让recv读取带外数据,发送的接口send也有这个flags,也有这个标志位MSG_OOB,就是发送紧急数据,send和recv都采用MSG_OOB才可以处理紧急数据。不过实际上,这个URG不常用,因为可发送的数据太少,不如开两个端口,一个正常处理,一个紧急处理。
2、TCP可靠性
1、应答机制
主机AB,应答机制体现在A到B和B到A,比如B到A,B会知道是否有应答,有应答,说明发送成功,没有应答说明失败,无论怎样,结果都知道,A到B也是如此,所以就能双端都有可靠性。应答可以是无关的,只要序号是对应的,回复了就行。
TCP不关心发送缓冲区的数据是什么。TCP是面向字节流的,以它的视角来看,发送缓冲区就是以字节为单位的,多个单位的空间,应用层数据拷贝过来后,TCP也当做一个字节流,放到缓冲区,缓冲区是一个数组,下标对应着每一个字节的数据,所以下标就会当作序号。以字节流拿到数据,以字节流发送数据。
2、超时重传机制
主机AB,A为发送方,B为接收方,发送方知道是否丢包的办法就是看有没有应答,有就没丢包,没有就说明发送的报文丢失了或者接收方的应答丢了。只要A没有收到应答,就会重发。
如果是因为B的应答丢失,A重发,B就收到了两次相同的报文,那么B就需要有去重功能,如果B收到相同序号的报文,就丢弃重复的那个。
A发送出去的数据,在得到应答前或者超时前都必须维护好这个数据,以便超时重传。那么超时重传的这个等待的时间间隔应该如何设置?为了得到合适的时间,TCP会动态计算这个最大超时时间,Linux以500ms为一个单位,每次判定超时重发的超时时间都是500ms的整数倍,第一次等待500ms,没等到就重发,并等待1000ms,没等到再重发,并等待1500ms。当多次这样做后都没有应答,就认为接收方没给应答。
3、连接管理机制
握手
客户端创建套接字,调用connect接口发起连接请求并等待其完成,也就是发起了3次握手,客户端向服务端发送SYN报头,也就是标志位SYN那里被置为1,但不包含数据,只有报头,服务端收到SYN报头后再发送SYN + ACK报头,客户端再发送ACK报头。
三次握手的目的是建立连接,握手的过程是双方的OS的TCP层自主完成的。同一时间段内,多个客户端要请求连接服务端,OS就会进行多个3次握手,建立多个连接,并用数据结构来管理它们。 通过以上得知,创建维护连接是有成本的,会消耗内存 + CPU资源。accept接口的作用就是等待建立完成,获取连接。
客户端发送连接请求,发送SYN报头后,客户端的状态变为SYN_SENT,服务端收到SYN报头后状态变成SYN_RCVD,客户端收到SYN + ACK报头并发送ACK报头后状态就变成 ESTABLISHED,服务端收到ACK报头后状态也变为ESTABLISHED,至此3次握手结束,两端都进行了3次接收或发送,两端也就都完成了3次握手。
很多表示握手的图,两端之间的线都是斜向下的,因为图的隐藏线是时间线,时间从上到下逐渐前进,发送请求是有时间差距的,客户端发送和服务端收到不是一个时间点,所以是斜向下。
3次握手中,如果第一次SYN发送失败,对服务器没有影响,客户端会重发;第二个SYN + ACK丢失,就说明没有建立起来连接,3次握手没成功,而且也有应答,所以这个也没什么问题;第三次ACK没有应答,所以客户端不知道有没有建立好连接,但只要发出ACK,客户端就认为3次握手成功,如果服务端收到,那就说明真正地成功了,如果服务端没收到,就没连接成功,这时候客户端认为成功了,就开始发送请求,服务端收到后认为握手没有成功,不应该开始交互,就意识到最后一次握手丢包了,服务端就发送连接重置报文,也就是RST报文,客户端就断掉连接,重新握手。多次重新握手的概率会逐渐叠加,所以不担心一直连接不上,除非两端的压力就是没办法很好地连接。连接不是必须一次成功,也不是必须连接上。
2次握手不行,客户端发送给服务端,服务端再回复给客户端,服务端发送出去后就认为建立完成,但如果丢失,客户端就知道没建立完成。这里的问题是客户端发送一次成功后,服务器就能发送回去,服务端就认为建立成功,既然这样,那么客户端可以发送海量SYN,然后对响应不做处理,不断发送,服务器一直认为建立成功,创建了海量连接,占据了大量的内存 + CPU资源,所以不行。这就是SYN洪水。
4次以上握手也不行。在3次握手中可以发现,最后一次发送报头的那一端就是先完成握手过程的一端,3次中就是客户端,而4次则是服务端,2次也是服务端,奇数次都是客户端先完成,偶数次都是服务端先完成。连接都有可能出现连接异常,偶数次握手由服务端最后发送报头,连接异常的问题就会出现在服务端上,所以要用奇数次握手,连接异常放在客户端上。服务端要跟很多个客户端建立连接,所以不能让服务端出现大问题。
3次握手相对来说不易被攻击,更安全,没有明显设计漏洞,一旦建立连接,出现异常,成本会出现在客户端,服务端成本较低;3次握手能够验证双方通信信道的通畅情况,且是验证全双工通信信道通畅的最小成本。3次握手通过两次SYN和SYN + ACK报头,两端都验证了自己的发送接收没有问题,2次握手服务端发送后并不知道客户端是否收到。
3次握手把这方面的安全隐患降到最低,但不是没有,还是可以有SYN洪水的,但这样的SYN洪水成本就高多了,攻击一直有,没法避免,只能每一部分都尽量将自己的安全问题降到最低,然后交给负责安全的人去处理外来干扰。
发送数据是把数据拷贝到TCP的发送缓冲区,由发送方的OS自主发送的,接收是由接收方的OS自主发送的。
挥手
上面的3次握手也可以看作4次握手,客户端SYN,服务端ACK,服务单SYN,客户端ACK。
客户端使用close时就开始了4次挥手,由客户端发起FIN报头,表明要断开连接,服务端发送ACK应答,表明已经知晓这个请求,服务端通过自己的调度断开连接后,就发起FIN报文告知客户端,客户端接收后就发送ACK应答,表明客户端已经知道连接断开了。
如上图,客户端发起FIN后,进入FIN_WAIT1状态,服务端收到后断开连接请求后变成CLOSE_WAIT状态。客户端要断开连接时,服务端无法阻止。服务端发送ACK,客户端收到后变成FIN_WAIT2状态,到服务端发送FIN报头后,服务端变成LAST_ACK状态,客户端收到后变成TIME_WAIT状态,客户端发送ACK应答,客户端变成CLOSED状态,服务端收到ACK后,就断开连接释放各种资源变成CLOSED状态。中间服务端发送给客户端的ACK和FIN报头,即使丢弃也有对应措施,最后一次客户端发送ACK没有应答,客户端就会等待一段时间,尽量保证服务端收到ACK再断开连接。
发出ACK后客户端就已经完成4次挥手,服务端收到ACK后才是4次挥手完成。如果服务端一直没法FIN,那么连接就断不开,服务端一直处于CLOSE_WAIT状态。当客户端处于FIN_WAIT2时,如果长时间服务端不关掉,那么客户端就自己断掉,所以服务端要及时close(fd)。客户端自己关闭了,服务端处于CLOSE_WAIT状态,文件描述符打开了多个,此时如果直接关掉服务器,那么服务端大概率处于LAST_ACK状态,然后给客户端发送FIN,告知客户端要断开连接了,但客户端此时早就CLOSED了,服务端就会重发好多次,等待响应,一直没有,那么服务端就不发了,断开连接,处于CLOSED状态。
服务端主动断开连接,TCP自动挥手,客户端会处于TIME_WAIT状态,此时连接断开了,不过资源没有完全释放,因为客户端还处于TIME_WAIT,维护连接的一些数据结构还没消除,所以这是一个边界状态,端口号还在被使用。此时想重新开启,就会发生绑定错误。为了能够立马重启服务器,无视TIME_WAIT状态,用setsockopt接口
level设置成SOL_SOCKET,服务器允许被复用;optname设置为SO_REUSEADDR,地址复用;optval设置成整型,optlen设为1。之前的Sock.hpp中
void Socket()
{
_sock= socket(AF_INET, SOCK_STREAM, 0);
if(_sock < 0)
{
logMessage(Fatal, "socket error, code: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
//添加下面的
//设置地址是可复用的
int opt = 1;
setsockopt(_sock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
}
服务器主动断开后,客户端为什么要处于TIME_WAIT而不是处于CLOSED状态?TCP协议规定,主动关闭连接的一方进入TIME_WAIT状态需要等到2MSL时间,MSL时间是一个报文在网络中存在的最大时间,也就是一端发给另一端的时间。客户端发送还需要接收,所以是2MSL。MSL时间是可以配置的,可以设置得大一些。 Linux中是60s,通过这个命令查看
cat /proc/sys/net/ipv4/tcp_fin_timeout
这样修改
sudo echo 10 > /proc/sys/net/ipv4/tcp_fin_timeout
等这个时间的原因是保证之前的报文都能够交付,让这些报文消失再彻底关闭。不消失的话,再重开服务器,服务器会收到之前有序号的报文,服务器会觉得这个序号之前的报文都已经完成了,所以会影响新发的报文,出现很奇怪的问题,不过这件事发生的概率很小。
连接尽量不要异常断开,比如上面服务端在CLOSE_WAIT状态待太久。在客户端是TIME_WAIT状态时,如果客户端发送的ACK丢失,那么服务端收不到回应,就会再次发送FIN的超时重发报文,客户端就知道ACK丢了,就再次发送ACK。如果服务端发送不了FIN,实际上概率也很小,因为刚刚才发了ACK,这样的就只能客户端等到时间结束自己关闭,最后异常断开连接。
3、流量控制
3次握手之后,才会开始发送带数据的报文,在握手时发送的都是报头,握手过程中双方就知道了之后发送数据时要发送多少,也就是在之后的第一次发送数据前就控制好了流量,之后再动态控制。
接收端发送的报文中,窗口大小填充了可以接收的缓冲区大小,窗口大小字段越大,网络吞吐量越高。如果缓冲区满了,窗口大小就是0,客户端就不发送消息,但会定期发送报头(不携带数据)询问缓冲区大小,也就是窗口探测报文。,长时间为0就催促接收端应用层赶快读走数据。
结束。