传输层协议深度拆解:端口寻址、UDP 报文与 TCP 可靠性机制全解析
-
- 一、端口号
-
- 端口号范围划分
- 认识知名端口号(Well-Known Port Number)
- netstat
- pidof
- 二、UDP协议
-
- 1. UDP协议端格式
- 2. UDP的特点
- 3. 面向数据报
- 4. UDP的缓冲区
- 5. UDP使用注意事项
- 6. 基于UDP的应用层协议
- 三、TCP协议
-
- 1. TCP协议段格式
- 2. TCP的可靠性保障
-
- 2.1 确认应答机制(ACK)
- 2.2 工作模式理解
- 2.3 序号和确认序号
- 2.4 16位窗口大小
- 2.5 6位标记位
- 2.6 超时重传机制
- 2.7 连接管理机制
- 2.8 滑动窗口
- 2.9 流量控制
- 2.10 拥塞控制
- 2.11 延迟应答
- 2.12 捎带应答
- 2.13 面向字节流
- 2.14 粘包问题
- 2.15 TCP异常情况
- 2.16 TCP小结
- 2.17 基于TCP应用层协议
- 2.18 TCP/UDP对比
- 2.19 用UDP实现可靠传输(经典面试题)
- 2.20 listen的第二个参数
一、端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序。
在TCP/IP协议中,用“源IP”、“源端口号”、“目的IP”、“目的端口号”、“协议号”这样一个五元组来标识一个通信(可以通过 netstat -n 查看)。

通过源IP地址、目标IP地址、协议号、源端口号和目标端口号这5个数字识别一个通信。
端口号范围划分
- 0-1023:知名端口号,HTTP、FTP、SSH等这些广为使用的应用层协议,它们的端口号都是固定的。
- 1024-65535:操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的。
认识知名端口号(Well-Known Port Number)
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:
- ssh服务器,使用22端口
- ftp服务器,使用21端口
- telnet服务器,使用23端口
- http服务器,使用80端口
- https服务器,使用443
执行下面的命令,可以看到知名端口号:
cat /etc/services
我们自己写一个程序使用端口号时,要避开这些知名端口号。
一个进程可以绑定多个端口号,但是每个端口号只能被一个进程绑定。如果一个进程绑定的端口号被占用,则会报错。
netstat
netstat是一个用来查看网络状态的重要工具。
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n:拒绝显示别名,能显示数字的全部转化成数字,显示不了的就显示正常字符串
- l:仅列出有在Listen(监听)的服务状态,就是处于监听状态的进程
- p:显示建立相关链接的程序名,会显示进程名
- t:(tcp)仅显示tcp相关选项
- u:(udp)仅显示udp相关选项
- a:(all)显示所有选项,默认不显示LISTEN相关的选项
pidof
在查看服务器的进程id时非常方便。
语法:pidof [进程名]
功能:通过进程名,查看进程id。
拿到进程id后,可以通过kill命令来终止进程:pidof [进程名] | xargs kill -9
二、UDP协议
1. UDP协议端格式
UDP协议端格式如下:
| 16位UDP长度 | 16位校验和 |
| 数据(可选) |
我们之前写代码时应用层端口号是 uint16_t 类型是因为UDP协议端口号是16位的。
校验和是16位的,用于检测数据是否在传输过程中被修改。
校验和的计算方法是:
16位UDP长度:表示整个数据报(UDP首部+UDP数据)的最大长度。
如果校验和出错,就会直接丢弃。
那么怎么实现报头和有效载荷的分离呢?
UDP采用定长报头的方式。因为报头的两行是8字节,所以分离时固定取前8字节作为报头,剩下的就是有效载荷。然后通过目的端口号,来判断数据是属于哪个进程的。
数据就是上层拷贝下来的有效字段。
协议的结构化理解:
因为我们知道传输层是属于linux内核的,所以本质上协议就是一种结构化的数据。因为linux是c语言实现的,我们可以想象成一个结构体,结构体中包含了报头和有效载荷。
struct udp_hdr {
uint16_t source_port;
uint16_t dest_port;
uint16_t len;
uint16_t checksum;
};
或者位段实现:
struct udp_hdr {
uint16_t source_port : 16;
uint16_t dest_port : 16;
uint16_t len : 16;
uint16_t checksum : 16;
};
所以未来就可以使用两个指针指向报头和有效载荷:
char *udp_hdr = malloc(XXX); // XXXX是数据报的长度
char *data = udp_hdr + sizeof(struct udp_hdr);
后续直接使用 udp_hdr 强制类型转换为 struct udp_hdr 类型,就可以访问报头中的字段了。
2. UDP的特点
UDP传输的过程类似于寄信。
- 无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接。
- 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
- 面向数据报:不能够灵活的控制读写数据的次数和数量。
3. 面向数据报
应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并。类似于发送邮件,一次发送一个邮件,一次接收一个邮件。
用UDP传输100个字节的数据:
- 如果发送端调用一次 sendto,发送100个字节,那么接收端也必须调用对应的一次 recvfrom,接收100个字节;而不能循环调用10次 recvfrom,每次接收10个字节。
4. UDP的缓冲区
- UDP没有真正意义上的发送缓冲区。调用 sendto 会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
- UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据就会被丢弃。
- UDP的socket既能读,也能写,这个概念叫做全双工。
5. UDP使用注意事项
我们注意到,UDP协议首部中有一个16位的最大长度也就是 2^16。也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。
然而64K在当今的互联网环境下,是一个非常小的数字。
如果我们需要传输的数据超过64K,就需要在应用层手动的分包,多次发送,并在接收端手动拼装。
6. 基于UDP的应用层协议
- NFS:网络文件系统
- TFTP:简单文件传输协议
- DHCP:动态主机配置协议
- BOOTP:启动协议(用于无盘设备启动)
- DNS:域名解析协议
DNS:域名解析协议,实际上就是你输入域名,服务器会返回对应的IP地址。浏览器有内置的DNS解析服务器的地址,当我们输入域名时,浏览器会先从内置的DNS解析服务器中获取IP地址,如果获取到,就直接使用IP地址,然后发送请求。
当然,也包括你自己写UDP程序时自定义的应用层协议。
三、TCP协议
TCP全称为“传输控制协议(Transmission Control Protocol)”。人如其名,要对数据的传输进行一个详细的控制。
所谓的传输控制就是:因为所有的应用层调用的发送实际上都是拷贝数据不是直接发送,而是拷贝到传输层的缓冲区中,然后数据什么时候发、发送多少、出错时的处理等,都是传输层TCP要控制的。
1. TCP协议段格式
| 32位序号 | |
| 32位确认序号 | |
| 4位首部长度 | 6位保留位 |
| 16位校验和 | 16位紧急指针 |
| 选项 | |
| 数据 |
源/目的端口号:表示数据是从哪个进程来,到哪个进程去。
32位序号/32位确认号:后面详细讲。
4位TCP报头长度:表示该TCP头部有多少个32位bit(有多少个4字节);所以TCP头部最大长度是 15*4 = 60 字节。
6位标志位:
- URG:紧急指针是否有效
- ACK:确认号是否有效
- PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走
- RST:对方要求重新建立连接;我们把携带RST标识的称为复位报文段
- SYN:请求建立连接;我们把携带SYN标识的称为同步报文段
- FIN:通知对方,本端要关闭了,我们称携带FIN标识的为结束报文段
16位窗口大小:后面再说。
16位校验和:发送端填充,CRC校验,接收端校验不通过,则认为数据有问题,此处的检验和不光包含TCP首部,也包含TCP数据部分。
16位紧急指针:标识哪部分数据是紧急数据。
40字节头部选项:暂时忽略。
tcp协议是有标准长度的,标准长度是20字节。 但是这个长度可以被应用层协议修改。
所以我们先读取20字节,然后转化成一个struct体。然后根据struct体中的4位首部长度来计算。首部长度就是TCP协议段的总长度(因为包含选项的话就会超过20字节)。4位bit位就是(0000-1111),在计算时要乘以4字节,所以实际长度的取值范围是20字节到60字节。
所以实际上就算什么选项也不写,首部长度也不会是0000,而是0101,也就是5(0101)*4字节=20字节。
如果首部长度不是0101,就表示有选项,所以就可以使用 首部长度*4字节-20字节 来计算数据的长度。
那么为什么TCP报头中没有有效载荷长度字段呢?
因为TCP是基于字节流的协议,我们只需要保证把数据拷贝到缓冲区里面。TCP不需要对有效载荷做任何解释,数据的可靠性已经保证了,按需到达了并且顺序正确,然后放到你的缓冲区里面,然后应用层再根据需要进行处理。
收到一个网络报文后,内核是如何找到对应进程的?
收到一个网络报文后,内核通过解析传输层头部中的目的端口号,利用操作系统维护的端口与套接字的映射关系(通常采用哈希表),快速找到对应的套接字对象。
由于Linux采用“一切皆文件”的设计哲学,套接字本质上也是一个文件。进程在创建套接字时,系统会返回一个文件描述符fd,该fd实际上是进程的 files_struct(文件描述符表)中的一个下标。通过这个下标可以找到对应的 struct file 结构体,其中不仅包含文件的各种操作函数指针(如读写方法),还关联着该套接字在内核中的接收缓冲区和发送缓冲区。
网络数据从网卡经过协议栈解析后,实际上是被放入该套接字关联的内核接收缓冲区中的。因此,进程可以通过 read(fd) 或 write(fd) 等标准的文件系统调用,像操作普通文件一样对TCP套接字进行数据读写。
整个流程可以概括为:网络报文 → 端口号 → 传输层套接字 → struct file → 进程文件描述符表 → 进程通过fd操作套接字缓冲区。
和UDP一样,TCP报头实际上是一个结构体,只是这个结构体的字段更多。和UDP一样,添加报头就是在数据前面添加这个结构体的字段,然后再发送。
2. TCP的可靠性保障
2.1 确认应答机制(ACK)
因为网络通信往往距离很远,所以需要确认应答机制来保证数据的可靠性。
确认应答机制就是发送端发送数据后,等待接收端返回确认,确认后发送端才会发送下一个数据。
但是虽然有确认机制,但是仍然没有办法保证绝对的可靠性。因为最新发送的消息是没法确认的。但是存在相对的可靠性,只要收到了确认,就表示数据到达了。

TCP将每个字节的数据都进行了编号,即为序列号。

每一个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据;下一次你从哪里开始发。
2.2 工作模式理解
我们之前的了解是在正常通信的时候除了正常的数据段,还有其他一些特殊的数据段。比如连接请求段、连接确认段、断开连接段等。即使是特殊的数据段,也需要被确认,也是一个完整的tcp数据段。
在之前的理解中客户端在发起连接请求后,服务器端会返回一个连接确认段,客户端收到确认段后,才会发送正常的数据段。
但是在实际通信中,在发起连接请求后,服务器端会返回数据,这个数据就既充当了确认段,也充当了正常的数据段。
我想说的是在实际通信中,不是一次确认一次应答,有可能会一次发送多个数据段,然后返回时返回多个确认。
2.3 序号和确认序号
因为在TCP中通信的双方是平等的,只有数据段和确认数据段,所以不管谁是客户端还是服务端,不存在发送端和接收端的概念。所以我们学习TCP只用关注一个方向就可以了。
我们之前说过在实际通信中,不是一次确认一次应答,有可能会一次发送多个数据段,然后返回时返回多个确认应答。
那么数据在到达接收端后到达顺序和发送顺序是相同的吗?答案是不一定。未来发送的多个数据段,在得到确认时发送方是怎么知道那个确认是对应哪个数据段的?所以TCP数据段中需要包含字段来标识每个数据段本身,所以就有了序号和确认序号。
所以在应答数据段里面一定会包含确认序号。就是一个数据段里面的序号是10,那么在确认数据段里面确认序号就是ack:11。确认序号是数据段的序号+1。因为数据段的序号是从0开始的,所以确认序号是从1开始的。
其实这个确认序号既表示了确认收到了当前数据段,也表示了收到了在这个序号之前的所有数据段。也就是说,确认序号是下一个数据段的序号。
所以假设一共发送了四个数据段10,11,12,13,那么确认序号就是11,12,13,14。
但是如果发送的数据段12丢失了,那么确认序号就是12。因为目前只能确认收到了10,11,即使13收到了,也不能确认收到了12。所以确认序号就是12。
为什么要有两组序号?
因为TCP是全双工协议,所以不仅是一方给对方发送数据,对方也会给我发送数据。
接收缓冲区和发送缓冲区都可以简单理解为一个数组,那么是数组就会有下标。TCP将每个字节的数据都进行了编号,即为序列号。每一个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据;下一次你从哪里开始发。
2.4 16位窗口大小
因为在网络通信的时候有可能发送方发送数据非常快,而接收方接收处理数据非常慢。一直发送到接收方处理不了,就会丢失数据(接收缓冲区满了)。
也有可能发送方发送的太慢了,导致接收方即使接收到数据也没法处理。
所以TCP发送数据时,快了不行,慢了也不行。
所以我作为发送方我怎么知道接收方处理能力是多少?所以我们需要知道对方的接收缓冲区的剩余空间的大小。因为TCP是全双工的,所以对方也要知道我的接收缓冲区大小。
所以16位窗口大小会填入自己的接收缓冲区大小。这个也叫做流量控制。
2.5 6位标记位
TCP报文也是有类型的。作为服务器是会接收到各种客户端发来的各种报文的,所以客户端要根据不同的报文提供不同的方法。
如果是一个连接请求的报文,那么我服务端就要和对方做好三次握手;如果是常规的报文,那么我就要有对应的处理方法。所以有了6位标记位来区分不同的报文。
- 假设是连接请求报文,那么会把SYN标记位设置为1。
- 如果是连接确认报文(不管这个报文是否包含数据段),那么会把ACK标记位设置为1。
- FIN标记位为1表示发送方想关闭连接。
- PSH标记位为1表示发送方想立即发送数据。
- URG标记位为1表示发送方有紧急数据需要立即处理。因为数据段是有序号的,所以正常情况下是按照序号处理的。但是如果有紧急数据,那么就会把紧急数据的序号放到紧急指针字段中,让接收方立即处理紧急数据。这个紧急数据不再存放到接收缓冲区中,而是通过报文中的紧急指针字段来标识。16位紧急指针字段是紧急数据在数据段中的偏移量。注意这个紧急数据只有一个字节大小,所以在找到紧急数据的位置后读取一个字节就是紧急数据。
- RST标记位为1表示发送方想重置连接。
我们知道TCP建立连接时需要三次握手,那么你能保证一定能成功建立连接吗?即使我们建立连接成功,也不能保证在通信过程中不会因为网络问题导致单方面关闭连接。就会导致一方觉得连接被关闭了,一边觉得连接还存在。那么存在的那边就会直接发送报文,但是关闭方会觉得连接没有建立你不应该发送报文,这个时候关闭方会给对方发送一个报文并且携带RST标志位,让对方和自己重新建立连接。
答案是不一定。因为在建立连接时,有可能会因为网络问题导致建立连接失败。所以重置连接标记位为1表示发送方想重置连接。挥手也是同理。
2.6 超时重传机制
主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发;但是,主机A未收到B发来的确认应答,也可能是因为ACK丢失了。那么这个时候主机A就和上面一样会进行超时重传。

因此主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。这时候我们可以利用前面提到的序列号,就可以很容易做到去重的效果。
因为可能会有发送失败的情况,所以发送方在发送完数据后,会等待一段时间,不会立刻把数据删除。那么这个数据会存放到哪里缓冲区呢?答案是发送缓冲区。注意计算机中的删除都不是真的删除,删除是覆盖数据。
超时时间怎么定?
首先一定不是固定的,因为TCP要保证效率,所以超时时间不能太短。发送到达时间是由网络决定的,但是网络是变化的,所以超时时间不是固定的。
最理想的情况下,找到一个最小的时间,保证“确认应答一定能在这个时间内返回”。
但是这个时间的长短,随着网络环境的不同,是有差异的。
如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包。
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
2.7 连接管理机制
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。注意三次握手是机制不代表一定会成功。 
三次握手:
首先客户端会向服务端发送一个tcp报文,报文的SYN标志位设置为1,这个时候客户端会有自己的状态SYN-SENT。
服务器收到客户端报文后,会发送一个tcp报文,报文的SYN标志位设置为1,ACK标志位设置为1,这个时候服务器会有自己的状态SYN-RCVD。
客户端收到服务器报文后,会发送一个tcp报文,报文的ACK标志位设置为1,这个时候客户端会有自己的状态ESTABLISHED,服务器会有自己的状态ESTABLISHED。
注意三次握手是站在各自的视角来看待的:
- 客户端:只要有两次发送一次接收,就算建立连接,即便最后一次ack丢失了也可以被视为建立连接成功。
- 服务端:只要有两次发送一次接收,就可以被视为建立连接成功。
只要不满足双方的条件,就会认为连接建立失败,这个时候就会有超时重传机制。
为什么要三次握手?
前两次握手我们并不担心ack丢失,因为前两次握手都会有ack确认。我们担心的是最后一次ack丢失。
但是我们也不害怕最后一次ack丢失造成的后果,因为如果最后一次ack丢失了,客户端向服务端发送的报文服务端会认为没有建立连接,然后会发送一个带有RST标志位的报文给客户端,让客户端重新建立连接。或者客户端长时间没有收到服务端的ack,也会认为没有建立连接,然后超时重传。
但是服务器往往会和多个客户端建立连接,所以服务器需要管理多个连接。tcp是传输层属于os,所以os会先描述再组织,管理多个连接。但是管理是要成本的。
三次握手是保证双方都能成功建立连接的最小次数,一次或者两次握手是不能保证建立成功的,四次是可以保证建立成功但是成本上去了。
并且三次握手可以有效防止单机对服务器的攻击,但是不能防止多台客户端对服务器的攻击(SYN洪水攻击,DDoS攻击)。
为什么要四次挥手?
TCP断开连接虽然涉及通信双方,但并不需要双方“事先同意”,任意一方都可以主动发起断开请求,另一方则被动响应这一过程。
首先,主动发起关闭的一方会发送一个携带FIN标志位的报文,并进入FIN-WAIT-1状态;被动方收到该FIN请求后,会立即回复一个携带ACK标志位的报文进行确认,并进入CLOSE_WAIT状态,此时主动方收到ACK后即转入FIN-WAIT-2状态,等待被动方完成剩余数据的传输。
如果被动方在close_wait状态下,我们不主动关闭连接(close(fd)),被动方会一直处在close_wait状态下。
随后,被动方在准备好关闭时,同样会主动(相对于自己而言)发送一个携带FIN标志位的报文,并进入LAST-ACK状态;主动方收到这个FIN报文后,发送最后一个携带ACK标志位的报文进行确认,并进入TIME-WAIT状态(等待2MSL后自动转为CLOSED),而被动方在收到这个最终ACK后则直接进入CLOSED状态,至此连接彻底关闭。
需要特别注意,实际上ACK和FIN是由被动方分两步在不同时机发出的,且最终主动方停留的状态是TIME-WAIT。主动方在TIME-WAIT状态下等待一段时间后,自动进入CLOSED状态。
为什么要等待一段时间?
为了确保所有数据都发送完毕,且没有数据包丢失。因为我们担心的是最后一次ack丢失。
如果最后一次ack丢失了,主动方会认为没有等待,就直接释放连接了,但是被动方会认为没有收到ack,会重新补发fin报文,但是主动方已经释放了连接了,会造成重复发送fin报文,导致连接关闭失败。
所以主动方需要等待一段时间,等待一段时间后,确保所有数据都发送完毕,且没有数据包丢失,被动方不再发送fin报文了,才会释放连接。
服务端状态转化:
- [CLOSED → LISTEN]:服务器端调用listen后进入LISTEN状态,等待客户端连接。
- [LISTEN → SYN_RCVD]:一旦监听到连接请求(同步报文段),将该连接放入内核等待队列中,并向客户端发送SYN确认报文。
- [SYN_RCVD → ESTABLISHED]:服务器一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
- [ESTABLISHED → CLOSE_WAIT]:当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT。
- [CLOSE_WAIT → LAST_ACK]:进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据);当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)。
- [LAST_ACK → CLOSED]:服务器收到了对FIN的ACK,彻底关闭连接。
客户端状态转化:
- [CLOSED → SYN_SENT]:客户端调用connect,发送同步报文段。
- [SYN_SENT → ESTABLISHED]:connect调用成功,则进入ESTABLISHED状态,开始读写数据。
- [ESTABLISHED → FIN_WAIT_1]:客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1。
- [FIN_WAIT_1 → FIN_WAIT_2]:客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2,开始等待服务器的结束报文段。
- [FIN_WAIT_2 → TIME_WAIT]:客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK。
- [TIME_WAIT → CLOSED]:客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态。
理解TIME_WAIT状态:
现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:server bind error: Address already in use。
这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。
TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。
我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7上默认配置的值是60s。可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值。
为什么是TIME_WAIT的时间是2MSL?
MSL是TCP报文的最大生存时间,因此TIME_WAIT持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK)。
解决TIME_WAIT状态引起的bind失败的方法:
在server的TCP连接没有完全断开之前不允许重新监听,某些情况下可能是不合理的。
服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)。这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接。
由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip,源端口,目的ip,目的端口,协议)。其中服务器的ip和端口和协议是固定的。如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了,就会出现问题。
使用 setsockopt() 设置socket描述符的选项 SO_REUSEADDR 为1,表示允许创建端口号相同但IP地址不同的多个socket描述符:
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
理解CLOSE_WAIT状态:
以之前写过的TCP服务器为例,我们稍加修改,将 new_sock.Close(); 这个代码去掉。
我们编译运行服务器。启动客户端链接,查看TCP状态,客户端服务器都为ESTABLISHED状态,没有问题。
然后我们关闭客户端程序,观察TCP状态:
tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 5038/./dict_server
tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 –
tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT 5038/./dict_server
此时服务器进入了CLOSE_WAIT状态,结合我们四次挥手的流程图,可以认为四次挥手没有正确完成。
小结:对于服务器上出现大量的CLOSE_WAIT状态,原因就是服务器没有正确的关闭socket,导致四次挥手没有正确完成。这是一个BUG。只需要加上对应的close即可解决问题。
2.8 滑动窗口
刚才我们讨论了确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差。尤其是数据往返的时间较长的时候。
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。

- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送。
- 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据;依次类推。
- 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉。
- 窗口越大,则网络的吞吐率就越高。

滑动窗口的理解:
我们可以把整个发送缓冲区看作是一个数组,滑动窗口就是这个数组的一个子数组。我们把左端点叫做 win_start(下标),右端点叫做 win_end(下标)。所以所谓的滑动就是下标 win_start 和 win_end 的移动。
1. 滑动窗口的大小是怎么设定的?未来怎么变化?
目前我们认为滑动窗口的大小是和对方的接收能力有关,所以 win_start = 0,win_end = win_start + window_size(16位窗口大小)。所以目前我们认为滑动窗口的大小 = 对方通知我的接收窗口大小。
2. 滑动窗口一定会向左或者向右滑动吗?
一定不会向左滑动,因为左边是已经确认的段,不能向左滑动。不一定向右滑动,因为右边是未发送的段,有可能对方一直通知你的窗口大小是没有变化的,所以滑动窗口的大小也不会变化。所以是有可能滑动窗口向右滑动的,有可能不会向右滑动。
3. 滑动窗口是怎么移动的?
我们说过确认序号就是我们下一个要发送的数据段的序号。所以当一个ack到了,win_start 就可以直接等于ack的确认序号。win_end = win_start + window_size(16位窗口大小)。
就会出现一种情况:对方一直确认 win_start 变大,但是窗口大小是一直减小的,所以滑动窗口的大小会一直减小,一直到 win_start = win_end。所以窗口是动态变化的,会变大也会变小,变化的依据是对方的接收能力。
4. 如果我们收到应答的时候,收到的应答不是滑动窗口最左端的ack,是中间部分的ack,那么我们怎么处理?
确认序号就是我们下一个要发送的数据段的序号。所以当一个ack到了,win_start 就可以直接等于ack的确认序号。这个时候要分两种情况讨论:
假设 win_start = 1000,win_end = 5000。
(1)数据没丢,只是ack丢失了。只是前面的数据段没有被确认ack丢了,但是后面的数据段没有丢失ack是收到的。我们确认序号的定义是ack确认序号表示在确认序号之前的所有的数据段都被确认了。所以滑动窗口可以直接向右滑动。
(2)数据真的丢失了。左端的数据丢失了,但是中间部分的数据没有丢失(假设中间部分的确认序号是3000),是收到报文了的。这个时候其实我们收到的ack序号是1000,不会是3000。
5. 发送缓冲区是一个数组那么就会有最右端,那么一直往右滑动会超过最右端吗?
其实在底层缓冲区被设计成了一个环形数组,所以滑动窗口会循环移动。
那么如果出现了丢包,如何进行重传? 这里分两种情况讨论:
情况一:数据包已经抵达,ACK被丢了。
这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认。 
情况二:数据包就直接丢了。
- 当某一段报文段丢失之后,发送端会一直收到1001这样的ACK,就像是在提醒发送端“我想要的是1001”一样。
- 如果发送端主机连续三次收到了同样一个“1001”这样的应答,就会将对应的数据1001-2000重新发送。
- 这个时候接收端收到了1001之后,再次返回的ACK就是7001了(因为2001-7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。

这种机制被称为“高速重发控制”(也叫“快重传”)。
2.9 流量控制
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
- 接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK端通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
- 发送端接受到这个窗口之后,就会减慢自己的发送速度。
- 如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。

接收端如何把窗口大小告诉发送端呢?回忆我们的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息。
那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?
实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位。
2.10 拥塞控制
我们之前考虑的一直都是端到端的问题,没有思考过网络的问题。
其实网络的问题也会出问题。在双方进行通信时,丢弃小部分包我们是可以接受的,我们有超时机制来处理。但是丢弃大部分包,我们就不能接受了,这个往往是网络的问题。因为我们有滑动窗口来进行流量控制,所以不会出现传输过多导致的对方没法接受那么多包的情况,所以只能是网络的问题了。
TCP的可靠性不仅考虑了端到端的问题,还考虑了网络的问题。
那么出现这种情况我们应该立刻重传吗?
答案是不能。如果我们继续重传,就会导致本来就出问题的网络问题更加严重,因为网络里面不止两台机器,有很多机器在通信,所以如果立刻重传,会导致网络问题雪上加霜。所以我们会等待网络恢复一段时间后再重传。
TCP引入了慢启动机制,先发送少量数据,探探路,摸清楚网络的恢复情况,再决定按照什么速度来发送数据。
这里引入一个概念,叫做拥塞窗口(就是一个数字),在超过这个数字就有可能会出现拥塞的情况。拥塞窗口就是描述网络的拥塞程度的一个指标。
所以我们在发送数据的时候往往要考虑对方的接受能力(报文中的窗口大小),也要考虑网络的拥塞程度(拥塞窗口)。
所以实际滑动窗口的大小就是 min(报文中的窗口大小, 拥塞窗口)。一般来说拥塞窗口会大于报文中的窗口大小。
所以在刚开始发送的时候,定义一个拥塞窗口为1,然后每次收到一个ack,就将拥塞窗口的大小乘以2,然后发送下一个包。
像这种刚开始的时候比较少然后后面成指数增长的情况,就叫做慢启动,指的是开始慢后面快。
为了不增长的那么快,我们需要一个机制来控制,不能使拥塞窗口的大小单纯地加倍。所以引入了一个概念,慢启动阈值。当拥塞窗口的大小超过慢启动阈值时,就会将拥塞窗口的大小不再指数增长,而是线性增长。
开始时使用指数增长是为了快速恢复网络的正常运行,而使用线性增长是为了避免网络的拥塞。
- 当TCP开始启动的时候,慢启动阈值等于窗口最大值。
- 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1。
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞。
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

2.11 延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
假设接收端缓冲区为1M。一次收到了500K的数据;如果立刻应答,返回的窗口就是500K;但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了;在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M。
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
那么所有的包都可以延迟应答么?肯定也不是。
- 数量限制:每隔N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次。
具体的数量和超时时间,依操作系统不同也有差异。一般N取2,超时时间取200ms。
TCP其实不用每一个报文都要返回ack,有的情况会用一个ack来应答多个报文。因为我们知道ack的序列号就代表了在这个ack之前的所有报文都被接收了,所以我们可以用一个ack来应答多个报文。 
2.12 捎带应答
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是“一发一收”的。意味着客户端给服务器说了“How are you”,服务器也会给客户端回一个“Fine, thank you”。那么这个时候ACK就可以搭顺风车,和服务器回应的“Fine, thank you”一起回给客户端。 
2.13 面向字节流
创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区。
- 调用write时,数据会先写入发送缓冲区中。
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出。
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。
- 然后应用程序可以调用read从接收缓冲区拿数据。
- 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做全双工。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次。
像这种不受限制的读写方式,就叫做面向字节流。而像UDP这种面向报文的协议,则是一次发送一个报文,一次接收一个报文,就不会出现你发10个报文我一次就一起接收10个报文的情况,必须是一对一的关系。面向字节流就是想怎么读怎么读,只要你应用层做好读取上的处理,就可以实现面向字节流的传输。
2.14 粘包问题
首先要明确,粘包问题中的“包”,是指的应用层的数据包。
在TCP的协议头中,没有如同UDP一样的“报文长度”这样的字段,但是有一个序号这样的字段。
站在传输层的角度,TCP是一个一个报文过来的。按照序号排好序放在缓冲区中。
站在应用层的角度,看到的只是一串连续的字节数据。
那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界。
- 对于定长的包,保证每次都按固定大小读取即可。例如上面的Request结构,是固定大小的,那么就从缓冲区从头开始按 sizeof(Request) 依次读取即可。
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
- 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)。
思考:对于UDP协议来说,是否也存在“粘包问题”呢?
对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是一个一个把数据交付给应用层。就有很明确的数据边界。站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收。不会出现“半个”的情况。
2.15 TCP异常情况
- 进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。因为这些资源都是os在管理的,进程终止后,os会自动释放这些资源。和你自己调用close()关闭连接,是一样的。
- 机器重启:和进程终止的情况相同。因为机器重启之前就会关闭进程。
- 机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。
2.16 TCP小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。
可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)
2.17 基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然,也包括你自己写TCP程序时自定义的应用层协议。
2.18 TCP/UDP对比
我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢?TCP和UDP之间的优点和缺点,不能简单,绝对的进行比较。
- TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
- UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ,视频传输等。另外UDP可以用于广播。
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
2.19 用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制,在应用层实现类似的逻辑。
例如:
- 引入序列号,保证数据顺序。
- 引入确认应答,确保对端收到了数据。
- 引入超时重传,如果隔一段时间没有应答,就重发数据。
2.20 listen的第二个参数
tcp协议要为上层维护一个连接队列,用于存储等待连接的客户端。这个队列的大小,就是listen的第二个参数。
这个队列的存在是为了保证我们资源的利用率,不会存在资源浪费的情况。为了保证我们的资源在高峰期的时候一直持续的处理连接,而不是在高峰期的时候等待连接到来,而是直接从队列中取连接。但是这个连接队列不能太小也不能太大。如果太小,则会导致连接丢失。如果太大,则会导致资源浪费。
所以,我们要根据实际情况,来调整listen的第二个参数。
理解listen的第二个参数:
基于刚才封装的TcpSocket实现以下测试代码。
对于服务器,listen的第二个参数设置为2,并且不调用accept。
此时启动3个客户端同时连接服务器,用netstat查看服务器状态,一切正常。
但是启动第四个客户端时,发现服务器对于第四个连接的状态存在问题了。
客户端状态正常,但是服务器端出现了SYN_RECV状态,而不是ESTABLISHED状态。
这是因为,Linux内核协议栈为一个tcp连接管理使用两个队列:
而全连接队列的长度会受到listen第二个参数的影响。全连接队列满了的时候,就无法继续让当前连接的状态进入established状态了。这个队列的长度通过上述实验可知,是listen的第二个参数+1。后续的连接就只能是半链接状态。如果这个半连接状态持续一段时间仍然没有建立成功,则会自动释放。
网硕互联帮助中心



评论前必须登录!
注册