初识Linux · 传输层协议TCP · 上
目录
前言:
TCP结构体
首部长度
确认应答机制
捎带应答机制
超时重传机制
连接管理机制
三次握手
SYN_SENT
SYN_RCVD
ESTABLISHED
TCP为什么是三次握手
四次挥手
什么是四次挥手
状态理解
CLOSE_WAIT
LAST_ACK
TIME_WAIT
前言:
前文有关传输层协议,我们介绍了UDP协议的基本字段以及简单介绍了如何UDP添加报头之后,如何分用,如何封装的,并且简单看了一下UDP的基本源码,发现的确很简单。
接着我们引入了报文如何管理起来的问题,我们使用了六字真言,先描述再组织,所以使用结构体sk_buff描述该类型,并且利用了数据结构双链表的方式对其进行管理,我们着重介绍了的是两个指针,分别是head指针和data指针,两个指针的类型都是char*,通过两个指针我们简单展示了报文在向上交付的时候,是如何对UDP的字段赋值的。
那么从本文开始呢,我们介绍TCP协议,TCP协议相对于UDP协议来说要复杂很多,所以我们大概分为两到三篇文章介绍。
本文主要介绍:TCP的字段,确认应答机制,捎带应答机制,连接管理机制。
话不多说,我们直接进入主题吧!
TCP结构体
以上是TCP的协议段格式,我们发现它相对于UDP报头的8字节来说,复杂了不少,比如抛开选项和数据部分,报头部分就有20字节了(还有4位标志位图中没有),而在这里我们能一下就理解的是16位源端口号和16位目的端口号。
那么对于其他字段,我们要介绍的分别是首部长度,6个标志位,16位窗口大小以及32位序号和32位确认序号,其中对于紧急指针我们简单介绍即可,因为实际上应用场景并不是很多。校验和我们暂时先忽略。
在2.62.32版本中的源码中,TCP的源码如下:
struct tcphdr {__be16 source;__be16 dest;__be32 seq;__be32 ack_seq;
#if defined(__LITTLE_ENDIAN_BITFIELD)__u16 res1:4,doff:4,fin:1,syn:1,rst:1,psh:1,ack:1,urg:1,ece:1,cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)__u16 doff:4,res1:4,cwr:1,ece:1,urg:1,ack:1,psh:1,rst:1,syn:1,fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif __be16 window;__sum16 check;__be16 urg_ptr;
};
可以发现不管是大端还是小端,我们发现一共有10个标志位,其中最为常用的是图中的6个,那么我们依次介绍,从首部长度开始。
首部长度
首部长度在TCP中的作用是用来表示TCP的报头有多长,可以表明数据部分是从多少开始的,因为报头的最小长度是20字节,即不带任何选项。
那么有问题了,我们清晰的看到首部长度的一共只有4位,那么它的表示长度只有[0,15],不管怎么看连TCP最小长度都没有办法表示,所以这里的单位方面做了一点改动,这里的基本单位表示的是4字节,也就是说它实际的范围是[0,60]字节。
通过首部长度字段,接收端能够准确地分离出报文头和数据部分,也就是 定位数据的起始位置,从而可以进行有效的数据解析。
但是要注意,这里的解析数据包并不能有效的解决粘包/拆包等问题,具体我们后面再谈。
那么如果首部长度为5,也就代表了只有一个TCP报头,没有任何选项和数据,在TCP中就显示0101了,如果首部长度为6,表示报头的长度为24字节,此时就包含了额外的选项,比如时间戳一类的。
接下里,我们将结合具体的TCP通信机制来介绍其他字段。
确认应答机制
确认应答机制使用到的字段是标志位ACK以及32位序号和32位确认序号。对于确认应答机制来说,它是TCP保证可靠传输的一个重要机制,在这里我们还需要额外理解一下什么是可靠传输。
假设有两位同学AB,其中AB相隔了100米,他们之间通信只能通过是否听到声音来确定,那么就有以下的通信示意图:
A向B发送消息,B收到了消息,并且给A应答,代表B收到了这条消息,但是很明显,我们能发现一个弊端是AB中间的通信总会有一条消息是没有办法确认是否收到的,即最后一条消息。
现在,我们将AB看作服务器和客户端,A给B发送了报文,如果B对A没有应答,那么A就认为报文丢失,经过特定的时间,A就会触发超时重传机制,重新给B发送报文。
将视角放回到最后一条报文之前,假如之前发送的所有报文都有应答,也就代表C发送的报文S全部收到了,此时这是对确认应答机制最基本的理解。
但是实际上,TCP通信的时候还有另一种发送报文的机制,如图:
即一次性发送多个报文,比如发送了4条报文,分别是1000,2000,3000,4000,S就要应答,应答的报文是1001,2001,3001,4001。
此时可能的问题,或者说确认应答本身有一个问题是,如果应答报文也丢失了呢?
应答报文丢失了,也就代表C收不到应答,也就默认自己发的报文S是没有收到的,既然是没有收到,那么就会重传。
但是实际上,在确认应答机制中规定:连续发送的应答报文中,一个应答报文表示该报文之前的所有报文都收到了。比如C收到了三个应答报文,分别是1001,3001,4001,那么2001没有收到问题不打,因为收到了3001和4001,也就代表了2000的报文是收到了的。
这是确认应答机制中对应答报文丢失做出的处理,对于应答报文来说,它发送之前,会将标志位ACK设置为1,代表这是一个应答报文。
而我们也发现了,所谓的可靠传输,并不是代表所有的报文都能收到,它能保证的可靠传输,是某个报文之前的所有报文都能收到,换句话说,是所有的历史记录都能收到,而非最新的报文一定能收到。
对于上面假设的1000等数字,相信敏锐的同学也发现了,似乎有确认序号有关?那么在介绍下去实在离不开捎带应答了,所以请看下文~
捎带应答机制
对于确认应答机制的第一个图:
B的应答其实并不是完全的应答,应答应该是“吃了”,而不是带上“你呢?”,这其实是一种捎带应答机制,这种机制是将应答报文和数据报文合并为一个报文发送出去的。
这个机制的出现意在提高TCP通信的效率,因为网络通信的时候资源都是宝贵的,在保证通信完整的情况下,能减少通信的次数就减少,如果没有捎带应答机制,那么三次通信就会变成四次通信,一次两次影响不大,但是在如今的网络时代,基本都是常服务,所以影响累计下来是非常大的。
那么捎带应答机制本质还是应答,也会将ACK标志位设置1。
上文提到了序号和确认序号,我们重点来理解什么是序号和确认序号?
首先确认序号=序号+数据长度。
虽然 TCP 的数据发送看似像一个“先进先出”的队列,但其内核实现是由 sk_buff 链表和一组序号变量(如 snd_nxt
, write_seq
, snd_una
)共同维护的高效缓冲结构,支持顺序发送、乱序接收与重传控制。也就是说分配序号是通过这个高效缓冲结构进行维护的,比如A发送1-1000字节的数据,那么序号为1,确认序号为1+1000,1001,即二者都是通过具体的数据长度确定的。
当我们通过sk_buff以及递增变量获取到了序号之后,我们有以下几个应用场景,最主要的是去重,按序到达。
当应答丢失的时候,服务端以为报文丢失,于是触发超时重传,重新传输了一遍报文,此时因为报文携带了对应的序号,在客户端被根据序号进行去重,此时对于客户端来说减少了资源的使用,因为不用额外的资源去管理新来的报文了。
在OS内存在很多的报文,我们知道OS可以通过sk_buff进行报文管理,但是报文如果是杂乱无章的,管理起来还是杂乱无章的,所以利用序号,我们可以有效的按照顺序来对报文进行管理。在这里可能会担心序号超出了32位,实际上不用担心,因为32位本身就很大了,加上内核中是使用的环形缓冲区来维护的,所以基本不会出现序号不够的情况。
那么为什么需要两个序号?而非一个序号?
实际上这个问题,与捎带应答的机制有关,假如携带的报文是为应答报文+数据,那么这个报文要做的工作是给另一个端发送应答,即ACK=1,并且确认上一条报文的序号,此时需要用到确认序号,并且自己因为携带了数据,所以自己也要设置为对应的序号。
这个工作怎么看都不像是一个序号能完成的,即两个序号只能说设置的刚刚好。
在序号里面我们涉及的问题是:序号怎么来的,序号如何计算,序号的作用以及为什么需要两个序号,也是通过捎带应答机制引出来的额外问题。
对于序号的作用如下图,我们在后续文章都会逐渐涉及:
作用领域 | 是否依赖序号 | 描述 |
---|---|---|
数据排序 | ✅ | 重组字节流 |
重传识别 | ✅ | 丢弃重复数据 |
流量控制 | ✅ | 滑动窗口边界依赖序号 |
拥塞控制 | ✅ | 判断是否确认,是否丢包 |
连接同步 | ✅ | 三次握手初始化 |
状态维护 | ✅ | snd_nxt / snd_una / rcv_nxt等核心状态 |
超时重传机制
对于超时重传机制,我们后续结合实际的策略介绍,这里我们记住,服务端发送了报文但是没有收到应答,Linux中设置的是经过了500ms没有收到就会触发超时重传,因为实际上的网络波动是不确定的。
所以一次重传还没收到,那么再过2*500ms,还没有收到就4*500ms,如果仍然收不到按照这种规律累计下去,累积到了一定的次数,就认为两者的连接出现了问题,就强制关闭连接。
实际上的重传还有不止这么简单,比如有快重传,我们后面介绍。
连接管理机制
TCP的连接管理机制,最重要的就是三次握手和四次挥手机制,我们通过以下示意图逐一讲解:
三次握手
介绍握手和挥手之前,我们要清楚OS中存在了很多的报文,其中对于有传输数据的报文,也就是建立连接的报文,也有断开连接到报文,也有应答报文,也有应答加传输数据的报文,根据不同的报文OS内部有自己的一套操作流程体系。
SYN_SENT
那么对于建立连接的报文,内核中的TCP网络协议栈收到请求之后,就会做出相应的处理。
由connect发起建立连接的请求,首先是client先置为SYN_SENT状态,然后构建报文,构建报文的时候发现状态为SYN_SENT,那么就会将报文中的标识符SYN置为1,代表这是一个请求连接的报文。由此,我们能推出一个知识点:先改状态,再发报文。具体是因为构建报文的时候需要根据客户端的状态来正确的构建报文,如果是先构建报文再改状态,那么报文的构建就是不正确的。也就是说,服务端构建 ACK 报文时也必须已处于 SYN_RCVD
状态,以正确设置报文中的标志位。
SYN_RCVD
server收到了SYN报文之后,采用捎带应答的方式,首先状态改变为SYN_RCVD,构建报文的时候将SYN和ACK设置为1,因为建立报文的报文也就是需要应答的,所以这里也需要设置ACK,构建好之后发送给client。
ESTABLISHED
client收到了SYN+ACK的报文之后,状态设置为ESTABLISHED,并且给上述报文应答,ACK设置为1并且发送对应的报文给到server。
以上的这个过程,connect明显参与了对应的连接,而accept真正意义上来讲,是没有参与三次握手的,对于accept来说,它只是通过三次握手,获取到了对应的文件描述符,然后进行后续的一系列操作。
以上是三次握手的一个基本介绍,那么,围绕三次握手,我们同样可以发现一种异常情况,如果最后一个ACK收到,就开始发报文了,该怎么办?
首先最后一个ACK没有收到,那么连接代表是失败的,即发生了异常连接,此时就会未收到ACK的端就会发送一个报文,该报文的RST标志位被设置为1,代表连接重置,即标识这次连接是异常的,需要重新进行连接。
TCP为什么是三次握手
那么关于TCP三次握手能引发的问题有很多,经典问题:为什么TCP是三次握手而不是一次二次?
首先针对这个问题,我们引出另一个概念,即SYN洪水,假如只需要握手一次,那么也就代表了只需要发送一次SYN即可,此时会衍生出一种情况是黑客模拟连接,短时间内发送大量的SYN报文,此时服务器就要一次性处理指数级增长的虚假连接,就容易导致系统崩溃。那么三次握手虽然也会面临SYN洪水,但是相对来说效果要没有那么强烈。
那么以上是一个引申,对于这个问题的核心答案分为两点:
第一点是验证全双工,即网络的连通性。因为TCP是全双工的,三次握手不管怎么看,客户端收发一次报文,服务端收发一次报文,以最少的次数验证了二者能够正常的收发消息。如果是一次握手,无法验证全双工,并且连接状态无法保障,如果是两次握手,无法保证双方都能够正常读写。
第二点是建立双方通信共识的意愿,对于服务端来说,有的连接是需要明确拒绝的,所以有的时候服务器不想进行通信,那么三次握手可以让服务器有效的拒绝连接,并且,网络中会残留旧的建立连接的报文,此时如果服务器恰好收到了,也能有效地通过三次握手判断出是否应该建立连接。
当然了,还有关于TCP三次握手还有很多常见的问题,就留给读者自行探索了~
比如,为什么TCP不是四次五次握手呢?这里我们就简单提及一下,因为连接实际上也是需要维护的,连接的本质是内核数据结构,那么既然是数据结构,维护起来就是需要时间和空间的,所以网络中意在用最少的次数保证最高的效率。
四次挥手
对于四次挥手,涉及到的状态是FIN_WAIT_1/2,以及CLOSE_WAIT和LAST_ACK和TIME_WAIT,我们先重点谈论什么是四次挥手,然后再着重于它涉及的状态讲解。
什么是四次挥手
前文我们介绍了系统中存在很多的报文,报文的类型各不相同,对于四次挥手涉及到的报文是断开连接的请求,所以四次挥手解决的就是断开连接的问题。
那么由图,当客户端关闭了文件描述符,也就是代表我不想和你通信了,此时将状态设置为FIN_WAIT_1,并且发送FIN报文,其中FIN标志位设置为1,标识这是一个断开连接的请求。服务端收到了请求之后,状态设置为CLOSE_WAIT,并且发送应答报文,客户端状态被设置为TIME_WAIT。
当服务端也想断开连接了,状态设置为LAST_ACK,构建FIN报文,给客户端发送FIN报文,此时客户端的状态已经是TIME_WAIT状态,收到FIN报文之后,构建ACK报文并发送,服务端收到之后,状态设置为CLOSED。
敏锐的同学会发现,什么叫做服务端也想断开连接?难道四次挥手不能像三次握手那样进行捎带应答?是的,因为有这么一种情况,客户端是发送完了对应的报文,但是服务端还没有发完,当服务端处理好了对应的报文,此时再断开连接,也就是后两次挥手。
那么既然是有的时候报文没有处理完,直接close文件描述符是不是也不行?直接close掉之后,read都不行了,所以可以使用shutdown接口,按照我们的意愿进行关闭:
此时我们就可以按照需求关闭了。
状态理解
通过上面的图片,我们发现了四次挥手涉及的状态有TIME_WAIT, CLOSE_WAIT,LAST_ACK。
我们现在就来重点理解这三种状态。
CLOSE_WAIT
对于CLOSE_WAIT,这个状态的本质是客户端主动关闭并且发送了FIN报文之后处于的一个中间状态,只要服务端一直不关闭文件描述符,就一直是这个状态,也就是说,服务端如果还有报文没有处理完,那么就会一直处于CLOSE_WAIT状态,并且通过套接字和客户端交互。
那么我们要验证这个状态也很简单,service函数的close我们设置为10秒之后即可:
void Service(int sockfd){while (true){// read writechar buffer[1024];ssize_t n = ::read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = 0;std::string echo = "[Server say]# ";echo += buffer;ssize_t wn = ::write(sockfd, echo.c_str(), echo.size());}else if (n == 0){std::cout << "client quit" << std::endl;break;}else{if (errno == EINTR) continue;std::cout << "read error" << std::endl;break;}}sleep(10);::close(sockfd);}
然后我们让一个客户端连接并退出,这个过程我们一直netstat -nltp,就可以看到出现了CLOSE_WAIT并且过了10秒就没了。
如果系统中存在了大量的CLOSE_WAIT,就会导致服务器卡顿,主要原因是因为服务器资源管理不当,导致文件描述符耗尽,无法打开新文件。
LAST_ACK
LAST_ACK
状态的出现时机是服务端在接收到客户端的 FIN
并调用 close()
后进入的状态,表示它正在等待客户端对自己的 FIN
的 ACK。
那么具体操作我们只需要先将客户端断开,再断开服务器,就能查看到对应的状态了,不过一个人不太好测试,可以使用多台机器一起测试:
TIME_WAIT
对于TIME_WAIT状态,我们发现先断开连接的一方会先变成TIME_WAIT状态,那么我们不妨让服务器主动断开,验证它是否会有TIME_WAIT状态。
这是验证结果。
所以其实通过LASK_ACK和TIME_WAIT我们发现服务端和客户端主动断开是不一样的,谁先主动断开就先变成TIME_WAIT状态。
有意思的是,如果我们通过netstat命令查看,我们会发现TIME_WAIT存在的时间是比较久的,大概是60s左右,这个和系统的配置有关。
到这里,我们就需要引出MSL(Maximum Segment Lifetime)报文最大生存时间的概念了,对于TIME_WAIT来说它的存在时间是2*MSL。
为什么是两倍的MSL呢?
我们从两个方面出发:
1. 确保最后的ACK报文能被对方收到,因为四次挥手最后一次的ACK是没有办法确认是否收到的,所以假设ACK没有收到,那么主动关闭连接的一方有足够的时间去重传,并且重传的速度是很快的,所以第一次ACK没收到,等待它消失也就是1个MSL,然后重发,并且再等待一个MSL保证这个ACK消失,避免影响后面的连接。
2. 避免旧连接的报文影响新连接,因为如果我们关闭服务端连接,关闭客户端连接,我们立马重启服务端,我们是发现重启不了的,显示:
就是因为TIME_WAIT还存在,这个时间主要是留给处理旧连接的报文,防止新连接连接到了旧报文导致连接错乱。
那么如果我们想要立即重启,使用的函数为:
int opt = 1;
setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
以上是传输层协议TCP的部分介绍,更多的介绍移步后文~
感谢阅读!