三次握手
三次握手涉及的核心函数调用
Server::socket, bind, listen
Client::socket, connect
便于理解的一些解释
关于三次握手, 几句话解释清楚
1.信道不安全 保证通信需要一来一回
2.客户端的来回和服务端的来回 共四次 这是最多四次
3.客户端的回和服务端的来合并成一个,就是那个SYN k 和 ACK j+1
4.这样就是三次握手
为什么需要三次握手,不是四次?
tcp 的连接需要确保双方收和发消息的能力都是正常的。
客户端第一次发送握手 SYN 消息 j 到服务端, 服务端收到握手 SYN 消息 j 后把自己 的握手消息 SYN k 和 握手应答 ACK j+1 一并发送给客户端, 这是第二次握手, 当客户端收到服务端送来的第二次握手消息后,客户端可以确认“服务端的收发能力都OK,客户端的收发能力也OK”, 但是服务端只能确认“客户端的发送OK,服务端的接受OK”,
所以还需要第三次握手, 客户端收到服务端的第二次握手消息后,发起第三次ACK k+1 ,服务端收到第三次消息后,就能够确定“服务端的发送OK, 客户端的接收OK ”,
至此,客户端和服务的都能够确认自己和对方的收发能力OK, tcp 连接建立完成。
四次挥手
流程概述
假设 发起端 主机A,接收端 主机B
- A 发送 FIN 报文 m , 进入FIN_WAIT_1
- B 回复 ACK 报文 m + 1 ,进入 CLOSE_WAIT 状态
- A 收到 ACK m + 1 , 进入FIN_WAIT_2
- 同时,B 通过read 调用获得 EOF,并将此结果通知应用程序进行主动关闭操作,发送 FIN 报文 n
接收到这个 FIN 包的对端执行被动关闭。这个 FIN 由 TCP 协议栈处理,TCP 协议栈为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。一定要注意,这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着接收端应用程序需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,被动关闭方进入 CLOSE_WAIT 状态。
- A 回复 ACK n + 1 ,进入 TIME_WAIT
- B 进入 CLOSED
TIME_WAIT
通常在 TIME_WAIT 停留持续时间是固定的,是最长分节生命期 MSL(maximum segment lifetime)的两倍,一般称之为 2MSL。和大多数 BSD 派生的系统一样,Linux 系统里有一个硬编码的字段,名称为TCP_TIMEWAIT_LEN,其值为 60 秒。也就是说,Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
MSL 是任何 IP 数据报能够在因特网中存活的最长时间。其实它的实现不是靠计时器来完成的,在每个数据报里都包含有一个被称为 TTL(time to live)的 8 位字段,它的最大值为 255。TTL 可译为“生存时间”,这个生存时间由源主机设置初始值,它表示的是一个 IP 数据报可以经过的最大跳跃数,每经过一个路由器,就相当于经过了一跳,它的值就减 1,当此值减为 0 时,则所在的路由器会将其丢弃,同时发送 ICMP 报文通知源主机。RFC793 中规定 MSL 的时间为 2 分钟,Linux 实际设置为 30 秒。
只有发起连接终止的一方会进入 TIME_WAIT 状态,现在我们来思考一下 TIME_WAIT 的作用。 为什么不直接进入 CLOSED 状态,而要停留在 TIME_WAIT 这个状态?
-
可靠地实现TCP 全双工连接的终止; 这样做是为了确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。 举个例子:以防一些情况下TCP 报文传输会出错,需要重传,如果ACK 传输失败,那么FIN 报文会被对端再次发出,若没有维护 TIME_WAIT 状态,直接进入CLOSED 状态,就失去了当前状态的上下文,只能回复RST 报文,从而导致被动关闭方出现错误。
-
允许老的重复分节在网络中消逝; 防止延时的报文‘迟到’, 此时旧连接已经不存在,但是恰巧有四元组相同的新连接,这个时候报文会被误发,对TCP 通信产生影响。所以,按照TCP 设计的规范,经过2MSL 的时间,旧报文就会被丢弃,从而解决上述这个问题。
2MSL 的时间是从主机 1 接收到 FIN 后发送 ACK 开始计时的;如果在 TIME_WAIT 时间内,因为主机 1 的 ACK 没有传输到主机 2,主机 1 又接收到了主机 2 重发的 FIN 报文,那么 2MSL 时间将重新计时。道理很简单,因为 2MSL 的时间,目的是为了让旧连接的所有报文都能自然消亡,现在主机 1 重新发送了 ACK 报文,自然需要重新计时,以便防止这个 ACK 报文对新可能的连接化身造成干扰。
TIME_WAIT 的坏处
-
内存资源占用, 这个问题几乎可以忽略
-
对端口资源的占用, 一个 TCP 连接至少消耗一个本地端口。要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过net.ipv4.ip_local_port_range 指定,如果 TIME_WAIT 状态过多,会导致无法创建新连接。
如何优化 TIME_WAIT ?
-
net.ipv4.tcp_max_tw_buckets 默认为18000 , 这个值的含义是,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有 TIME_WAIT 连接状态重置,并打印出警告信息。不过这个方法过于暴力,治标不治本,带来的问题远比解决的问题多,不推荐使用。
-
调低 TCP_TIMEWAIT_LEN,重新编译系统,需要一些内核方面的知识,操作起来有一定的困难。
-
设置 SO_LINGER ,比较危险,暂不详细阐述
-
net.ipv4.tcp_tw_reuse 那么 Linux 有没有提供更安全的选择呢? 就是net.ipv4.tcp_tw_reuse选项。 Linux 系统对于net.ipv4.tcp_tw_reuse的解释如下:
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. Default value is 0.It should not be changed without advice/request of technical experts.
这段话的大意是从协议角度理解如果是安全可控的,可以复用处于 TIME_WAIT 的套接字为新的连接所用。 那么什么是协议角度理解的安全可控呢? 主要有两点:
- 只适用于连接发起方(C/S 模型中的客户端);
- 对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。
使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即net.ipv4.tcp_timestamps=1(默认即为 1)。
要知道,TCP 协议也在与时俱进,RFC 1323 中实现了 TCP 拓展规范,以便保证 TCP 的高可用,并引入了新的 TCP 选项,两个 4 字节的时间戳字段,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
不过有一种说法: net.ipv4.tcp_tw_reuse 要慎用,当客户端与服务端主机时间不同步时,客户端的发送的消息会被直接拒绝掉。
究竟应该如何优化, 仍待考究。
总结
- TIME_WAIT的作用:
1) 确保对方能够正确收到最后的ACK 报文,帮助其关闭;
2) 防延时报文对程序带来的影响。
- TIME_WAIT的危害:
1) 占用内存;
2) 占用端口。
细节概述
关闭连接
- close 函数
这个函数会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并且会关闭 TCP 两个方向的数据流。
- shutdown 函数
想彻底关闭双向连接的时候用close, 想只关闭自己这端到对端的连接时用shutdown。
TCP Keep-Alive 选项
有没有办法开启类似的“轮询”机制,让 TCP 告诉我们,连接是不是“活着”的呢?
这就是 TCP 保持活跃机制所要解决的问题。实际上,TCP 有一个保持活跃的机制叫做 Keep-Alive。
这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
上述的可定义变量,分别被称为保活时间、保活时间间隔和保活探测次数。在 Linux 系统中,这些变量分别对应 sysctl 变量
net.ipv4.tcp_keepalive_timenet.ipv4.tcp_keepalive_intvlnet.ipv4.tcp_keepalve_probes
默认设置是 7200 秒(2 小时)、75 秒和 9 次探测。
如果开启了 TCP 保活,需要考虑以下几种情况:
第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。TCP 保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启。如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”;而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。
如果使用 TCP 自身的 keep-Alive 机制,在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个“死亡”连接。这个时间是怎么计算出来的呢?其实是通过 2 小时,加上 75 秒乘以 9 的总和。实际上,对很多对时延要求敏感的系统中,这个时间间隔是不可接受的。
所以,必须在应用程序这一层来寻找更好的解决方案。一般可以通过在应用程序中模拟 TCP Keep-Alive 机制,来完成在应用层的连接探活。
流量控制和拥塞控制
流量控制
我们可以把理想中的 TCP 协议可以想象成一队运输货物的货车,运送的货物就是 TCP 数据包,这些货车将数据包从发送端运送到接收端,就这样不断周而复始。
我们仔细想一下,货物达到接收端之后,是需要卸货处理、登记入库的,接收端限于自己的处理能力和仓库规模,是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步,比如接收端通知发送端:“后面那 20 车你给我等等,等我这里腾出地方你再继续发货。”
其实这就是发送窗口和接收窗口的本质,我管这个叫做“TCP 的生产者 - 消费者”模型。
发送窗口和接收窗口是 TCP 连接的双方,一个作为生产者,一个作为消费者,为了达到一致协同的生产 - 消费速率、而产生的算法模型实现。
说白了,作为 TCP 发送端,也就是生产者,不能忽略 TCP 的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。如果都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过使得生产者又重传,发送更多的数据包,最后导致网络崩溃。
我想,理解了“TCP 的生产者 - 消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,我们就会恍然大悟了。
拥塞控制
TCP 的生产者 - 消费者模型,只是在考虑单个连接的数据传递,但是, TCP 数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样,TCP 就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。
举个形象一点的例子,有一个货车行驶在半夜三点的大路上,这样的场景是断然不需要拥塞控制的。
我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。正是因为有多个 TCP 连接,形成了高速公路上的多队运送货车,高速公路上开始变得熙熙攘攘,这个时候,就需要拥塞控制的接入了。
在 TCP 协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。
拥塞控制常用的算法有“慢启动”,它通过一定的规则,慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后,慢启动就结束了,另一个叫做“拥塞避免”的算法登场。在这个阶段,TCP 会不断地探测网络状况,并随之不断调整拥塞窗口的大小。
现在你可以发现,在任何一个时刻,TCP 发送缓冲区的数据是否能真正发送出去,至少取决于两个因素,一个是当前的发送窗口大小,另一个是拥塞窗口大小,而 TCP 协议中总是取两者中最小值作为判断依据。比如当前发送的字节为 100,发送窗口的大小是 200,拥塞窗口的大小是 80,那么取 200 和 80 中的最小值,就是 80,当前发送的字节数显然是大于拥塞窗口的,结论就是不能发送出去。
发送窗口和拥塞窗口的区别:
发送窗口反应了作为单 TCP 连接、点对点之间的流量控制模型,它是需要和接收端一起共同协调来调整大小的;
而拥塞窗口则是反应了作为多个 TCP 连接共享带宽的拥塞控制模型,它是发送端独立地根据网络状况来动态调整的。
其他要点
- 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
- 小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如 Nagle 算法、延时 ACK 等机制。
- 在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用 writev 批量发送。
TCP BBR 致力于解决两个问题:
- 在有一定丢包率的网络链路上充分利用带宽。
- 降低网络链路上的 buffer 占用率,从而降低延迟。
TCP 是一种流式协议
为了理解 TCP 数据是流式的这个特性,分别从发送端和接收端来阐述。
在发送端,当调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,不能假设每次 send 调用发送的数据,都会作为一个整体完整地被发送出去。换言之,我们在发送数据的时候,不应该假设“数据流和 TCP 分组是一种映射关系”。
关于接收端字节流,有两点需要注意:
第一,先调用 send 函数发送的字节,总在后调用 send 函数发送字节的前面,这个是由 TCP 严格保证的;
第二,如果发送过程中有 TCP 分组丢失,但是其后续分组陆续到达,那么 TCP 协议栈会缓存后续分组,直到前面丢失的分组到达,最终,形成可以被应用程序读取的数据流。
和我们预想的不太一样,TCP 数据流特性决定了字节流本身是没有边界的,一般我们通过显式编码报文长度的方式,以及选取特殊字符区分报文边界的方式来进行报文格式的设计。而对报文解析的工作就是要在知道报文格式的情况下,有效地对报文信息进行还原。
TCP 异常
第一类,是对端无 FIN 包发送出来的情况;
第二类是对端有 FIN 包发送出来。而这两大类情况又可以根据应用程序的场景细分,接下来我们详细讨论。