文章目录
TCP协议
一、协议需要解决的根本问题
1、如何将报头和有效载荷分离
2、将有效载荷交付给上层的哪一个协议
二、什么是TCP协议
TCP,即传输控制协议(Transmission Control Protocol),是一种面向连接的、可靠的、基于字节流的传输层协议。
TCP协议并非100%安全可靠,但是可以保证在可承受范围内的安全,同时尽可能地提高网络传输数据的效率。
三、TCP报文格式
- 源端口号、目的端口号:数据发送方和接收方的端口号
- 32位序号:一次TCP通信(从TCP连接建立到断开)过程中某一个传输方向上的字节流的每个字节的编号。假设主机A和主机B进行TCP通信,A发送给B的第一个TCP报文段中,序号值被系统初始化为某个随机值ISN (初始序号值,Initial Sequence Number)。那么在从A到B的传输方向上,后续的TCP报文段中序号值将被系统设置成ISN加上该报文段所携带数据的第一个字节在整个字节流中的偏移。例如,某个TCP报文段传送的数据是字节流中的第1025~2048字节,那么该报文段的序号值就是ISN+1025。另外一个传输方向(从B到A)的TCP报文段的序号值也具有相同的含义
TCP通过数据分段中的序列号来保证所有传输的数据可以按照正常的顺序进行重组,从而保障数据传输的完整
- 32位确认号:用作对另一方发送来的TCP报文段的响应,其值是收到的TCP报文的32位序号值加1。假设主机A和主机B进行TCP通信,那么A发送出的TCP报文段不仅携带自己的序号,而且包含对B发送来的TCP报文段的确认号。反之,B发送出的TCP报文段也同时携带自己的序号和对A发送来的报文段的确认号。
在TCP传送一个数据包时,它会把这个数据包放入重发队列中,同时启动计时器,如果收到了关于这个包的确认信息,便将此数据包从队列中删除,如果在计时器超时的时候仍然没有收到确认信息,则需要重新发送该数据包。
-
4位首部长度:以4字节为单位,标记TCP报头的大小。由于4位二进制能表示的最大数为15,且**一个标准的TCP报头的大小为20字节**(即不含有选项),因此4位首部长度的表示范围为[20, 60]
-
保留(6位):为将来定义新的用途保留,现在一般置0
-
六大标志位:
URG
:紧急指针标志(urgent),为1时表示紧急指针有效,为0则忽略紧急指针。ACK
:确认序号标志(acknowledgement),为1时表示该报文为确认报文,32位确认号有效,为0表示报文中不含确认信息,忽略确认号字段。PSH
:push标志,为1表示是带有push标志的数据,指示接收方在接收到该报文段以后,应尽快将这个报文段交给应用程序,而不是在缓冲区排队。RST
:重置连接标志(reset),用于重置由于主机崩溃或其他原因而出现错误的连接,或者用于拒绝非法的报文段和拒绝连接请求。SYN
:同步序号(synchronization),若为1则标记该报文是建立连接的请求报文FIN
:finish标志,用于释放连接,为1时表示发送方已经没有数据发送了,即本次报文是断开连接请求报文。
-
16位窗口大小:用来告诉对方本端的TCP接收缓冲区剩余空间的大小,这样对方就可以控制发送数据的速度,这是TCP流量控制的一个手段。由于是16位,因此窗口最大为65535。
-
16位校验和:用于发现TCP首部和数据在发送端到接收端之间发生的任何改动。如果接收方检测到检验和有差错,则该TCP段会被直接丢弃。
-
16位紧急指针:用来标记只有1字节的紧急数据的下一字节,只当URG标志位1时才有效。数据从第一字节到指针所指字节就是紧急数据,不进入接收缓冲区就直接交给上层进程,余下的数据要进入接收缓冲区。
-
选项:常见的有最大报文段长度MSS。
四、建立TCP连接——三次握手
-
第一次握手:client发送建立连接请求的SYN报文,并通过32位序号指明客户端的初始化序号ISN。此时客户端处于SYN_SENT状态。SYN报文不能携带数据,但是会消耗一个序号,因为下一次收到的确认序号是当前序号+1。
-
第二次握手:服务端收到SYN报文后,会同样应答SYN报文,其中包含了服务端的初始化序号ISN,同时服务端需要确认客户端发来的序号,因此该报文置ACK=1,并添加32位确认序号。此时服务端处于SYN_RCVD状态。
-
第三次握手:客户端收到服务端的SYN报文后,会发送一个ACK报文,表示确认服务端的序号。ACK报文可以不携带数据,若携带数据,则需要消耗序号。
最后一次握手的报文发出后,客户端处于ESTABLISHED状态。服务端收到第三次握手的ACK报文后也会处于ESTABLISHED状态。自此,双方的连接正式建立。
Q1:为什么需要三次握手?
TCP是全双工的,这意味着client与server至少需要3次握手交互,才能确保双方发送与接收的能力是正常的:
第一次握手client发送SYN,第二次server发送SYN+ACK,client收到后才可以确定自己具有正常的收发能力;第三次握手client发送ACK,服务端收到后才能够确定自己具有正常的收发能力。
Q2:两次握手是否可行?
不可行。设想以下情况:
客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接。在这个过程中,客户端共发出了两个连接请求报文段,其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络结点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误认为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接。
若采用两次握手,那么这次服务端发出确认报文之后,就建立新的连接了。此时客户端已经断开连接,则服务端会一直等待客户端发送数据,导致浪费资源(连接的本质就是由操作系统描述并组织一系列结构体资源)。
在此基础上扩充:如果让server作最后一次报文应答(即偶数次握手),那么一旦出错,这个资源浪费问题就会转嫁到server上。如果应对成千上万的请求,这样的浪费无疑会越积越多!
Q3:某一次握手的报文丢失会怎么样?
-
对于第一次握手,当client收不到ACK则可认定丢失,此时就会触发client端的超时重传机制。
-
对于第二次握手,一旦丢失,则client端可以认为自己第一次握手的SYN丢失,会触发重传;同时,server端也得不到响应,也会进行重传。
-
对于第三次握手,作为ACK报文,它不存在响应,因此一旦它丢失,client端并不会重传,而是由server端重传第二次握手的SYN+ACK。
五、断开TCP连接——四次挥手
客户端和服务端都可以主动断开连接,这里假设断开连接发起方为A,另一方为B。
-
第一次挥手:A向B发送断开连接的FIN报文,此时A处于FIN_WAIT1状态,等待B的确认。
-
第二次挥手:B收到FIN报文后,向A发送确认断开连接的ACK报文,此时B处于CLOSE_WAIT状态,A收到ACK报文后处于FIN_WAIT2状态,等待B端发送FIN报文。
第二次挥手和第三次挥手之间可能会有B向A发送剩余数据的情况。
-
第三次挥手:B向A发送断开连接的FIN+ACK报文,此时B处于LAST_ACK状态,等待A的确认。
-
第四次挥手:A向B发送ACK报文,确认断开连接,此时A处于TIME_WAIT状态,需要等待一段时间。如果B收到该ACK报文,则B直接进入CLOSED状态。当A的TIME_WAIT时间结束后,会自动进入CLOSED状态。
Q1:为什么第三次挥手还有对第一次挥手的ack?
TCP的报文规定:在第一次握手的SYN报文之后,所有的报文都必须带有ACK标志和对应的ack序号!但是ack序号不一定要改变。
由于A端最后一次发送信息就是在第一次挥手,因此第二次和第三次挥手的ack都是对第一次挥手的应答序号。
Q2:关于服务器上出现大量的 CLOSE_WAIT
服务器上出现大量的 CLOSE_WAIT 状态,原因在于:四次挥手的前两次已经完成,即客户端发起断开连接的请求并得到了ACK,但是后两次还没有完成,即服务端一直没有断开连接。
服务器没有断开连接的原因有很多,比如:在断开连接前系统出现BUG,或者系统的设计代码中根本没有关闭对端的socket。
因此解决CLOSE_WAIT问题的关键,就是找到系统是因为什么没有关闭对端socket(BUG或是根本没写close()
)。
Q3:关于TIME_WAIT
当最后一次挥手的ACK报文发出后,断开连接发起方就会处于TIME_WAIT状态,并且该状态会持续2MSL。
MSL即最长报文段寿命(Maximum Segment Lifetime),指任何报文在网络上存在的最长时间,超过该时间则报文被丢弃,一般MSL为30秒、1分钟等。
I.TIME_WAIT状态等待两倍MSL的意义
- 如果第四次挥手的ACK报文因为超时等原因对方暂时没有收到,那么2MSL的时间可以用来处理对方重发的第三次挥手FIN报文,并发送ACK报文。这样就可以尽最大的可能保证四次挥手的完整性。
- 2MSL时间刚好可以使本次连接持续的时间内产生的**所有报文(**比如因为重发而迟到的)都从网络中消失,这样在之后新的连接中就不会出现旧连接的请求报文。
II.TIME_WAIT的副作用
在断开连接发起方维持TIME_WAIT的2MSL时间段内,进程使用的端口依然会被占用,得不到释放。
-
如果是客户端主动断开连接,则该端口被占用并没有什么影响,因为创建新连接可以由OS自主分配可用端口。
-
如果是服务端因为异常主动断开连接,那么由于其端口不能随意更改,所以在2MSL时间内服务将无法在该端口下被重启。这可能会造成一定的损失!
III.如何取消TIME_WAIT
只要在监听套接字bind
之前调用setsockopt()
函数进行初始化,就可以使服务端在主动关闭时不经历TIME_WAIT状态。
int setsockopt(int socket, int level, int option_name, const void *option_value, socklen_t option_len)
具体调用格式为:
int opt = 1;
setsockopt(listen_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))
其中,listen_socket是我们要设置的监听套接字;SOL_SOCKET
表示在套接字级别上进行设置;SO_REUSEADDR
表示打开或关闭地址复用功能,当opt为1表示打开,opt为0表示关闭。
六、TCP缓冲区
TCP使用
send/write
发送数据,recv/read
接收数据。
事实上:
-
调用
send/write
函数时,只会将数据拷贝到内核的tcp发送缓冲区,至于什么时候发送、发送给谁、出错了怎么办,是由TCP协议决定的。 -
调用
recv/read
函数时,就是从内核的tcp接收缓冲区拷贝数据到应用层使用。 -
如果数据一直没有
recv
来拷贝,那么会导致接收窗口越来越小,当接收缓冲区满时,会将接收窗口关闭,停止接收数据。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节;读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次(TC基于字节流)。
TCP会为每一个连接双方都单独地创建接收缓冲区和发送缓冲区。而所谓的数据发送,本质上就是经由网络从一台主机的缓冲区拷贝到另一台主机的缓冲区。
此外,缓冲区在OS中本质是一个循环数组,用数组下标就可以唯一地标识数据的每一个字节。
七、TCP的特性
1、确认应答机制
TCP的每一个报文都有一个应答报文(ACK报文)。当然,应答报文是没有应答报文的。
应答报文中包含ACK + 确认序号来告知对方自己已接收之前的数据,并且下一次对方发送的序号应该是多少。
2、超时重传机制
对于主机A发送给主机B的数据,如果超过一定时间没有得到应答,则主机A会进行数据重传。累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
Linux下的累计次数最多为15次。
没有收到应答的情况有两种:
- 主机A发送给主机B,但是因为网络拥堵等原因报文无法及时到达主机B,因此主机B无法给出应答
- 主机B的应答ACK丢失
TCP为了保证无论在任何环境下都有比较高性能的通信,会动态计算最大超时时间。
解决重传导致的报文重复问题
当报文由于网络拥堵没有及时到达,从而触发超时重传,那么这两份报文就会导致报文重复问题。TCP会根据报文的序号来判断是否有重复,如果有则将重复报文丢弃。
3、滑动窗口机制
滑动窗口分为发送窗口和接收窗口,本质上对应发送缓冲区和接收缓冲区的一段空间,用来解决TCP传输中的流量控制问题。
发送缓冲区中的数据可以分为四种:
-
已发送且收到ACK的
-
已发送尚未收到ACK的
-
未发送但是允许发送的(即这部分发送过去不会超出对方的接受能力)
-
未发送且不允许发送的(即这部分发送过去会超出对方的接受能力)
而窗口中的数据对应的就是第2种与第3种。
因此,滑动窗口的大小就是一次性可以发送且无需获取ACK的最大数据量。
Q1:滑动窗口的意义
-
如果没有滑动窗口,那么TCP传输就是串行的方式:一方发送一次数据后,必须等到应答报文,才会发下一个。这样一来,效率非常差。
-
而滑动窗口使得该窗口内所有未发送的多个数据可以并行地被发送出去,不需要等待ACK响应,如此一来,效率就提高了。
-
同时,由于有滑动窗口的存在,发送方可以根据对方的接收窗口大小很好地控制自己的发送窗口大小,从而进行传输流量的控制。
Q2:滑动窗口如何工作
-
在三次握手阶段,双方会在报文中交换彼此的16位窗口大小,这个窗口大小可以决定了双方发送缓冲区的窗口初始大小(真正决定窗口大小的是接收缓冲区大小和拥塞窗口大小)。
-
当收到ACK报文时,窗口左侧会根据报文的ack序号进行移动。例如:ack是1000,那么左侧就会移动到1001的位置。
-
根据对方报文中的16位窗口大小,决定右侧窗口是否需要向右移动,即扩张窗口。
如果左边沿到达右边沿,则称其为一个"零窗口",此时发送方不能够发送任何数据。
Q3:如果发生丢包,滑动窗口如何变化
1、数据报正常到达,而ACK应答报文丢失。
一方面,如果发送方长时间收不到任何ACK报文,那么就可能会触发超时重传。
另一方面,如果在超时时间内发送方收到了ACK报文,那么发送方就会认为ack序号之前的所有数据对方都已收到,此时窗口左侧就会滑动到ack位置。当然,如果本次ack序号没有覆盖到某些已发送的报文序号,那么依然可能触发超时重传。
2、数据包丢失
假设发送方发送序号[101,200],[201,300],[301,400]
的报文,[101,200]
的报文在传送过程中丢失,而剩下的报文正常递达,那么接收端会检测到这样一个失序的报文段,并立即向发送方发送ack=101的ACK报文。当发送方收到三个及以上这样的ACK报文时,就会触发快速重传,将丢失的[101, 200]
报文重新发送。接收方在收到报文后就可以将ack=401的ACK报文发送给对方了。
Q4:滑动窗口是否会滑出缓冲区
不会。因为缓冲区本质是一个循环数组!
4、流量控制机制
TCP根据接收端的接收能力控制发送端发送速度的机制称为流量控制。
这种控制机制通过滑动窗口+窗口大小来实现。在双方通信时,接收方通过报文中“窗口大小”字段告知对方自己接收缓冲区的剩余大小。
-
当==“窗口大小”较小==时,发送方就可以根据它适时地减小自己发送缓冲区的滑动窗口大小以减缓发送速度;
-
当==“窗口大小”为0时,说明接收缓冲区已满,发送方的滑动窗口大小为0。此时,发送方会启动一个**“定时器”。在这段时间内,如果收到对方更新接收窗口大小的通知报文,则正常发送数据,如果没有收到(通知报文丢失或者确实还没有闲置空间),那么发送方会发送一个"零窗口探测报文段"**,该报文段携带1字节的数据。接收方收到该报文后,会给出接收窗口大小的通知报文。如果此时
“窗口大小”增大,则正常发送数据;如果依旧为0==,则重新启动定时器,重新发送探测报文。
5、拥塞控制机制
当一段时间内对网络资源的需求量超出了供给量,就会导致网络拥塞。比如:对于发送出去的报文,大部分都没有ACK应答,此时就可以判断网络发生拥塞,对此需要采取降低发送速度的方式缓解拥塞的状况。而降低发送速度通过==“拥塞窗口”==完成。
拥塞窗口本质是发送方维护的一个状态变量cwnd,通过2cwnd来衡量窗口的大小,从而限制当前能向网络发送的最大数据量。由于滑动窗口真正控制了当前的最大发送量,因此,滑动窗口的大小为:
min{拥塞窗口大小,根据对方报文中“窗口大小字段”调节后的窗口大小}
cwnd的变化规则
当网络没有发生拥塞时,拥塞窗口逐渐增大,并通过阈值ssthresh控制增长的速度。
1、cwnd < ssthresh时,使用“慢开始”算法:cwnd的初始大小为1,即本轮只能传输1个数据报文段。当这一轮的报文段收到ACK应答后,说明本轮次的传输结束,下一轮cwnd翻二倍增长到2。在每一轮传输结束后,cwnd都会翻倍,进行指数增长。因此,慢开始算法是指一开始传输量较小,而不是cwnd的增长速度慢!
2、cwnd > ssthresh时,使用“拥塞避免”算法:每一轮传输结束后,cwnd只增长1,即进行线性增长。该算法并不能做到避免拥塞,而是以线性增长的方式使拥塞比较不容易发生。
3、cwnd = ssthresh时,两种算法皆可。
无论是在“慢开始”阶段还是“拥塞避免”阶段,只要发送方判断当前网络拥塞,就会将ssthresh设置为出现拥塞时cwnd的一半,同时将cwnd恢复成初始值1,重新执行慢开始算法。
注:由于网络是共享的资源,因此发生拥塞时,所有与该网络相关的主机都要进行拥塞控制!
6、延迟应答机制
接收端在收到数据包时,如果立即返回ACK报文,那么可能会导致报文中的窗口大小较小。如果延迟一会儿再返回ACK报文,那么假如接收端处理数据速度比较快,在这段时间内可以从缓冲区取走相当一部分数据,那么本次应答的“窗口大小”就会更大一些,从而提高传送效率。
例如:接收端缓存区大小为1M,一次接收到了512K的数据,剩余大小为512K。若是延时一段时间,等待接受方处理了该缓存区中的数据,那么返回的窗口大小就是1M。
并非所有包都会延迟应答:
-
数量限制:每隔N个包会应答一次
-
时间限制:超过最大延迟时间M就会应答一次
-
发现收到的包出现丢失而不连续时,会立即发送ACK使对方快速重传
M和N依据不同的操作系统而定,一般N取2,M取200ms
7、捎带应答机制
捎带应答建立在延迟应答之上,如果在延迟应答的时间段内,接收方有数据已经处理好,那么这些数据就可以跟随ACK报文一起发送出去。
这种“搭乘顺风车”的方式可以减少包的传送次数,从而提高传送效率。
8、TCP面向字节流
TCP存在发送缓冲区和接收缓冲区,当有数据需要发出时,需要先将其存放到缓冲区当中。
- 如果数据过大,那么可能这份数据会被分成多个TCP数据包发出。
- 如果数据过小,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
举例说明:
假设我们连续调用两次send分别发送两段数据data1和data2,在接收端有以下几种接收情况(当然不止这几种情况,这里只列出了有代表性的情况):
- 先接收到data1,然后接收到data2
- 先接收到data1的部分数据,然后接收到data1余下的部分以及data2的全部
- 先接收到了data1的全部数据和data2的部分数据,然后接收到了data2的余下的数据
- 一次性接收到了data1和data2的全部数据
简单来说就是:
应用层的数据并不会被看做有结构的完整的数据,而是会被看作无结构的字节流,这样的字节流可以被分割成多份发送出去,也可以和其他字节流合在一起被发送出去!
TCP粘包问题
TCP是面向字节流的,因此应用层传递下来的多份数据包可能会被TCP合成一个字节流发送出去,那么就可能会发生:一个数据包的头紧跟着另一个数据包的尾的现象,这就是粘包问题。而解决粘包问题实际上就是将一个字节流进行结构划分,明确不同数据包之间的边界。
比如:
-
对于定长的数据包,保证每次都按固定大小读取即可;
-
对于变长的数据包,可以在包头约定一个包总长度的字段,这样就可以知道包的结束位置。
-
此外,还可以在包和包之间使用明确的分隔符(应用层协议,是程序员自己定义的,只要保证分隔符不和正文冲突即可)。
注:当应用层读数据时,TCP的报头已经被TCP协议给去掉了,因此应用层只需要根据自己的协议进行数据粘包处理即可。
UDP有没有粘包问题
没有,因为UDP是面向数据报的,每一个数据报都对应一份完整的数据。
八、TCP全连接队列与半连接队列
1、概念
当服务端调用 listen
函数时,TCP 进入LISTEN状态,于此同时内核创建了两个队列:
-
半连接队列,又称 SYN 队列
-
全连接队列,又称 Accept 队列
2、工作流程
- 服务端收到客户端发起的SYN请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK。
- 当服务端收到第三次握手的ACK,且全连接队列有空位,那么内核会把连接从半连接队列移除,并将该连接置为ESTABLISHED状态,添加到全连接队列,等待进程调用
accept
函数把连接取出来。
对于函数int listen(int socket, int backlog)
,第二个参数backlog可以用来设定Accept队列的长度,而根据源码设定,队列中连接的最大数量是队列长度+1。
3、全连接队列满的处理方式
当Accept队列满时:
- 如果
tcp_abort_on_overflow=1
,那么服务端发送RESET包给客户端,表示废弃这次的三次握手,对端需要重新发起连接 - 如果
tcp_abort_on_overflow=0(默认)
,那么服务端会将客户端第三次握手的ACK丢弃,对端需要重新发送ACK。
tcp_abort_on_overflow
可以通过vim /proc/sys/net/ipv4/tcp_abort_on_overflow
设置
一般情况下,应当令tcp_abort_on_overflow=0
,这样能更好地应对突发流量,因为:
即使TCP 全连接队列满导致服务器丢掉了 ACK,但是第三次握手的ACK报文发出去后客户端的连接状态就是ESTABLISHED了。那么客户端就会在已经建立好的连接上发送请求。由于服务器端没有建立好连接,因此不会回复 ACK,那么请求就会被多次重发。如果服务器只是短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接!
全连接队列的长度一般不能设计得太长,一方面维护该队列是消耗资源的,另一方面队列太长,客户端等待服务的时间也会更长。
九、TCP小结
1、可靠性支撑
校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制
2、性能支撑
滑动窗口、快速重传、延迟应答、捎带应答
十、基于TCP应用层协议
HTTP 80端口
HTTPS 443端口
SSH 22端口
Telnet 23端口
FTP 21端口
…