文章目录
一、端口号
端口号(port)标识了一个主机上进行通信的不同的应用程序。
在 TCP/IP 协议中,用源 IP 、源端口号、目的 IP 、目的端口号、协议号这样一个五元组来标识一个通信。
1.端口号范围划分
- 0 ~ 1023:知名端口号,HTTP、FTP、SSH 等这些广为使用的应用层协议,它们的端口号都是固定的。
- 1024 ~ 65535:操作系统动态分配的端口号。客户端进程的端口号,就是由操作系统从这个范围分配的。
有些服务器是非常常用的,为了使用方便,人们约定,一些常用的服务器都是使用固定的端口号:
① SSH 服务器:22 端口。
② FTP 服务器:21 端口。
③ Telnet 服务器:23 端口。
④ HTTP 服务器:80 端口。
⑤ HTTPS 服务器:443 端口。
我们自己写一个网络程序使用端口号时,要避开这些知名端口号。
一个进程可以 bind 多个端口号,一个端口号不可以被多个进程 bind 。
因为端口号是用于标识进程的唯一性的。
2.常用命令
-
pidof
命令:通过进程名称,查看进程的 pid 。
语法:pidof [进程名称]
-
netstat
命令:查看网络状态。
语法:netstat [选项]
常用选项:
① -n:拒绝显示名称,能显示数字的全部转化成数字。
② -l:仅列出有在 Listen(监听)状态的连接。
③ -p:显示相关连接的程序信息。
④ -t:仅显示协议是 TCP 的选项。
⑤ -u:仅显示协议是 UDP 的选项。
二、UDP 协议
UDP(User Datagram Protocol,用户数据报协议)
1.格式
说明:
① 源端口号和目的端口号:表明报文是本端的哪个进程发的,要发到对端的哪个进程。
② UDP 长度:表示整个报文的长度。
③ UDP 检验和:如果检验不通过,就会直接丢弃整个报文。
-
UDP 的解包:当读到一个完整的 UDP 报文,读取定长的报头,剩下的就是有效载荷。
-
UDP 的封装:给有效载荷添加上定长报头。
-
UDP 的分用:把报头和有效载荷分离后,根据报头中的目的端口号,把有效载荷交付给应用层的某个具体进程。
Q:为什么我们写代码时,需要绑定端口号?
A:因为 UDP 报文会根据报头中的目的端口号,把数据交给绑定该端口号的进程。
Q:端口号为什么是 16 位呢?
A:因为协议规定。
Q:如何根据报头中的目的端口号,找到主机上对应的进程呢?
A:根据目的端口号和哈希,就能找到目标进程。根据目标进程的相关指针数据,就可以找到该进程打开的 socket 文件对应的缓冲区,然后把数据拷贝到缓冲区,于是该进程就拿到了数据。
2.特点
- 无连接:知道对端的 IP 和端口号就直接进行传输,不需要建立连接。
- 不可靠:本身没有确认机制,没有重传机制。如果因为网络故障而无法发到对方,UDP 协议层也不会给应用层返回任何错误信息。
- 面向数据报:不能够灵活地控制读写数据的次数和数量。
Q:如何理解面向数据报?
A:应用层交给 UDP 多长的报文,UDP 原样发送,既不会拆分,也不会合并。比如,用 UDP 传输 100 个字节的数据:发送端调用了一次sendto
,发送了 100 个字节,那么接收端也必须调用对应的一次recvfrom
,接收 100 个字节,而不能分 10 次循环调用recvfrom
每次接收 10 个字节。
所以在双端主机看来,发送和接收的都是一个一个独立的 UDP 报文,报文和报文之间是有明显的边界的。
3. UDP 的缓冲区
- UDP 没有真正意义上的发送缓冲区。调用
sendto
会直接将数据交给内核,由内核将数据传给网络层协议进行后续的传输动作。 - UDP 具有接收缓冲区。但是这个接收缓冲区不能保证收到的 UDP 报文的顺序和发送 UDP 报文的顺序一致。如果缓冲区满了,再到达的 UDP 报文就会被直接丢弃。
UDP 的 socket 既能读,又能写,这叫做全双工。
UDP 全双工:
① recvfrom
、sendto
,可以被同时调用。
4. UDP 使用注意事项
UDP 报文首部中有一个 16 位的最大长度,也就是说一个 UDP 能传输的数据最大长度是 64K(包含 UDP 首部)。然而 64K 在当今的互联网环境下,是一个非常小的数字。如果我们需要传输的数据超过 64K ,就需要在应用层手动地分包,多次发送,并在接收端手动拼装。
5.基于 UDP 的应用层协议
- NFS:网络文件系统。
- TFTP:简单文件传输协议。
- DHCP:动态主机配置协议。
- BOOTP:启动协议(用于无盘设备启动)。
- DNS:域名解析协议。
当然,也包括我们自己写 UDP 程序时自定义的应用层协议。
三、TCP 协议
TCP(Transmission Control Protocol,传输控制协议)
1.格式
说明:
① 源端口号和目的端口号:表明报文是本端的哪个进程发的,要发到对端的哪个进程。
② 首部长度:表示 TCP 报头的长度,基本单位是 4 字节。
③ 选项:TCP 报头中允许携带额外的选项。
④ 序号:表明当前报文的序号,为了保证可靠性。
⑤ 确认序号:表明对端已收到了哪个历史报文,为了保证可靠性。
⑥ 窗口大小:接收端用于通知发送端,自己的 TCP 接收缓冲区中剩余空间的大小。
⑦ 紧急指针:标识 1 字节的紧急数据在数据中的偏移量,需要配合标记位 URG 一起使用。
⑧ 检验和:如果检验不通过,就会直接丢弃整个报文。
⑨ 六个标记位:
ACK:确认(在 TCP 通信的过程中,几乎所有的 TCP 报文的 ACK 都会被设置)。
SYN:连接建立请求。
RST:重置异常连接。
PSH:告知对方尽快将接收缓冲区中的数据向上交付。
URG:表明该报文中存在紧急数据,需要被优先处理,紧急指针标识 1 字节的紧急数据在数据中的偏移量。
FIN:连接关闭请求。
由于 TCP 报头的标准长度是 20 个字节,所以首部长度的值一般是 20 / 4 = 5 ,即二进制的 0101 。
首部长度的最大值是 15 ,即 TCP 报头的最大长度是 15 * 4 = 60 个字节,意味着选项最多是 60 - 20 = 40 个字节。
-
TCP 的解包:当读到一个完整的 TCP 报文,先提取它的前 20 个字节,从前 20 个字节里分析出首部长度的值(TCP 报头的长度),若首部长度的值等于 20 个字节,则说明没有选项字段,前 20 个字节就是报头,剩下的就是有效载荷;若首部长度的值大于 20 个字节,则再读取选项字段,读完选项字段后,剩下的就是有效载荷。
-
TCP 的封装:给有效载荷添加上报头。
-
TCP 的分用:把报头和有效载荷分离后,根据报头中的目的端口号,把有效载荷交付给应用层的某个具体进程。
Q:为什么要有不同的标记位呢?
A:在任何一个时刻,都有可能存在成百上千个 client 在向 server 发送报文。server 面对大量的 TCP 报文,是通过 TCP 的标记位来区分各个报文的类别的,不同种类的 TCP 报文的处理策略是不一样的。
TCP 协议,是自带发送缓冲区和接收缓冲区的。
write
/send
:与其叫做发送接口,不如理解为拷贝函数。应用层调用write
/send
,并不是把数据发送到网络上,而是把数据拷贝到 TCP 的发送缓冲区。
Q:为什么 TCP 要自带缓冲区呢?
A:
- 提高应用层的效率。应用层只要把数据拷贝到发送缓冲区里,就可以直接返回了。至于发送缓冲区里的数据什么时候发、怎么发,这些都是 TCP 的事情,应用层不关心。
- 只有 OS 中的 TCP 可以知道网络,乃至对方的状态信息,所以也只有 TCP 能处理如何发、什么时候发、发多少、出错了怎么办等细节问题。所以 TCP 协议才叫做传输控制协议。
- 因为缓冲区的存在,所以可以做到应用层和 TCP 进行解耦。
2.确认应答机制
要理解 TCP 的可靠性,必须理解基于序号的确认应答机制!
确认应答机制:通过应答,来保证上一条信息一定被对端收到。只要一条消息有应答,就能确认该消息一定被对端收到了。在双方通信的时候,总会遇到最新的一条消息是没有任何应答的,所以 TCP 并不是百分之百可靠的。
TCP 的可靠性,是体现在对历史数据的可靠性,而对当前最新数据不能保证可靠性。
其实,世界上不存在长距离传输时百分之百可靠的协议。
TCP 的可靠性,除了保证能被对端收到,也要保证按序到达,TCP 报头里的序号字段,是为了保证报文能够按序到达。
即使本端按序发送一批报文,但经过网络传送后,也不能保证对端一定能够按序收到报文。因为有可能 1 号报文在路由转发的时候选择的路径比较长,2 号报文比较快,或者 3 号报文在传送过程中网突然卡了,4 号报文在传送过程中网突然又好了。总之,网络的环境是很复杂的。
因为有了 TCP 报头里的序号字段,对端就能根据序号,对收到的乱序的报文进行排序,从而保证报文能够按序到达。
因为 TCP 是面向字节流的,所以可以把 TCP 的发送缓冲区理解成一个字节流式的数组 uint8_t send_buffer[ ] ,TCP 将每个字节的数据都进行了编号,即为序号,可以理解为序号就是数组下标。
TCP 报头里的确认序号字段,是为了保证发送方知道它历史上的哪些报文被对方收到。
每一个 ACK 都带有对应的确认序号,用于接收方告诉发送方,我已经收到了哪些数据,下一次你从哪里开始发。
确认序号字段,它的值等于历史报文的序号 + 1 。发送方收到确认应答的 TCP 报文之后,可以通过其中的确认序号,来辨别是对哪一些报文的确认。比如,如果发送方收到的 TCP 报文的确认序号是 13 ,它的含义是,13 号之前的所有报文,接收方都已经收到了,下次发送请从 13 号报文开始发送。
Q:一个 TCP 报文里,既有序号,又有确认序号,为什么要是两个独立的字段呢?
A:根本原因是 TCP 协议是一个全双工的通信协议!本端的发送和对端的接收,对端的发送和本端的接收,会同时发生。换言之,本端在发送报文的时候,报文里既有自己的发送序号,可能也有对对端报文确认的确认序号,也就是说,在双方通信时,一个报文,既可以携带要发送的数据,也可能携带对历史报文的确认。如果序号和确认序号不是两个独立的字段,那么就做不到全双工了。
3.超时重传机制
发送方可以给每个刚发出去的报文设置定时器,若经过特定的时间间隔,没有收到对应的确认应答,发送方就会认为丢包了,于是就会重传。
发出去的数据,在收到对应的 ACK 之前,都会被发送方暂时保存起来。
当发送方发送完报文后,没有收到对应的 ACK ,有两种情况:要么是报文真的丢了,要么是接收方发送的 ACK 丢了。无论是哪种情况,发送方都会认为报文丢了,都会重传。如果是第二种情况,接收方就会收到重复的报文,于是接收方就会根据报文的序号进行去重,这也保证了可靠性。
超时重传的时间如何确定?
最理想的情况下,是找到一个最小的时间,保证 “确认应答一定能在这个时间内返回” 。但是这个时间的长短,随着网络环境的不同,是有差异的。如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包。
TCP 为了保证在任何环境下都能比较高性能地通信,因此会动态计算这个最大超时时间。
Linux 中,超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。
如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
累计到一定的重传次数,TCP 认为网络或者对端主机出现异常,强制关闭连接。
4.连接管理机制
在正常情况下,TCP 要经过三次握手建立连接,四次挥手关闭连接。
三次握手
TCP 协议是面向连接的,在通信前,需要先建立连接。
TCP 建立连接,需要双方进行三次握手(交换三次报文)。
注:为了简便,在双方收发报文时,在图中只标出了报文中的标记位而没有标出整个报文。实际上,双方收发的一定都是报文。
-
第一次握手:客户端给服务器发送报文(SYN 被设置),表示请求建立连接。
-
第二次握手:服务器收到上一次的报文后给客户端发送报文(SYN 和 ACK 被设置),表示请求建立连接并确认客户端的连接建立请求。
-
第三次握手:客户端收到上一次的报文后给服务器发送报文(ACK 被设置),表示确认服务器的连接建立请求,此时客户端认为连接建立成功。服务器收到报文后,认为连接建立成功。
server 中存在大量连接,server 的 OS 要管理这些连接,那么如何管理呢?先描述再组织。
建立连接的本质:三次握手成功,一定要在双方的 OS 内,为维护该连接创建对应的数据结构。双方维护连接是有成本(时间 + 空间)的。
每一个连接建立好后,双方都会有各自的描述连接的结构体对象来维护这个连接。上面所说的连接状态,就是该结构体内的一个字段。
在三次握手中,我们并不害怕第一次和第二次丢了,我们害怕的是第三次丢了。因为前两次握手都有各自对应的下一次握手对其进行应答,能够保证被对方收到,但第三次握手是没有对应的应答的(因为第三次握手是对上一次握手的应答),所以第三次握手发送的报文有可能有被丢失的风险。三次握手不是一定能成功的,而是一个以较大概率成功建立连接的过程。
由于最后一个 ACK 根本没有响应,所以 client 也就无法得知最后一个 ACK 是否被 server 收到,所以 client 只要把第三次握手的 ACK 发出去,它就认为连接建立成功了。若 server 收到最后一个 ACK ,它就认为三次握手完成,建立连接成功。一般而言,双方握手成功,是有一个短暂的时间差的;若最后一个 ACK 丢失了,client 认为连接已经建立好了,而 server 认为连接还没有建立好。由于最后一个 ACK 丢失了,导致 server 长时间得不到确认应答,所以 server 就会将第二次握手的报文超时重传,客户端收到后,就会重新发送最后一个 ACK 。或者 client 在建立好连接后就有较大的可能性给 server 发消息,server 收到消息后,就认为连接还没建立好就给它发消息了,所以就会向 client 发送一个响应报文(RST 被设置),client 收到该报文后,就意识到该连接建立失败了,就会关闭该连接。这种情况是连接异常的其中一种情况,只要是双方连接出现异常了,都可以设置 RST 标记位,来进行连接的重置。
三次握手是双方 OS 中的 TCP 协议自动完成的,用户层完全不参与。
client ->
connect
,server ->accept
。中间的三次握手的过程由 OS 中的 TCP 协议自动完成,用户不需要关心。用户只需要关心调用connect
(发起三次握手)和accept
(三次握手完成后,双方已经建立了 TCP 连接,调用accept
只是获得连接)。
为什么是三次握手,而不是一次、两次、四次、五次呢?
双方在建立连接的时候,无非就是要确认两件事情。
- 确认双方主机是否健康和网络状况是否良好。
- 验证全双工。
三次握手,是能看到双方都有收发的最小次数:
① 对客户端来讲,当收到 SYN + ACK 时候,就证明自己是能发数据的,同时也证明自己是能收数据的,这就验证了客户端是全双工的。
② 对服务器来讲,当收到 SYN 时,就证明自己是能收数据的。当收到 ACK 时,就证明自己是能发数据的。这就验证了服务器是全双工的。
所以,双方就能够以最小的成本次数,去验证全双工。
一次握手是不行的,因为客户端只发了一次报文,无法验证自己收和发的能力。服务器即便收到这个报文,也只能验证自己收的能力,无法验证自己发的能力。
两次握手也是不行的,因为客户端发一次报文给服务器,收一次服务器发的报文,能验证客户端自己收和发的能力。同时能验证服务器自己收的能力,但因为服务器无法得知自己发的数据能否被客户端收到,所以无法验证自己发的能力。
三次握手可以,所以三次是验证全双工的最小次数。
既然三次握手已经达成目的,那么四次、五次握手也就没必要了。
验证 ESTABLISHED 状态:
代码:让服务器在调用
listen
后进入一个死循环,只是在 sleep ,不调用accept
。
让客户端连接服务器。
运行结果:
说明 TCP 建立连接的三次握手跟accept
无关,accept
只是把已经建立好的连接拿到应用层。
四次挥手
TCP 关闭连接,需要双方进行四次挥手。
注:为了简便,在双方收发报文时,在图中只标出了报文中的标记位而没有标出整个报文。实际上,双方收发的一定都是报文。
-
第一次挥手:客户端给服务器发送报文(FIN 被设置),表示请求关闭连接。
-
第二次挥手:服务器收到上一次的报文后给客户端发送报文(ACK 被设置),表示确认客户端的连接关闭请求。
-
第三次挥手:服务器给客户端发送报文(FIN 被设置),表示请求关闭连接。
-
第四次挥手:客户端收到上一次的报文后给服务器发送报文(ACK 被设置),表示确认服务器的连接关闭请求。
主动断开连接的一方,会进入 TIME_WAIT 状态,它会认为四次挥手已经完成,但是它不会立马释放连接资源,而是得维持一段时间,因为它无法保证最后一个 ACK 被对方收到,如果最后一个 ACK 丢包了,此时它也能收到对方超时重传的第三次挥手的报文,然后重发最后一个 ACK 。还有一个原因,就是尽量保证双方历史发送的报文在网络中消散。
为什么是四次挥手?
因为四次挥手是双方协商断开连接的最小次数。以四次挥手的方式,达成连接关闭的共识。
调用一次close
,对应 TCP 的两次挥手。双方分别调用一次close
,对应 TCP 的四次挥手。
验证 TIME_WAIT 状态:
代码:让服务器在调用
listen
后进入一个死循环,在循环体内调用accept
。
让客户端连接服务器,最后让服务器主动断开连接。
运行结果:主动断开连接的服务器,它的连接状态是 TIME_WAIT ,并且不能立即重启(显示 bind error! )。
一个报文从一端到另一端的最大传送时间,称之为 MSL(Maximum Segment Lifetime,报文最大生存时间)。换个角度,也就是一个报文在网络里存活的最长时间是 MSL 。不同的操作系统,一般对 MSL 的设置是不一样的。
其实,TIME_WAIT 的时间是 2*MSL 。
为什么 TIME_WAIT 的时间是 2*MSL 呢?
- 尽量保证在网络的两个传输方向上尚未被接收或迟到的报文消散。在发送了最后一次 ACK 时,在网络里可能还会存在历史报文,但此时双方已经不再发送报文了,所以历史报文也就不会再增多,而且历史报文残留的时间最多是 2*MSL ,所以能尽量保证历史报文消散。否则,若服务器立刻重启,可能会收到来自上一次连接的迟到的报文,但是这种报文很有可能会干扰本次通信。
- 尽量地保证最后一个 ACK 被对方收到。假设最后一个 ACK 丢失了,那么对方会超时重传一个 FIN ,此时就还能重发最后一个 ACK 。
为什么主动断开连接的服务器不能立即重启(显示 bind error! )呢?
因为服务器是主动断开连接的一方,而主动断开连接的一方会进入 TIME_WAIT 状态(时间是 2*MSL),此时连接并没有释放,意味着这个端口仍然被占用,若此时重启服务器再次绑定该端口,就相当于一个端口被另一个进程绑定,但是端口号只能被一个进程绑定,所以就会显示 bind error! 。
主动断开连接的服务器不能立即重启,在某些情况下可能是不合理的。
如何解决服务器 TIME_WAIT 状态导致的无法立即重启的情况呢?
Linux 给我们提供了对应的系统接口setsockopt
,其作用是设置套接字的选项。
我们需要给要绑定端口的 listen_socket 文件描述符设置 SO_REUSEADDR 选项,表示可重用。
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// SO_REUSEADDR是个布尔值
// setsockopt表示把opt的值设置进SO_REUSEADDR
运行结果:
设置了 SO_REUSEADDR 选项后,服务器即使主动断开了连接,也能立即重启。
TIME_WAIT 状态依然存在,但是并不影响服务器的立即重启。
验证 CLOSE_WAIT 状态:
代码:让服务器在调用
listen
后进入一个死循环,在循环体内调用accept
,不调用close
。
让客户端连接服务器,最后让客户端主动断开连接。
运行结果:
客户端已经断开连接了,而服务器因为没有调用
close
还在维持着连接,此时服务器的状态就是 CLOSE_WAIT 状态。
启示:
① 一个 fd 被用完了,千万不要忘记释放!
② fd 是有限的,忘记释放会导致 fd 泄漏!
一旦发现服务器上存在大量的 CLOSE_WAIT 状态的连接,一定是服务器的代码没有调用close
。
5.滑动窗口
双方在进行 TCP 通信时,可以一次给对方发送多条数据,这样就可以将等待多个确认应答的时间重叠在一起,从而提高效率。
滑动窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。
上图的滑动窗口大小就是 4000 个字节(四个段)。发送前四个段的时候,不需要等待任何 ACK ,直接发送。
需要注意的是,这里的无需等待 ACK ,指的是发出去一条数据之后,在没有收到对应 ACK 的时候,也可以发下一条数据。也就是说,每条数据还是需要收到对应 ACK 的,只是可以暂时不收到。
滑动窗口,本质是发送缓冲区的一部分。
数据只有收到 ACK ,才能从发送缓冲区被删掉(滑动窗口的左侧越过这部分数据)。
滑动窗口的大小,是和对方的接收能力强相关的。
滑动窗口越大,网络的吞吐率就越高。
如何理解滑动窗口?
定义两个下标/指针,这两个下标/指针之间的内存区域就是滑动窗口。
在双方通信时,win_start 和 win_end 会根据对方不断发过来的报文的确认序号字段和窗口大小字段,不断地调整滑动窗口的大小,从而实现滑动窗口整体向右移动。
不要认为滑动窗口的左侧和右侧只能向右移动,它也有可能左侧向右移动而右侧不移动,从而不断变窄,因为它要根据对方的窗口大小来进行调整。
总之,滑动窗口会随着对方的窗口大小而不断变宽或变窄,但这两个下标/指针只能向右移动或暂时不移动。
发送缓冲区可以理解为环形队列,滑动窗口不断地移动,其实就是在这个环里不断地转圈。
如果出现了丢包,如何进行重传?这里分两种情况进行讨论。
- 数据已经抵达,但是中间部分数据的 ACK 丢了:
主机 A 发送了 1000 到 6000 的数据之后,收到了 1001 的 ACK ,没有收到 2001、3001、4001、5001 的 ACK ,但是收到了 6001 的 ACK 。
此时主机 A 并不认为中间部分的数据对方没收到,主机 A 也会认为 6001 之前的所有数据对方全收到了,虽然它没有收到中间部分数据的 ACK 。为什么呢?因为报头中有确认序号,确认序号的定义是该确认序号之前的数据已经全部收到了,这是 TCP 协议的规定,也是协议特点。主机 B 认为,只要主机 A 不给它重复发数据,它就认为它发的 ACK 对方也收到了,即便这个 ACK 丢失了。
这种情况下,中间部分的 ACK 丢了并不要紧,因为可以通过后续的 ACK 进行确认。
换言之,有了确认序号的定义,发送方在没有收到某些 ACK 的情况下,通过后续收到的 ACK 也照样可以判断出之前有哪些数据实际上对方也收到了。
如果主机 A 前面部分数据的 ACK 收到了,但是后面部分数据的 ACK 没有收到,后面部分数据的 ACK 丢了,那么主机 A 会对后面部分数据进行超时重传。
- 发出去的中间部分数据丢了,其它部分数据对方收到了:
主机 A 发送了 1000 到 7000 的数据,但是 1001 ~ 2000 的数据丢了。
主机 B 已经收到了除了 1001 ~ 2000 之外的数据,但就是没收到 1001 ~ 2000 的数据,所以主机 B 给主机 A 应答时,它应答的都是 1001 的 ACK 。为什么呢?因为确认序号的定义是该确认序号之前的数据已经全部收到了,这是 TCP 协议这么规定的,所以大家都要遵守。所以,虽然主机 B 收到了后续的数据,但是没有收到 1001 ~ 2000 的数据,又因为之前已经收到了 1 ~ 1000 的数据,所以它发的全部都是 1001 的 ACK 。主机 A 连续收到了三个以上的 1001 的 ACK 之后,就会意识到,有可能是 1001 之后的数据丢失了,所以它会立即尝试将历史数据进行重传。主机 B 收到 1001 ~ 2000 的数据后,发出了 7001 的 ACK ,意思是主机 A 发的 7001 之前的所有数据主机 B 都已经收到了。主机 A 收到 7001 的 ACK 后,就会意识到,只是 1001 ~ 2000 的数据丢了,2001 ~ 7000 的数据对方都收到了,所以主机 A 下次发送数据从 7001 开始。
TCP 协议规定,当发送方连续收到三个以上的同样确认序号的 ACK 后,发送方会立马进行补发。这种机制叫做高速重发控制,也叫做快重传。
换言之,如果真的是数据丢了,由于有协议的规定,接收方在对数据进行确认时,一定会不断地发送确认序号是丢失数据序号的 ACK ,发送方收到后就会立马补发这部分数据。
快重传和超时重传:
① 快重传需要发送方连续收到三个以上的同样确认序号的 ACK 才会被触发。
② 超时重传是保底的,必须存在。快重传是在重传的基础上提高效率的。
③ 所以,在 TCP 中,快重传和超时重传都存在。
6.流量控制
接收端处理数据的速度是有限的。如果发送端发得太快,就会导致接收端的接收缓冲区被打满。此时如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应,这是不合理的。
因此 TCP 支持根据接收端的接收能力,来决定发送端的发送速度。这个机制就叫做流量控制。
接收端为了通知发送端自己还能接收多少数据,会在应答报文的报头中填上窗口大小字段,表明接收端的接收缓冲区中剩余空间的大小。这样发送端就能得知接收端的接收能力,就能根据它来动态地调整发送的数据量。
因为缓冲区的存在,所以双方在互相通信时,就能在 TCP 报头中始终填上自己的窗口大小字段。因此,双方分别根据对方报文的窗口大小字段,就能动态地调整发送的数据量,做到合理地控制流量。
如果接收端给发送端通知的窗口大小比较小,发送端就会减小发送的数据量。
如果接收端给发送端通知的窗口大小比较大,发送端就会增大发送的数据量。
在通信前,双方如何得知对方的窗口大小呢?
在三次握手的前两次握手,双方都会告知对方自己的窗口大小。
握手的发起方在发送报文时,除了 SYN 被设置,也会填上自己的窗口大小。对方在发送响应报文时,除了 SYN 和 ACK 被设置,也会填上自己的窗口大小。
前两次握手的报文都没有携带任何数据,因为三次握手并没有完成。
双方在第一次向对方发送数据时,就会根据对方的窗口大小来设置自己的滑动窗口的初始值。
如果接收端的接收缓冲区的窗口大小为 0 ,接下来会怎样?
若发送端发现收到的报文中的窗口大小为 0 ,就会知道对方的接收缓冲区被打满了,于是发送端就暂时不会再给对方发送数据。经过一定的时间间隔,若还没有收到对方的窗口更新的报文,发送端就会给对方发送一个窗口探测的报文(不携带数据),对方收到后便会给发送端发送一个告知窗口大小的响应报文。如果发送端收到的响应报文中窗口大小还是为 0 ,那么发送端就会主动地轮询式地向对方发送窗口探测报文。
除了发送端主动地检测对方有没有窗口更新之外,接收端也可以在窗口大小更新后立马主动给发送端发送一个窗口更新的报文(可以不携带数据)。在 TCP 中,这两种策略都会被使用,便于尽快地让发送端得知接收端的窗口更新情况。
TCP 报头中的 16 位窗口大小字段的最大值是 65535 ,那么 TCP 窗口最大就是 65535 字节吗?
理论上是的,但实际上,如果想让窗口更大也可以(前提是 OS 允许建立这么大的接收缓冲区),TCP 报头的选项中包含了一个窗口扩大因子,但是一般很少使用。
7.拥塞控制
双端主机在进行 TCP 通信时,出现个别数据丢包的情况是很正常的,此时发送端可以通过快重传或超时重传对数据进行重发。但如果双方在通信时出现了大量丢包的情况,很可能是网络出现了比较严重的拥堵,不能认为是正常现象。同一局域网的其它主机使用的也是 TCP 协议,很有可能也会出现大量丢包的情况,此时如果所有的发送端再重传大量的数据,就只会加剧网络的拥塞状况。
发送端主机一旦识别到有大量丢包的情况出现,不会立即对数据进行重传,而是等待一段时间,让网络自己缓过来。同一局域网的其它发送端主机也是使用 TCP 协议,所以也会这么做,就像是一种共识。这样的机制叫做拥塞控制。
拥塞控制,控制的就是网络的情况。
TCP 协议不仅考虑了通信双端主机的情况,也考虑了网络的情况。
TCP 引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
此处会引入一个概念,叫做拥塞窗口。
拥塞窗口:描述网络可能会发生拥塞的临界值。
- 发送开始的时候,定义拥塞窗口大小为 1 。
- 每次收到一个 ACK 应答,拥塞窗口加 1 。
- 每次发送数据包时,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口(滑动窗口)。
像上面这样的拥塞窗口增长速度,是指数级别的。“慢启动” 只是指初始时慢,但是增长速度非常快。
为了不增长得那么快,不能只是让拥塞窗口单纯地加倍。此处引入一个概念,叫做慢启动的阈值。当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
- 当 TCP 刚开始启动的时候,慢启动的阈值设置为对方窗口大小的最大值。
- 在每次超时重传的时候,慢启动的阈值会变成当前拥塞窗口的一半,同时拥塞窗口置回 1 。
- 上面的过程如此不断地循环下去。
为什么拥塞窗口在慢启动时采用指数级别的增长呢?
因为一开始是为了探测网络的拥堵状况,所以发送少量数据。经过探测,发现都有 ACK 应答,说明网络已经准备好了,那么此时就不应该再慢下去了,而应该快速恢复数据的发送量。而指数级增长的特点就是前期慢后期快,正好符合要求。
当 TCP 通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。
少量的丢包,我们仅仅是触发超时重传或快重传。大量的丢包,我们就认为网络拥塞。
拥塞控制,归根结底是 TCP 协议想尽可能快地把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
8.延迟应答
如果接收端主机收到数据后立刻返回 ACK 应答,这时候返回的窗口可能比较小。
延迟应答,就是接收端主机收到数据后,不立刻返回 ACK 应答,而是等一会儿再返回 ACK 应答。因为这样的话,应用层会有较大的概率把数据取走,此时返回的窗口可能会更大。
假设接收端的接收缓冲区为 1M ,一次收到了 500K 的数据。
如果立刻应答,返回的窗口就是 500K 。
但实际上接收端主机的应用层的处理速度可能很快,10ms 之内就把 500K 数据从接收缓冲区中取走了(在这种情况下,应用层的处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来)。
如果稍微等一会儿再应答,比如等待 200ms 再应答,那么此时返回的窗口大小就是 1M 。
窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是,在保证网络不拥塞的情况下尽量提高传输效率。
当然,延迟应答不是对所有的包都适用。
如果适用,延迟应答的策略:
① 数量限制:每隔 N 个包就应答一次。
② 时间限制:超出一段时间就应答一次。
具体的数量和超时时间,依操作系统不同也有差异,一般 N 取 2 ,超时时间取 200ms 。
比如每两个报文应答一次:
9.捎带应答
TCP 通信的大部分报文,是既携带数据,也进行 ACK 应答的,这就叫做捎带应答。捎带应答提高了通信的效率。
比如,主机 A 给主机 B 发送了一条数据,主机 B 收到后需要发出 ACK 应答,但同时主机 B 也有一条数据要发给主机 A ,于是为了提高效率,主机 B 发送一条数据的同时捎带 ACK 应答给主机 A 。
所以在三次握手中,在发送最后一个 ACK 时,也有可能携带数据。
10.面向字节流
创建一个 TCP 的 socket ,同时在内核中创建一个发送缓冲区和一个接收缓冲区。
因为缓冲区的存在,所以可以做到应用层和 TCP 进行解耦。
- 调用
write
时,数据会先写入发送缓冲区中。- 如果发送的字节数太长,会被拆分成多个 TCP 的数据包发出。
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。
- 然后应用程序可以调用
read
从接收缓冲区拿数据。- 另一方面,TCP 的一个连接,既有发送缓冲区也有接收缓冲区,那么对于这一个连接,既可以读数据也可以写数据,所以是全双工。
由于缓冲区的存在,TCP 应用程序的读和写不需要一一匹配。
- 写 100 个字节数据时,可以调用一次
write
写 100 个字节,也可以调用 100 次write
,每次写一个字节。- 读 100 个字节数据时,完全不需要考虑写的时候是怎么写的,既可以一次
read
100 个字节,也可以一次read
一个字节,重复 100 次。
如何理解面向字节流?
TCP 的发送/接收缓冲区在发送/接收时,都是以字节为单位进行发送/接收的,它们只关心能发送/接收多少个字节。TCP 通信双方的数据就如同流水一般,从一端到另一端。
应用层读上来的字节数据,至于如何解释,完全由应用层自己决定。
11.粘包问题
首先要明确的是,粘包问题中的 “包” ,指的是应用层的数据包。
在 TCP 报头中,没有如同 UDP 一样的 “报文长度” 这样的字段,但是有一个序号这样的字段。
- 站在传输层的角度,TCP 是一个一个报文过来的,按照序号排好序放在缓冲区中。因为 TCP 是面向字节流的,所以数据和数据之间是没有边界的。
- 站在应用层的角度,看到的只是一串连续的字节数据。
粘包问题,是由应用层解决的。应用层要定制协议,根据协议从 TCP 的缓冲区中将数据取走。
解决粘包问题的指导思想是,明确两个包之间的边界。
可以采用定长报文、特殊字符、以及自描述字段来明确两个包之间的边界。
比如 HTTP 的报头里会携带空行,会携带 Content-Length ,其实就是在字节流数据中来明确报文和报文之间的边界,从而解决粘包问题。HTTP 就是使用了特殊字符 + 自描述字段来表明自己报文的报头和有效载荷的界限,从而决定自己的整个报文的长度。
对于 UDP 协议来说,不存在粘包问题。因为 UDP 是面向数据报的,要么不读,要么全读上来。UDP 的报头中包含总长度字段,报头是定长报头,去掉报头就是有效载荷,所以报文和报文之间的边界是明确的。
12. TCP 异常情况
-
进程终止:进程终止时会自动释放文件描述符,对应的 TCP 底层仍然会发起四次挥手,和进程正常退出没有什么本质区别。
-
机器重启:操作系统会先终止进程,然后再进行重启。所以机器重启和进程终止的情况是一样的。
-
机器掉电/网线断开:当发送端掉线后,接收端是无法得知的,它会认为连接还在。若接收端给发送端发送数据,长时间得不到应答,就会认为连接已经不在了,就会把连接释放。即使没有写入操作,TCP 自己也内置了一个保活定时器,会定期发送报文询问对方是否还在,如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接中,也会定期检测对方的状态,比如 QQ ,在 QQ 断线之后,也会定期尝试重新连接。
13. TCP 小结
TCP 如此复杂,是因为既要保证可靠性,又要尽可能地提高性能。
-
可靠性:
① 校验和
② 序号(按序到达)
③ 确认应答
④ 超时重传
⑤ 连接管理
⑥ 流量控制
⑦ 拥塞控制 -
提高性能:
① 滑动窗口
② 快速重传
③ 延迟应答
④ 捎带应答 -
其他:
① 定时器(超时重传定时器,保活定时器,TIME_WAIT 定时器等)
14.基于 TCP 的应用层协议
- HTTP:超文本传输协议。
- HTTPS:超文本传输安全协议。
- SSH:安全外壳协议。
- Telnet:远程终端协议。
- FTP:文件传输协议。
- SMTP:电子邮件传输协议。
当然,也包括我们自己写 TCP 程序时自定义的应用层协议。
15. TCP/UDP 对比
我们说 TCP 是可靠的,UDP 是不可靠的,不是说 TCP 就一定优于 UDP 。TCP 和 UDP 都各有优缺点,不能简单绝对地进行比较。
- TCP 用于对可靠性要求较高的领域,比如文件传输、重要状态更新等场景。
- UDP 用于对高速传输和实时性要求较高的通信领域,比如视频传输等。另外 UDP 也可以用于广播。
归根结底,TCP 和 UDP 都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判断。
16.用 UDP 实现可靠传输
参考 TCP 的可靠性机制,在应用层对 UDP 实现类似的逻辑即可。
- 引入序号,保证数据顺序。
- 引入确认应答,确保对端收到了数据。
- 引入超时重传,如果隔一段时间没有应答,就重发数据。
- …
17.理解 listen 的第二个参数
代码:对服务器,将
listen
的第二个参数设为 1 ,不调用accept
。
让多个客户端连接服务器。
运行结果:
我们发现,处于 ESTABLISHED 状态,但是没有被accept
取走的连接的个数是受限制的,最大个数是listen
的第二个参数 + 1 。不是所有的连接都会进入 ESTABLISHED 状态,超过最大限制个数后的连接会处于 SYN_RECV 状态。
实际上,Linux 内核协议栈为一个 TCP 连接管理使用两个队列:
- 全连接队列:用于保存处于 ESTABLISHED 状态,但是应用层没有调用
accept
取走的连接。其长度是listen
的第二个参数 + 1 。 - 半连接队列:用来保存处于 SYN_SENT 和 SYN_RECV 状态的连接。
换言之,全连接队列维护的是三次握手已完成的连接,半连接队列维护的是三次握手未完成的连接。
全连接队列的长度受listen
的第二个参数的影响,满了的时候,就无法继续让后续连接的状态进入 ESTABLISHED 状态了。
不要理解成只能在服务器维护
listen
的第二个参数 + 1 个连接,而是只能维护建立好但未被accept
的连接的最大个数是listen
的第二个参数 + 1 。
新连接的状态不是 ESTABLISHED ,而是 SYN_RECV ,意思是服务器暂时把新连接吊着,一旦等到应用层调用accept
把全连接队列里的连接取走,把位置空出来时,才让新连接继续完成三次握手,连接建立好后再放到全连接队列里。新连接只有三次握手完成了,才可能被放进全连接队列。
为什么要维护全连接队列?为什么该队列不能没有?为什么该队列不能太长?
因为应用层的服务有可能已经满载了,不能让新连接立刻使用服务,只能用一个全连接队列先把它维护好,等到应用层调用accept
时,再把新连接放入到服务内部去使用服务。如果没有全连接队列,就有可能导致服务资源没有被充分利用。
维护全连接队列是有成本的,如果队列过长,在队列尾部的新连接很有可能因为长时间得不到响应而断开,所以与其把资源用于维护过长的队列,倒不如把资源用于服务上,这更能保证给更多的新连接提供服务。因此我们不维护长队列,而维护短队列。