传输层协议UDP和TCP
1、UDP
传输层:负责数据能够从发送端传输接收端。
再谈端口号:端口号(Port)标识了一个主机上进行通信的不同的应用程序。

如图,主机A上有许多服务,那么当传输层接收到数据,如何得知要将数据交给上层的哪个进程呢?网络通信本质就是进程间通信。所以就需要端口号来标识要将数据交给上层的哪个进程。一个端口号只能被一个进程绑定,而一个进程可以绑定多个端口号。

如图:客户端B向服务器发起请求,客户端A的web浏览器有两个页面向服务器发起请求。服务器要将响应的资源返回,而请求的时候会携带自己的源IP地址和源端口号,因此服务器将来返回的时候就可以根据IP地址找到主机,但是对于客户端A的浏览器请求了两个不同的页面,所以还需要端口号来区分要交给谁,而不会给反了或给错了。
所以之前我们是通过源IP+源端口+目的IP+目的端口四元组来标识一个网络通信的。那么实际上源IP和目的IP是添加在IP报头中的,然后源端口和目的端口是添加在TCP首部的,并且还需有一个协议号来标识传输层用的是什么协议。所以现在就通过上面的四元组+协议号来标识一个通信。
0 - 1023:知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议,他们的端口号都是固定的。
1024 - 65535:操作系统动态分配的端口号. 客户端程序的端口号,就是由操作系统从这个范围分配的。
cat /etc/services:可以查看一些知名端口。
UDP协议:

UDP协议报文如图:第一行32位,4个字节,前十六位表示源端口号,后十六位表示目的端口号。第二个四字节表示16位UDP长度和16位校验和。16位UDP长度是整个报文的长度,也就是报头8个字节加上数据的长度。如果校验和出错,就会直接将报文丢弃。
1、UDP如何做到解包的?
在读取UDP报文的时候,直接读取前八个字节,就是UDP报文的报头,剩下的就是有效载荷了。
2、UDP如何做到分用?
UDP报头里面就有16位目的端口号,根据16位目的端口号就可以找到进程。
3、接收方收到的UDP报文可能有多个黏在一起,这就是粘包问题。那么操作系统是如何准确的把一个UDP报文读上来呢?
UDP报文的前八个字节就是固定的报头,读取前八个字节将16位UDP长度提取出来,然后将长度减8算出来的就是有效载荷的长度,就可以根据这个长度去读取数据了。
16位UDP长度,这种自己报头里会描述有效载荷的特性我们称为自描述字段。

上图就是Linux内核中UDP报头的结构体信息。

应用层发送数据hello,交给下层,我们知道操作系统要对报文进行管理,所以struct sk_buff就是一个一个的报文。head指针指向数据头部,end指向尾部。并且之前也说了TCP全双工是存在两个队列的,一个接收队列一个写队列。

那么现在报头无非就是两步,第一步:将head指针往前移动udphdr大小个字节。第二步:接着将指针强转成struct udphdr*然后就可以访问里面的成员,对里面的成员进行赋值。
UDP的特点:
无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接。
不可靠:没有确认机制,没有重传机制。如果因为网络故障该段无法发到对方,UDP 协议层也不会给应用层返回任何错误信息。
面向数据报:不能够灵活的控制读写数据的次数和数量。应用层交给 UDP 多长的报文,UDP原样发送,既不会拆分,也不会合并。
UDP的缓冲区:
UDP没有真正意义上的发送缓冲区。调用 sendto 会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报文的顺序和发送UDP报文的顺序一致。如果缓冲区满了,再到达的 UDP 数据就会被丢弃。
比如主机A给主机B发送的顺序是ABC,而主机B接收到的顺序可能就是BCA,这也是属于不可靠传输的一种。
我们注意到,UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大长度是64K(包含 UDP首部)。然而64K在当今的互联网环境下,是一个非常小的数字。如果我们需要传输的数据超过 64K,就需要在应用层手动的分包,多次发送,并在接收端手动拼装。
基于UDP的应用层协议:
NFS:网络文件系统
TFTP:简单文件传输协议
DHCP:动态主机配置协议
BOOTP:启动协议(用于无盘设备启动)
DNS:域名解析协议
2、TCP
TCP全称为"传输控制协议(Transmission Control Protocol)"。人如其名,要对数据的传输进行一个详细的控制。

TCP有两个缓冲区,一个发送缓冲区一个接收缓冲区,当我们调用write函数时,本质是将数据拷贝到发送缓冲区中,对方调用read获取数据时,本质上是从接收缓冲区中拷贝数据。而发送数据本质就是将主机A的发送缓冲区的数据拷贝到主机B的接收缓冲区中,所以网络通信的本质就是拷贝。
上层将数据拷贝到发送缓冲区,本质就是拷贝给操作系统,未来数据发送的相关问题:什么时候发、发多少、出错了怎么办等,都是由操作系统自主决定的,所以TCP叫做传输控制协议。而UDP不存在发送缓冲区,write之后将数据交给下层,UDP做不到传输控制。
2.1、TCP协议段格式

如图,TCP报文包含报头和数据,其中前20个字节是固定的,图中就是五行,每一行四个字节,总共20字节。第一行为16位源端口、16位目的端口。第二行32位序号。第三行32位确认序号。第四行,4位首部长度,保留六位,还有六个标志位,16位窗口大小。第四行有16位校验和与16位紧急指针。
另外TCP还可以携带选项,如果不带选项就是20字节的报头+数据。
1、TCP是如何解包的?
首先读取前20个字节的固定长度,然后提取4位首部长度,根据4位首部长度的大小就可以获取完整的TCP报头,这个4位首部长度是包含固定的20字节+选项的长度的。那有人就会说了,4位首部长度最大就是1111,转换成十进制就是15,连前二十个字节都表示不了?
4位首部长度是有基本计算单位的,它的基本单位是4字节。
4位首部长度的取值范围为0000->1111,也就是[0,15],还需要再乘以4,所以最后4位首部长度可以表示的范围就是[0,60]。但是TCP前面20字节是固定的,因此实际取值返回就是[20,60]。
比如提取出来的4位首部长度是8,乘以基本单位4字节就是32字节,32字节减去固定的20字节还剩下12字节,说明剩下的12字节就是选项的长度。这样就可以读取整个TCP报头,那么剩下的就是数据了。那么如果今天TCP报头没有选项,TCP报头就只有20字节,那么对应的首部长度就是20/4=5。
2、TCP是如何分用的?
读取固定长度的前20个字节,报头中含有16位目的端口号,提取出16位目的端口号,就可以知道要将数据交付给上层的哪个进程。
下面来看一下Linux内核中的TCP报头结构体:

2.2、确认应答(ACK)机制

客户端给服务器发送数据,这个报文在网络上跑也是需要花时间的。那么客户端怎么知道我发出去的报文是否被服务端收到了呢?我发出去的报文是否因为某些原因丢失了呢?客户端是无法得知的。因此就需要服务端对客户应答,当服务端接收到客户端发送的报文后,需要对客户端做应答。
同样的,服务端给客户端发数据,客户端也需要应答,这种策略就是确认应答机制。
那么服务端给客户端作应答,表示我收到你发给我的报文了,那么服务端如何得知客户端是否收到我的应答呢?所以就需要客户端对服务端的应答继续做应答,然后客户端又需要知道服务端是否接收到我的应答,所以又需要服务端继续做应答。那么如果这样就会陷入了死循环。我们发现长距离通信的时候,其实没有100%的可靠性!因为总有一条最新的消息是没有应答的!

当客户端给服务端发送数据后,服务端接收到数据需要对客户端做应答,那么此时服务端就不再关心我的应答是否被客户端接收到了,因为是客户端要操心我的数据服务端有没有接收到。当客户端接收到应答就说明我之前发送的数据服务端接收到了,保证了老的消息是可靠的,保证了客户端到服务器的可靠性,不需要再对服务端的应答做应答了。如果客户端没有接收到应答,那么可能是服务端没有接收到数据,也可能是服务端的应答丢失了,这时候客户端会再去问服务端的。
所以老消息是有应答的,保证100%可靠。
那么此时从客户端到服务端就是可靠的。同样的,服务端给客户端发送数据,客户端也需要做应答,如此一来,也保证了服务端到客户端的可靠性。
所以TCP是可靠的,它的核心协议就是确认应答。

所以现在客户端发送request,服务端就需要确认应答。同样的,服务端给客户端发送数据,客户端也需要对服务端做应答。
此时客户端给服务端发送的request是TCP报头+有效载荷,也就是一个完整的报文。而服务端给客户端的确认应答是一个裸的TCP报头。所以双方在发送数据的时候,至少都要有一个报头。
再谈序号和确认序号:
上面我们的通信方式是:客户端给服务端发送消息,然后服务端做应答,客户端接收到确认应答后然后再给服务端发送数据。这种通信方式是串行的,这是我们第一阶段的认识,那么这种通信方式效率就很低。

实际上,客户端会给服务端发送一批报文,比如上图客户端C给服务端S发送了四个报文,而每个报文都需要做应答,因此服务端在接收到报文后再给客户端做应答,每一个报文都要有应答。通过这种方式,既可以保证可靠性同时又提高了效率。但是问题是,服务端给客户端应答,如果客户端只收到了三个应答,那么客户端如何得知这三个应答针对的是哪三个报文呢?

因此客户端给服务端发送数据的时候,需要给每个报文带上编号,这个编号就是序号。比如客户端给服务端发送的四个报文编号分别为10、20、30、40。将来服务端给客户端确认应答的时候,应答往往是收到的报文序号的值再加1,比如服务端收到客户端发送的编号位10的报文,将来应答的就是11,收到20,应答就是21。所以给报文带上序号,就可以对报文进行区分了。
服务端给客户端返回的序号称为确认序号,确认序号=序号+1。表示序号之前的内容已经全部收到了。
比如客户端给服务端发送的报文序号为10,服务端收到后给客户端作应答,确认序号为11。客户端再收到服务端的应答后就知道,11号之前的内容对方已经全部收到了。
报文携带序号还有另外一个意义。当客户端给服务端发送报文,比如按照10、20、30、40的顺序进行发送的,但是服务端在接受的时候并不一定是按照10、20、30、40的顺序接收的。因为网络可能存在各种各样的情况,所以可能后发送的先到了,先发送的反而后到。而报文如果是乱序的,也是不可靠的表现。因此就需要根据序号来对接收到的报文进行排序。
服务端在接收到报文后可以根据报文的序号进行升序排序,这样就保证了服务端缓冲区的报文是有序的。
为什么要有两个序号?
服务端接收到客户端发送的数据,比如对应的报文序号为10,那么服务端直接设置序号11给客户端返回做确认应答就好了,也就是说它们使用一个序号不就可以了吗,为什么要有序号和确认序号呢?
服务端在给客户端做应答的时候,如果只是单纯的应答,那就是裸的TCP报头,但是有没有可能服务端也要给客户端发送数据呢?当然是有可能的,那么这时候服务端给客户端发送的就是一个报文,这个报文既是对客户端之前发送数据的应答,同时也给客户端发送数据。这种情况称之为捎带应答机制。TCP报文在很大的概率上,既是应答,又是数据。
所以这时候就需要序号和确认序号了,确认序号用来表示之前客户端给我发送的数据我服务端接收到了,同时我也要给客户端发送数据,所以也要给报文设置序号,方便将来客户端做应答。那么将来客户端如果还要发送数据,那发送的报文就是既是应答也是数据,如果没有数据要发送了,那就是单纯的应答。
因此,在TCP通信中,大部分情况下报文既是应答,又携带了数据。这才是真实的TCP通信。
再谈16位窗口大小:

如果对方来不及接收数据呢?
客户端给服务端发送数据,假设服务端接收缓冲区有100字节,现在已经有80字节的数据了,还剩下20字节的数据。服务端上层还在进行数据的处理,来不及将这80个字节的数据取走,如果这时候客户端再给服务端发送一大批数据的话,那么接收缓冲区就会满,满了服务端就接收不了数据了,因此服务端就会将报文直接丢弃。那么这种做法当然是可以解决的,如果服务端直接将报文丢弃,就不会给客户端做确认应答,因此客户端没有接收到确认应答就会重新给服务端发送报文。
但是操作系统不做浪费时间,浪费空间的事情。
客户端今天发送了一个报文,这个报文经过网络千里迢迢到达了服务端,占用了各种网络资源,结果服务端直接就丢弃了,那不就浪费时间空间了吗。因此客户端会动态的控制自己发送的数据量,来调整让服务端可以接收数据,这种策略我们叫做流量控制。
那么客户端凭什么进行流量控制?你怎么知道对方来不及接收数据了?
我们需要思考服务端的接收能力是由什么决定的,服务端的接收能力实际上是由服务端接收缓冲区的剩余空间来决定的。客户端给服务端发送数据,服务端在确认应答的时候,发送的至少是一个TCP报头,所以服务端就可以在TCP报头的16位窗口大小中填入自己接收缓冲区的剩余空间大小,客户端就能时时刻刻知道服务端接收能力了。
那么服务端给客户端发送数据,客户端也是如此,因此流量控制双方都要进行。通信双方的报文都是发送给对方的,都应该填写自己接收缓冲区剩余空间的大小,要把自己的接收能力告知对方。16位窗口大小是自己接收缓冲区剩余空间的大小。
流量控制不仅仅是减少发送,今天服务端接收缓冲区空间所剩空间不多了,客户端可以采取策略减少数据发送量,让服务端可以接收。同理服务端如果缓冲区剩余的空间非常大,服务端嗷嗷待哺,客户端就可以增加数据发送量。

如图表示了确认应答机制,客户端给服务端发送数据,比如数据1-1000,那么服务端返回的确认序号就是1001,表示1001之前的数据我全收到了。
另外我们需要再谈TCP面向字节流的特点:
TCP的4位首部长度是不包含数据长度的,所以将来服务端接收到报文中,提取出报头就直接把数据放到接收缓冲区中,再来一个报文也是同样的操作,因此TCP的接收缓冲区多个报文就会连在一起,形成流式结构。而UDP报头中有16位UDP长度,这个长度是UDP报头长度+数据长度,因为UDP是面向数据报的,它要保证将来交给上层的是一个完整的报文。
TCP缓冲区在内核中大小一般都是固定的。根据TCP报头16位窗口大小我们可以知道,TCP接收缓冲区最大就是2^16。

我们把TCP的发送缓冲区想象成一个数组:char outbuffer[N],接收缓冲区同样如此。这个数组中要发的每个字节数据天然的就有了编号,而这个编号就是上面序号的来源。当我们将数据拷贝到发送缓冲区中,每个字节的数据都有对应的下标,比如下标从0-100的数据要发送,将来序号就是100。所以数据发送就是将char数组里面的数据发送给对方缓冲区的char数组,这就是字节流。
网络接收到数据要往char数组里面放,然后上层通过read来读取数据,如果接收缓冲区如果为空,那么read就要阻塞住,这就是生产者消费者模型,char数组就是交易场所,当char数组中没有数据,消费者就要阻塞住,这本质就是在做生产者消费者模型中的同步。当发送缓冲区满了,write也会阻塞住,生产者就不能继续生产数据。
2.3、超时重传机制
TCP的丢包问题:

如图,主机A给主机B发送数据,丢包有两种情况。
第一种情况是左边这种情况:主机A发送数据给主机B之后,可能因为网络拥堵等原因,数据无法到达主机B。如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发。
第二种情况是右边这种情况:主机A发送数据给主机B后,主机B接收到了数据,但是主机B给主机A发送的确认应答丢了,主机A接收不到。因此经过一段时间间隔后,主机A会继续发送数据给主机B。那么这样主机B不就接收到重复数据了吗?报文=报头+有效载荷,而报头中涵盖了序号字段,所以可以根据序号进行去重。
主机A可以确认是数据丢了还是应答丢了吗?主机A能确认自己是报文丢了吗?
主机A是无法确认的,因此只能规定超时重传。
最理想的情况下,找到一个最小的时间,保证确认应答一定能在这个时间内返回。
这个时间既不能太短,太短就会导致频繁的重复发送,也不能太长,如果太长数据包早都丢了,主机A还在这一直等,就会影响重传效率。并且也不能是固定的,因为网络的情况随时都会变化。
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中(BSD Unix 和 Windows 也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。
如果仍然得不到应答,等待 4*500ms 进行重传,依次类推,以指数形式递增。
累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
2.4、连接管理机制

为什么要有那么多标志位?
我们知道,客户端和服务器通信之前,客户端需要发起connect,所以客户端和服务器之间就会有建立连接的请求。连接建立好之后,就会有正常的数据通信。那么将来也还会有断开连接的请求。这些本质上都是TCP报文,TCP报文=报头+有效载荷。所以服务端接收到的TCP报文是有种类的,那么就要求服务端能够区分出不同的报文类型,从而针对不同的报文类型进行处理。
因此就需要在TCP报头中添加标志位,用于区分报文类型。
ACK:表明自己是一个确认报文,对方要关心确认序号。
ACK并不意味着就不能携带数据了,ACK只是对应的标志位置1,表示该报文具有确认应答的功能。如果你要携带数据也是可以的。
并且我们要清楚,虽然很多教材上面画的图都是直接标注ACK,但是我们要清楚这是一个报文,可以有数据也可以没有数据,但是报头的ACK标志位一定要置1。也就是它至少是一个完整的TCP报头。
因为有捎带应答,所以大部分TCP报文的ACK标志位都为1。
SYN:同步标志位,表示建立连接的请求。
FIN:连接断开标志位,通信结束的时候,进行握手协商。
TCP在通信之前需要进行三次握手,当通信完毕要进行四次挥手。

当今天你在街上遇到的一个女孩,你很喜欢这个女孩,所以你就跑过去跟这女孩说做我女朋友吧,女孩答复说好啊,什么时候?你说就现在。这就是建立连接进行三次握手的过程。
后来这个女孩发现你课也不上,天天打游戏,代码也不写,然后她就跟你说我们分手吧,你回答说可以啊。但是这是她要跟你分手,你同意了。你也要跟她分手,女孩说好啊。分手过程双方都同意,此时这种过程就是四次挥手。

客户端和服务器通信之前需要进行三次握手,客户端给服务端发送SYN,表示建立连接请求。什么SYN,你要知道这个SYN实际上是一个TCP报头,是对应的TCP报头中的SYN标志位置1了。服务端接收到之后给客户端发送SYN + ACK,ACK表示对客户端刚才发送的数据进行确认应答,并且服务端也要跟客户端建立连接。然后客户端接收到之后再给服务端发送ACK表示确认应答。至此,双方三次握手完成,就可以开始进行通信了。
在服务端的视角:刚开始文件的是CLOSED关闭的,服务端要先创建套接字,然后进行绑定,接着将套接字设置为监听状态,此时服务端就是LISTEN状态,然后服务端进行accept,阻塞等待获取新连接。一旦accept返回,就可以获取一个新的文件描述符,继续向后进行通信。
三次握手是由双方操作系统自动完成的。accpet并不参与三次握手的过程,它只是在等,底层三次握手完成了,accept条件满足,accept才会把新连接获取上来。
如果套接字已经是listen状态了,不进行accept,客户端来连你了,也能自动完成三次握手,跟你调不调用accept没有关系。所以accept并不参与三次握手。
在客户端视角:先创建套接字,然后再发起connect。connect也没有严格参与三次握手,connect只是在用户层触发操作系统,让操作系统发送SYN,等到三次握手完成,connect才会返回。
1、SYN_SENT:是将SYN发出就成为SYN_SENT状态,还是等到对方接受了才成为SYN_SENT状态?只要将SYN发出就成为SYN_SENT状态。
2、SYN_RCVD:服务端是收到SYN成为SYN_RCVD,还是将SYN+ACK发出后才成为SYN_RCVD。只要收到了,发送是必然的,所以理解收到或发出后都是可以的。严格来说,只要收到了就是SYN_RCVD状态。
3、ESTABLISHED:客户端把连接建立好是把ACK发出去就认为连接建立好,还是必须等到对方收到才认为连接建立好?作为客户端,只要把ACK发送出去,就认为连接已经建立好了,因为他的三次握手已经完成。在网络通信里,并不存在真正意义上的可靠性,但老数据是可靠的。
三次握手以我们现在的认知,本质就是在赌,赌最后一个ACK对方收到了。
三次握手一定要成功吗?三次握手不一定会成功,但我能保证只要三次握手成功,双方通信一定是可靠的。
4、服务端在收到ACK后,服务端就完成了连续三次的收发报文,此时服务端也就认为连接已经建立好了。因此往往都是客户端先认为连接已经建立好了,服务端要比晚一点。

断开连接双方需要进行四次挥手。客户端给服务器发送FIN,服务端收到了FIN就要给客户端发送确认应答ACK,保证了从左到右的可靠性。服务端给客户端发送FIN,客户端给服务器发送确认应答ACK,保证了从右向左的可靠性。
为什么是四次挥手?——因为断开连接,需要征得双方同意。
那么为什么要征得双方同意?——因为TCP是全双工的,要关闭两个朝向上的连接。
客户端要跟服务器断开连接,服务端还想给客户端发消息,你可以不跟我说话,但是我还想和你说话呢。所以要协商完毕,必须四次挥手。客户端发送FIN,服务端确认应答ACK,这样一对FIN+ACK保证了从左向右的可靠性。同样的,服务端发送FIN,客户端确认应答ACK,这样一对保证了从右向左的可靠性。
为什么是三次握手?

客户端发送SYN请求建立连接,服务端接收到给客户端发送ACK。服务端再给客户端发送SYN,表示服务端也想和客户端建立连接,客户端给服务端发送ACK。所以三次握手本质上是四次握手,只不过客户端给服务端发送了SYN,服务端要做确认应答ACK,服务端也要给客户端发送SYN,所以把SYN+ACK合在一起捎带应答了。第一对SYN+ACK保证了从客户端到服务端的可靠性,第二对保证了从服务端到客户端的可靠性。
所以为什么三次握手?因为建立连接需要征得双方同意,TCP是全双工的,要建立两个朝向上的连接。这就是可靠的建立双方通信的意愿。
为什么不是三次挥手?四次挥手为什么中间两次不合并?因为建立连接时百分之百可以做捎带应答,而断开连接时不一定能做,如果服务端不再给客户端发送数据,那么是可以合并的,但是如果服务端还想给客户端发送数据,就不能这么做了。

用户通过fd,可以进行read和write。为什么进行三次握手还有一个理由,当客户端给服务端发送SYN,这是客户端的I,服务端接收后捎带应答给客户端发送SYN+ACK,客户端接收到就是O,所以就保证了客户端IO的通畅性。对于服务端来说,服务端发送SYN+ACK就是I,客户端接收到后发送ACK,服务端接收到就是O,也保证了服务端IO的通畅性,因此两个方向上的IO通畅性就得到的验证。
所以为什么要进行三次握手?
1、建立双方主机通信的意愿共识。
2、双方验证全双工信道的通畅性。
建立好了连接?什么是连接?我该如何理解?
我们知道一个连接一定会和一个文件对应,因为一个连接一个fd。而OS内部必定会存在很多个连接,操作系统对于这么多连接也要进行管理,要管理就需要先描述,再组织。
所以OS内部会存在很多连接,这些连接还存在状态的变化,连接本质就是一个结构体类型,将来操作系统要对该结构体面的status字段进行修改。
所以操作系统维护连接一定是有成本的(时间+空间)。
空间:需要开辟空间创建连接结构体对象。时间:需要初始化连接结构体对象成员,并且握手完成需要修改连接的状态。
而这个连接结构体在内核中就是之前谈到的struct socket。
2.5、理解CLOSE_WAIT状态

再来谈四次挥手的套接字状态,当主动断开连接的一方发送FIN,如上图客户端先发送FIN,发送后客户端就会进入FIN_WAIT_1状态,然后服务端在接收到FIN后进行确认应答,给客户端发送ACK,此时服务端就会变成CLOSE_WAIT状态,客户端在接收到来自服务端的ACK后就会进入FIN_WAIT2。
然后服务端说我也要跟你断开连接,所以服务端给客户端发送FIN,服务端进入LAST_ACK状态,当客户端接收到FIN会给服务端发送ACK,客户端发送ACK后就进入TIME_WAIT状态。服务端接收到ACK就由LAST_ACK变成CLOSED状态。至此双方四次挥手完成。
这里虽然是FIN、ACK,但是你一定要清楚双方发送的一定是一个完整的TCP报头,只不过把对应的标志位置1了,可以不携带数据。
那么是什么触发了四次挥手呢?当应用层调用close的时候触发两次挥手。
客户端调用close底层就是让操作系统给服务端发送FIN,然后服务端ACK。一个close触发一对FIN+ACK。服务端也调用close触发一对FIN+ACK。这样就完成了四次挥手。
当客户端要跟服务端断开连接,应用层调用了close,完成了两次挥手的过程。但是服务端并不一定要跟客户端断开连接,服务端可能还想给客户端发送消息。如果这时候服务端给客户端继续发送消息,由于客户端已经调用close关闭了文件描述符,客户端再接收到来自服务端的数据,上层怎么读取数据呢?
首先我们要思考清楚,我们既然调用了close,就说明双方都认为通信已经完成了,这是由应用层来决定的。但是如果我今天就是想知道客户端跟服务端断开连接,但是服务端还想给客户端发消息,客户端该如何获取?

关闭文件描述符还有一个系统调用shutdown。第一个参数表示要关闭哪个文件描述符,第二个how表示关闭的方式。第二个参数可以传:SHUT_RD表示关闭读,SHUT_WR表示关闭写,SHUT_RDWR表示读写都关闭。
所以未来如果客户端想关闭连接不想写了,可以调用shutdown传入SHUT_WR,当服务端再给客户端发送数据时,客户端还可以继续从该文件描述符中读取数据。
今天客户端跟服务端断开连接,服务端发送ACK后就会进入CLOSE_WAIT状态,如果服务端故意不关闭sockfd,那么只会完成两次挥手,无法从CLOSE_WAIT状态转换成LASK_ACK,那么服务器就会长时间处于CLOSE_WAIT状态。
下面我们进行验证,我们使用Socket变成TCP中EchoServer的代码进行测试,服务端获取新连接后不做处理。

如图,右边是服务端,服务端启动后左边客户端连接,查看双方当前状态都处于ESTABLISHED,连接建立成功。本地地址这里显示的内网IP地址,远端地址显示的是对方主机的公网IP地址。

接着我们让客户端断开连接,客户端发送FIN,服务端接收到后发送ACK状态变成CLOSE_WAIT,然后客户端接收到变成FIN_WAIT2状态。

过了一会我们再查看,服务端还是处于CLOSE_WAIT状态,而客户端变成了TIME_WAIT状态。如果客户端长时间接收不到FIN,服务端不再给我发数据,客户端就会变成TIME_WAIT状态。

又过了一会,我们查看发现客户端的连接已经不存在了,但是服务端这里还是CLOSE_WAIT状态。这是因为服务端我们对获取的连接不做处理,所以也就不会close关闭sockfd,因此就不会进入LAST_ACK状态,一直处于CLOSE_WAIT状态。

接着我们让客户端连接断开连接断开重复几次后再次查看,我们发现服务端这里就会存在大量的处于CLOSE_WAIT的连接。
所以服务器端,一旦使用完毕sockfd,一定要调用close关闭不要的sockfd。否则就会导致服务器上处于CLOSE_WAIT状态的连接越来越多,带来的直接后果就是服务器的可用资源越来越少,造成文件描述符泄漏的同时造成比较严重的内存泄漏。

接着我们把服务端进程ctrl c结束掉,再次查看我们发现所有连接都进入了LAST_ACK。因为文件描述符的生命周期是随进程的,进程结束后底层自动进行两次握手,变成LAST_ACK状态。但是客户端早就关掉了,没有人给他ACK,所以过一段时间自动进入CLOSED。
2.6、理解TIME_WAIT状态

在进行四次挥手的时候,主动断开连接的一方会进入TIME_WAIT状态。
如图:客户端发送FIN,服务端确认应答ACK,服务端再发送FIN,此时客户端接收到之后再给服务端发送ACK,此时客户端已经完成四次挥手的过程,正常来说应该直接进入CLOSED状态了,但是TCP并没有让主动断开连接的一方进入CLOSED状态。
主动断开连接的一端,会在发送完毕最后一次ACK后进入TIME_WAIT状态等待一段时间。
TCP协议规定,主动关闭连接的一方要处于 TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。
MSL是TCP报文在网络中允许存在的最大时间,即从发送到被丢弃的生存周期。它确保报文不会因网络延迟、路由错误等问题无限滞留。
为什么主动断开连接的一方要进入TIME_WAIT状态?
防止旧连接的延迟报文干扰新连接:
假设今天主动断开连接的一方是服务器,服务器由于某些原因挂掉了,客户端也断开了连接,我们知道服务是不能停的,所以需要马上重启服务,那么很巧的是之前客户端给服务器发送的报文还在网络中残留,这时候服务器重启后刚好接收到这些报文,服务器就很奇怪通信不应该先建立连接吗,怎么报文就直接过来了,此时服务器可以甄别然后丢弃。但是如果服务器重启,客户端恰好又来连了,更巧的是客户端和服务器历史通信的游离报文也到了,那么游离报文就有可能被服务器误处理。所以需要等待2MSL时间,等待历史的游离报文在网络中消散。
确保被动关闭方正确终止连接:
主动关闭连接一方的最后一个ACK不一定被对方收到了,如果直接进入CLOSED,那么连接就关了,如果最后一个ACK丢了,服务端就需要给客户端补发FIN,补发若干次过一段时间后服务端才会关闭连接。但如果处于TIME_WAIT,最后一个ACK丢了,对方补发FIN,客户端可以立即给对方应答,让对方进入关闭状态。
1、等待历史游离报文在网络中消散。2、尽可能的正常进行四次挥手,完成连接断开。

我们发现之前启动服务端程序经常出现绑定错误,需要更换端口号才能正常启动。
上面我们先启动服务端,然后再启动客户端去连接,接着我们让服务端先退出,客户端再退出。我们查看服务端连接的状态,发现此时处于TIME_WAIT状态,然后我们再次启动服务端和之前绑定同一个端口号,我们发现bind error了。
为什么会绑定失败?因为主动断开连接的一方会进入TIME_WAIT状态,此时连接并没有关闭,ip和端口就还在被使用,而一个端口只能被一个进程绑定,再启动程序绑定同一个端口出现了冲突。
如何解决?

调用setsockopt进行设置:


第三个参数可以写成SO_REUSEADDR | SO_REUSEPORT,也可以只写SO_REUSEADDR。
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7/Ubuntu上默认配置的值是60s。
可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看msl的值。

为什么TIME_WAIT的时间是2MSL?
MSL是TCP报文的最大生存时间,因此TIME_WAIT持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。
2.7、流量控制
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
接收端将自己可以接收的缓冲区大小放入TCP首部中的 “窗口大小” 字段,通过ACK通知发送端。窗口大小字段越大,说明网络的吞吐量越高。接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值(本质就是接收缓冲区剩余空间大小)通知给发送端。发送端接受到这个窗口之后,就会减慢自己的发送速度。

主机A给主机B发送1-1000数据,那么序号就是1000,我们假设主机B上层正在忙,来不及取走接收缓冲区的数据,主机B接收到数据后给对方确认应答,确认序号为1001,表示1001之前的全部数据我都收到了,然后主机B的接收缓冲区剩余空间为3000字节,所以在报头中16位窗口大小填入3000,发送给主机A。主机A接收到后就知道主机B已经收到了数据,并且剩余3000字节的空间,所以主机A连续发送3个报文,每个报文1000字节。主机B收到后分别对这三个报文应答,那么主机A就知道主机B的接收缓冲区剩余空间已经为0了,此时就不能再给主机B发送数据了,如果再发送有很大概率会被直接丢弃。因为发送的过程中主机B的上层如果取走数据,那么主机B就还能接收,如果主机B的上层不取走数据,那么主机B接收到报文之后就会直接丢弃,所以是很大概率会被丢弃。
因此当对方窗口大小为0,主机A就不能再发数据了,要等到主机B接收缓冲区空间腾出来了。那么主机A如何得知主机B上层把数据取走了?如何知道我可以再发送了呢?所以主机A在等主机B的时候,可以给主机B发送一个窗口探测的报文。所以主机A并不是一直在那等,而是会不断地发送窗口探测询问主机B。而根据TCP的确认应答机制,主机A只要给主机B发消息,主机B就必须应答,因此主机B收到窗口探测报文后进行应答可以将自己窗口大小告知主机A。
可是万一窗口探测报文丢失了呢?而且主机A要周期性的去问主机B,而主机B一旦腾出空间了就应该迅速告诉主机A。所以还有一种策略,主机B窗口一旦更新,主机B就给主机A发送一个裸的TCP报头,将窗口大小告诉主机A。也就是说主机B会给主机A发送窗口更新通知,主机A知道后就可以继续向主机B发送报文。
那么这两种策略用哪种呢?窗口探测和窗口更新通知会同时使用。
根据TCP报头16位窗口大小,那么也就是说接收缓冲区大小是固定的?没错,一般是2^16,但是也可以扩大。
实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位。
所以如果将来双方想要更大的通信窗口,就可以扩大接收缓冲区,同时在TCP报头中携带选项——窗口扩大因子M。如果我想扩大1倍,窗口因子M就设置为1。
1、流量控制并不等于减少发送。当对方窗口大小变小了,我就可以减少发送。而当对方窗口大小比较大,我也可以增加发送。上图第二次发送三个报文就是明显的数据量增大了。所以流量控制不等于减少发送,也不等于增加发送,而是让发送更加合理。
2、首次发送,我怎么知道对方的接收能力?虽然第一次发送数据对方大概率都能hold得住,但是万一呢,万一我发送得数据超出对方接受能力了呢?不要忘记我们曾经做过三次握手,三次握手是做过报文交换和窗口协商的。
2.8、滑动窗口

主机A给主机B发送消息,我们刚开始说的是左边这种情况,主机A要等待收到主机B的应答然后再发送下一个,然后再等,收到了再发。这样就是串行的,发送效率很低。所以实际上是右边这种情况,主机A给主机B发一批报文,然后主机B给主机A每个报文应答。之后主机A再给主机B发一批报文,这样就能大大提高性能,其实就是将多个段等待的时间重叠在一起了。
所以今天主机A一次可以给主机B发很多数据,但是我们还有流量控制机制,所以主机A也不能给主机B发送太多数据,因为主机A要考虑主机B的接收能力。那么如何实现主机A可以给主机B发送很多数据的同时又考虑到主机B的接受能力,不会给主机B发送太多数据呢?

如图,TCP的发送缓冲区中红色的方框表示的就是数据,其中青色的方框表示的是可以直接发送的区域,暂时可以不需要应答,可以将数据都发出去然后对端再统一做应答。这个区域的左侧表示的是已经发送的已经确认的数据。这个区域的右侧表示的是待发送区域。我们把青色方框的这个区域称之为滑动窗口。
理解滑动窗口:

我们前面说过把发送缓冲区想象成一个char sendbuffer[N]。那么上层将数据拷贝到发送缓冲区中,天然的就有了编号。而所谓的滑动窗口,本质就是双指针,两个下标start、end所指向的一段区域。如图,start和end之间的范围就是滑动窗口。
1、滑动窗口的大小是多少?
滑动窗口表示的是可以直接发送的区域,但是也不能超出对方的接收能力,因此滑动窗口=对方接收窗口win的大小(目前!后面校正)。
2、滑动窗口的数据发送问题?
滑动窗口区域中的数据是对方可以接收的,那么为什么发送的时候我不直接把滑动窗口内的所有数据干成一个大报文给对方发送过去,为什么还要有上面的图中拆分成几个报文发送呢?这是因为数据链路层不允许发送大的报文。

3、如何理解滑动窗口的滑动?
基于上面所说,滑动窗口本质就是两个下标start和end,所以我们让start和end+=?,让他们加等于某个值那么滑动窗口就向右滑动了。
数据是要按序到达的,对于应答也是如此。应答报头中又包含了确认序号和win窗口大小。
确认序号:比如1001,表示1001之前的已经全部收到了,下次发从1001开始。
win窗口大小:表示的是对方的接收能力。
所以滑动窗口如何滑动?-> start = 确认序号, end = start + win。
上图中原来:start = 1001, end = start+win=1001+4000=5001,然后主机A给主机B发送1001~2000的数据,我们假设主机B接收到数据上层也刚好取走了一千字节的数据,所以主机B给主机A的应答确认序号就是2001,表示2001之前的数据我全收到了,win窗口大小还是4000不变。所以主机A的窗口现在就变成了:start = 2001, end = start+win = 2001 + 4000 = 6001,所以窗口就向右滑动了。
所以流量控制是如何实现的?通过滑动窗口实现的。
4、滑动窗口只能向右滑动吗?可以向左滑动吗?
滑动窗口只能向右滑动,不能向左滑动,滑动窗口的左边区域表示的是已发送已确认的区域,而已发送已确认本质就是表示数据可以被覆盖,也就是已经被删除了。
5、滑动窗口可以变大吗?可以变小吗?可以不变吗?可以为0吗?
我们上图中的例子就是滑动窗口不变的例子,原来对方接收缓冲区剩余4000字节空间,然后主机A给主机B发送1000字节数据,恰好主机B上层取走了1000字节,又收到1000字节,所以确认应答win窗口大小不变,主机A接收后滑动窗口向右移动,滑动窗口大小不变。
如果原来对方win窗口大小为4000,然后主机A给主机B发送1000字节数据,假设主机B的缓冲区总共8000字节,那么主机B在接收到数据后上层就把缓冲区数据全部取走了,所以这时候给对方的应答报头中窗口大小就是8000,确认序号就是2001。那么主机A接收到报文后,滑动窗口右移,start = 2001, end = 2001 + 8000 = 10001。所以滑动窗口可以变大。
如果主机A给主机B发送数据,主机B接收到数据了,但是上层迟迟不取数据,那么缓冲区剩余空间就由4000变成了3000,所以给主机A的应答报头中确认序号为2001,win窗口大小为3000。主机A接收到后滑动窗口右移,start = 2001,end = 2001 + 3000 = 5001。所以滑动窗口可以变小。
假设今天主机B上层一直不取走数据,那么主机A发送四个报文:1001->2000,2001->3000,3001->4000,4001->5000,主机B接收到这四个报文后,最后一个应答报头中的确认序号为5001,win窗口大小为0,主机A接收到后:start = 5001,end = 5001 + 0 = 5001。所以滑动窗口大小可以为0。这时候就进入到上方流量控制图中的窗口探测和窗口更新通知了。
6、滑动窗口会不会超过缓冲区导致越界?
我们把发送缓冲区想象成一个环形结构,所以到最后就会回绕继续从头开始,这样就不会越界了。而左边的区域表示已发送已确认,本质就是数据已经被删除,未来可以覆盖。这本质上就是一个基于环形队列的生产者消费者模型。
理解滑动窗口异常丢包问题:

主机A给主机B发数据,丢包有两种情况,第一种就是数据丢了,第二种就是主机B的应答丢了,但是不管是哪种情况,本质上都是主机A没收到应答,因此我们就考虑主机A没有收到应答。
1、滑动窗口最左侧丢失
如图:主机A给主机B发送四个报文,假设1001->2000、2001->3000,3001->4000这三个报文主机B都接收到了,而1->1000主机B没有接收到,那么对于这三个报文,主机B的应答报文中确认序号应该填多少呢?根据确认序号的定义:表示该序号之前的报文已经全部收到了,下次发从该确认序号开始发。所以主机B的三个应答报文中确认序号都应该是1,因为1->1000的数据并没有接收到。而确认序号如果是1的话,滑动窗口就不会向右移动,就不会直接跳过丢失的报文。主机A收到连续的多个应答中确认序号为1,即便没有收到多个应答,主机A至少可以确认1->1000丢包了,后面不清楚,但是第一个一定丢了,所以主机A就可以进行补发。那么此时主机B接收到1->1000后,主机B需要再做应答,此时应答确认序号就应该是4001了,表示4001之前的数据我都收到了。
2、滑动窗口中间丢失
现在假设主机B没有收到1001->2000,那么对于收到的另外三个报文都要做应答,对于1->1000的应答确认序号应该是1001,对于后面两个报文的应答确认序号也只能是1001,所以三个应答报文中的确认序号都是1001,此时主机A的start = 1001,滑动窗口右移,而滑动窗口最左侧就是丢包的数据,因此滑动窗口中间丢失问题转换成了滑动窗口最左侧丢失问题。
3、滑动窗口最右侧丢失
现在主机B没有收到3001->4000报文,那么对于前三个应答中的确认序号分别是:1001,2001,3001,所以主机A的滑动窗口:start = 3001,滑动窗口右移,此时滑动窗口最左侧就是丢包的数据,所以滑动窗口最右侧丢失问题转换成了滑动窗口最左侧丢失问题。
所以确认序号的定义:表示该序号之前的报文已经全部收到了。该定义给我们带来的好处就是所有丢失问题都会转换成滑动窗口最左侧丢失问题。
快重传机制:

如图,主机A给主机B发送了一批报文,但是1001->2000报文丢了,那么主机B对于后面报文应答的确认序号就都是1001,而主机A在收到3个连续的相同确认序号的应答时,就会对1001->2000这个报文进行补发。我们把这种机制被称为"高速重发控制"(也叫 “快重传”)。
有了快重传,为什么还要超时重传呢?
可能主机A给主机B只发送3个报文,比如:1001->2000,2001->3000,3001->4000,后面两个报文接收到了,而前面的1001->2000丢失了,那么主机B的两个应答确认序号都是1001,但是不满足快重传的条件,因为必须要有连续的3个才会触发。因此就需要过一段时间,触发超时重传机制。
所以快重传是用来提高效率的,超时重传是用来兜底的。
PSH标志位:PUSH

当主机B的接收缓冲区空间满了后,主机A就不能再发送数据了,而主机A要不断的进行窗口探测,如果主机B上层一直不取走数据,主机A受不了了,就可以发送一个裸的TCP报头,该报头中PSH标志位置1,告诉对方,请尽快将你的缓冲区数据交给上层。
我们刚才说的是比较极端的情况,并不一定是对方的窗口大小为0了。其实PSH想表达的是:我的数据很重要,请尽快交付。比如我们在使用xshell,输入指令通过网络推送到服务器端,就可以给TCP标志位PSH置1,让服务器立即响应。
RST标志位:RESET

TCP通信之前双方需要进行三次握手,对于客户端来说,只要ACK发出后,客户端就认为连接已经建立好了,而服务端需要等到收到来自客户端的ACK才认为连接建立好了。如果客户端ACK发出后,客户端认为连接已经建立好了,所以直接给服务端发送数据,而ACK丢失了,服务端没有接收到ACK,然后接收到来自客户端的数据,服务端就会很奇怪,我们通信之前不应该先建立连接吗,怎么数据就直接过来了,所以此时服务端会给客户端应答,应答就是一个裸的TCP报头,报头中的RST标志位置1。客户端接收到之后,发现RST标志位为1,客户端就知道连接建立异常了,会进行连接重置,客户端会释放连接,重新进行三次握手建立连接。
上面这种情况也比较极端,在正常通信中,服务器可能突然挂掉了,网络出了问题,客户端和服务器没有完成四次挥手过程,这时候服务器重启了,客户端连接还在,客户端继续给服务器发送数据,服务器就会应答并设置RST标志位进行连接重置。
URG标志位:
TCP是面向字节流的,并且TCP为了保证可靠性数据要按序到达。如果今天我想让数据提前插队让上层读走就做不到了。比如今天我们使用百度网盘的上传功能,给百度网盘传送数据,百度网盘的接收缓冲区快满了,这时候我们要暂停上传,就需要再给百度网盘发送暂停上传的数据。如果百度网盘上层处理数据比较慢,那么就会导致需要等待比较长的时间,上层将缓冲区数据都取走,然后再读到暂停上传的数据才暂停。所以我们在上传时想终止,可以在同样一条连接中发送报文,报头的URG紧急标志位置1,标识这个报文是个紧急报文,操作系统要让该报文的数据让上层read的时候优先读取到。
问题是URG置1了,上层要读取怎么知道紧急数据在哪里呢?所以TCP报头中还有16位紧急指针,这个值是一个偏移量,表示在报文的有效载荷中紧急数据在什么位置。但是紧急数据的范围呢?规定了紧急数据只能是1个字节。所以我们可以设置状态码,比如0表示断开连接,1表示暂停上传。

在应用层中,如果我们想要读取紧急数据使用recv函数的时候,需要设置标志位flags。之前我们都是设置为0,只能读取普通数据,而如果要读取紧急数据需要设置为MSG_OOB。在TCP通信中,紧急数据又叫做带外数据out-of-band data。
2.9、拥塞控制

我们上面谈的所有内容,都是客户端到服务器,服务器到客户端之间的,端到端的问题。但是我们不能只考虑双端主机的问题,我们要需要考虑网络的问题。
如果主机A给主机B发数据,少量数据丢了,主机A可以进行重传,但如果主机A给主机B发送了大量数据,而这些数据大部分都丢包了,首先不会是B接收的问题因为做了流量控制,当然也不会是A的问题。那还能直接重传吗?当然不行。如果主机A给主机B发送数据出现了大量的报文丢失,发送端会判断网络出现了拥塞问题。
如果判断网络拥塞了,大量报文丢失,那么要么进行重传,要么采用其他策略。但是不能采用重传的方式,因为网络已经拥堵了,你再往里面塞数据,只会让网络负担更大,并且我们要注意到客户端不仅仅只有你一个,还有很多其他的客户端,所以如果大家都继续往网络里发数据,只会让网络更加拥堵。因此不能重传,要采用其他策略——慢启动。所以此时所有主机都等一等,这样就可以给网络充足的时间去恢复,让它把所有挤压的报文吞吐到服务端。
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

此处引入一个拥塞窗口的概念,发送开始的时候,定义拥塞窗口为1,发送一个报文,每次收到一个ACK应答,拥塞窗口加1。所以收到应答后拥塞窗口变成2,再发送两个报文。收到应答后拥塞窗口变成4,再发送4个报文。收到应答后拥塞窗口变成8,再发送8个报文。
之前我们说,滑动窗口大小=对端接收缓冲区剩余空间大小,这是不准确的。因为主机A想发送多少数据不仅仅由主机B的接收能力决定,还由网络决定。
所以实际上:滑动窗口大小 = min(接收窗口,拥塞窗口)
所以主机A发现网络拥塞了,要执行慢启动算法,如何做到的?维护一个拥塞窗口,起始值为1,然后调整拥塞窗口大小为2、4、8…,当然还要由主机B的接收窗口大小决定,只不过这里我们认为主机Bhold得住,都能接收。

我们都知道,指数级别的增长,刚开始是比较慢的,但是增长速度是很快的。
那么为什么要采用指数增长的算法呢?指数增长的特点是前期慢,增长快。一旦发送网络拥塞,我们就应该让大家前期慢一些,所以这是符合前期少量发送的需求的。而一旦我们发送1、2、4…报文,对方都应答了,那么我们就应该尽快的恢复网络通信。所以指数增长的增长曲线完美的符合我们的需求,因此采用指数增长。
那如果一直增长拥塞窗口不就会变得很大吗?
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值。当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。

如图,起初慢启动阈值为16,拥塞窗口为1,然后指数增长到16,达到了慢启动阈值,之后就进行线性增长,每次+1,我们称之为加法增大。当拥塞窗口增大到某个值出现了网络拥塞,就要重新执行慢启动算法,而此时慢启动阈值更新为:上次拥塞窗口的大小 / 2,我们称之为乘法减小。然后拥塞窗口从1开始继续指数增长,达到慢启动阈值再加法增大。
这就是拥塞控制算法:慢启动 + 加法增大 + 乘法减小。
为什么拥塞窗口要一直变化?如果拥塞窗口大于接收窗口,此时发送的数据量就由接收窗口决定了,拥塞窗口继续增大还有什么意义呢?因为无论是指数增长还是加法增大,拥塞窗口都还会一直增大,这是为什么呢?
拥塞窗口的定义,如果你发送数据在拥塞窗口以内,很大概率不会拥塞,如果你发送数据超过拥塞窗口,那么就有可能发生网络拥塞。所以拥塞窗口本质是网络健康情况的评估值,而网络的拥堵健康等状态是会一直变化的,这就需要发送方不断的更改拥塞窗口大小来评估网络的情况。
2.10、延迟应答
主机A在给主机B发数据时,主机B需要确认应答。那么如果主机B在接受到报文后,将数据拷贝到缓冲区中,主机B接收到报文暂时先不做应答,而是等一小会,那么在这期间,上层就过来取数据了,上层过来取走数据后我的接收缓冲区的剩余空间就变得更大了,此时主机B给主机A应答报头中的窗口大小就更大了,那么主机A的滑动窗口就变得更大,主机A就能给主机B发送更多的数据了。我们把这种等一小会再给对方应答称之为延迟应答。
要注意,延迟应答不一定能起效果,但是你不做一定不能起效果,你做了就有可能起效果,从而提高效率。
如果接收数据的主机立刻返回 ACK 应答,这时候返回的窗口可能比较小。
假设接收端缓冲区为1M。一次收到了 500K的数据。如果立刻应答,返回的窗口就是500K。但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了。在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M。
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

那么所有的包都可以延迟应答么?肯定也不是。
数量限制:每隔 N 个包就应答一次。
时间限制:超过最大延迟时间就应答一次。
具体的数量和超时时间,依操作系统不同也有差异。一般 N 取 2,超时时间取 200ms。
如果隔包延迟应答,那么就不会每个报文都有应答了。但是这样并不会影响双方通信。
基于流量控制和滑动窗口,TCP允许少量ACK丢失或者隔报应答。
2.11、捎带应答

双方在三次握手的前两次中是会交换接收窗口大小和要发送的报文的起始序号的。前两次不能携带数据,只有裸的TCP报头。并且双方的序号支持随机化,所以未来通信报头中的序号就是实际的序号+随机序号。收到报文后提取序号,将序号减去起初握手时你给我的随机值,就可以恢复出序号。因为我们之前说过四次挥手不一定完成,所以如果服务器重启可能会有网络中历史通信的游离报文直接过来,因此这种方式可以在一定程度上减少网络中游离报文的影响。
三次握手中,前两次是不能携带数据的,而客户端第三次ACK的时候可以携带数据,然后报头ACK标志位置1,因为客户端此时认为连接已经建立好了。而这时候就是捎带应答了。
2.12、面向字节流

现在再来看这张图,用户层数据先经过序列化形成一个大字符串,然后通过write系统调用拷贝到内核中TCP的发送缓冲区,再由操作系统自主决定何时将数据发送到对方的接收缓冲区中,同时如果对方接收缓冲区只剩下20个字节,那么就只能发送20个字节,这就是为什么TCP叫做传输控制协议。同时,对端读取数据读到这20个字节,这20个字节可能并不一定是完整的一个报文,这就是面向字节流。
另一方面,TCP 的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做全双工。
而UDP是面向数据报的,UDP要保证发送的数据是一个完整的报文,将来对端上层读取的时候读到的也是一个完整的报文。
由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:
写100个字节数据时,可以调用一次write 写100个字节,也可以调用100次write,每次写一个字节。
读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
因此,TCP需要用户自己保证应用层报文的完整性。
2.13、粘包问题
使用TCP,应用层在读取的时候,可能读取半个报文,可能读取一个半报文,可能读取两个半报文等等。
粘包问题:我们用户层基于字节流读取应用层报文的时候,没有读到完整报文的情况。
如何解决:在应用层,我们要有明确报文边界的方式。比如:报文长度固定、使用特殊符号作为分隔符、固定长度报头+报头属性中添加自描述长度字段。
2.14、TCP异常情况
1、双方进行通信,连接建立好了,这时候服务器突然异常终止了,此时连接怎么办?
进程挂了,而文件生命周期是随进程的,那么双方之间的连接会由双方操作系统正常进行四次挥手,释放连接。
2、双方进行通信,连接建立好了,这时候机器直接重启,此时连接怎么办?
重启之前要关闭所有进程,而文件生命周期是随进程的,所以双方操作系统会进行四次挥手释放连接。
2、双方进行通信,连接建立好了,这时候客户端直接断电/网线断开,此时连接怎么办?
此时服务器端认为连接还是OK的,但是不会接收到来自客户端的任何应答。而客户端长时间不给服务器发送数据,服务端可能就会给客户端发送询问报文,发了若干次客户端没有响应,服务器就会把这个连接直接关掉。这种机制我们在TCP中称为连接保活。
但是这种保活一般都是应用层自己做的,比如今天你登录QQ,连接上QQ服务器,长时间不跟人聊天,那么肯定不能让TCP去管理这个连接,因为TCP并不懂应用层。
另外,服务端可能还会给客户端发数据,如果此时客户端已经重启了,或者网络恢复了。客户端接收到服务端的报文,就会给服务端发送RST进行连接重置。
基于TCP的应用层协议:HTTP、HTTPS、SSH、Telnet、FTP、SMTP。
2.15、用UDP实现可靠传输
根据实际应用场景,参考TCP的可靠性机制,在应用层实现类似逻辑。
引入序列号,保证数据顺序。
引入确认应答,确保对端收到了数据。
引入超时重传,如果隔一段时间没有应答,就重发数据。
…
3、TCP全连接队列和tcpdump抓包
首先使用之前实现的基于TCP的EchoServer进行测试,并把listen的第二个参数backlog设置为1,并且把while循环内全部注释掉,也就是说服务端监听后压根就不进行accept获取新连接。

我们启动服务端,然后通过另一台主机启动客户端连接服务器,这里是以后台进程的方式启动的。
这时候我们通过netstat -natp分别查看两台机器的tcp服务。左边这台机器确实存在8080端口的服务,并且建立好了与47.117.157.14这台机器的连接,也就是我右边这一台。然后在右边这台主机查看,我们也发现了这台主机和左边服务器42.194.197.13建立好了连接。
所以三次握手建立连接的过程跟用户是否accept无关!

我们在右边这台机器再次以后台进程的方式启动一个客户端连接服务端。然后查看两台机器的tcp服务,发现服务端多了一个连接,客户端也是如此。

接着我们在右边这台机器再起一个后台客户端,接着分别查看两边的tcp服务。左边发现只有两个建立好的连接,右边这台机器我们发现也是只有两个建立好的连接,而我们刚新起的进程还处于SYN_SENT状态。
这是因为服务器在来不及进行accept的时候,底层的TCPlistensocket允许用户三次握手,建立连接成功,但是不能建立成功太多的连接。最多就是backlog+1,我们刚才设置backlog为1,所以最大就是2。
建立好的连接操作系统在底层会用一个队列帮我们维护好,方便上层获取。这个队列我们称为全连接队列。所以listen的第二个参数表示全连接队列中已经建立三次握手成功连接的个数 = backlog + 1。
假设服务端比较繁忙,来不及获取新连接,当客户端过来连接,三次握手成功后就会建立连接,这些连接操作系统也要管理起来,所以这些连接本质上就是一个结构体,然后将连接链到全连接队列中。所以会往连接队列中放数据,上层也会从连接队列中获取连接,因此这就是一个生产者消费者模型。
全连接队列不能为空,如果上层来不及获取连接,客户端过来连就都会被拒绝掉,而当服务器闲置下来了,就必须得等客户端来连,如果全连接队列中有连接,服务端就可以直接获取上来。
全连接队列也不能太长,当已经开始排队了就说明服务器压力很大了,而排在尾部的连接就需要等待很久,如果客户端关闭连接,那么服务端就需要花空间来维护这个连接,所以也不能太长。

使用TCPsocket创建套接字,返回的是一个监听套接字,是一个文件描述符,也就是说将来获取连接要通过该文件描述符获取的。我们都知道进程PCB为task_struct,里面有个成员struct flies_struct* files指向了struct files_struct对象,该对象中有一个文件描述符表struct files* fd_array[],当我们创建tcp套接字返回的fd为3。在struct file中还有一个无类型的指针private_data,如果我们打开的是普通文件,这个指针就为空,如果我们进行网络通信,这个指针就会指向一个struct socket的对象。这是我们网络socket的入口。
struct socket中有一个struct file* file指针,这个指针回指向struct file对象。里面还有个const struct proto_ops* ops,而struct proto_ops这个对象里面有很多函数指针,所以将来根据创建的socket类型,如果是tcp就指向tcp的方法,如果是udp就指向udp的方法,有的方法是udp没有而tcp有的,所以udp来说设置为空就行了。操作系统要管理连接,连接本质上也是一个结构体对象,连接在操作系统中并不是struct socket对象,而是struct tcp_sock对象。所以struct socket中还有一个struct sock*的指针指向了struct tcp_sock对象,至于为什么是这样需要后面才能解答。
struct tcp_sock是我们在内核创建的一个真实的tcp套接字,里面有很多字段,比如snd_ssthresh表示慢启动的阈值等。我们关注它的第一个字段struct inet_connection_sock,这个对象包含了tcp连接相关的信息。比如超时的时间、重传计数等。我们关注两个字段,第一个是struct request_sock_queue,这个就是全连接队列。还有一个字段是struct ient_sock对象,这个对象里面有目的IPdaddr,目的端口dport等信息。inet_sock里面包含了各种各样的网络信息。而inet_sock的第一个成员又是struct sock,这个struct sock在前面我们讲过,里面包含了两个队列:接收队列和发送队列,这两个队列就是TCP的发送和接收缓冲区。我们再看这两个队列对象里面的字段,里面有sk_buff的指针,而sk_buff就是报文,sk_buff里面有head、tail、data、end用来指向报文的开头、结尾、数据等信息。

所以一个struct tcp_sock对象如上图所示,第一个成员是struct inet_connection_sock对象,然后struct inet_connection_sock的第一个成员是struct ient_sock对象,然后struct ient_sock的第一个成员是struct sock对象。
最上面就是struct sock对象,所以上面的struct socket对象中有一个struct sock*的指针,直接指向tcp_sock中最上面的sock对象。未来想访问struct ient_sock的字段就直接将该指针强转成struct inet_sock*指针就行了。想访问struct inet_connection_sock对象就将该指针强转成struct inet_connection_sock*。想访问struct tcp_sock,就将该指针强转成struct tcp_sock*。
这就是C风格的多态。
当然UDP也要有,所以也有udp_sock对象:

我们发现udp_sock第一个对象是struct inet_sock,因为udp也要包含网络信息,什么源IP、源端口、目的IP、目的端口都要有。但是没有struct inet_connection_sock,因为tcp是面向连接的所以有这个对象,而udp不是面向连接的,所以不需要有。同时我们发现上面的struct socket对象还有个type字段,可以用来标识SOCK_DGRAM或SOCK_STREAM,将来初始化struct proto_ops里面的方法就可以根据是tcp/udp进行初始化了。
因此struct socket是一个基类,是我们网络socket的入口,BSD socket——通用socket接口。
所以我们创建套接字进行网络通信时,首先创建struct file,让文件描述符表中的指针指向struct file对象,然后创建struct socket,struct socket和struct file对象内都有指针互指。然后让struct socket中的struct sock*指针指向struct sock对象,未来要通过3号文件描述符获取新连接就是通过struct file一直往下找,找到struct sock*指针,强转成struct inet_connection_sock对象,然后访问全连接队列,获取一个新连接。
下面介绍使用tcpdump进行抓包,默认Linux上已经安装tcpdump,如果没有安装请自行安装。
1、sudo tcpdump -i any tcp
-i any 指定捕获所有网络接口上的数据包, tcp指定捕获TCP协议的数据包。 i可以理解成为interface的意思。

2、使用host关键字可以指定源或目的IP地址。例如,要捕获源IP地址为192.168.1.100的TCP报文。
sudo tcpdump src host 192.168.1.100 and tcp

由于服务端进程没有启动,所以客户端连接失败,但是我们可以左边抓取到了SYN的报文。并且可以看到交换了win窗口、mss和序号信息。
3、要捕获目的 IP 地址为 192.168.1.200 的 TCP 报文,可以使用以下命令:
sudo tcpdump dst host 192.168.1.200 and tcp
4、同时指定源和目的 IP 地址,可以使用 and 关键字连接两个条件:
sudo tcpdump src host 192.168.1.100 and dst host 192.168.1.200 and tcp
5、使用 port 关键字可以指定端口号。例如,要捕获端口号为 80 的 TCP 报文(通常是HTTP 请求),可以使用以下命令:
sudo tcpdump port 80 and tcp
1万+

被折叠的 条评论
为什么被折叠?



