概念:TCP(Transmission Control Protocol)是一种面向连接的、可靠的、面向字节流的传输层协议,它用于在计算机网络中实现端到端的可靠数据传输。
数据就是发送方发给接收方的资源(应用层报文数据),一个TCP报文也可以不携带数据而只有报头,因此TCP报头(Header)比较重要。
📝各个Header字段的作用如下:
在应用层,用户建立以TCP协议为基础的socket套接字,并使用系统调用
write/send
和read/recv
进行数据的发送和接收。那么,用户调用系统调用收发的数据,是从网络的对端直接收发的吗?不。实际上,当一台主机建立了一个TCP连接,其OS中会同时存在一个发送缓冲区和一个接收缓冲区(由socket打开文件句柄维护),当应用层调用write/send
,是向发送缓冲区中拷贝数据,当应用层调用read/recv
,是从接收缓冲区拷贝数据。 OS负责将发送缓冲区中的数据送到网络中,将网络中的数据收到接收缓冲区中。底层的网络传输是由传输层以下的网络层、数据链路层等负责的,传输层只负责将数据发送给和接收到正确的端口(进程)。
补充:
发送缓冲区和接收缓冲区中,存储的都是一个个加了Header的TCP报文(OS自动添加报头和解除报头),而不是应用层的原始数据。
发送缓冲区和接收缓冲区都只用于缓存应用层数据,一些TCP报文可以不进入缓冲区,直接由OS负责发送和接收,如:不带数据的ACK,SYN,FIN报头。
因此,TCP通信是全双工的,可以同时发送数据和接收数据,不会相互干扰。
要确保协议在网络传输中的可靠性,就必须知道哪些情况是不可靠的,再来解决这些不可靠的情况。网络传输中的不可靠情况有以下几种:
- 丢包:大量丢包和少量丢包
- 乱序:报文的发送顺序和接收顺序不一致
- 重复:接收端收到来自发送端的报文相同,浪费额外成本
💭为了保证可靠性,TCP引入了确认应答机制。确认应答机制需要与TCP报头中的序列号、确认序列号和标志位ACK搭配使用,因此需要先了解序列号和确认序列号。
序列号(Seq Number)
TCP中每一个字节都进行了编号,TCP的数据存在于发送缓冲区和接收缓冲区中,可以把这两个缓冲区看成是两个以字节为单位的数组,字节编号就是数组下标。由于TCP传输面向字节流,发送端发送的一个报文就是发送缓冲区中的一段以字节为单位的序列,该报文的序号(又称序列号)就是这段字节中第一个字节的编号。
🔎假设1001-2000字节是一个报文,则该报文的序列号为1001
确认序列号(ACK Number)
每个ACK应答都会有一个有效的确认序列号,以告知发送端下次期待接收的数据序列号。 确认序列号等于已成功接收到的数据的最后一个字节的序号加一。比如收到的报文是1001-2000字节(序列号是1001,大小由OS自动确认),那么确认序列号就是2001,告知发送端的信息是:2001之前的字节都已经收到了,下次请从2001号字节开始发送。注意,只有ACK标志位被置为1,确认序列号才有效。
TCP发送端向接收端发送一个报文,该报文如果在网络中丢包,接收端就无法正确地收到完整的TCP报文了。因此,为了解决丢包问题,发送端必须先得知是否发生了丢包,确认应答机制保证了发送端能够获知报文发送的状态: 发送端向接收端发送一个报文,若接收端成功收到报文,则返回一个应答(应答就是ACK标志位被置为1的TCP报文),以告知发送端接收端已经成功收到报文了,没有丢包或其它问题发生;若接收端没有收到报文,则不会返回应答。
站在接收端的角度,已经告知了发送端它发送的报文是否被接收。而站在发送端的角度,对于应答的接收有两种情况。
序列号除了在确认应答中发挥作用,还有其它两个重要的作用:
⏱发送端发出一个报文后,会等待一段时间,等待接收端的应答,时间一到,若未收到应答,则表示确认应答失败,此时发送端需重新发送该报文,这就是超时重传机制(RTO, Retransmission Timeout)。
💭那么,如何确定超时重传的时间呢?
最理想的情况是,找到一个最小时间,保证正常情况下(不出现丢包)确认应答一定能在这段时间内到达。但是这个时间难以准确设定,不同的网络环境,注定了传输速度的不同,时间也有长有短。若时间设置过长,会导致重传效率降低 (丢包早就发生了,还一直不重传);若时间设置过短,可能会在发送报文或确认应答尚未到达目标端就触发重传,导致频繁发送重复的报文。
因此,TCP为了确保无论在任何网络环境下都保证较高的通信效率,会不断动态更新这个最大超时时间。
在Linux中,超时时间以500ms为单位,初始时为500ms,若第一次重传失败(重传后仍没有收到应答),则将超时时间改为2*500ms,若第二次再次失败,再变为4*500ms,以此类推,以指数形式递增。累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接,这种情况是异常断开连接。
TCP是面向连接的,即两个网络进程在通信之前,必须先建立连接。TCP采用三次握手的方式建立连接。
🔗连接本质上就是在OS传输层中组织并管理的一种数据结构,用于保存连接的本地ip和端口号、对端ip和端口号、协议、连接状态等信息,netstat
指令可以查看网络状态,即查看本地维护的连接。一个已建立的TCP连接与对应的套接字socket相关联。
netstat命令使用指南
语法:netstat [选项]
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服务状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
客户端与服务器通信前三次握手建立连接的过程图示:
⭕三次握手的过程:
🔎什么是全连接和半连接?
连接状态为SYN_SEND或SYN_RCVD的为半连接,状态为ESTABLISHED的为全连接。服务端的TCP传输层内核维护着两个队列:半连接队列和全连接队列,这两个队列与服务端的listensocket相关联,全连接队列的长度= listen的第二个参数backlog
+ 1。
半连接队列中保存处于SYN_SEND或SYN_RCVD的连接请求,半连接长时间未变为全连接会被清除。
连接队列(即全连接队列)用于存储尝试连接到该listensocket,但尚未被服务器应用层accept
函数接受的客户端连接请求。服务端应用层调用accpet
就是获取全连接队列中的连接并建立新的文件fd供用户读写数据。
如果全连接队列满了,多余的连接则无法进入ESTABLISHED状态(始终处于同步接收状态),即无法从半连接队列进入全连接队列,过段时间会被OS自动删除。
设置backlog为1,全连接队列长度为2,此时只能存储两个连接,第三个连接到达服务端时,状态为SYN_RECV,无法进入全连接队列
过了一会,服务端清除SYN_RECV连接,客户端仍然维护着该连接
📝三次握手的优越性
三次握手建立连接时,发送的报文也存在丢包问题。
如果第一次握手的SYN报文丢包,客户端会触发超时重传机制,即使超时重传也失败了,对双方也并没有任何影响,因为客户端和服务端都没有消耗维护连接的成本。如果第二次握手的SYN+ACK报文丢包,同上,也不会对双方超时影响。
问题在于第三次握手。第三次握手是客户端已经建立好连接,向服务器发送应答,此时客户端认为三次握手已完成,连接建立成功。如果第三次握手的应答丢包了,服务器没有收到应答,不会创建ESTABLISHED连接。此时客户端和服务器对于连接的状态认知不一致,连接失败。
三次握手保证了前两次握手都有应答来确认报文发送情况,可无论如何都会有最新一次应答无法确认,因此第三次握手的ACK应答无法确认是否发到服务器上。TCP协议采用的策略是不对第三次握手的ACK应答进行确认。客户端建立ESTABLISHED连接并将应答发出后,默认三次握手已完成,不管服务器是否收到应答,它便直接通过已建立的连接向服务器发送报文了。服务器收到客户端的报文后,如果发现本地没有与报文首部端口号相对应的连接,则会向客户端发送连接重置报文(RST标志位置1的报头)。客户端收到RST报文,重新触发三次握手,建立连接。 因此三次握手的本质是在“赌”最后一次握手是否成功,成功是大概率事件,即使失败也能后续重连。
根据上一条目所述,若三次握手的最后一次握手发送的应答丢包,此时客户端已维护了一个连接,而服务器没有,此时连接失败的成本就在客户端,无效的连接会占用客户端内存资源。在CS模式(Client and Server)中,客户端是主动发起连接请求的一方,而服务器端是等待接收连接请求的一方。因为服务器一般会同时维护多个连接,处理来自不同客户端的数据,如果连接失败的成本让服务端承担,会导致服务器性能下降,浪费服务器资源,所以连接失败的成本最好嫁接到客户端上。而三次握手恰能满足这种需求。如果是两次握手呢?见如下分析:
🤝🏼两次握手:如果第一次握手丢包,没问题,双方都没建立连接对象,消耗成本。由于第二次握手即最后一次,服务端发出SYN+ACK后就认为两次握手结束,便建立了连接对象,如果SYN+ACK丢包,没有到达目标客户端,则连接失败,此时客户端并没有建立连接,连接失败的成本在服务端上,并不符号我们的预期。两次握手还有另外的问题,客户端任意发送SYN请求,服务器收到后没有其它确认机制,直接在本地创建连接,而客户端却可以不对服务端的应答进行处理,也就不会消耗成本。这样一来,服务端极易收到大量客户端的SYN请求,造成严重的空间泄漏,这种攻击称为SYN洪水。真正预防攻击的机制应该由应用层完成,但是传输层TCP也绝不能有这种明显的漏洞出现。综上得,两次握手的方案不可取。
综合三次握手和两次握手的过程,得出结论:最后一次应答报文由哪一方发出,连接失败的成本就由哪一方承担。 又因为客户端是主动发起连接请求的一方,服务器端是等待接收连接请求的一方,由此推出:奇数次握手——失败成本在客户端,偶数次握手——失败成本在服务端。 因此必须采取奇数次握手的策略!
TCP连接通信结束后,要经过四次挥手断开连接。
通信双方,任一方都可以调用close
主动发起断开连接请求,触发四次挥手,下面以客户端向服务器主动发起断连请求为例。
断开连接必须双方达成共识后才能断,即客户端调用一次
close
,对应服务端也应调用一次close
。不同于三次握手,因为服务端收到断连请求FIN后,必须由应用层调用close
后,才能再向客户端发送FIN请求,因此不能像三次握手一样发送ACK+SYN,而是先发送确认应答后,等待应用层close
后才发送FIN请求。因此挥手规定是四次,两次挥手无法保证让双方达成共识,三次挥手更无法保证,因此四次挥手是断开连接的最小成本。
⭕四次挥手的状态变化
close(fd)
,连接立即进入FIN_WAIT_1状态,并向服务端发送FIN请求,即告知服务端“我”已经关闭连接了,等待应答。close
。应用层可以通过read的返回值0,得知客户端已关闭连接,进而调用close
关闭连接。close
,则会进入LAST_ACK状态,并向客户端发送FIN请求。💭四次挥手过程中,客户端会等待一次应答,服务端也会等待一次应答。这里都存在超时重传机制,若累积到一定重传次数,认为连接异常,强制关闭连接,这样也关闭了连接。因此,如果服务端在客户端FIN_WAIT_2状态等待时间结束自动关闭连接之后再调用
close
,就会进入LAST_ACK状态并不断重发,因为客户端已经关闭连接,重发多少次都是无效的,因此达到一定上限后服务端就异常断连了。这种机制提醒程序员们在进行网络服务器开发时,切记要在通信结束后
close(fd)
关闭连接,否则连接的客户端一多,服务器中就会残留非常多CLOSE_WAIT状态的连接,这种连接会维持很长一段时间,严重占用了服务器内存资源。这种情况称为文件描述符泄漏。
关于TIME_WAIT
为什么要有TIME_WAIT状态?
主动发出断连请求的一方会进入TIME_WAIT状态,主要目的是尽量保证对端能够收到最后一个ACK应答,以完成正常的关闭连接过程。因为如果发出ACK后就直接关闭连接,而不进入TIME_WAIT状态,可能ACK还没到达对端,连接异常无法发送过去,即使对端想要重发也无济于事,因为“我”已经关闭连接了。另一个目的是保证历史数据在通信信道中消失,防止影响下一次通信。要知道,通信双方进入四次挥手阶段,可能还有一些尚未被接收或迟到的数据残留在信道中,如果不让它们消失,可能下次建立同客户端同服务端的连接时,就会收到历史残留的数据,产生意料之外的影响。
TIME_WAIT要等多久?
TCP规定TIME_WAIT的时间是两个MSL(Maximum Segment Lifetime, TCP报文在网络中的最大存活时间),以保证全双工两个信道上的历史数据全部消失。MSL的值通常是2分钟(120秒),但在不同的TCP实现中,它也可以有所不同。
Centos7默认配置为60s
TIME_WAIT导致服务器无法立刻重启问题
若服务器端主动与客户端断开连接(这种场景也是存在的,比如服务器满载崩溃),必定会进入TIME_WAIT状态,此时再用相同的端口号重启服务器会
bind
失败,因为本地还有该端口号的连接处于TIME_WAIT状态,bind
无法绑定已被使用的端口号。一般服务器的端口号是不能随意改变的,所以要想以相同端口号重启服务器,必须等待TIME_WAIT状态结束。但这是不可取的,TIME_WAIT的等待时间较长,一般大型服务器挂个几秒钟就会亏损很多,更何况是一两分钟。
解决方案:
setsockopt
函数可以改变套接字状态,使其可以重复绑定已被使用的端口号
// 函数原型:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
// 用法:
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
⭕完整流程图:
💭TCP协议为了实现可靠性,设计十分复杂,这势必会导致一些通信场景下的效率降低,因此,TCP在可靠性的基础上,也采用了一些提高性能的机制。
💭发送端的发送速度必须与接收端的接收能力相匹配,否则会导致效率下降。
- 若发送速度过快,接收端接收能力不足。这里的“接收能力”指的是接收端接收缓冲区的剩余容量,比如发送端一次发送了1024个字节,而此时接收端接收缓冲区剩余空间仅有512字节,且应用层迟迟不
read
数据,那么接收端就没能力接收数据,溢出的512字节会被丢弃。根据确认应答机制,发送端还会重传一部分数据,影响了通信效率。- 若发送速度过慢,接收端长时间空闲。好比现在接收端缓冲区还有1024字节的剩余空间,而发送端每次只发送1字节数据,接收端的应用层一次
read
的数据非常少,处理数据的效率也大大降低。
流量控制是TCP协议中用于确保在数据传输期间发送方发送速度与接收方接收处理能力相匹配的一种机制。
TCP报头中的窗口大小字段是流量控制机制的核心。 接收端每次向发送端发出一个报文,都会携带其目前接收缓冲区的剩余空间大小,即窗口大小。发送端根据接收端的窗口大小,控制发送数据的大小,确保发送速度与接收端接收能力匹配,这就是流量控制机制。
前面我们讨论的情况,都是假设通信是串行的,即TCP发送端每次只发送一个报文,然后再根据接收端应答的确认序列号,发送下一个报文。这么做效率太低,实际TCP中每次是同时发送多个报文,一个报文的发送不用等待上一个报文的确认应答,并发发送,时间重叠,提高效率。
TCP中的滑动窗口实际上是发送缓冲区中的一段区间,这段区间中的数据是可以不用等待应答直接发送的数据。
滑动窗口控制每次发送的数据范围,即每次发送应用层数据都是发送滑动窗口中的数据。
TCP初始滑动窗口大小一般在三次握手建立连接期间被确定,滑动窗口的大小变化根据应答报文中的窗口大小决定。(拥塞窗口也是决定因素之一,后面讲)
滑动窗口在发送缓冲区中的结构:
滑动窗口之前的数据是已发送已确认应答的数据,这部分数据已在发送缓冲区中无效,可以被覆盖。
滑动窗口中,前段部分是已发送但未确认应答的数据,根据确认应答-超时重传机制,这部分数据必须保留一段时间,以支持重传,后段部分是可以直接发送,不用等待上个报文确认应答的数据。每次收到接收端的应答,发送端都会根据该应答更新滑动窗口的大小。应答的确认序列号决定滑动窗口前半部分哪些数据已被确认,应答的窗口大小影响滑动窗口这次能够发送多大数据。
滑动窗口之后的数据是尚不能发送的数据,应用层wirte/send
写入数据尾插到这一部分,等待滑动窗口到达。
⭕滑动窗口工作过程:
初始时,假设发送端滑动窗口大小为5,此时窗口内的5个报文可以同时发送,但这5个报文的应答不一定同时到达发送端。
发送端收到最新一个ACK应答后,会根据该应答调整滑动窗口的大小。假设滑动窗口的区间为[winBegin, winEnd] :
winBegin = 确认序列号
winEnd = winBedin + 窗口大小
发送端将已更新的滑动窗口中的数据(包含前半部分的重传数据和后半部分首次传输的数据)全部发出。
💡总而言之,滑动窗口的工作过程就是不断向右滑动(不能向左,因为左边的数据已发送已应答),发送窗口中的数据。值得注意的是,由于TCP发送缓冲区设置为环状结构,因此滑动窗口不会越界。
🔎异常情况:
若是发出的数据报文丢包了
如图,如果是第一个报文丢包了,其它报文没丢,接收端会收到2001~5000的三个报文,并且后续每个报文的应答确认序列号都是1001(因为1001~2000的报文没收到,要告知发送端下次从1001开始发)。随后发送端会收到三个重复的确认序列号。
原本报文丢包是超时重传机制解决的,第一个报文没收到对应的确认应答会等待超时重传。而发送端在等待期间却收到了三个重复的确认序列号1001,根据重复的确认序列号1001,锁定最近的报文,判断1001~2000已丢包,不再等待超时重传,直接补发1001~2000的数据。接收端成功收到空缺的数据后,下次就发送确认序列号5001了。 这种机制称为“高速重传机制”(又称快重传)。
快重传与超时重传的区别
快重传规定发送端收到三个或三个以上(两个可能是其它因素导致的重复ACK,不可靠)的重复确认应答,立即补发以快速恢复丢失报文,不等待超时。 超时重传是重传机制的下限,快重传是重传机制的上限,是在超时重传的基础上做效率优化。
实际上,在网络世界中,通信双方进行数据传输时的效率,不仅会受到彼此的影响,还会受到网络的影响。网络中存在大量的主机,可能当前的网络情况比较拥堵,如果通信双方在不清楚当前网络状态下,贸然向网络中发送大量数据,可能会引发大量的丢包。而丢包又会触发重传,重传又再次将大量数据发入网络,又加剧了网络的拥堵情况,这是一种恶性循环。
🚄为了保证传输的可靠性,提高传输效率,通信双方必须清楚网络状态,根据网络情况决定数据吞吐量,TCP设计了拥塞控制机制。拥塞控制机制一般由慢启动和拥塞避免两部分构成。
慢启动
通信刚开始时,双方并不清楚网络状态,只能先以较慢速度传输数据,以摸清网络状态。若传输数据正常收到应答,则加快传输速度(这里的传输速度指每次传输的数据量大小),直到出现丢包问题,这就是慢启动。慢启动的加快呈指数增长,指数增长的特点是先慢后快,符合刚开始不能向网络发送大量数据,又想尽快摸清网络状况的需求。
拥塞避免
为了不增长的过快,慢启动达到某个阈值后会由指数增长变为线性增长,此后的过程称为拥塞避免。
💬拥塞窗口:
为了量化网络重载通信传输的能力,TCP中维护了一个拥塞窗口,慢启动到拥塞避免的过程称为拥塞探测,拥塞探测的本质就是在不断更新拥塞窗口的大小,因为网络状况是不断变化的,所以拥塞探测在通信过程中是持续进行的。
拥塞窗口是更新滑动窗口大小的另一指标,发送端每次更新滑动窗口时:滑动窗口大小 = min(接收端窗口大小,拥塞窗口大小)
⭕拥塞探测的状态变化:
刚开始先是慢启动,到达ssthresh阈值后(TCP一般设置初始ssthresh为65535字节)进入拥塞避免,即线性增长,每次传输拥塞窗口加1。探测时发生大量丢包则认为是网络拥塞,此时将拥塞窗口reset为1,以降低网络压力,并重新开始拥塞探测(先让网络缓一缓,再慢慢试探它)。
每次新的探测的ssthresh阈值是上一次的一半。
网络拥塞在探测全程都有可能出现,不一定是在拥塞避免阶段。
💭通信双方发送数据,希望每次发送的数据量尽量大一些,这样可以减少发送次数,因为过多的网络发送次数会降低效率。发送端发送的数据量,取决于网络情况(拥塞窗口cwnd)和对端接受能力(接收窗口大小rwnd),网络情况不是通信双方能决定的,因此TCP的策略是尽量加大每次发送端收到的rwnd。rwnd由接收端交付给发送端,因此rwnd需在接收端计算。rwnd是接收缓冲区的窗口大小,这不是TCP能随意扩大的,而是要等TCP上层的应用程序read/recv读取缓冲区中的数据后,rwnd才会变大。因此,TCP能做的只有延缓应答的时间,加大应用层读取数据的概率,尽可能让rwnd更大再交付给发送端,这就是延迟应答。
延迟应答除了延时应答,还有隔包应答:
具体的数量和超时时间,依操作系统不同也有差异;一般N取2,延时取200ms;
💭在确认应答机制里,TCP报头中的起作用的只有ACK标志位和确认序列号。因此,为了减少网络发送次数,应答不一定独立成为一个报文,而是搭乘其它数据报文的“顺风车”,仅需将该报文中的ACK标志位置为1,填上正确的确认序列号即可。
🌊TCP协议是面向字节流的,发送端应用层调用write/send是先向TCP的发送缓冲区写入应用层报文数据,数据怎么发,发多少由TCP决定。
若TCP收到的数据过长,则会拆分成多个TCP报文,若收到的数据过短,则会等待一段时间再打包一个TCP报文。
接收端应用层调用read/recv,并无法保证一次就能提取一个完整的应用层报文,因为TCP是面向字节流的,TCP数据发送到接收端时是多个TCP报文,这些TCP报文可能一个里面包含多个应用层报文,也可能多个TCP报文才能组成一个应用层报文。TCP会根据报文序号自动拼接,组成一段顺序与发送方数据一致的数据,再交付给应用层。
应用层调用read/recv看到的永远都是一段字节流数据。由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节;读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次。
粘包问题
因为应用层调用read/recv看到是字节流数据,无法直接区分报文与报文之间的间隔,因此需要应用层自己定制协议规定如何提取报文,明确两个应用层报文之间的边界。例如HTTP协议中采用的是Content-Length加空行的策略提取报文。
💭TCP/UDP对比
我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢?TCP和UDP之间的优点和缺点,绝对不能简单地进行比较
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
ENDING…