在完成HTTPS的学习后,我们就完成了应用层的所有讲解,下面我们开始讲解传输层,这一层常用的协议为TCP和UDP。

目录
(4)滑动窗口必须要滑动吗?会不会不动?滑动窗口大小会一直不变吗?会不会变为0?
(5)收到的确认应答不是滑动窗口最左边数据的确认应答,而是中间的,或者结尾的,要滑动吗?
端口号(port)标识了一个主机上进行通信的不同的应用程序。
如图所示,在一个机器上运行着许多进程,每个进程使用的协议都不一样,比如FTP,SSH,SMTP,HTTP,FTP等。

正是因为每一个进程都有自己的端口号(如图中TCP21,TCP22这些,后面的数字就是端口号)当发来的数据从网络中传输到应用层后,操作系统就能根据端口号将数据交给对应的进程。
TCP/IP协议是传输数据常用的协议,它会用 “源IP”、“源端口号”、“目的IP”、“目的端口号”、“协议号” 这样一个五元组来标识一个通信。
如下图所示,客户端A打开了两个浏览器页面(可认为是两个进程)向服务器分别发送数据1和数据2。所以在这两份数据的五元组中,源IP地址都是客户端A的IP地址,目标IP地址都是服务器的IP地址,源端口号分别是2001和2002,它们能标识这两个进程,目标端口号都是服务端的HTTP进程,HTTP使用的端口号是80。
客户端B也打开一个浏览器页面(可认为是一个进程)向服务器发送的数据3。所以在这份数据的五元组中,源IP地址就是客户端B的IP地址,目标IP是服务端的IP地址,源端口号是这个页面进程的端口号,目标端口号是服务器HTTP进程的端口号80。
协议号是通信协议的编号,指定的协议号可以标识一种协议,比如6号就标识TCP协议。
IP地址属于IP协议的内容,IP协议属于网络层,在传输层处不做讨论。

在前面编写套接字代码的时候,端口号的类型是uint16_t,本质是一个16位的无符号整数。由于这个整数的最大值是65535,所以端口号的范围是0~65535。
这些端口号可以分为知名端口号和操作系统动态分配的端口号。
0~1023范围内的端口号叫做知名端口号,这些端口号常被固定的服务绑定,如HTTP、FTP、SSH等这些广为使用的应用层协议,他们的端口号都是固定的。由于应用层协议本质上是进程在使用,所以在这里应用层协议、进程可以看作是等价的。
可以这样理解,110对应的是报警电话,120对应的是急救电话,119是火警电话,这些号码已经跟这些公共服务强绑定了。这些放在端口号上也一样,0~1023这些端口号都有对应的协议,不能随便更改,就像报警电话不会随便更改一样。所以,我们自己写程序时,要避开0~1023这些知名端口号。
下面是一些服务的端口号:
| 服务 | 端口号 |
| SSH | 22 |
| FTP | 21 |
| TELNET | 23 |
| HTTP | 80 |
| HTTPS | 443 |
在Linux机器中,有一个文件/etc/services,它存储了所有具体端口号对应的服务协议。我截取了该文件的一部分,可以清楚地看到http的端口号是80。

剩下的1024~65535这些端口号就可以由我们指定去绑定某个进程,比如我们之前使用的8080、8081、8082这些。
除了自己用bind函数显式绑定,操作系统还可以自动从这些端口号中选择一个分配给某个进程。比如说,我们之前写的客户端的套接字就并没有使用bind绑定端口号,在调用sendto函数时,操作系统就会自己从1024~65535中随机指定一个端口号绑定。
由于端口号是标识一个进程的标识符,所以根据一个端口号必须找到一个唯一的进程。
换句话说,一个端口号不能被多个进程绑定。
因为一个进程绑定多个端口号并不破坏一个端口号必须找到一个唯一的进程的特性。
所以,一个进程可以绑定多个端口号。
netstat指令前面已经用过好多次。
语法:netstat+[选项](例:netstat -lntp)
功能:可用于查看当前机器的网络状态。
常用选项:
还有一个指令pidof使用起来也非常的方便。
语法:pidof+[进程名](例:pidof server)
功能:得到进程名对应的pid值。
我们之前查看进程的pid时总需要用使用ps ajx查看所有进程,还要自己找对应的进程。现在直接使用pidof就能返回进程的pid。

还有我们如果想对进程进行处理,往往都需要它的pid,pidof与其他指令异同使用会很方便。我以终止进程为例。
我们的httpserver还在运行,想中止它,我们可以输入pidof httpserver | xargs kill -9,其中xargs表示把管道传送过来的数据追加到后面的语句中。这样整个语句就变为kill -9 pid,从而终止进程。
![]()
下图就是UDP协议的格式,前八个字节属于协议报头,之后的是有效载荷。

UDP协议的前八个字节作为报头,64个比特位。其中这八个字节的前16位0-15存放的是源端口号,第16~31位存放目的端口号,32~47位存放的是UDP的长度,48~64位存放的是UDP校验和。
UDP数据段最长不能超过64KB,所以如果传输的数据超过了64KB,就需要用户在应用层手动将数据分成多个大小小于等于64KB的数据包,并进行多次发送,在接收端用户也需要手动将各分包重新拼装。
解包的最关键问题在于如何把报头和有效载荷进行分离,而分用最关键的在于计算机收到的信息怎么传递给需要的进程。
UDP协议是传输层协议,由操作系统维护,而Linux操作系统又是由C语言写的,所以UDP的报头一定会在操作系统中有自己的结构化数据,我们如果自己写一个结构体描述差不多就是这样:
- struct udp_hdr
- {
- uint16_t src_port;//16位源端口号
- uint16_t dest_port;//16位目的端口号
- uint16_t length;//16位UDP长度
- uint16_t check;//16位UDP校验和
- };
当然将各变量改为unsigned int src_port:16;这样的结构也可以。
以C语言的知识理解报头与有效数据的分离,我们可以认为当计算机接收UDP数据时,该数据会先放在操作系统的已创建的内核缓冲区内(最大为64KB)。然后创建一个hdr指针指向UDP报头起始位置,再再偏移报头字节数(sizeof(udp_hdr))构建一个start指针指向内核中有效载荷的起始位置。最后根据报头的结构化数据将数据将各个数据(包括正文内容)拷贝到内存,这样就能实现了分离。
上面过程的伪代码:
- char* hdr = malloc(XXX);//操作系统创建内核缓冲区
- char* start = hdr + sizeof(struct udr_hdr);//指向有效载荷
- strcpy(start,buffer,len);//将用户缓冲区中数据复制到内核缓冲区有效载荷处。
- (struct udp_hdr*)hdr->src_port = xxx;//赋值源端口
- (struct udp_hdr*)hdr->dest_port = xxx;//赋值源端口
- (struct udp_hdr*)hdr->length = xxx;//赋值源端口
- (struct udp_hdr*)hdr->check = xxx;//赋值源端口
分用时,操作系统中已经维护了一个哈希表,使用绑定的端口号作为key值,就能直接找到这个进程,将有效载荷交给进程即可。所以,进程只要使用网络就一定要绑定端口号,不管是否使用bind显式绑定,这个绑定的过程本质上就是将数据插入哈希表。
UDP协议有以下特点:
(1)无连接:只需要指定目的IP和目的端口就可以直接进行传输,不需要建立连接。
(2)不可靠:没有确认机制,没有重传机制,如果因为网络故障该数据段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
UDP发送数据的方式就像发邮件,发送者只管发出去,至于对方能不能收到完全不关心。
(3)面向数据报:不能够灵活控制读写数据的次数和数量。
比如快递,你发快递只能一个一个发,不能先发半个,然后再发半个。收快递也是,不能先收半个,然后再收半个,只能一个个完整的快递进行收发。
我需要先解释TCP的缓冲区,然后UDP的缓冲区我们就更好理解了。
还记得之前用过的send/sendto、recv/recvfrom这样的的接口吗?这些系统调用直接就能把数据发出去了?
结论当然是否定的。系统调用的工作在操作系统和用户应用的界限之间,而在网络的分层模型中,数据会一层一层向下传递并在物理层发出,所以这些系统调用的任务一定是从应用层将数据向操作系统传递。
在TCP协议中,操作系统会为通信双方各自维护一个发送缓冲区,一个接收缓冲区。用户层在调用send/sendto函数之后,操作系统会自动对需要发送的内容拼接TCP报头(TCP报头的具体内容后面会讲)形成数据段,然后拷贝到到发送缓冲区中。
同样,本主机从对端主机收到数据段后,也会先将其放入接收缓冲区中。当用户层读使用recv/recvfrom这样的系统调用时,系统调用就会将接收缓冲区内的数据拷贝到应用层中,具体点说就是拷贝到接收数据的字符串中。

所以,send/sendto、recv/recvfrom本质上是一个拷贝接口,它们前者负责将应用层的数据拷贝到发送缓冲区,后者负责将数据从接收缓冲区拷贝到应用层。
在两个缓冲区中的数据由TCP的内部代码进行发送和接收,用户只负责将数据放入或拿出这两个缓冲区。正是因为数据的传输无需用户参与,所以TCP的全名叫做传输控制协议。
而且这两个缓冲区使得通信双方进程在同一时刻,既可以发送数据,也可以接收数据,且互不影响,我们称之为全双工。
UDP协议与TCP相似,只是少了一个发送缓冲区。当客户端使用UDP协议将用户层的数据使用sendto发送的时候,数据会被直接拷贝到内核中,经过一定处理后立即发出。
在使用UDP协议接收数据时,操作系统将发送来的数据存放在接收缓冲区中,在适当的时候,操作系统会将数据交给用户层。
UDP的信息传输方式也实现了发送和接收数据同时进行和互不干扰,也叫做全双工。

| 缩写 | 全名 |
| NFS | 网络文件系统 |
| TFTP | 简单文件传输协议 |
| DHCP | 动态主机配置协议 |
| BOOT | 启动协议(用于无盘设备启动) |
| DNS | 域名解析协议 |
上面这些协议在传输层都是使用的UDP协议,其中很多我们天天都在用。
比如说DNS域名解析协议,我们之前说url中像baidu.com这样的域名本质上是该网站服务器的IP地址。

当我们在浏览器地址栏中输入某个Web服务器的域名时。用户主机首先用户主机会首先在自己的DNS高速缓存中查找该域名所应的IP地址。
如果没有找到,则会使用UDP协议向网络中的某台DNS服务器发送该域名IP地址的查询请求,DNS服务器中有域名和IP地映射关系的数据库。当DNS服务器收到DNS查询报文后,在其数据库中查询,之后将查询的IP地址发送给用户主机。
此时,用户主机中的浏览器可以通过Web服务器的IP地址对其进行访问了。
TCP全称为 “传输控制协议(Transmission Control Protocol”).,它可以对数据的传输进行细致的控制。
如图所示,TCP协议格式包括20位固定长度数据、选项和数据,其中前20字节加上选项组成报头。我们在这里不讲解选项的内容,你只需要知道这部分不是定长的就可以了。

TCP协议同样内置于操作系统中,它的报头也是一个C语言编写的结构化数据,只是结构体中的成员变量大小和类型与UDP不同而已。
- struct tcp_hdr
- {
- uint32_t src_port:16;
- uint32_t dest_port:16;
- uint32_t seq;
- uint32_t ack_seq;
- uint32_t header_length:4;
- ......
- };
TCP的解包与UDP很相似,TCP的报头已经有20字节被固定占用,报头的剩余部分是长度不固定的选项。
所以我们先建立一个指针p1指向这20各字节,可以通过指针访问这些数据。
在TCP报头中存有首部长度,通过首部长度乘以4就得到了报头的字节数,头部长度*4 - 20 = 选项长度,此时我们就可以根据选项的长度构建另一个指向选项头部的指针p2,即p1后移20字节,可以获取选项的内容。
最后根据报头字节数将p1后移构建指针p3,获取正文的内容。
最后分用和之前一样,根据端口号在哈希表中对应进程即可。
TCP缓冲区前面已经说过了,操作系统维护了一个发送缓冲区,一个接收缓冲区,实现了全双工。
什么时候将数据段从发送缓冲区发出去,什么时候将数据从接收缓冲区交给应用层?
一次从缓冲区发送多少个字节的数据,一次接受多少数据?
这些全部由内置在操作系统中的TCP协议自行决定。所以说,TCP作为传输控制协议对数据的收发完全自主。
我们常说TCP面向字节流的,这又是什么意思呢?
TCP不像UDP那样,一次必须发送或者接收一个完整的数据段。TCP发送与接收数据以字节为单位,不考虑接收到的数据是不是一个完整的数据段。这些流动的字节像河流一样,所以说TCP是面向字节流的。
由于TCP面向字节流的特点,所以每次发送的数据并不能保证是一个完整报文。
比方说,你要发送的是“最近怎么样?”,“有时间出来玩吗?”,TCP可能会将其分为三个包“最近怎么样”,“?有时间出来”,“玩吗?”。
一个完整报文可能会分散在多个数据报内,如果对报文多读了一部分,或者少读了一部分,这就是粘包问题。
所以为了应用层能处理一个完整的报文,需要程序开发者自己写读取一个完整报文的代码。
解决粘包问题的本质,就是明确各个报文之间的边界。
我们通常采用以下方案明确报文的边界。
如下图所示,应用层协议本质上就是进程,而每个进程都有pid,需要使用网络的进程还有一个或多个端口号port,操作系统会维护一个哈希表。操作系统会根据所有使用网络的进程构造(port:pid)的键值对存放在哈希表中,port是key值,pid是value。
在操作系统完成数据段的解包以后,会根据TCP协议中的目的port在哈希表中查找对应进程的pid,有了pid操作系统就能找到PCB,而PCB的文件描述符表中必定有一个fd指向的文件是一个网络套接字。
而套接字本质上也是个文件,所以也会存在一个struct file结构体来管理它,该结构体中的读写缓冲区其实就是TCP协议的接收和发送缓冲区。
最后,将TCP层解包后的有效载荷放入找到的struct file的接收缓冲区中。此时分用中最重要的将数据交给对应进程的任务就完成了,应用层就可以根据文件描述符fd用read读取有效载荷(报文)了,这就完成了数据的接收。
发送的过程也是一样,不过是走了相反的方向。

我们之前说过,网络通信中的问题都来自双方距离的增大。
那么什么样的现象表明通信不可靠呢?
总而言之,丢包、乱序、重复、校验失败、发送太快发送、太慢都是不可靠的表现。可靠的通信都需要避免这些问题。
而在其中,最核心的就是避免丢包问题。
网络使用确认应答机制确认是否丢包。
确认应答机制反映的问题在我们的生活中就存在。
比如,你和你同学QQ聊天,你给同学发了一条“最近怎么样?”
由于你看不到对方,所以你只能等待对方回信,对方回信了你才知道对方已经收到了你的消息。
你同学回信了,说:“我挺好的,你呢?”
此时你知道消息已经被对方收到了,但如果你不回信,他也不知道你到底收没收到消息。
在网络中也是一样的,发送报文后,接收方如果不回应,发送方也不知道报文有没有被对方接收。
所以,在双方通信时,发送方每发送一个数据包,接收方就要返回一个ACK应答。如果在一定的时间内没有接收到应答,发送方就只能再次将同样的数据发送,直到收到应答。
也就是说,可靠性是通过收到应答保证的,而且在TCP/UDP协议中,客户端和服务器的地位是对等的。
为了更深入地理解TCP的确认应答机制与TCP能实现可靠通信的原因,我们还需要学习TCP报头中的数据信息。(选项的内容我们不具体讲解)

若主机A向主机B发送消息,主机A如何判断数据是否丢包呢?
TCP报文是否丢包的判定标准是在特定的时间范围有没有收到应答,收到应答,则说明对方收到了;若没收到应答,则说明报文丢包了。
而主机A收不到应答的情况又分为两种:
第一种,主机A给主机B发了数据,但该数据主机B没收到,当然也不会应答。
第二种,主机A给主机B发送数据,主机B收到了,但是发给主机A的应答丢失了。
不论是哪一种,主机A在超过一定的时间没收到应答后,都会将该报文再次发送,这种机制称为超时重传。
如果通信双方都只是发一个数据包,接收一个ACK应答,那效率就太低了。
所以每次发送方都会一次向接收方发送大量的数据,并接收相应数量的ACK应答。

效率确实提高了,但是也出现了两个问题:
为了解决这两个问题, 报头中就引入了序号和确认序号。

实际上,我们可以将TCP数据段看作一个元素为char类型的数组,每个元素大小是一字节。TCP数据段(包含报头和有效数据)就放在这个数组中。
所以每个数据段起始位置的下标就相当于每个数据的编号,而确认编号就代表了该下标前的数据已经被接收到了。

编号,标识某个报文的唯一性,也支持接收方将对这些数据进行排序。
确认编号,标识接收方已经接收到的数据的尾部。
我们查看下面的数据传输逻辑:
现象一:
假设客户端给服务器发送一个序号为10的报文,服务器给客户端的应答中的确认序号就需要加一,变为11,表示11前面的信息我都收到了。
现象二:
假设客户端继续给服务器发送序号为11和12的两个报文。服务器只收到了序号为11的报文,所以服务器会给客户端发送确认序号为12的ACK应答,告诉客户端12前的数据我收到了。客户端接收到信息后,超过一定时间,会将报文序号为12的报文重传。
现象三:
假设客户端继续给服务器发送序号为11和12的两个报文。服务器会返回确认序号为12和13的表四12前和13前的报文已经全部收到了。如果只有确认序号为13的应答被客户端接收,那么客户端知道了,13前的数据全被收到了。即使它没收到确认序号为12的ACK应答,13前的都收到了,也可以确认数据已经收到了。
为什么序号和确认序号在不同的字段?
我们不妨设想我们现实生活中发消息的过程。
比方说,你先给你同学发了一个“你吃饭了吗?”
你同学给你回一个“吃了,你呢?”
你又给同学回了一个“我吃完了,最近学校的事情多吗?”
……
我们发现,很多时候,一方往往做出应答的同时也会给对方发送一个问题。
放在网络通信中也很类似。由于TCP是全双工的,读和写都能同时进行,所以通信一方往往会完成两件事:给对方的请求做出应答,给对方发送自己的请求。
在大部分时候,一个请求报文和一个应答报文都会被压缩成一个报文,这种应答方式也叫做捎带应答,提高了通信效率。
为了支持该报文进行请求,需要32位序号,而支持该报文进行应答,又需要32位确认序号。所以,为了实现捎带应答,两个数据必须保存在不同的字段中。
我们之前讲到过,TCP在内核中维护了一个发送缓冲区和一个接收缓冲区,用户可以使用send、write对发送缓冲区进行写入,read、recv对接收缓冲区进行读取。
如果TCP发送数据太快,接收缓冲区很快就会被占满,之后所有发来的报文都会被丢弃。
虽然TCP的重传机制保障数据不会丢失,但是这些被丢弃过的报文会多次经过网络资源转发,才能到达目标主机。它们相比其他传输一次的报文消耗了更多的网络资源。
如果TCP发送数据太慢,接收缓冲区的数据太少,甚至长期为空,也就导致了接收方进程的饥饿问题。
为了避免这样的情况,发送方往往会根据接收方的接受能力控制自己发送数据的速度,这种策略称为流量控制。
由于接收方的接收能力取决于其接收缓冲区的剩余空间大小,而缓冲区剩余空间大小变化很快,又需要实时告知发送方。
我们也知道,接受方在收到报文后,需要对每一个已收到的报文进行应答。
所以我们就在TCP的报头中加入了16位窗口大小这个字段,它可以标识接收方接收缓冲区的剩余空间大小。

所以发送方既可以实时得知对方的接受能力,也能根据这个能力控制发送速度,以达到双方的高效率通信。
TCP报头中有六个标志位,每一个标志位占一个比特位的空间,这些标志位将报文分成不同的类型,对应了不同的处理方式。

比如,你是一家小餐馆的老板,每天会有各种各样的人来到你的餐馆。比如说,有的人是来吃饭的,你会给他们推荐店里的菜品;有的人是来进货的,你就会去确认订单并且等待他们将货物运到餐馆内;有的人是来拿外卖的,你就会把做好的外卖交给外卖员。
来餐馆的人有不同的类型,报文也是一样的。对于不同的报文,服务器的处理动作也会不同。
在大部分的网络通信中,许多的客户端都会同时给服务器发大量的消息。又因为TCP通信必须建立链接,所以服务器收到的数据很多都是链接请求。
通信开始前,需要建立链接,客户端就会给服务器发送建立链接的请求。
链接建立后,客户端就要给服务器发送正常的数据进行通信。
通信完成时,需要断开链接,客户端就会给服务器发送断开链接的请求。
当然,这样的分类的方式很粗糙。
TCP通过前面所说的六个标志位对报文进行分类。下面讲解这些标志位的作用。

该标志位为1,表示该报文是一个应答报文,当然该报文可以作为捎带应答进行传输。
该标志位为1,表示该报文是申请链接的请求报文。具体TCP如何建立连接,我们会在后面的三次握手处讲到。
该标志位为1,表示该报文是一个申请断开链接的请求报文。具体TCP如何断开连接,我们会在后面的四次挥手处讲到。
该标志位为1,表示催促对方尽快将接收缓冲区的数据向上交付以腾出缓冲区的空间,没有强制作用。
比如说,最开始客户端和服务器使用流量控制进行通信,假设客户端不断给服务器发数据,但是服务器不把数据从缓冲区中取走。客户端发来的数据就会逐渐占满服务器的接收缓冲区,此时客户端根据发来的16位窗口大小,停止发送数据。
虽然服务器的接收缓冲区满了,但是客户端也会定期给服务器发送一些不携带有效数据的询问报文。当然,客户端会进行确认应答,告知客户端窗口大小依旧为0。
经过多次询问,客户端会将自己报文对应的PSH位置为1。表示催促服务器的应用层赶紧把接收缓冲区的内容取走,腾出空间,此时客户端才能继续发送数据。
该标志位为1,表示告知对方当前链接非法,要想通信需要对链接进行重置。
先说一个前提,TCP需要双方保持链接才能通信。所以两个主机间每建立一个链接,操作系统都会在各自的内存中维护一个描述链接的数据结构,所有的链接也会通过链表等数据结构管理起来。
我们还是举个例子,还是服务器和客户端在通信。
但是突然服务器断电了,服务器主机会直接停止运行。
此时服务器内存中的所有数据都会消失,维护链接的数据结构也同样会消失。但是客户端维护的链接数据结构还在。
所以客户端认为它与服务器的链接依旧正常,还会给服务器发数据。
此时,服务器重新启动。它发现客户端给它发了一大堆数据,但服务器发现自己并没有和客户端建立链接。
此时,服务器就给客户端发送一个RST标志位为1的报文,告诉对方你维护的这个链接不合法。请你将其释放,如果你还想和我通信,请再次与我建立新链接。
链接重置我们很可能在之前的浏览器中就遇到过,比方说下面的浏览器页面:
URG也叫紧急指针标志位,该标志位为1,表示报文中存在紧急数据,16位紧急指针有效。
16位紧急指针本质是紧急数据在有效载荷中的偏移量,而且这个紧急数据仅有一字节。

对于普通数据而言,它们会先被放入接收缓冲区,然后应用层再将缓冲区内的数据拿到应用区。而这个一字节的紧急数据可以直接被呈递到应用层,保证数据及时被读取到。
在大部分情况下,我们都不会使用16位紧急指针。
TCP的链接管理机制主要是链接的建立和断开,可总结为“三次握手和四次挥手”,这两个过程完全由操作系统维护。

TCP建立连接的流程可总结为三次握手,其过程如图所示:

客户端使用socket创建套接字。
服务器使用socket创建套接字,bind绑定端口号,使用listen将套接字设为监听状态。
此时三次握手完成,双方的套接字状态都为ESTABLISHED,链接成功建立。而且三次握手的流程中,我们发现三次握手的申请者会优先建立链接。
在系统调用的角度上,我们可以认为:
那为什么就必须是三次握手?一次、两次、四次行不行?
假设一次握手就能建立连接。
那么客户端发出SYN标志位为1的数据段后,客户端建立链接,服务端收到数据也直接建立连接。
假如客户端只发送了链接请求,并没有在系统中建立相关的数据结构。那么服务端就维护着一个除了占用系统资源以外完全没用的套接字。
如果客户端频繁向服务端发起连接申请请求,服务端会为其建立连接,但客户端自己不建立连接。此时服务端的系统资源很快就会被占用完,服务端也就崩溃了。
这种现象被叫做SYN洪水,这种攻击方式即使使用三次握手也无法规避其危害。后面我们还会说明这一点。
假设两次握手就能建立连接。
客户端向服务端发起建立连接的请求,服务端收到后直接建立连接,并给客户端发送了同意应答和SYN连接请求,让客户端也建立连接。
同样,客户端在收到服务端的数据后也可以选择不建立连接,同样会导致SYN洪水。
现实中的三次握手。
客户端发送第一次握手的请求后,服务端收到后会给客户端发送确认包含确认应答和发起链接请求的报文。
客户端在收到服务端第二次握手的数据段后,建立连接,再向服务端发送确认应答,表示自己的链接已经建立。
服务端在收到客户端发来的第三次握手应答后,知道客户端已经建立了连接,自己再建立连接。
说到这里大家想一想,三次握手建立链接的本质是什么?
第一次握手客户端向服务器发送请求,第二次握手服务器向客户端发送应答。通过这个过程,客户端知道服务器确实能接收到自己发送的数据。
第二次握手服务器也向客户端发送了请求,第三次握手客户端向服务器发送应答。通过这个过程,服务器也知道客户端确实能接收到自己发送的数据。
由于在三次握手的过程中,双方都完成了一次数据传输和应答,确认了数据可以互相传达,所以三次握手就可以使双方建立可靠的链接。
由于三次握手已经能实现双方的一次数据传递,所以四次及以上的握手也就显得没有必要。
三次握手为什么不能防范SYN洪水攻击?
对于单一客户端而言,通过三次握手进行SYN洪水攻击需要双方都建立连接。而常规服务器的配置往往比客户端高,可用的资源也更多,所以客户端往往耗不过服务端,自己先崩溃了。
所以,三次握手可以有效防止单一客户端向服务端发起攻击。
但是,不法分子可以制造木马(木马与病毒不同,病毒会瘫痪计算机,但木马会在保证计算机正常运行的同时窃取用户的信息)将恶意程序散播到网络内。
随着木马的扩散,网络内的许多计算机都会被植入木马,这些计算机就成为了肉鸡。恶意程序就不断在这些肉鸡上不断向某个网站发起握手请求,直到该网站的服务器崩溃。
甚至更简单一点,叫上一大群人用浏览器同时不断刷新一个网页也能达到同样的效果。
所以说,三次握手只负责建立链接,不会保证链接的安全性,而且保证安全性也不是它应该负责的问题。
TCP断开连接的流程可总结为四次挥手,其过程如图所示:

通信双方的任何一方调用close关闭文件描述符,就表示四次挥手开始。这里我们假设客户端先调用close。
对于全过程而言,有以下特点:
为什么客户端在发起四次挥手请求到完成后并没有第一时间断开连接而是等待一段时间呢?
一方面,为了保证最后一个ACK应答尽可能的被对方收到,需要给第四次握手的一方提供足够触发超时重传的时间,然后再关闭套接字。
另一方面,在教材上常常这样说,双方在断开连接的时候,网络中可能有滞留的报文。为了保证滞留报文能够被撤销,所以需要等待一段时间。
从TIME_WAIT到CLOSED状态的转换,维持的时间有多久?
这个维持的时间是2倍的MSL,通信数据段从一方到另一方花费的最长时间称为MSL,它是一个经过统计学得到的均值。
这段时间恰好足够数据两次从一方传递到另一方,从而保证触发一次超时重传,也足够做出一次应答。
一个之前尚未解决的问题。
在前面写套接字代码的时候,服务端绑定了一个端口,进行网络通信。
然后我们使用ctrl + c终止服务端服务进程,之后我们绑定同样的端口打开该服务器进程。
此时我们发现,服务器bind失败了,只有你等一段时间后才能继续绑定该端口。

其实这就涉及到了我们前面的内容。假设服务端进程结束,会有一段时间处于TIME_WAIT状态,套接字并没有完全关闭。
如果我们直接再次运行,由于套接字还没关闭,所以无法绑定。
在绑定失败后,我们也可以通过netstat -alntp | grep 8080查看失败端口号8080的网络状态。而此时套接字的状态正是TIME_CLOSE状态,这个状态一般会维持4分钟,也就是两个MSL时间。

该问题的严重性。
虽然在咱们看来,只不过等了几分钟,也并没什么大碍。但是对于高并发的网络服务器而言,其重启会带来巨大的损失。
假如,618当天大家都去淘宝买东西,但同时访问的人太多。淘宝的服务器崩溃了,需要立刻重启服务器程序。但是端口号的占用还需要两个MSL时间(4分钟)才能解除,而在这段时间内,已经和服务器建立连接的客户端会因为超时而断开连接。无数笔交易被打断,造成的损失不可估量。
解决方法
在调用bind前加上黑框中的代码就能实现端口复用,允许处于TIME_WAIT状态的端口号进行绑定。

但是,我们也说过,这段时间还有回收报文的工作。端口复用可能数据丢失等等问题,所以是否需要打开端口复用还要结合具体情况。
通信双方只有各自在用户层调用close系统调用,才能向对方发送断开链接的申请。
我们假设只有客户端调用close,而服务端不执行close。
客户端首先发出标志位FIN置1的报文,请求断开连接。服务器接收到请求,将自己的套接字设为CLOSE_WAIT状态,并发送同意客户端断开链接的应答,该应答也能被客户端接收到。
但是,服务器迟迟不调用close,也就不会向客户端发送断开链接的请求,客户端收不到请求,也当然不会做出应答。换句话说,第三次和第四次挥手一直没有执行。
根据四次挥手的流程示意图,此时服务端的套接字就会维持在CLOSE_WAIT状态。

根据上面的现象,我们能够知道,当一方开始前两次握手断开连接时,另一端也要发起断开连接请求,而发送断开链接报文的前提是调用close系统调用。
如果我们不调用或者忘记调用close,套接字就会一直处于CLOSE_WAIT状态。类似于我们之前学到的僵尸进程,这些文件描述符也会占用系统资源,导致资源泄露的问题。
当两个主机在使用各自的文件描述符进行通信时,不免会出现各种异常情况导致通信终止。
如果一方的通信进程突然终止,那么不管是父进程还是操作系统,都会将终止的进程回收。由于四次挥手的过程由操作系统管理,所以即使进程意外终止,双方也能够正常关闭链接。
大家关电脑的时候,肯定碰到过下面这样的页面。

关机之前,操作系统要把这些进程的资源回收,所以关机前,还需要等一段时间。
所有使用网络通信的进程也都会进行四次挥手断开链接,本质上与进程终止相同。
此时一方链接断开,但另一方链接依旧正常,双方状态认知不一致。所以,关机的一方会提醒对方当前链接需要重置,我们可使用重置后的链接进行通信。
而且,服务器有对应的保活策略,它会定期给客户端发送询问报文,若没有应答,才会把链接关掉。
我们知道,正常的数据发送需要先把数据从应用层拷贝到发送缓冲区里。然后TCP会从发送缓冲区内将数据拿出来并分包发送。一方每发送一个数据都需要接收另一方的一个ACK响应,以表明该报文是否被送达。如果超过一定的时间没接收到应答,会触发TCP的超时重传机制。
所以滑动窗口中的数据可以分为:已发送并收到ACK应答的数据、已发送但未收到ACK应答的数据、还未发送的数据。
对于第一种而言,发送方已经能确定该报文被对方接收,所以这部分数据已经没有存在的必要了。当新数据被拷贝进缓冲区后,这部分数据会被新数据覆盖。
对于第二种而言,发送方虽然发送了数据,但是不知道该数据对方有没有接收到。所以这些数据还需要保留,以支持TCP进行超时重传。
对于第三种而言,数据还没发送,依旧等待被处理。
我们将已发送但未收到应答的这部分数据占据的空间称为滑动窗口。正是由于数据的不断发送,已发送并受到应答的数据组件增多,未发送的数据不断减少,窗口就会向右滑动,所以称为滑动窗口。

发送缓冲区可以看成是一个char outbuffer[N]数组,操作系统维护了win_start和win_end两个下标来标识滑动窗口的范围,随着各部分数据属性的变化而不断变化。
也就是说,窗口滑动的本质就是数组下标的更新。

TCP协议的报头存在16位的窗口大小字段,该值就是滑动窗口的大小。
在通信双方三次握手建立连接的过程中,接收方会将自己的接收能力通过16位窗口大小告诉发送方。
所以在最开始,滑动窗口的大小是从发送缓冲区起始位置开始,直到向后的第tcp_win个字节。此时一方就知道了对方接收数据的能力,也会通过该能力设置滑动窗口的大小。同样的方式,另一方也会设置好自己的滑动窗口大小,以保证双方的正常通信。
如图所示,假设每份数据的大小是1000字节,滑动窗口的大小是4个数据段(4000字节)。主机A先发送一个数据段(1001~2000),当主机B收到并且返回ACK时,确认序号是2001。
确认序号表示这个序号前的数据已经被接收到了,接下来从该序号开始发送即可。
此时原本滑动窗口中的1001-2000数据段就变成了已发送并收响应的数据。所以滑动窗口向右滑动,右侧的5001-6000数据段就进入了滑动窗口。
由于数据的发送是从左到右的,所以滑动窗口的滑动方向必定是向右的,不会向左滑动。

我们设想一个比较极端的情况,客户端发送数据,服务器接收数据。但服务器接收数据放到接收缓冲区后,不会将数据拿出接收缓冲区。
随着数据不断发送并收到应答,服务器端的接收缓冲区剩余空间会不断减少,每次返回的ACK应答中的16位窗口大小会不断减小。
而滑动窗口大小的变化依据对方接收能力,随着对方接收能力的下降,滑动窗口的大小不断减小。你就会看到这样的场景,win_start不断向后移动,而win_end几乎不变,直到二者相遇。服务器的接收缓冲区被填满了,客户端发送缓冲区的滑动窗口大小也减为0。
当发生丢包等情况时,滑动窗口不会发生滑动,因为无法确定对方是否收到了发生的数据,此时就会保持不动,等待下一步策略执行。
当对方的接收缓冲区满了,并且应用层不读走数据时,此时接收能力就是0。所以ACK确认应答中的16为窗口大小也是0,发送方的滑动窗口大小也会变成0。
所以,滑动窗口不一定会滑动,其大小会按照对方的接收能力进行变化,可以为0。
如图所示,滑动窗口中有六份数据被发送。其中第二、五、六份数据接收到了响应,而第一、三、四份数据没有接收到响应。

我们还是要记住确认序号的含义:确认序号前的数据已经被接收到,下次发送请从该序号开始。
所以即使是上面的情况,由于后面的数据应答被接收了,那么前面的也必定被接收了,窗口依旧可以正常滑动。
如果你还是以数组的角度去看待滑动窗口,那么它一直向右滑动,迟早会有越界的情况。
但实际的发送缓冲区是一个环状结构,所以滑动窗口无论怎么右滑都不会越界。
客户端要发给服务器1000个数据段,每个数据段都会收到其确认应答。
客户端发送完这1000个数据段后,发现有两个数据段没有收到响应。此时客户端就会认为这是自己的问题,只需要进行超时重传就可以了。
但是如果999个数据段都没收到应答,此时客户端只能认为网络出了问题,导致大量数据不能正常发送。
少量的丢包,触发超时重传即可解决;大量的丢包,大概率出现了网络拥塞。

那客户端该如何处理这些数据呢?
如果将1000份数据重传,那么本就拥塞的网络会雪上加霜。而且一个局域网中发送数据的客户端很多很多。如果都重传,那么本就有问题的网络中就会出现更多拥塞的数据,加重网络的问题。
所以网络拥塞时,需要采用拥塞控制的策略。
不管拥塞控制的具体实现,我们首先要认识到拥塞控制一定将网络的承载能力纳入了考量。
假设现在主机A给主机B发数据,当网络发生拥塞时,主机A会立即停止大量数据的发送。而是先发送一个数据段探探路,如果能够收到这个数据段的应答,再将每次发送的数据段个数逐渐增加。这种机制叫做TCP的慢启动机制。
由于使用TCP通信的两台主机不知道整个网络是如何工作的,所以它们不知道彼此发送数据的最高效的数量。因此,他们必须找到一种方法来确定它。我们称之为拥塞窗口,它是在网络发生拥塞之前允许发送的字节数。
在网络发生拥塞后,拥塞窗口的大小会被设为1,即一个数据段(1KB)的大小。
如果这次的发送确实收到了响应,拥塞窗口就会增长到2。下一次发送会按照新的拥塞窗口大小发送数据。再下一次,拥塞窗口会继续以2的指数级增长,发送的数据逐渐变多。直到再次发生拥塞,拥塞窗口又会变为1。

之所以称这种方式是慢启动,是因为我们也都学过指数函数,最开始数据量的增长很慢,但是后面数据量就会爆炸式增长。所以慢启动可以让发送方发送的数据量快速恢复到正常水平,提高了网络通信的效率。
但是指数的后期增长过快,会出现非常恐怖的数据量,所以一直按指数增加拥塞窗口是行不通的。
为了解决指数增长过快的问题,慢启动都会设置一个阈值。到达这个阈值后,拥塞窗口便不再按照指数增长,而是按照线性方式增长。

网络拥塞控制机制触发,势必是已经发生了网络拥塞,当上一次网络拥塞发生时,图中阈值大小为16。当发送方以慢启动方式开始发送数据后,拥塞窗口会按照指数级增长到16后变为线性增长。
拥塞窗口线性增长到24以后,再次发生网络拥塞,发送的数据量在不断增加。此时将阈值更新为24的一半12。
然后发送方再以慢启动的方式发送数据,拥塞窗口变成12以后再线性增长,直到发送网络拥塞,再次更新阈值。
如此反复,不断更新阈值和拥塞窗口的最大值,以便试探出当前网络状况下效率最高的数据传输量,其后保持不变。
虽然拥塞窗口确实限制着单次数据的传输量,但是让网络拥塞所需的数据量大部分情况都会超过对方的接收能力。就算网络情况再好,发送方也不能以超出接收方接收能力数据量来通信。
由于滑动窗口的大小决定数据的发送量,所以滑动窗口大小拥塞窗口大小和对方接收能力大小的较小值。
在网络状况良好的情况下,发送方的滑动窗口大小取决于接收方的接收能力;在网络状况差的情况下,发送方的滑动窗口大小取决于拥塞窗口的大小。
假设接收端缓冲区大小为1MB,但该主机一次收到了500KB的数据,此时该主机有两种选择:
一种是接收端立刻应答,返回的窗口大小就是500KB。
另一种是接收端处理接收缓冲区中数据的速度很快,比方说10ms内就把500KB数据从缓冲区中拿走了,并且接收端处理速度也绰绰有余,即使窗口再放大,也能处理。此时,接收端可以稍微等一会再应答,比如等待200ms,那么返回的窗口大小就是1MB了。
采用延迟应答的处理方式,一定程度上能保证网络在不拥塞的情况下,尽量提高传输效率。
但是TCP也不会对每一个报文都做延迟应答,其常用的方案有两种:
具体的数量和超时时间根据操作系统会有不同。
我们当时在编写TCP套接字时曾经埋了一个坑,没有讲listen系统调用的第二个参数backlog。当时只是说设置一个不要太大的数字即可。
我们再看看listen的函数声明:
int listen(int sockfd, int backlog);
头文件:sys/socket.h
功能:设置该文件描述符为监听状态。
参数:int sockfd表示之前使用socket()返回的文件描述符sockfd。
int backlog表示全连接队列的长度。
返回值:成功返回另一个用于通信的文件描述符,失败返回-1并设置错误码errno。
为了理解这个队列,我们不妨设想去餐馆吃饭的场景。
比如说,在各种商场的餐馆的外面常常会给排队的顾客留一排凳子,在里面人满的时候,顾客可以在外面坐着等待。等到里面有座位了,才会在工作人员的指引下入座用餐。
其实,这个全链接队列就非常类似于外面这一排凳子。
大部分服务器进程中,都用一个listen套接字监听,多个套接字进行数据通信。
由于系统资源是有限的,所以当用于通信的套接字数量达到限制以后,系统就无法维护更多的套接字了。但大部分服务器都是很繁忙的,想与它建立链接的客户端很多。所以我们需要尽量保证系统资源能及时服务客户端。
当服务器中用于通信的套接字使用完毕后,这部分系统资源被释放。如果此时没有新的连接到来,这部分系统资源就会处于空闲状态,资源不能及时服务客户端。
所以TCP维护了一个全链接队列,队列中存放处于等待状态的并且已经完成三次握手建立连接的套接字。
当系统资源不足时,这些链接就会进入全连接队列中等待。当系统资源有空余时,队列里的链接会立刻被全连接队列中的套接字使用。此时,系统资源就不会再出现空闲状态,提高了效率。
而listen的第二个参数backlog就能指定全连接队列的长度,具体长度等于backlog+1。

那为什么说backlog值不能太大?
毕竟维护全链接队列也要消耗系统资源,如果全连接队列太长,耗费的资源完全够系统维护更多的套接字用于通信。毕竟饭馆外面的凳子和饭馆里面的座位比还是增加里面的凳子更好。如果太大,属于丢了西瓜捡芝麻的做法。
而且全连接队列太长,里面的套接字等待的时间增大,太长的时间又会触发超时重传,进而导致其他问题。
我们将标识全链接队列长度的backlog设为1,其具体长度等于2。

然后将之前TCP服务器的accept有关代码全部注释掉,此时服务器便不会从全连接队列中拿走链接。

运行服务器

使用Xshell先打开两个窗口,各自使用telnet对服务器进行连接。

然后使用netstat查看网络状态。
我们发现了五个有状态的套接字,第一个表示监听套接字,状态为LISTEN。
第二个和第五个进程pid都为41756,一个表示服务器维护的链接的状态,另一个是telnet进程的状态,它们的状态都为ESTABLISHED,表明双方都建立了链接,等待数据发送。
第三个和第四个进程pid都为41734,和上面是一样的。

此时,已经全链接队列已经满了。我们再打开一个新窗口使用telnet。
此时再次查看链接状态,发现新增了两个套接字,pid为41876。其中telnet的客户端链接为ETABLISHED,而服务器的套接字状态为SYN_RECV。

三次握手的过程中,服务端第一次收到客户端的SYN请求以后,服务端套接字的状态就变成了SYN_RECV,只有服务端也发送第二次握手的SYN+ACK报文以后,再收到客户端的ACK以后,服务端才会变成ESTABLISHED,表示连接建立。
而上面的过程中,第三个客户端发起连接请求后,服务端套接字停在了SYN_RECV状态,说明服务端已经收到了客户端的第一次握手的请求。但是全连接队列已经满了,服务端没有资源维护这个套接字了,所以不会向客户端发起SYN连接请求。
所以,我们称SYN_RECV状态的套接字为半连接状态。
对于半连接状态的套接字,操作系统同样维护着一个半连接队列,里面放着的是处于SYN_SNET和SYN_RECV等半连接状态的套接字。
然而客户端telnet的状态是ESTABLISHED,客户端自己认为连接已经建立,但是服务端认为该链接没有建立,所以本质上通信没有建立。
当客户端开始发送数据的时候,服务端知道自己没有对应套接字,就会发回RST报文,请求重置连接。
所以最终来看,操作系统维护了两个队列:
半连接是否会一直被操作系统维护?
我们等待一段时间后再次查看网络状态,发现服务器端SYN_RECV状态的半连接套接字不见了,而客户端的ESTABLISHED状态的套接字仍然存在。

所以,处于半连接状态的套接字,如果在一定时间内没有变成ESTABLISHED状态,操作系统就会将这个套接字释放掉。