【网络编程】十一、四万字详解 TCP 协议
文章目录
- Ⅰ. TCP 协议概述
- 1、为什么网络中会存在不可靠?
- 2、为什么会存在UDP协议?
- 3、TCP 的主要特点
- Ⅱ. TCP 报文段的首部格式
- 1、首部各字段的意义
- TCP如何将报头与有效载荷进行分离?
- TCP如何决定将有效载荷交付给上层的哪一个协议?
- 2、序号和确认应答号
- 什么是真正的可靠?
- 32位序号
- 32位确认序号
- 报文丢失怎么办?
- 为什么要用两套序号机制?
- 3、窗口大小
- TCP的接收缓冲区和发送缓冲区
- TCP的发送缓冲区和接收缓冲区存在的意义
- 窗口大小
- 4、六个标志位
- 为什么会存在标志位?
- SYN报文
- ACK报文
- FIN报文
- URG报文
- PSH报文
- RST报文
- Ⅲ. 确认应答机制(ACK)
- 如何理解TCP将每个字节的数据都进行了编号?
- Ⅳ. 超时重传机制
- 两种丢包情况
- 超时重传时间的选择
- Ⅴ. 连接管理机制
- 1、三次握手
- 为什么是三次握手,而不选择一次、两次、四次……握手呢?
- 2、四次挥手
- 为什么是四次挥手?
- 3、理解 CLOSE_WAIT 状态
- 4、理解 TIME_WAIT 状态
- TIME_WAIT 的等待时长是多少?
- 解决 TIME_WAIT 状态引起的 bind 失败的方法 -- `setsockopt()`
- Ⅵ. 流量控制
- 1、利用滑动窗口实现流量控制
- 16 为窗口最大字节位数,表示 65535 字节,那 TCP 窗口最大就是 65535 字节吗?
- 第一次向对方发送数据时如何得知对方的窗口大小?
- 2、延迟应答
- Ⅶ. 滑动窗口
- 1、通信时候的真实情况
- 2、滑动窗口
- 滑动窗口一定会整体右移吗?
- 如何实现滑动窗口
- 3、丢包问题
- 快重传 VS 超时重传
- Ⅷ. 拥塞控制
- 1、为什么会有拥塞控制?
- 2、如何解决网络拥塞问题?
- 3、拥塞控制
- Ⅸ. 捎带应答
- 粘包问题
- 1、什么是粘包?
- 2、如何解决粘包问题
- 3、UDP是否存在粘包问题?
- TCP异常情况
- 1、进程终止
- 2、机器重启
- 3、机器掉电/网线断开
- TCP小结
- TCP定时器
- 用UDP如何实现可靠传输(经典面试题)
- 理解listen的第二个参数
- listen的第二个参数
- 为什么底层要维护连接队列?
- 为什么连接队列不能太长?
- 全连接队列的长度
- SYN洪水攻击
- 如何解决SYN洪水攻击?
- 使用 Wireshark 分析 TCP 通信流程

Ⅰ. TCP 协议概述
TCP
全称为传输控制协议(Transmission Control Protocol
),它是当今互联网当中使用最为广泛的传输层协议,没有之一。
TCP
协议被广泛应用,其根本原因就是提供了详尽的可靠性保证,基于 TCP
的上层应用非常多,比如 HTTP
、HTTPS
、FTP
、SSH
等,甚至 MySQL
底层使用的也是 TCP
。
1、为什么网络中会存在不可靠?
虽然输入设备、输出设备、内存、CPU都在一台机器上,但这几个硬件设备是彼此独立的。如果它们之间要进行数据交互,就必须要想办法进行通信,因此这几个设备实际是用“线”连接起来的,其中连接内存和外设之间的“线”叫做 IO
总线,而连接内存和 CPU
之间的 “线” 叫做系统总线。由于这几个硬件设备都是在一台机器上的,因此这里传输数据的“线”是很短的,传输数据时出现错误的概率也非常低。
但如果要进行通信的各个设备相隔千里,那么连接各个设备的“线”就会变得非常长,传输数据时出现错误的概率也会大大增高,此时要保证传输到对端的数据无误,就必须引入可靠性。
总之,网络中存在不可靠的根本原因就是,长距离数据传输所用的“线”太长了,数据在长距离传输过程中就可能会出现各种各样的问题,而 TCP
就是在此背景下诞生的,TCP
就是一种保证可靠性的协议。
此外,单独的一台计算机可以看作成一个小型的网络,计算机上的各种硬件设备之间实际也是在进行数据通信,并且它们在通信时也必须遵守各自的通信协议,只不过它们之间的通信协议更多是描述一些数据的含义。
2、为什么会存在UDP协议?
TCP
协议是一种可靠的传输协议,使用 TCP
协议能够在一定程度上保证数据传输时的可靠性,而 UDP
协议是一种不可靠的传输协议,那 UDP
协议这种不可靠的协议存在有什么意义呢?
不可靠和可靠是两个中性词,它们描述的都是协议的特点。
TCP
协议是可靠的协议,也就意味着TCP
协议需要做更多的工作来保证传输数据的可靠,并且引起不可靠的因素越多,保证可靠的成本(时间+空间)就越高。- 比如数据在传输过程中出现了丢包、乱序、检验和失败等,这些都是不可靠的情况。
- 由于
TCP
要想办法解决数据传输不可靠的问题,因此TCP
使用起来一定比UDP
复杂,并且维护成本特别高。UDP
协议是不可靠的协议,也就意味着UDP
协议不需要考虑数据传输时可能出现的问题,因此UDP
无论是使用还是维护都足够简单。- 需要注意的是,虽然
TCP
复杂,但TCP
的效率不一定比UDP
低,TCP
当中不仅有保证可靠性的机制,还有保证传输效率的各种机制。
UDP
和 TCP
没有谁最好,只有谁最合适,网络通信时具体采用 TCP
还是 UDP
完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,那么就必须采用 TCP
协议,如果应用场景允许数据传输出现少量丢包,那么肯定优先选择 UDP
协议,因为 UDP
协议足够简单。
3、TCP 的主要特点
-
面向连接:
TCP
协议一定是「一对一」才能连接,而无法像UDP
协议可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的。 -
提供可靠交付的服务:无论的网络链路中出现了怎样的链路变化,
TCP
协议都可以保证每个报文无差错、不丢失、不重复,并且按序到达。 -
全双工通信:
TCP
连接的两端都设有发送缓冲区以及接收缓冲区,而应用层一般也同样会常见接收缓冲区以及发送缓冲区来拷贝传输层中TCP
所有的数据缓存! -
面向字节流:
TCP
把应用程序交付下来的数据仅仅看成是一连串的无结构的字节流,所以其并不知道所传送的字节流的含义。接收方应用程序收到的字节流必须和发送方应用程序发出的字节流完全一样才行!
Ⅱ. TCP 报文段的首部格式
虽然 TCP 是面向字节流的,但是 TCP 传送的数据单位是报文段。一个 TCP 报文段分为首部和数据两部分,而 TCP 的全部功能都体现在它首部中个字段的作用!
TCP
协议首部的格式如下所示:
其首部的前 20
个字节是固定的,后面的 4n
个字节是根据需要而增加的选项(n
是整数),其余的就是数据部分内容了。因此 TCP
首部的最小长度是 20
字节!
并且和 UDP
一样,TCP
报头在内核当中本质就是一个位段类型,给数据封装 TCP
报头时,实际上就是用该位段类型定义一个变量,然后填充 TCP
报头当中的各个属性字段,最后将这个 TCP
报头拷贝到数据的首部,至此便完成了 TCP
报头的封装。
struct tcphdr
{uint16_t th_sport; /* 源端口号 */uint16_t th_dport; /* 目的端口号 */uint32_t th_seq; /* 序列号 */uint32_t th_ack; /* 确认号 */uint8_t th_off; /* 偏移量,指明TCP报文头的长度,单位是4个字节 */uint8_t th_flags; /* 控制标志,如SYN、ACK、FIN等 */uint16_t th_win; /* 接收窗口大小 */uint16_t th_sum; /* 校验和 */uint16_t th_urp; /* 紧急指针 */
};
1、首部各字段的意义
-
源端口和目的端口
- 各占
2
字节,表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
- 各占
-
序列号
- 占
4
字节,所以序列号的范围为 [0, 2 32 − 1 2^{32}-1 232−1],一共 2 32 2^{32} 232 (即4294967296
)个序号。序号增加到 2 32 − 1 2^{32}-1 232−1 之后,下一个序号就又回到 0,相当于是一个环形结构! - 首部中序列号字段值指的是 本报文段所发送的数据的第一个字节的序号,用来解决网络包乱序问题。例如一报文段的序号字段值是
301
,而携带的数据共有100
字节,则本报文段的数据的第一个字节的序号是301
,最后一个字节的序号是400
,所以下一个报文段的数据序号应当从401
开始! - 因为
TCP
是面向字节流的,所以传送的字节流中的每一个字节都按顺序编号。并且整个要传送的字节流的起始序号必须在连接建立时设置。
- 占
-
确认应答号
- 占
4
字节,表示 期望收到对方下一个报文段的第一个数据字节的序号。 - 比如,
B
正确收到了A
发送过来的一个报文段,其序号字段值为501
,而数据长度是200
字节(序号501 ~ 700
),这表明B
正确收到了A
发送的到序号700
为止的数据。因此,B
期望收到A
的下一个数据序列号为701
,于是B
在发送给A
的确认报文段中把确认号置为701
。要注意的是,现在的确认号不是501
和700
,而是701
。 - 总之,若确认应答号为
N
,那么则表示到序号N-1
为止的所有数据都已经正确收到了! - 由于序号字段有
32
位长,相当于可以对4GB
的数据进行编号,在一般情况下是可以保证当序号重复使用时,旧序号的数据早已通过网络到达终点了!
- 占
-
首部长度
- 占
4
位,它指出TCP
报文段的数据起始处距离TCP
报文段的起始处有多远,相当于 指出TCP
报文段的首部长度。由于首部中还有长度不确定的选项字段,所以这个字段也被一些教材称为数据偏移。 - 因为该字段占
4
个比特位,所以其最大的十进制能表示15
,而因为 首部长度的单位是4
个字节,因此首部长度的最大值应该是4*15=60
字节,这也是TCP
首部的最大长度,所以减去固定长度之后,选项长度不能超过40
字节。
- 占
-
保留
- 占
6
位,保留为今后使用,但目前应置为0
。
- 占
-
六个标志位(后面会专门讲)
- 紧急
URG
:紧急指针是否有效。 - 确认
ACK
:确认序号是否有效。 - 推送
PSH
:提示接收端应用程序立刻将TCP
接收缓冲区当中的数据读走。 - 复位
RST
:表示要求对方重新建立连接。 - 同步
SYN
:表示请求与对方建立连接。 - 终止
FIN
:通知对方,本端要关闭了。
- 紧急
-
窗口大小
- 占
2
字节,所以窗口大小范围在 [0, 2 16 − 1 2^{16}-1 216−1] 之间的整数。窗口指的是 发送本报文段的一方当前接收窗口的大小,之所以有这个限制,是因为接收方的数据缓存空间但是有限的,所以这是保证TCP
可靠性机制和效率提升机制的重要字段。 - 比如发送一个报文段,其确认号是
701
,窗口字段是1000
。这就表示从701
号算起,发送此报文段的一方的接收缓存空间还可以接收1000
个字节的数据,即701 ~ 1700
字节序号的数据,所以另一方在发送数据的时候,必须要考虑到本方的接收缓存容量! - 总之,窗口大小字段明确指出了现在允许对方发送的数据量,并且这个窗口大小是一直 动态变化 的。
- 占
-
检验和
- 占
2
字节,由发送端填充,采用CRC
校验。接收端校验不通过,则认为接收到的数据有问题。检验和字段检验的范围 包含首部和数据部分,在计算检验和时,要在TCP
报文段前面加上12
字节的伪首部,这和UDP
是一样的,只不过要将伪首部第四个字段置为6
,表示是TCP
协议。
- 占
-
紧急指针
- 占
2
字节。紧急指针只有在URG = 1
时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据)。因此紧急指针 指出了紧急数据的末尾在报文段中的位置。当所有紧急数据都处理完时,TCP
就告诉应用程序恢复到正常操作。 - 值得注意的时,即使窗口为零时,也是可以发送紧急数据的。
- 占
-
选项
- 长度可变,最长可达
40
字节,常见的选项比如窗口扩大选项、时间戳选项、选择确认(SACK
)选项等。这些可以自行查阅了解!
- 长度可变,最长可达
TCP如何将报头与有效载荷进行分离?
当 TCP
从底层获取到一个报文后,虽然 TCP
不知道报头的具体长度,但报文的前 20
个字节是 TCP
的固定报头,并且这 20
字节当中涵盖了 4
位的首部长度。
因此 TCP
是这样分离报头与有效载荷的:
- 当
TCP
获取到一个报文后,首先读取报文的前20
个字节,并从中提取出4
位的首部长度,此时便获得了TCP
报头的大小size
。- 如果
size
的值大于20
字节,则需要继续从报文当中读取size - 20
字节的数据,这部分数据就是TCP
报头当中的选项字段。- 接着读取完
TCP
的基本报头和选项字段后,剩下的就是有效载荷了。
需要注意的是,TCP
报头当中的 4
位首部长度描述的基本单位是 4
字节,这也恰好是报文的宽度。4
为首部长度的取值范围是 0000 ~ 1111
,因此 TCP
报头最大长度为 15 × 4 = 60
字节,因为基本报头的长度是 20
字节,所以报头中选项字段的长度最多是 40
字节。
如果 TCP
报头当中不携带选项字段,那么 TCP
报头的长度就是 20
字节,此时报头当中的 4
位首部长度的值就为 20 ÷ 4 = 5
,也就是 0101
。
TCP如何决定将有效载荷交付给上层的哪一个协议?
应用层的每一个网络进程都必须绑定一个端口号。
服务端进程必须显示绑定一个端口号。
客户端进程由系统动态绑定一个端口号。
而 TCP
的报头中涵盖了目的端口号,因此 TCP
可以提取出报头中的目的端口号,找到对应的应用层进程,进而将有效载荷交给对应的应用层进程进行处理。
说明一下: 内核中用哈希的方式维护了端口号与进程 ID
之间的映射关系,因此传输层可以通过端口号快速找到其对应的进程 ID
,进而找到对应的应用层进程,如下图所示:
2、序号和确认应答号
什么是真正的可靠?
在进行网络通信时,一方发出的数据后,它不能保证该数据能够成功被对端收到,因为数据在传输过程中可能会出现各种各样的错误,只有当收到对端主机发来的响应消息后,该主机才能保证上一次发送的数据被对端可靠的收到了,这就叫做真正的可靠。
下图中实线表示该数据能够被对方可靠的收到,虚线则不能保证。
但 TCP
要保证的是双方通信的可靠性,虽然此时主机 A
能够保证自己上一次发送的数据被主机 B
可靠的收到了,但主机 B
也需要保证自己发送给主机 A
的响应数据被主机 A
可靠的收到了。因此主机 A
在收到了主机 B
的响应消息后,还需要对该响应数据进行响应,但此时又需要保证主机 A
发送的响应数据的可靠性……这样就陷入了一个死循环。
因为只有当一端收到对方的响应消息后,才能保证自己上一次发送的数据被对端可靠的收到了,但双方通信时总会有最新的一条消息,因此无法百分之百保证可靠性。
所以严格意义上来说,互联网通信当中是不存在百分之百的可靠性的,因为双方通信时总有最新的一条消息得不到响应。但实际没有必要保证所有消息的可靠性,我们只要保证双方通信时发送的每一个核心数据都有对应的响应就可以了。而对于一些无关紧要的数据(比如响应数据),我们没有必要保证它的可靠性。因为对端如果没有收到这个响应数据,会判定上一次发送的报文丢失了,此时对端可以将上一次发送的数据进行 重传。
这种策略在 TCP
当中就叫做确认应答机制。需要注意的是,确认应答机制不是保证双方通信的全部消息的可靠性,而是只要一方收到了另一方的应答消息,就说明它上一次发送的数据被另一方可靠的收到了。
32位序号
如果双方在进行数据通信时,只有收到了上一次发送数据的响应才能发下一个数据,那么此时双方的通信过程就是串行的,效率可想而知。
因此双方在进行网络通信时,允许一方向另一方连续发送多个报文数据,只要保证发送的每个报文都有对应的响应消息就行了,此时也就能保证这些报文被对方收到了。
但在连续发送多个报文时,由于各个报文在进行网络传输时选择的路径可能是不一样的,因此这些报文到达对端主机的先后顺序也就可能和发送报文的顺序是不同的。但报文有序也是可靠性的一种,因此 TCP
报头中的序号的作用之一实际就是用来保证报文的有序性的。
TCP
将发送出去的每个字节数据都进行了编号,这个编号叫做序列号。
- 比如现在发送端要发送
3000
字节的数据,如果发送端每次发送1000
字节,那么就需要用三个TCP
报文来发送这3000
字节的数据。- 此时这三个
TCP
报文当中的序号填的就是发送数据中首个字节的序列号,因此分别填的是1
、1001
和2001
。
- 此时接收端收到了这三个
TCP
报文后,就可以根据TCP
报头当中的序列号对这三个报文进行顺序重排(该动作在传输层进行),重排后将其放到TCP
的接收缓冲区当中,此时接收端这里报文的顺序就和发送端发送报文的顺序是一样的了。- 接收端在进行报文重排时,可以根据当前报文的序号与其有效载荷的字节数,进而确定下一个报文对应的序号。
32位确认序号
TCP
报头当中的确认序号是告诉对端,我当前已经收到了哪些数据,你的数据下一次应该从哪里开始发。
以刚才的例子为例,当主机 B
收到主机 A
发送过来的序号为 1
的报文时,由于该报文当中包含 1000
字节的数据,因此主机 B
已经收到序列号为 1-1000
的字节数据,于是主机 B
发给主机 A
的响应数据的报头当中的确认序号的值就会填成 1001
。
- 一方面是告诉主机
A
,序列号在1001
之前的字节数据我已经收到了。- 另一方面是告诉主机
A
,下次向我发送数据时应该从序列号为1001
的字节数据开始进行发送。
之后主机 B
对主机 A
发来的其他报文进行响应时,发给主机 A
的响应当中的确认序号的填法也是类似的道理。
注意:
- 响应数据与其他数据一样,也是一个完整的
TCP
报文,尽管该报文可能不携带有效载荷,但至少是一个TCP
报头。
报文丢失怎么办?
还是以刚才的例子为例,主机 A
发送了三个报文给主机 B
,其中每个报文的有效载荷都是 1000
字节,这三个报文的序号分别是 1
、1001
、2001
。
如果这三个报文在网络传输过程中出现了丢包,最终只有序号为 1
和 2001
的报文被主机 B
收到了,那么当主机 B
在对报文进行顺序重排的时候,就会发现只收到了 1~1000
和 2001~3000
的字节数据。此时主机 B
在对主机 A
进行响应时,其响应报头当中的确认序号填的就是 1001
,告诉主机 A
下次向我发送数据时应该从序列号为 1001
的字节数据开始进行发送。
此时主机 B
在给主机 A 响应时,其确认序号不能填 3001
,因为 1001-2000
是在 3001
之前的,如果直接给主机 A
响应 3001
,就说明序列号在 3001
之前的字节数据全都收到了。
因此主机 B
只能给主机 A
响应 1001
的确认序号,而当主机 A
发现发出了多个报文之后,一直收到的都是 1001
的确认序号的话,那么就会启动超时重传对 1001~2000
的数据进行重传!
为什么要用两套序号机制?
如果通信双方只是一端发送数据,另一端接收数据,那么只用一套序号就可以了。
- 发送端在发送数据时,将该序号看作是【序号】。
- 接收端在对发送端发来的数据进行响应时,将该序号看作是【确认序号】。
但实际 TCP
却没有这么做,根本原因就是因为 TCP
是全双工的,双方可能同时想给对方发送消息。
- 双方发出的报文当中,不仅需要填充【序号】来表明自己当前发送数据的序号。
- 还需要填充
32
位【确认序号】,对对方上一次发送的数据进行确认,告诉对方下一次应该从哪一字节序号开始进行发送。
因此在进行 TCP
通信时,双方都需要有确认应答机制,此时一套序号就无法满足需求了,因此需要 TCP
报头当中出现了两套序号。总结如下所示:
- 【序号】的作用是,保证数据的按序到达,同时这个序号也是作为对端发送报文时填充确认序号的根据。
- 【确认序号】的作用是,告诉对端当前已经收到的字节数据有哪些,对端下一次发送数据时应该从哪一字节序号开始进行发送。
- 序号和确认序号是确认应答机制的数据化表示,确认应答机制就是由序号和确认序号来保证的。
- 此外,通过序号和确认序号还可以判断某个报文是否丢失。
3、窗口大小
TCP的接收缓冲区和发送缓冲区
-
接收缓冲区用来暂时保存接收到的数据。
-
发送缓冲区用来暂时保存还未发送的数据。
-
这两个缓冲区都是在TCP传输层内部实现的。
-
发送缓冲区当中的数据由上层应用应用层进行写入。当上层调用
write
/send
这样的系统调用接口时,实际不是将数据直接发送到了网络当中,而是将数据从应用层 拷贝 到了TCP
的发送缓冲区当中。 -
接收缓冲区当中的数据最终也是由应用层来读取的。当上层调用
read
/recv
这样的系统调用接口时,实际也不是直接从网络当中读取数据,而是将数据从TCP
的接收缓冲区 拷贝 到了应用层而已。 -
就好比调用
read
和write
进行文件读写时,并不是直接从磁盘读取数据,也不是直接将数据写入到磁盘上,而对文件缓冲区进行的读写操作。
当数据写入到 TCP
的发送缓冲区后,对应的 write
/send
函数就可以返回了,至于发送缓冲区当中的数据具体什么时候发,怎么发等问题实际都是由 TCP
决定的。
我们之所以称 TCP
为传输层控制协议,就是因为最终数据的发送和接收方式,以及传输数据时遇到的各种问题应该如何解决,都是由 TCP
自己决定的,用户只需要将数据拷贝到 TCP
的发送缓冲区,以及从 TCP
的接收缓冲区当中读取数据即可。
TCP的发送缓冲区和接收缓冲区存在的意义
发送缓冲区和接收缓冲区的作用:
-
数据在网络中传输时可能会出现某些错误,此时就 可能要求发送端进行数据重传,因此
TCP
必须提供一个发送缓冲区来暂时保存发送出去的数据,以免需要进行数据重传。只有当发出去的数据被对端可靠的收到后,发送缓冲区中的这部分数据才可以被覆盖掉。 -
接收端处理数据的速度是有限的,为了保证没来得及处理的数据不会被迫丢弃,因此
TCP
必须提供一个接收缓冲区来暂时保存未被处理的数据,因为数据传输是需要耗费资源的,我们不能随意丢弃正确的报文。此外,TCP
的数据重排也是在接收缓冲区当中进行的。
经典的生产者消费者模型:
- 对于发送缓冲区来说,上层应用不断往发送缓冲区当中放入数据,下层网络层不断从发送缓冲区当中拿出数据准备进一步封装。此时上层应用扮演的就是生产者的角色,下层网络层扮演的就是消费者的角色,而发送缓冲区对应的就是“交易场所”。
- 对于接收缓冲区来说,上层应用不断从接收缓冲区当中拿出数据进行处理,下层网络层不断往接收缓冲区当中放入数据。此时上层应用扮演的就是消费者的角色,下层网络层扮演的就是生产者的角色,而接收缓冲区对应的就是“交易场所”。
- 因此引入发送缓冲区和接收缓冲区相当于引入了两个生产者消费者模型,该生产者消费者模型将上层应用与底层通信细节进行了解耦,此外,生产者消费者模型的引入同时也支持了并发和忙闲不均。
窗口大小
当发送端要将数据发送给对端时,本质是把自己发送缓冲区当中的数据发送到对端的接收缓冲区当中。但缓冲区是有大小的,如果接收端处理数据的速度小于发送端发送数据的速度,那么总有一个时刻接收端的接收缓冲区会被打满,这时发送端再发送数据过来就会造成数据丢包,进而引起丢包重传等一系列的连锁反应。
因此 TCP
报头当中就有了 16
位的窗口大小,这个 16
位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力。
接收端在对发送端发来的数据进行响应时,就可以 通过 16
位窗口大小告知发送端自己当前接收缓冲区剩余空间的大小,此时发送端就可以根据这个窗口大小字段来调整自己发送数据的速度。
- 窗口大小字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
- 窗口大小字段越小,说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度。
- 如果窗口大小的值为
0
,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
理解现象:
- 在编写
TCP
套接字时,我们调用read
/recv
函数从套接字当中读取数据时,可能会因为套接字当中没有数据而被阻塞住,本质是因为TCP
的接收缓冲区当中没有数据了,我们实际是阻塞在接收缓冲区当中了。 - 而我们调用
write
/send
函数往套接字中写入数据时,可能会因为套接字已经写满而被阻塞住,本质是因为TCP
的发送缓冲区已经被写满了,我们实际是阻塞在发送缓冲区当中了。 - 在生产者消费者模型当中,如果生产者生产数据时被阻塞,或消费者消费数据时被阻塞,那么一定是因为某些条件不就绪而被阻塞。
4、六个标志位
为什么会存在标志位?
-
TCP
报文的种类多种多样,除了正常通信时发送的普通报文,还有建立连接时发送的请求建立连接的报文,以及断开连接时发送的断开连接的报文等等。 -
收到不同种类的报文时完美需要对应执行动作,比如正常通信的报文需要放到接收缓冲区当中等待上层应用进行读取,而建立和断开连接的报文本质不是交给用户处理的,而是需要让操作系统在
TCP
层执行对应的握手和挥手动作。 -
也就是说不同种类的报文对应的是不同的处理逻辑,所以我们要能够区分报文的种类。而
TCP
就是使用报头当中的六个标志字段来进行区分的,这六个标志位都只占用一个比特位,为0
表示假,为1
表示真。
SYN报文
- 报文当中的
SYN = 1
而ACK = 0
的话,表明该报文是一个连接建立的请求报文。若对方同意建立连接,则在响应报文中设置SYN = 1
和ACK = 1
。 - 只有在连接建立阶段,
SYN
才被设置,正常通信时SYN
不会被设置。
ACK报文
- 报文当中的
ACK
被设置为1
,表明该报文可以对收到的报文进行确认。 TCP
规定,除了第一个请求报文没有设置ACK
以外,其余报文基本都会设置ACK
。
FIN报文
- 报文当中的
FIN
被设置为1
,表明该报文是一个连接断开的请求报文。 - 只有在断开连接阶段,
FIN
才被设置,正常通信时FIN
不会被设置。
URG报文
双方在进行网络通信的时候,由于 TCP
是保证数据按序到达的,即便发送端将要发送的数据分成了若干个 TCP
报文进行发送,最终到达接收端时这些数据也都是有序的,因为 TCP
可以通过序号来对这些 TCP
报文进行顺序重排,最终就能保证数据到达对端接收缓冲区中时是有序的。
TCP
按序到达本身也是我们的目的,此时对端上层在从接收缓冲区读取数据时也必须是按顺序读取的。但是有时候发送端可能发送了一些 “紧急数据”,这些数据需要让对方上层提取进行读取,此时应该怎么办呢?
此时就需要用到 URG
标志位,以及 TCP
报头当中的 16
位紧急指针。
- 当
URG
标志位被设置为1
时,需要通过TCP
报头当中的紧急指针来找到紧急数据,否则一般情况下不需要关注紧急指针。- 紧急指针代表的就是紧急数据在报文中的偏移量。因为紧急指针只有一个,它 只能标识数据段中的一个位置,因此紧急数据只能发送一个字节,而至于这一个字节的具体含义这里就不展开讨论了。
recv
函数的第四个参数 flags
有一个叫做 MSG_OOB
的选项可供设置,其中 OOB
是带外数据(out-of-band
)的简称,带外数据就是一些比较重要的数据,因此上层如果想读取紧急数据,就可以在使用 recv
函数进行读取,并设置 MSG_OOB
选项。
与之对应的 send
函数的第四个参数 flags
也提供了一个叫做 MSG_OOB
的选项,上层如果想发送紧急数据,就可以使用 send
函数进行写入,并设置 MSG_OOB
选项。
PSH报文
报文当中的 PSH
被设置为 1
,是在告诉对方尽快将你的接收缓冲区当中的数据交付给上层。
我们一般认为:当使用 read
/recv
从缓冲区当中读取数据时,如果缓冲区当中有数据 read
/recv
函数就能够读到数据进行返回,而如果缓冲区当中没有数据,那么此时 read
/recv
函数就会阻塞住,直到当缓冲区当中有数据时才会读取到数据进行返回。
实际这种说法是不太准确的,其实接收缓冲区和发送缓冲区都有一个水位线的概念。
- 比如我们假设接收缓冲区的水位线是
100
字节,那么只有当接收缓冲区当中有100
字节时才让read
/recv
函数读取这100
字节的数据进行返回。 - 如果接收缓冲区当中有一点数据就让
read
/recv
函数读取返回了,此时read
/recv
就会频繁的进行读取和返回,进而影响读取数据的效率(在内核态和用户态之间切换也是有成本的)。 - 因此不是说接收缓冲区当中只要有数据,调用
read
/recv
函数时就能读取到数据进行返回,而是 当缓冲区当中的数据量达到一定量时才能进行读取。
当报文当中的 PSH
被设置为 1
时,实际就是在告知对方操作系统,尽快将接收缓冲区当中的数据交付给上层,尽管接收缓冲区当中的数据还没到达所指定的水位线。这也就是为什么我们使用 read
/recv
函数读取数据时,期望读取的字节数和实际读取的字节数是不一定吻合的。
RST报文
- 报文当中的
RST
被设置为1
,表示需要让对方重新建立连接。 - 在通信双方在连接未建立好的情况下,一方向另一方发数据,此时另一方发送的响应报文当中的
RST
标志位就会被置1
,表示要求对方重新建立连接。 - 在双方建立好连接进行正常通信时,如果通信中途发现之前建立好的连接出现了异常(如主机崩溃等原因)也会要求重新建立连接。
Ⅲ. 确认应答机制(ACK)
TCP
保证可靠性的机制之一就是确认应答机制。确认应答机制就是由 TCP
报头当中的序号和确认序号来保证的。
需要再次强调的是,确认应答机制不是保证双方通信的全部消息的可靠性,而是 通过收到对方的应答消息,来保证自己曾经发送给对方的某一条消息被对方可靠的收到了。
这种机制也叫做停止等待协议!
如何理解TCP将每个字节的数据都进行了编号?
TCP
是面向字节流的,我们可以将发送缓冲区和接收缓冲区都想象成一个字符数组。
- 此时上层应用拷贝到发送缓冲区当中的每一个字节数据天然有了一个序号,这个序号就是字符数组的下标,只不过这个下标不是从
0
开始的,而是从1
开始往后递增的。而双方在通信时,本质就是将自己发送缓冲区当中的数据拷贝到对方的接收缓冲区当中。 - 发送方发送数据时报头当中所填的序号,实际就是发送的若干字节数据当中,首个字节数据在发送缓冲区当中对应的下标。
- 接收方接收到数据进行响应时,响应报头当中的确认序号实际就是,接收缓冲区中接收到的最后一个有效数据的下一个位置所对应的下标。
- 当发送方收到接收方的响应后,就可以从下标为确认序号的位置继续进行发送了。
Ⅳ. 超时重传机制
双方在进行网络通信时,发送方发出去的数据在一个特定的事件间隔内如果得不到对方的应答,此时发送方就会进行数据重发,这就是超时重传机制。
需要注意的是,TCP
保证双方通信的可靠性,一部分是通过 TCP
的协议报头体现出来的,还有一部分是通过实现 TCP
的代码逻辑体现出来的。
比如超时重传机制实际就是发送方在发送数据后开启了一个 定时器,它记录一个报文段发出的时间,以及收到相应的确认的时间,这两个时间之差就是报文段的 往返时间 RTT
。若是在这个时间内没有收到刚才发送数据的确认应答报文,则会对该报文进行重传,这就是通过 TCP
的代码逻辑实现的,而在 TCP
报头当中是体现不出来的。
两种丢包情况
丢包分为两种情况,一种是发送的数据报文丢失了,此时发送端在一定时间内收不到对应的响应报文,就会进行超时重传。
另一种丢包的情况其实不是发送端发送的数据丢包了,而是对方发来的响应报文丢包了,此时发送端也会因为收不到对应的响应报文,而进行超时重传。
- 当出现丢包时,发送方是无法辨别是发送的数据报文丢失了,还是对方发来的响应报文丢失了,因为这两种情况下发送方都收不到对方发来的响应报文,此时发送方就只能进行超时重传。
- 其实还有一种情况,就是如果对方发来的报文迟到了,而导致对方进行超时重传之后,收到了多个重复的报文段,那么当前主机就会做简单的处理:收下重复的报文段后就丢弃,并重传确认应答!
- 需要注意的是,当发送缓冲区当中的数据被发送出去后,操作系统不会立即将该数据从发送缓冲区当中删除或覆盖,而会让其 保留在发送缓冲区当中,以免需要进行超时重传,直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖。
像上述的这种可靠传输协议,也被称为自动重传请求 ARQ
(Automatic Repeat reQuest
),意思就是重传的请求是自动进行的。
超时重传时间的选择
超时重传的时间不能设置的太长也不能设置的太短,原因如下所示:
- 如果超时重传的时间设置的太长,会导致丢包后对方长时间收不到对应的数据,进而影响整体重传的效率。
- 如果超时重传的时间设置的太短,会导致对方收到大量的重复报文,可能对方发送的响应报文还在网络中传输而并没有丢包,但此时发送方就开始进行数据重传了,并且发送大量重复报文会也是对网络资源的浪费。
因此超时重传的时间一定要是合理的,最理想的情况就是找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”。但这个 时间的长短,是与网络环境有关的。网好的时候重传的时间可以设置的短一点,网卡的时候重传的时间可以设置的长一点,也就是说超时重传设置的等待时间一定是上下浮动的,因此这个时间不可能是固定的某个值。
TCP
为了保证无论在任何环境下都能有比较高性能的通信,因此会动态计算这个最大超时时间。
Linux
中(BSD Unix
和Windows
也是如此),超时以500ms
为一个单位进行控制,每次判定超时重发的超时时间都是500ms
的整数倍。- 如果重发一次之后,仍然得不到应答,下一次重传的等待时间就是
2 × 500ms
。- 如果仍然得不到应答,那么下一次重传的等待时间就是
4×500ms
。以此类推,以指数的形式递增。- 当累计到一定的重传次数后,
TCP
就会认为是网络或对端主机出现了异常,进而强转关闭连接。
上面这种其实就是 Karn
算法的修正,就是 报文段每重传一次,就把超时重传时间 RTO
(Retransmission Time-Out
)变成之前的两倍!实践证明这种策略是较为合理的!
Ⅴ. 连接管理机制
TCP
是面向连接的
TCP
的各种可靠性机制实际都不是从主机到主机的,而是基于连接的,与连接是强相关的。比如一台服务器启动后可能有多个客户端前来访问,如果 TCP
不是基于连接的,也就意味着服务器端只有一个接收缓冲区,此时各个客户端发来的数据都会拷贝到这个接收缓冲区当中,此时这些数据就可能会互相干扰。
而我们在进行 TCP
通信之前需要先建立连接,就是因为 TCP
的各种可靠性保证都是基于连接的,要保证传输数据的可靠性的前提就是先建立好连接。
操作系统对连接的管理
面向连接是 TCP
可靠性的一种,只有在通信建立好连接才会有各种可靠性的保证,而一台机器上可能会存在大量的连接,此时操作系统就不得不对这些连接进行管理。
- 操作系统在管理这些连接时需要“先描述,再组织”,在操作系统中一定有一个描述连接的结构体,该结构体当中包含了连接的各种属性字段,所有定义出来的连接结构体最终都会以某种数据结构组织起来,此时操作系统对连接的管理就变成了对该数据结构的增删查改。
- 建立连接,实际就是在操作系统中用该结构体定义一个结构体变量,然后填充连接的各种属性字段,最后将其插入到管理连接的数据结构当中即可。
- 断开连接,实际就是将某个连接从管理连接的数据结构当中删除,释放该连接曾经占用的各种资源。
- 因此连接的管理也是有成本的,这个成本就是管理连接结构体的时间成本,以及存储连接结构体的空间成本。
1、三次握手
双方在进行 TCP
通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手,这个我们之前已经讲过了!
以服务器和客户端为例,当客户端想要与服务器进行通信时,需要先与服务器建立连接,此时客户端作为主动方会先向服务器发送连接建立请求,然后双方 TCP
在底层会自动进行三次握手。
- 第一次握手:客户端向服务器发送的报文当中的
SYN
位被设置为1
,表示请求与服务器建立连接。 - 第二次握手:服务器收到客户端发来的连接请求报文后,紧接着向客户端发起连接建立请求并对客户端发来的连接请求进行响应,此时服务器向客户端发送的报文当中的
SYN
位和ACK
位均被设置为1
。 - 第三次握手:客户端收到服务器发来的报文后,得知服务器收到了自己发送的连接建立请求,并请求和自己建立连接,最后客户端再向服务器发来的报文进行响应。
需要注意的是,客户端向服务器发起的连接建立请求,是请求建立从客户端到服务器方向的通信连接,而 TCP
是全双工通信,因此服务器在收到客户端发来的连接建立请求后,服务器也需要向客户端发起连接建立请求,请求建立从服务器到客户端方法的通信连接。
为什么是三次握手,而不选择一次、两次、四次……握手呢?
首先我们需要知道,连接建立不是百分之百能成功的,通信双方在进行三次握手时,其中前两次握手能够保证被对方收到,因为前两次握手都有对应的下一次握手对其进行响应,但第三次握手是没有对应的响应报文的,如果第三次握手时客户端发送的 ACK
报文丢失了,那么就认为建立连接失败。
像上图,虽然客户端发起第三次握手后就完成了三次握手,但服务器却没有收到客户端发来的第三次握手,此时服务器端就不会建立对应的连接。所以建立连接时不管采用几次握手,最后一次握手的可靠性都是不能保证的。(上面这种情况当客户端发消息给服务器的时候,服务器会发一个 RST
报文回去告诉重新建立连接)
既然连接的建立都不是百分之百成功的,因此建立连接时具体采用几次握手的依据,实际是看几次握手时的优点更多。
而 三次握手是验证双方通信信道的最小次数。为什么呢❓❓❓
- 首先我们先来谈谈如果是一次握手的情况,如果客户端直接发了一个
SYN
报文段给服务器,连接就建立了,看似好像没问题,如果报文丢失了还能靠服务器发RST
报文重新建立连接。但问题是,这样子的建立方式,其实是很容易遭到SYN
洪水攻击的,也就是通过一台主计不断地刷新,向服务器发送非常多的请求,而一旦请求多了,服务器的压力就上来了,很容易就被搞垮的,所以 一次握手的问题在于容易遭到SYN
洪水攻击!- 然后是两次握手的情况,其实遇到的问题,依然是
SYN
洪水攻击问题,因为虽然服务器会进行确认应答,但对方主机只是发了请求,服务器就建立连接了,本质和一次握手是同样的情况!- 如果是三次握手的话,在第三次握手的时候,就需要客户端进行确认应答,此时服务器只有收到了这个确认应答之后,才会建立连接,这就在一定程度上规避了单主机进行
SYN
洪水攻击的问题,因为这种情况下是客户端收到服务器的确认应答之后先建立连接,而服务器是在收到客户端的确认应答之后再建立连接的,虽说客户端还是可以进行SYN
洪水攻击,但是这波是属于杀敌一千自损两千的做法,也就是说进行SYN
洪水攻击的话,服务器是避免不了,但是该客户端也需要遭到同样的损失!所以一般不会有人傻傻的去攻击服务器的。- 如果是四次握手,或者更多次握手的话,就是服务器最后还要对客户端进行确认应答,那么其实意义就不大了,因为在三次握手后,双方其实都看到了请求和响应的一来一回,这就足够确保信息已经发给对方了,如果此时服务器还继续进行确认应答的话,就是做了无用功,没有意义,所以只需要三次握手即可!
但是上述三次握手也是有问题的,既然一台主机进行 SYN
洪水攻击吃不消,那么肯定就有黑客进行多主机的操控,让多台主机在同一时刻向服务器发送大量的请求,这样子服务器也是扛不住的,这叫做 分布式拒绝服务DDOS
此外,第三次握手也是 为了防止已失效的连接请求报文延迟后又突然发给了服务器而造成的错误。
比如说,客户端在发送第一次请求的时候,该请求在网络中阻塞了,在客户端又因为超时了所以进行重传,此时如果是两次握手的话,那么服务器就直接建立连接了,在通信完之后双方就进行断开连接了。但不巧的是之前阻塞的请求突然就发到了服务器,此时服务器就会创建一个新连接,但是这并不是客户端此时想创建的,所以也不知道创建了连接,也不会向服务器发送消息,此时服务器就只是干等着客户端的消息,白白的浪费了资源(虽说有保活计数器,但是时间比较长,成本高)。
总结一下,使用三次握手的原因如下:
- 三次握手使用了最小成本验证了通信信道是通畅的。
- 在一定程度上规避了单主机的
SYN
洪水攻击,但是无法避免分布式的SYN
洪水攻击,需要配合其它机制来规避。 - 防止已失效的连接请求报文延迟发给了服务器而造成的错误。
2、四次挥手
由于双方维护连接都是需要成本的,因此当双方 TCP
通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手。
还是以服务器和客户端为例,当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手。
- 第一次挥手:客户端向服务器发送的报文当中的
FIN
位被设置为1
,表示请求与服务器断开连接。- 第二次挥手:服务器收到客户端发来的断开连接请求后对其进行响应。
- 第三次挥手:服务器收到客户端断开连接的请求,且已经没有数据需要发送给客户端的时候,服务器就会向客户端发起断开连接请求。
- 第四次挥手:客户端收到服务器发来的断开连接请求后对其进行响应。
四次挥手结束后双方的连接才算真正断开。
为什么是四次挥手?
- 由于
TCP
是全双工的,建立连接的时候需要建立双方的连接,断开连接时也同样如此。在断开连接时不仅要断开从客户端到服务器方向的通信信道,也要断开从服务器到客户端的通信信道,其中每两次挥手对应就是关闭一个方向的通信信道,因此断开连接时需要进行四次挥手,断开连接是双方的事情,需要征得双方的同意! - 需要注意的是,四次挥手当中的第二次和第三次挥手不能合并在一起,因为第三次握手是服务器端想要与客户端断开连接时发给客户端的请求,而当服务器收到客户端断开连接的请求并响应后,服务器不一定会马上发起第三次挥手,因为服务器可能还有某些数据要发送给客户端,只有当服务器端将这些数据发送完后才会向客户端发起第三次挥手。
3、理解 CLOSE_WAIT 状态
双方在进行四次挥手时,如果只有客户端调用了 close
函数,而服务器不调用 close
函数,此时服务器就会进入 CLOSE_WAIT
状态,而客户端则会进入到 FIN_WAIT_2
状态。
而 进入 CLOSE_WAIT
状态其实就是为了将当前在缓冲区中没有发送的数据进行发送!
但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源。如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于 CLOSE_WAIT
状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
也就是说如果不及时关闭不用的文件描述符,除了会造成文件描述符泄漏以外,可能也会导致连接资源没有完全释放,这其实也是一种内存泄漏的问题。
因此在编写网络套接字代码时,如果发现服务器端存在大量处于 CLOSE_WAIT
状态的连接,此时就可以 检查一下是不是服务器没有及时调用 close
函数关闭对应的文件描述符。
这里我们可以简单的做一下测试,将我们之前写的 TCP
服务器套接字代码中,去掉关闭描述符的代码,看看效果如何:
4、理解 TIME_WAIT 状态
四次挥手中前三次挥手丢包时的解决方法:
- 第一次挥手丢包:客户端收不到服务器的应答,进而进行超时重传。
- 第二次挥手丢包:客户端收不到服务器的应答,进而进行超时重传。
- 第三次挥手丢包:服务器收不到客户端的应答,进而进行超时重传。
- 第四次挥手丢包:服务器收不到客户端的应答,进而进行超时重传。
如果客户端在发出第四次挥手后立即进入 CLOSED
状态,此时服务器虽然进行了超时重传,但已经得不到客户端的响应了,因为客户端已经将连接关闭了。
服务器在经过若干次超时重发后得不到响应,最终也一定会将对应的连接关闭,但在服务器不断进行超时重传期间还需要维护这条废弃的连接,这样对服务器是非常不友好的。
为了避免这种情况,因此客户端在四次挥手后没有立即进入 CLOSED
状态,而是进入到了 TIME_WAIT
状态进行等待,此时要是第四次挥手的报文丢包了,客户端也能收到服务器重发的报文然后进行响应。
TIME_WAIT
状态存在的必要性:
客户端在进行四次挥手后进入
TIME_WAIT
状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN
报文并对其进行响应,能够 较大概率保证最后一个ACK
被服务器收到。客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入
TIME_WAIT
状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
实际第四次挥手丢包后,可能双方网络状态出现了问题,尽管客户端还没有关闭连接,也收不到服务器重发的连接断开请求,此时客户端 TIME_WAIT
等若干时间最终会关闭连接,而服务器经过多次超时重传后也会关闭连接。这种情况虽然也让服务器维持了闲置的连接,但毕竟是少数,引入 TIME_WAIT
状态就是争取让主动发起四次挥手的客户端维护这个成本。
因此 TCP
并不能完全保证建立连接和断开连接的可靠性,TCP
保证的是建立连接之后,以及断开连接之前双方通信数据的可靠性。
我们可以简单的做个测试,让服务器进行异常的退出,看看什么效果:
TIME_WAIT 的等待时长是多少?
等待时间太长会让等待方维持一个较长的时间的 TIME_WAIT
状态,在这个时间内等待方也需要花费成本来维护这个连接,这也是一种浪费资源的现象。
而等待时间太短可能没有达到我们最初目的,没有保证 ACK
被对方较大概率收到,也没有保证数据在网络中消散,此时 TIME_WAIT
的意义也就没有了。
所以 TCP
协议规定,主动关闭连接的一方在四次挥手后要处于 TIME_WAIT
状态,等待两个 MSL
(Maximum Segment Lifetime
,报文最大生存时间)的时间才能进入 CLOSED
状态。
MSL
在 RFC1122
中规定为两分钟,但是各个操作系统的实现不同,比如在 Centos7
上默认配置的值是 60s
。我们可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout
命令来查看 MSL
的值。
[liren@VM-8-7-centos http]$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60
TIME_WAIT
的等待时长设置为两个 MSL
的原因:
MSL
是TCP
报文的最大生存时间,因此TIME_WAIT
状态持续存在2MSL
的话,就能 保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失,否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的。- 同时也是在理论上保证最后一个报文可靠到达的时间。
解决 TIME_WAIT 状态引起的 bind 失败的方法 – setsockopt()
主动发起四次挥手的一方在四次挥手后,会进入 TIME_WAIT
状态。如果在有客户端连接服务器的情况下服务器进程退出了,就相当于服务器主动发起了四次挥手,此时服务器维护的连接在四次挥手后就会进入 TIME_WAIT
状态。
这个我们上面其实是做了实验的,如果在该连接处于 TIME_WAIT
期间,如果服务器想要再次重新启动,就会出现 绑定失败 的问题。
因为在 TIME_WAIT
期间,这个连接并没有被完全释放,也就意味着 服务器绑定的端口号仍然是被占用的,此时服务器想要继续绑定该端口号启动,就只能等待 TIME_WAIT
结束。
但当服务器崩溃后最重要实际是让服务器立马重新启动,如果想要让服务器崩溃后在 TIME_WAIT
期间也能立马重新启动,需要让服务器在调用 socket
函数创建套接字后,调用 setsockopt
函数进行设置端口复用,这也是编写服务器代码时的推荐做法。
该函数的函数原型如下:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数说明:
sockfd
:需要设置的套接字对应的文件描述符。level
:被设置选项的层次。比如在套接字层设置选项对应就是SOL_SOCKET
。optname
:需要设置的选项。该选项的可取值与设置的level
参数有关,如SO_REUSEADDR
表示复用地址。optval
:指向存放选项待设置的新值的指针。optlen
:待设置的新值的长度。
返回值说明:
-
设置成功返回
0
,设置失败返回-1
,同时错误码会被设置。 -
我们这里要设置的就是监听套接字,将监听套接字在套接字层设置端口复用选项
SO_REUSEADDR
,该选项设置为非零值表示开启端口复用。
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
此时当服务器崩溃后我们就可以立马重新启动服务器,而不用等待 TIME_WAIT
结束!
此外从上面的实验中可以看到,即便通信双方对应的进程都退出了,但服务器端依然存在一个处于 TIME_WAIT
状态的连接,这也更加说明了进程管理和连接管理是两个相对独立的单元。连接是由 TCP
自行管理的,连接不一定会随进程的退出而关闭。
Ⅵ. 流量控制
1、利用滑动窗口实现流量控制
所谓的 流量控制(Flow Control
),其实就是 让发送方的发送速率不要太快,要让接收方来得及接收。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,此时发送端继续发送数据,就会造成丢包,进而引起丢包重传等一系列连锁反应。
因此接收端可以将自己接收数据的能力告知发送端,让发送端控制自己发送数据的速度。而这就是通过滑动窗口机制来实现对发送方的流量控制:
接收端将自己可以接收的缓冲区大小放入
TCP
首部中的 “窗口大小” 字段,通过ACK
报文段通知发送端。窗口大小字段越大,说明网络的吞吐量越高。
而接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
发送端接收到这个窗口之后,就会减慢自己发送的速度。
如果接收端缓冲区满了,就会将窗口值设置为
0
,这时发送方不再发送数据,但需要定期发送一个 窗口探测数据段,使接收端把窗口大小告诉发送端。
当发送端得知接收端接收数据的能力为 0
时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据。
- 等待告知。接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个
TCP
报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了。- 主动询问。发送端每隔一段时间向接收端发送报文,该报文不携带有效数据,只是为了询问发送端的窗口大小,直到接收端的接收缓冲区有空间后发送端就可以继续发送数据了。而对方在接收到 零窗口探测报文段(仅携带
1
字节的数据)的时候,就会启动持续计时器(persistence timer
),只要计时器到时了,就会向对方返回当前的窗口值!
要注意的是,TCP
的窗口单位是字节,而不是报文段 !
16 为窗口最大字节位数,表示 65535 字节,那 TCP 窗口最大就是 65535 字节吗?
理论上确实是这样的,但实际上 TCP
报头当中 40
字节的选项字段中包含了一个窗口扩大因子 M
,实际窗口大小是窗口字段的值左移 M
位得到的。
第一次向对方发送数据时如何得知对方的窗口大小?
双方在进行 TCP
通信之前需要先进行三次握手建立连接,而双方在握手时除了验证双方通信信道是否通畅以外,还进行了其他信息的交互,其中就包括告知对方自己的接收能力,因此在双方还没有正式开始通信之前就已经知道了对方接收数据能力,所以双方在发送数据时是不会出现缓冲区溢出的问题的。
简单地说,就是 双方在三次握手的时候就已经协商好了!
2、延迟应答
如果接收数据的主机收到数据后立即进行 ACK
应答,此时返回的窗口可能比较小。
- 假设对方接收端缓冲区剩余空间大小为
1M
,对方一次收到500KB
的数据后,如果立即进行ACK
应答,此时返回的窗口就是500KB
。- 但实际接收端处理数据的速度很快,
10ms
之内就将接收缓冲区中500KB
的数据消费掉了。- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再进行
ACK
应答,比如等待200ms
再应答,那么这时返回的窗口大小就是1MB
。
需要注意的是,延迟应答的目的不是为了保证可靠性,而是 留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时在进行 ACK
响应的时候报告的窗口大小就可以更大,从而 增大网络吞吐量,进而提高数据的传输效率。
此外,不是所有的数据包都可以延迟应答。
- 数量限制:每个
N
个包就应答一次。- 时间限制:超过最大延迟时间就应答一次(这个时间不会导致误超时重传)。
延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般 N
取 2
,超时时间取 200ms
。
Ⅶ. 滑动窗口
1、通信时候的真实情况
刚才我们讨论了确认应答策略,对每一个发送的数据段,都要给一个 ACK
确认应答。收到 ACK
后再发送下一个数据段,这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。
双方在进行 TCP
通信时可以一次向对方发送多条数据,这样可以将等待多个响应的时间重叠起来,相当于是一个流水线,进而提高数据通信的效率。
需要注意的是,虽然双方在进行 TCP
通信时可以一次向对方发送大量的报文,但不能将自己发送缓冲区当中的数据全部打包发送给对端,在发送数据时还要考虑对方的接收能力。
2、滑动窗口
TCP
使用流水线传输和滑动窗口协议实现高效、可靠的传输,并且 滑动窗口是以字节为单位 的。发送方 A
和接收方 B
分别维持一个发送窗口和一个接收窗口。
- 发送窗口:在没有收到确认的情况下,发送方可以连续把窗口内的数据全部发送出去。凡是已经发送过的数据,在未收到确认之前都必须暂时保留,以便在超时重传时使用。
- 接收窗口:只允许接收落入窗口内的数据。
其实可以将发送缓冲区当中的数据分为三部分:
- 已经发送并且收到确认的数据
- 已经发送但没有收到确认的数据。
- 还没有发送的数据。
这里发送缓冲区的第二部分就叫做滑动窗口。(也有人把这三部分整体称之为滑动窗口,而将其中的第二部分称之为窗口大小)
而滑动窗口描述的就是,发送方不用等待 ACK
一次所能发送的数据最大量。
滑动窗口存在的最大意义就是可以提高发送数据的效率:
- 滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小的较小值,因为发送数据时不仅要考虑对方的接收能力,还要考虑当前网络的状况。
- 我们这里先不考虑拥塞窗口,并且假设对方的窗口大小一直固定为
4000
,此时发送方不用等待ACK
一次所能发送的数据就是4000
字节,因此滑动窗口的大小就是4000
字节。(四个段)- 现在连续发送 1001-2000、2001-3000、3001-4000、4001-5000 这四个段的时候,不需要等待任何
ACK
,可以直接进行发送。- 当收到对方响应的确认序号为
2001
时,说明1001-2000
这个数据段已经被对方收到了,此时该数据段应该被纳入发送缓冲区当中的第一部分,而由于我们假设对方的窗口大小一直是4000
,因此滑动窗口现在可以向右移动,继续发送5001-6000
的数据段,以此类推。- 滑动窗口越大,则网络的吞吐率越高,同时也说明对方的接收能力很强。
当发送方发送出去的数据段陆陆续续收到对应的 ACK
时,就可以将收到 ACK
的数据段归置到滑动窗口的左侧,并根据当前滑动窗口的大小来决定,是否需要将滑动窗口右侧的数据归置到滑动窗口当中。
TCP
的重传机制要求暂时保存发出但未收到确认的数据,而这部分数据实际就位于滑动窗口当中,只有滑动窗口左侧的数据才是可以被覆盖或删除的,因为这部分数据才是发送并被对方可靠的收到了,所以滑动窗口除了限定不收到 ACK
而可以直接发送的数据之外,滑动窗口也可以支持 TCP
的重传机制。
此外,其实窗口大小和缓存空间都是有限的,但是它们是可以循环使用的,也就是一个 环形结构!
滑动窗口一定会整体右移吗?
滑动窗口 不一定会整体右移的,以刚才的例子为例,假设对方已经收到了 1001-2000
的数据段并进行了响应,但对方上层一直不从接收缓冲区当中读取数据,此时当对方收到 1001-2000
的数据段时,对方的窗口大小就由 4000
变为了 3000
。
当发送端收到对方的响应序号为 2001
时,就会将 1001-2000
的数据段归置到滑动窗口的左侧,但此时由于对方的接收能力变为了 3000
,而当 1001-2000
的数据段归置到滑动窗口的左侧后,滑动窗口的大小刚好就是 3000
,因此滑动窗口的右侧不能继续向右进行扩展。
因此滑动窗口在向右移动的过程中并不一定是整体右移的,因为对方接收能力可能不断在变化,从而滑动窗口也会随之不断变宽或者变窄。
如何实现滑动窗口
TCP
接收和发送缓冲区都看作一个 字符数组,而滑动窗口实际就可以看作是两个指针限定的一个范围,比如我们用 start
指向滑动窗口的左侧,end
指向的是滑动窗口的右侧,此时在 start
和 end
区间范围内的就可以叫做滑动窗口。
当发送端收到对方的响应时,如果响应当中的确认序号为 x
,窗口大小为 win
,此时就可以将 start
更新为 x
,而将 end
更新为 start+win
。
3、丢包问题
当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
情况一: 数据包已经抵达,ACK
丢包
在发送端连续发送多个报文数据时,部分 ACK
丢包并不要紧,此时可以通过后续主机 A
进行 超时重传 即可。
情况二: 数据包丢了。
-
当
1001-2000
的数据包丢失后,发送端会一直收到确认序号为1001
的响应报文,就是在提醒发送端“下一次应该从序号为1001
的字节数据开始发送”。 -
如果发送端连续收到三次确认序号为
1001
的响应报文,此时就会将1001-2000
的数据包重新进行发送。 -
此时当接收端收到
1001-2000
的数据包后,就会直接发送确认序号为6001
的响应报文,因为2001-6000
的数据接收端其实在之前就已经收到了。
这种机制被称为 “高速重发控制”,也叫做“快重传”。
需要注意的是,快重传需要在大量的数据重传和个别的数据重传之间做平衡,实际这个例子当中发送端并不知道是 1001-2000
这个数据包丢了,当发送端重复收到确认序号为 1001
的响应报文时,理论上发送端应该将 1001-7000
的数据全部进行重传,但这样可能会导致大量数据被重复传送,所以发送端可以尝试先把 1001-2000
的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包。
快重传 VS 超时重传
- 快重传是能够快速进行数据的重发,当发送端连续收到三次相同的应答时就会触发快重传,而不像超时重传一样需要通过设置重传定时器,在固定的时间后才会进行重传。
- 虽然快重传能够快速判定数据包丢失,但 快重传并不能完全取待超时重传,因为有时数据包丢失后可能并没有收到对方三次重复的应答,此时快重传机制就触发不了,而只能进行超时重传。
- 因此快重传虽然是一个效率上的提升,但超时重传却是所有重传机制的保底策略,也是必不可少的。
Ⅷ. 拥塞控制
1、为什么会有拥塞控制?
两个主机在进行 TCP
通信的过程中,出现个别数据包丢包的情况是很正常的,此时可以通过快重传或超时重发对数据包进行补发。但如果双方在通信时出现了大量丢包,此时就不能认为是正常现象了。
TCP
不仅考虑了通信双端主机的问题,同时也考虑了网络的问题。
- 流量控制:考虑的是对端接收缓冲区的接收能力,进而控制发送方发送数据的速度,避免对端接收缓冲区溢出。
- 滑动窗口:考虑的是发送端不用等待
ACK
一次所能发送的数据最大量,进而提高发送端发送数据的效率。 - 拥塞窗口:考虑的是双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
双方网络通信时出现少量的丢包 TCP
是允许的,但一旦出现大量的丢包,此时量变引起质变,这件事情的性质就变了,此时 TCP
就不再推测是双方接收和发送数据的问题,而判断是双方通信信道网络出现了拥塞问题。
2、如何解决网络拥塞问题?
流量控制关心的是通信双端的情况,而拥塞控制关心的是网络全局情况。
网络出现大面积瘫痪时,通信双方作为网络当中两台小小的主机,看似并不能为此做些什么,但“雪崩的时候没有一片雪花是无辜的”,网络出现问题一定是网络中大部分主机共同作用的结果。
-
如果网络中的主机在同一时间节点都大量向网络当中塞数据,此时位于网络中某些关键节点的路由器下就可能排了很长的报文,最终导致报文无法在超时时间内到达对端主机,此时也就导致了丢包问题。
-
当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担。
-
双方通信时如果出现大量丢包,不应该立即将这些报文进行重传,而应该少发数据甚至不发数据,等待网络状况恢复后双方再慢慢恢复数据的传输速率。
需要注意的是,网络拥塞时影响的不只是一台主机,而几乎是该网络当中的所有主机,此时 所有使用 TCP
传输控制协议的主机都会执行拥塞避免算法。
因此拥塞控制看似只是谈论的一台主机上的通信策略,实际这个策略是所有主机在网络崩溃后都会遵守的策略。一旦出现网络拥塞,该网络当中的所有主机都会受到影响,此时所有主机都要执行拥塞避免,这样才能有效缓解网络拥塞问题。通过这样的方式就能保证雪崩不会发生,或雪崩发生后可以尽快恢复。
3、拥塞控制
虽然滑动窗口能够高效可靠的发送大量的数据,但如果在刚开始阶段就发送大量的数据,就可能会引发某些问题。因为网络上有很多的计算机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题。
因此 TCP
引入了 慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
TCP
除了有窗口大小和滑动窗口的概念以外,还有一个窗口叫做 拥塞窗口。拥塞窗口是可能引起网络拥塞的 阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。- 刚开始发送数据的时候拥塞窗口大小定义为
1
,每收到一个ACK
应答拥塞窗口的值就加一。- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。
每收到一个 ACK
应答拥塞窗口的值就加一,此时拥塞窗口就是 以指数级别进行增长 的,如果先不考虑对方接收数据的能力,那么滑动窗口的大小就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化情况如下:
但指数级增长是非常快的,因此 “慢启动”实际只是初始时比较慢,但越往后增长的越快。如果拥塞窗口的值一直以指数的方式进行增长,此时就可能在短时间内再次导致网络出现拥塞。
- 为了避免短时间内再次导致网络拥塞,因此不能一直让拥塞窗口按指数级的方式进行增长。
- 此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长。
- 当
TCP
刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。- 在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为
1
,如此循环下去。
如下图:
图示说明:
指数增长。刚开始进行
TCP
通信时拥塞窗口的值为1
,并不断按指数的方式进行增长。加法增大。慢启动的阈值初始时为对方窗口大小的最大值,图中慢启动阈值的初始值为
16
,因此当拥塞窗口的值增大到16
时就不再按指数形式增长了,而变成了的线性增长。乘法减小。拥塞窗口在线性增长的过程中,在增大到
24
时如果发生了网络拥塞,此时慢启动的阈值将变为当前拥塞窗口的一半,也就是12
,并且拥塞窗口的值被重新设置为1
,所以下一次拥塞窗口由指数增长变为线性增长时拥塞窗口的值应该是12
。
主机在进行网络通信时,实际就是在不断进行指数增长、加法增大和乘法减小。
需要注意的是,不是所有的主机都是同时在进行指数增长、加法增大和乘法减小的。每台主机认为拥塞窗口的大小不一定是一样的,即便是同区域的两台主机在同一时刻认为拥塞窗口的大小也不一定是完全相同的。因此在同一时刻,可能一部分主机正在进行正常通信,而另一部分主机可能已经发生网络拥塞了。
Ⅸ. 捎带应答
捎带应答其实是 TCP
通信时最常规的一种方式,就好比主机 A
给主机 B
发送了一条消息,当主机 B
收到这条消息后需要对其进行 ACK
应答,但如果主机 B
此时正好也要给主机 A
发生消息,此时这个 ACK
就可以搭顺风车,而不用单独发送一个 ACK
应答,此时主机 B
发送的这个报文既发送了数据,又完成了对收到数据的响应,这就叫做捎带应答。
捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了。
此外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的 ACK
应答也被对方可靠的收到了。
粘包问题
1、什么是粘包?
首先要明确,粘包问题中的“包”,是指的应用层的数据包。
在 TCP
的协议头中,没有如同 UDP
一样的“报文长度”这样的字段。而站在传输层的角度,TCP
是一个一个报文过来的,按照序号排好序放在缓冲区中。
但站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分是一个完整的应用层数据包,这就是粘包问题。
2、如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界。
- 对于定长的包,保证每次都按 固定大小 读取即可。
- 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如
HTTP
报头当中就包含Content-Length
属性,表示正文的长度。 - 对于变长的包,还可以在包和包之间 使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
3、UDP是否存在粘包问题?
对于 UDP
,如果还没有上层交付数据,UDP
的报文长度仍然在,同时,UDP
是一个一个把数据交付给应用层的,有很明确的数据边界。
站在应用层的角度,使用 UDP
的时候,要么收到完整的 UDP
报文,要么不收,不会出现“半个”的情况。
因此 UDP
是 不存在粘包问题的,根本原因就是 UDP
报头当中的 16
位 UDP
长度记录的 UDP
报文的长度,因此 UDP
在底层的时候就把报文和报文之间的边界明确了,而 TCP
存在粘包问题就是因为 TCP
是面向字节流的,TCP
报文之间没有明确的边界。
TCP异常情况
1、进程终止
当客户端正常访问服务器时,如果客户端进程突然崩溃了,此时建立好的连接会怎么样?
当一个进程退出时,该进程曾经打开的文件描述符都会自动关闭,因此 当客户端进程退出时,相当于自动调用了 close
函数关闭了对应的文件描述符,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。也就是说,进程终止时会释放文件描述符,TCP
底层仍然可以发送 FIN
,和进程正常退出没有区别。
2、机器重启
当客户端正常访问服务器时,如果将客户端主机重启,此时建立好的连接会怎么样?
当我们选择重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此 机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。
3、机器掉电/网线断开
当客户端正常访问服务器时,如果将客户端突然掉线了,此时建立好的连接会怎么样?
当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为 TCP
是有 保活策略 的。
服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到 ACK
应答,此时服务器就会关闭这条连接。
此外,客户端也可能会定期向服务器“报平安”,如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。其中服务器定期询问客户端的存在状态的做法,叫做 基于保活定时器的一种心跳机制,是由 TCP
实现的。此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的 HTTP
,也会定期检测对方的存在状态。
TCP小结
TCP
协议这么复杂就是因为 TCP
既要保证可靠性,同时又尽可能的提高性能。
可靠性:
检验和
序列号
确认应答
超时重传
连接管理
流量控制
拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
需要注意的是,TCP
的这些机制有些能够通过 TCP
报头体现出来的,但还有一些是通过代码逻辑体现出来的。
TCP定时器
此外,TCP
当中还设置了各种定时器。
- 重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。
- 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔。
- 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔。
TIME_WAIT
定时器:双方在四次挥手后,主动断开连接的一方需要等待的时长。
用UDP如何实现可靠传输(经典面试题)
当面试官让你用 UDP
实现可靠传输时,你一定要立马想到 TCP
协议,因为 TCP
协议就是当前比较完善的保证可靠性的协议,面试官让你用 UDP
这个不可靠的协议来实现可靠传输,无非就是让你在应用层来实现可靠性,此时就可以参考 TCP
协议保证可靠性的各种机制。
例如:
- 引入序列号,保证数据按序到达。
- 引入确认应答,确保对端接收到了数据。
- 引入超时重传,如果隔一段时间没有应答,就进行数据重发。
- …
但 TCP
保证可靠性的机制太多了,当你被面试官问到这个问题时,最好与面试官进一步进行沟通,比如问问这个用 UDP
实现可靠传输的应用场景是什么。因为 TCP
保证可靠性的机制太多了,但在某些场景下可能只需要引入 TCP
的部分机制就行了,因此在不同的应用场景下模拟实现 UDP
可靠传输的侧重点也是不同的。
理解listen的第二个参数
深入探索 Linux listen() 函数 backlog 的含义_杨博东的博客的博客-CSDN博客
在编写 TCP
套接字的服务器代码时,在进行了套接字的创建和绑定之后,需要调用 listen
函数将创建的套接字设置为监听状态,此后服务器就可以调用 accept
函数获取建立好的连接了。其中 listen
函数的第一个参数就是需要设置为监听状态的套接字,而 listen
的第二个参数我们一般设置为 5
,那你知道 listen
函数的第二个参数具体的含义是什么吗?
下面通过一个实验来说明 listen
的第二个参数的具体含义:
- 先编写
TCP
套接字的服务器端代码,服务器初始化时依次进行套接字创建、绑定、监听,但服务器初始化后不调用accept
函数获取底层建立好的连接。- 为了方便验证,这里将
listen
函数的第二个参数设置为2。
代码如下:
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
using namespace std;const uint16_t port = 8080;int main()
{// 创建套接字int socketfd = socket(AF_INET, SOCK_STREAM, 0);if(socketfd == -1){cout << "socket error" << endl;exit(1);}// 设置地址复用int opt = 1;setsockopt(socketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));// 绑定套接字信息struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(port);local.sin_addr.s_addr = INADDR_ANY;if(bind(socketfd, (struct sockaddr*)&local, sizeof(local)) == -1){cout << "bind error" << endl;exit(2);}// 监听套接字// 并将backlog参数设为1if(listen(socketfd, 1) == -1) {cout << "listen error" << endl;exit(3);}// 死循环,不调用accept获取连接while(1){}return 0;
}
运行服务器后使用 netstat -nltp
命令,可以看到该服务器当前正处于监听状态。
接下来用 Postman
向我们的服务器发起一个连接请求:
说明一下: 为了让实验现象更加更加明显,这里 最好不要用浏览器进行测试,因为浏览器发起连接请求后如果得不到响应会进行重发。
实验 Postman
发起连接请求后,此时通过以下命令就可以看到,此时在服务器端新增了一个连接,该连接当前处于 ESTABLISHED
状态:
sudo netstat -ntp | head -2 && sudo netstat -ntp | grep 8080
继续用 Postman
发起第二个连接请求,然后再用上面的指令进行查看:
继续用 Postman
发起第三个连接请求,然后再用上面的指令进行查看:
此时在服务器端没有继续新增状态为 ESTABLISHED
的连接,而是新增了一个状态为 SYN_RCVD
的连接。
此时就算再用 Postman
向服务器发起连接请求,在服务器端也不会再新增任何状态的连接了。
而对于刚才状态为 SYN_RCVD
的连接,由于服务器长时间不对其进行应答,三次握手失败后该连接会被自动释放。
总结一下上面的实验现象:
- 无论有多少客户端向服务器发起连接请求,最终在服务器端最多只有两个连接会建立成功,这和我们
listen
的第二个参数有关。- 当发来第三个连接请求时,服务器只是收到了该客户端发来的
SYN
请求,但并没有对其进行响应。- 当发来更多的连接请求时,服务器会直接拒绝这些连接请求。
listen的第二个参数
实际 TCP
在进行连接管理时会用到两个连接队列:
- 半连接队列(
syn
队列):用于保存处于SYN_SENT
和SYN_RCVD
状态的连接,也就是还未完成三次握手的连接。- 全连接队列(
accept
队列):用于保存处于ESTABLISHED
状态,但没有被上层调用accept
取走的连接。
而全连接队列的长度实际会受到 listen
第二个参数的影响,一般 TCP
全连接队列的长度就等于 listen
第二个参数的值加一。
因为我们实验时设置 listen
第二个参数的值为 1
,此时在服务器端全连接队列的长度就为 2
,因此服务器最多只允许有两个处于 ESTABLISHED
状态的连接。
如果将刚才代码中 listen
的第二个参数值设置为 3
,此时服务器端最多就允许存在 4
个处于 ESTABLISHED
状态的连接。在服务器端已经有 4
个 ESTABLISHED
状态的连接的情况下,再有客户端发来建立连接请求,此时服务器端就会新增状态为 SYN_RCVD
的连接,该连接实际就是放在半连接队列当中的。
此后就算再有客户端发来连接请求,在服务器端也不会新增任何状态的连接。
为什么底层要维护连接队列?
一般 当服务器压力较大时连接队列的作用才会体现出来,如果服务器压力本身就不大,那么一旦底层有连接建立成功,上层就会立马将该连接读走并进行处理。
- 服务器端启动时一般会预先创建多个服务线程为客户端提供服务,其中主线程从底层调用
accept
上来连接后就可以将其交给这些服务线程进行处理。 - 如果向服务器发起连接请求的客户端很少,那么连接一旦在底层建立好就被主线程立马
accept
上来并交给服务线程处理了。 - 但如果向服务器发起连接请求的客户端非常多,当每个预设的服务线程都在为某个连接提供服务时,底层再建立好连接,主线程就不能获取上来了,此时底层这些已经建立好的连接就会被放到连接队列当中,只有等某个服务线程空闲时,主线程才会从这个连接队列当中获取这些建立好的连接。
- 如果没有这个连接队列,那么当服务器端的服务线程都在提供服务时,其他客户端发来的连接请求就会直接被拒绝,这 可能导致大量的重传。
- 并且有可能正当这个连接请求被拒绝时,某个服务线程提供服务完毕,此时这个服务线程就无法立马得到一个连接为之提供服务,所以一定有一段时间内这个服务线程是处于闲置状态的,浪费了服务线程的资源,只能等到再有客户端发来连接请求。
- 而如果设置了连接队列,当某个服务线程提供完服务后,如果连接队列当中有建立好的连接,那么主线程就可以立马从连接队列当中获取一个连接交给该服务线程进行处理,此时就可以保证服务器几乎是满载工作的。
这就好比一家火锅店,店里的座位就是每个服务线程,数量是有限的,而如果没有连接队列,也就是门口外面给排队提供的椅子的话,那么很大可能客户一来了就走了,相当于被直接拒绝。而如果有连接队列的话,也就是有椅子给客户排队,那么留住这些客户进行等待的几率就非常的大!
为什么连接队列不能太长?
全连接队列不能太长,系统一般设置为 5
,但这要取决于是什么系统!
虽然维护连接队列能让服务器处于几乎满载工作的状态,但连接队列也不能设置得太长。
- 如果队列太长,也就意味着在队列较尾部的连接需要等待较长时间才能得到服务,此时客户端的请求也就迟迟得不到响应。
- 此外,服务器维护连接也是需要成本的,连接队列设置的越长,系统就要花费越多的成本去维护这个队列。
- 但与其与其维护一个长连接,造成客户端等待过久,并且占用大量暂时用不到的资源,还不如将部分资源节省出来给服务器使用,让服务器更快的为客户端提供服务。
因此虽然需要维护连接队列,但连接队列不能维护的太长。
这就好比上面所说的火锅店,座位队列不能太长,就算有座位,但是太长客户也不会去排的,倒不如把这些钱花在扩大火锅店规模方面上来得实在!
全连接队列的长度
全连接队列的长度由两个值决定:
- 用户层调用
listen
时传入的第二个参数backlog
。- 系统变量
net.core.somaxconn
,默认值为128
。
通过以下命令可以查看系统变量 net.core.somaxconn
的值。
sudo sysctl -a | grep net.core.somaxconn
全连接队列的长度实际等于 listen
传入的 backlog
和系统变量 net.core.somaxconn
中的较小值加一。
SYN洪水攻击
连接正常建立的过程:
- 当客户端向服务器发起连接建立请求后,服务器会对其进行
SYN+ACK
响应,并将该连接放到半连接队列(syns queue
)当中。 - 当服务器发出的
SYN+ACK
得到客户端响应后,就会将该连接由半连接队列移到全连接队列(accept queue
)当中。 - 此时上层就可以通过调用
accept
函数,从全连接队列当中获取建立好的连接了。
连接建立异常:
- 但如果客户端在发起连接建立请求后突然死机或掉线,那么服务器发出的
SYN+ACK
就得不到对应的ACK
应答。 - 这种情况下服务器会进行重试(再次发送
SYN+ACK
给客户端)并等待一段时间,最终服务器会因为收不到ACK
应答而将这个连接丢弃,这段时间长度就称为SYN timeout
。 - 在
SYN timeout
时间内,这个连接会一直维护在半连接队列当中。
此时服务器虽然需要短暂维护这些异常连接,但这种情况毕竟是少数,不会对服务器造成太大影响。
但如果有一个恶意用户故意大量模拟这种情况:向服务器发送大量的连接建立请求,但在收到服务器发来的 SYN+ACK
后故意不对其进行ACK应答。
- 此时服务器就需要维护一个非常大的半连接队列,并且这些连接最终都不会建立成功,也就不会被移到全连接队列当中供上层获取,最后会 导致半连接队列越来越长。
- 当半连接队列被占满后,新来的连接就会直接被拒绝,哪怕是正常的连接建立请求,此时就会导致正常用户无法访问服务器。
- 这种向服务器发送大量
SYN
请求,但并不对服务器的SYN+ACK
进行ACK
响应,最终可能导致服务器无法对外提供服务,这种攻击方式就叫做SYN
洪水攻击(SYN Flood
)。
如何解决SYN洪水攻击?
首先这一定是一个综合性的解决方案,TCP
作为传输控制协议需要对其进行处理,而上层应用层也要尽量避免遭到 SYN
洪水攻击。
- 比如应用层可以记录,向服务器发起连接建立请求的主机信息,如果发现某个主机多次向服务器发起
SYN
请求,但从不对服务器的SYN+ACK
进行ACK
响应,此时就可以对该主机进行 黑名单 认证,此后该主机发来的SYN
请求一概不进行处理。
而 TCP
为了防范 SYN
洪水攻击,引入了 syncookie
机制:
- 现在核心的问题就是半连接队列被占满了,但不能简单的扩大半连接队列,就算半连接队列再大,恶意用户也能发送更多的
SYN
请求来占满,并且维护半连接队列当中的连接也是需要成本的。 - 因此
TCP
引入了syncookie
机制,当服务器收到一个连接建立请求后,会根据这个SYN
包计算出一个cookie
值,将其作为将要返回的SYN+ACK
包的初始序号,然后将这个连接放到一个暂存队列当中。 - 当服务器收到客户端的
ACK
响应时,会提取出当中的cookie
值进行对比,对比成功则说明是一个 正常连接,此时该连接就会从暂存队列当中移到全连接队列供上层读取。
引入了 syncookie
机制的好处:
- 引入
syncookie
机制后,这些异常连接就不会堆积在半连接队列队列当中了,也就不会出现半连接队列被占满的情况了。 - 对于正常的连接,一般会立即对服务器的
SYN+ACK
进行ACK
应答,因此正常连接会很快建立成功。 - 而异常的连接,不会对服务器的
SYN+ACK
进行ACK
应答,因此异常的连接最终都会堆积到暂存队列当中。
简单地说,就是 用了一个临时队列来缓解 syn
对立的问题!
使用 Wireshark 分析 TCP 通信流程
Wireshark
(前称 Ethereal
)是一个 Windows
下的网络抓包工具。
在使用 Wireshark
时可以通过设置过滤器,来抓取满足要求的数据包。
- 针对
IP
地址 进行过滤:- 抓取指定源地址的包:
ip.src == 源IP地址
- 抓取指定目的地址的包:
ip.dst == 目的IP地址
- 抓取源或目的地址满足要求的包:
ip.addr == IP地址
等价于ip.src == 源IP地址 or ip.dst == 目的IP地址
- 抓取除指定IP地址之外的包:
!(表达式)
- 抓取指定源地址的包:
- 针对 特定协议 进行过滤:
- 抓取指定协议的包:
协议名
(注意只能小写) - 抓取多种指定协议的包:
协议名1 or 协议名2
- 抓取除指定协议之外的包:
not 协议名
或!协议名
- 抓取指定协议的包:
- 针对 端口 进行过滤(以 TCP 协议为例):
- 抓取指定端口的包:
tcp.port == 端口号
- 抓取多个指定端口的包:
tcp.port >= 2048
(抓取端口号高于2048的包)
- 抓取指定端口的包:
- 针对 长度和内容 进行过滤:
- 抓取指定长度的包:
udp.length < 30 http.content_length <= 20
- 抓取指定内容的包:
http.request.urimatches "指定内容"
- 抓取指定长度的包:
下面举个例子,当我们用 telnet
命令连接该服务器后,就可以抓取到三次握手时双方交互的数据包。
而当我们退出 telnet
命令后,就可以抓取到四次挥手时双方交互的数据包。(此处四次挥手时进行了捎带应答,第二次挥手和第三次挥手合并在了一起)