技术: TCP 模型网络编程

tcp, 连接管理, 滑动窗口, 拥塞控制(及其算法), 重发机制, 校验和, 心跳保活等等

理论性内容的梳理, 也为编程实践 & 网络调试服务(比如tcp常用选项, tcp状态的流转)

概览

为了保证可靠传输, TCP比UDP多了很多控制协议和算法, 可以闭上眼数一数:

  • 连接管理——3次握手和4次握手
  • 数据破坏——通过校验和
  • 丢包——应答与超时重发机制
  • 分片乱序——序列号
  • 窗口滑动——提高发送效率,对发送端和接收端流量进行控制
  • 快速重发——加快通信速度, 三次收到重发消息进行重发
  • 流控制——避免网络流量浪费
  • 拥塞控制——慢启动算法,拥塞窗口

为了日常开发, 对于tcp客户端和服务器端各个 tcp状态 也要非常熟悉.

由于和日常工作息息相关, 所以一条条细说.

正文

协议本身

传输层的一个重要协议, 实现跨网络的应用程序之间的通信, 具体说就是控制如何传输数据.
(网络层能确保不同数据链路网络的可达, 网络接口层处理物理主机端口的通信)

TCP的发展, 是随着操作系统的发展而发展的; 毕竟TCP/IP, 网络通讯的细节都在内核中. (现在貌似有用户态的TCP/IP实现)

面向连接
TCP 协议是面向有连接的协议,也就是说在使用 TCP 协议传输数据之前一定要在发送方和接收方之间建立连接。
输层的协议中新增了三个要素:源端口号,目标端口号和协议号。通过这五个信息,可以唯一识别一个通信。
(任意一个不同, 就可以认为是不同的连接; 并且首部中的校验和部分也检验这5个信息)

NCP是半双工的, 即通信要建立两个连接(自然也就需要两个端口号); 而TCP是全双工的, 即可读也可以写, 只需要建立一个连接, 同时保留了奇数端口号(详见TCP/IP 奇数端口趣事).

可靠传输
建立 TCP 连接后,由于有数据重传、流量控制等功能, TCP 协议能够正确处理丢包问题,保证接收方能够收到数据,与此同时还能够有效利用网络带宽。
(UDP 协议是面向无连接的协议,它只会把数据传递给接收端,但是不会关注接收端是否真的收到了数据)

实时性差
TCP 协议中定义了很多复杂的规范(指首部内容)&还要保证可靠传输, 因此效率不如 UDP 协议,不适合实时的视频和音频传输。

TCP首部
大致信息如下图:

关键性字段解释:

  • 序列号:它表示发送数据的位置,假设当前的序列号为 s,发送数据长度为 l,则下次发送数据时的序列号为 s + l (避免重复接收)
    在建立连接时通常由计算机生成一个随机数作为序列号的初始值。
    (各种协议栈的实现关于起始序列号的选择是不一样的, 有些抓包软件是用的相对序列号)
    一般最大是2^32(每个500ms增加64000, 每隔9.5秒又归零; 每次建立连接都采用不同的序列号, 一般是直接增加64000)
    ack序号总是确认为下一个要接受包的序号, 也是发送端的滑动窗口左端需要到的位置(右边界=ack+win)
  • 确认应答号:它等于下一次应该接收到的数据的序列号。假设发送端的序列号为 s,发送数据的长度为 l,那么接收端返回的确认应答号也是 s + l
    发送端接收到这个确认应答后,可以认为这个位置以前所有的数据都已被正常接收。
  • 数据偏移:表示TCP 首部的长度,单位为 4 字节。如果没有可选字段,那么这里的值就是 5。表示 TCP 首部的长度为 20 字节
  • 控制位:改字段长度为 8 比特,分别有 8 个控制标志。
    依次是 CWR,ECE,URG,ACK,PSH,RST,SYN 和 FIN, 一般说的分节名称就是用它们来代指.
  • 窗口大小:用于表示从应答号开始能够接受多少个 8 位字节。
    接收窗口, 另外一个名字, 未经确认最大能发送的字节数目(窗口大小);
    实际上是接收端的流控机制, 给一个数值, 让发送端根据接收端的窗口大小调整发送端的窗口(缓存大小和边界位置).
  • 紧急指针标识位
    这个标识位仅仅通知对方有紧急数据, 但是真正在哪不确定. (紧急指针表明放在数据段的位置, 需要应用层指定)
    紧急标识, 告诉对方当前包有紧急数据(并告知了紧急指针的指向, 让对方应用层去处理; TCP还是按照原版原样的发送, 它并不知道紧急指针的存在),
    赶快让应用程度读取窗口数据, 让数据流动起来(通常就是window size=0, 但仍想发送数据, 此时用紧急指针标识催一下对方快点读)
  • 紧急指针:尽在 URG 控制位为 1 时有效。表示紧急数据的末尾在 TCP 数据部分中的位置.
    通常在暂时中断通信时使用(比如输入 Ctrl + C), 而且优先发送该分节, 并且要求对方也优先处理, 一般是为了让应用快点读取数据.
  • 可选字段
    • MSS是常见的可选字段, 也是通讯双发协商的结果(一般由接收端指定), 用这个字段来限定数据部分的大小(window上一般就是1460). 一般本地链路网络就是1460, 而非本地(协议栈可以判断)那么一般就是采用默认值536. 也可以在某些封装协议里面先采用pMTU进行路径MTU探测再协商.
    • 最新标准还有一个字段, 选择性确认字段(SACK), 表明收到报文是否一定要ack确认.
  • 校验
    通常各首部的校验和只校验各自头部, 比如以太网帧协议头部, IP头部都只校验自己头部, 但是ICMP, TCP, UDP校验和需要校验头部+数据; udp校验和可选, tcp强制.

注意: 发送端的发送数据除了受限于对方的通知窗口大小, 还受限与MSS(一般窗口大小是远远大于MSS值的)以及拥塞控制中拥塞窗口cwnd的大小,(即网络中一些低速链路)

复位报文 (RST)
RST比特, 复位报文段. (R标识)
这样的报文一般用在

  • 客户端请求建立连接, 但是服务器没有开发所请求的端口, 那么会立马RST, 要求客户端断开连接.
  • 异常终止一个连接(而是不慢悠悠的采用四次握手), 直接发送RST回去. 但是这样情况, 发送方会丢弃当前要发送的其他报文数据; 并且接收端需要能正常处理这种异常关闭情况.
  • 两端已经建立了连接, 但是一端莫名断开了, 但是另外一段不知道, 还继续往那边儿发, 这个时候接收端就直接回复RST.

TCP选项
一般要求是 TLV, 即其组成是 Type, Length, Value, 一般不是4的倍数(边界)需要填充补齐. Length(规定了本选项所占得字段的数目)

push标识
其实这是一个建议标识, 它告知接收方, 尽快方接收到的数据交付给应用进程.
(如果压根不能发了, 比如对方通知窗口为0, 此时可以借用紧急指针, 告知应用快点儿读数据)

附加时间戳
为什么要加上时间戳选项?

  • 避免序号回绕, 特别是千兆或者万兆网络, 序号回绕重复的几率更大(以10M太网大概是60分钟, 千兆以太网可能是34秒; 单位时间网速越快越好, 发送的字节越多, 重用周期越短), 这个时候在时间戳选项上加上序号回绕的保护算法(因为时间总是向前的, 它不可能回绕), 来避免包的混乱.还有一种情况, 新的连接收到旧的连接延迟的包, 首先是等待2MSL保证(即最多4分钟)消耗这些包; 之后这些延迟的包也会受到TTL的限定, 可能是255跳或者255秒等.
  • 计算RTT, 但是window不用, 它是系统自己计算. 并且时间戳选项是单向递增的, 即接收方只是简单回显回去(让原来的发送方自己计算), 所以根本不必要进行时钟同步.

套接字模型

基本的TCP套接字模型已经说过了, 参考我的这篇文章 链接 .

连接管理

连接管理, 包括通常所说的三次握手, 四次分手(连接终止协议).

为什么是3次, 为什么是4次?
个人认为这些问题都离不开关键词, 可靠传输, 换句话说, TCP总是最大程度交付, 保证可靠传输. 具体说:

  • 网络不可靠(不保证数据可达)
  • 网络有延迟(不保证数据可达的时限)

整体的图如下:

为什么是三次握手?

首先网络不可靠, 你可以简单认为IP传输是不可靠的, 然后网络有延迟.
假设, 两次握手时, 服务器就建立了连接(established), 即没有第三次的ack确认, 正常情况下, 没有问题;
但是考虑网络的上述两点问题, 比如说客户端的syn延迟了, 它自个儿又重发了一份syn给服务器, 服务器收到就建立连接(并返回ack信息), 并不需要客户端再发ack确认;
但是等延迟到达的syn发给服务器时, 服务器又开始建立一个新的连接(不必客户端再发ack确认真的要建立), 但是这个连接其实是无效的, 因为客户端已经通过刚才的连接传输数据了, 所以不会在利用发送任何数据了, 这样就白白占用了一些服务器资源.

这也就说明, 为了建立连接时保证现在确实是有客户端请求需要通信, 则必须要第三次握手, 即客户端ack确认.
(有了第三次握手的情况下, 客户端发现服务器响应的是一个延迟的请求, 所以直接抛弃, 不给于ack回复, 也就不必建立连接了, 该延迟问题化解)

第二次握手的ack反馈, 客户端没有收到怎么办?, 见下面的连接失败.

连接失败
建立连接客户端可能遇到的失败, 客户端发送的SYN包可能会遇到失败, 可能有以下几种情况:

  • syn未到达: 如果SYN在中间路由遇到目的不可达, 客户端收到ICMP报文, 客户端保存这个报文信息, 并采用第一种情况方案解决(也就是重发)
  • syn到达但服务端不返回ACK分节: 如果目的主机没有监听目的端口号, 就会返回一个RST的分节, 客户端收到RST后, 立刻返回错误
  • syn到达但服务端返回的ACK分节未被收到: 如果客户端没有收到SYN的响应包(ACK), 根据TCP的超时重发机制进行重发(服务端重发ACK).
    75秒后(连接计时器)还没收到, 就返回错误, 即RST分节进入close状态, 取消原来的半连接状态, 也告知客户端, 要想通信, 请重新开始.

客户端故意不发送ack, 即第三次握手, 相当于洪水攻击; 好在tcp连接计时器有在记录时间, 不怕.

控制连接数
socket创建的套接字是主动套接字, 调用listen后变成监听套接字. TCP状态由CLOSE跃迁到LISTEN状态.

监听套接字有两个队列, 一个是未完成队列, 一个是已完成队列.

  • 未完成队列:客户端发送一个SYN包, 服务器收到后变成SYN_RCVD状态, 这样的套接字被加入到未完成队列中
  • 已完成队列:TCP已经完成了3次握手后, 将这个套接字加入到已完成队列, 套接字处于ESTABLISHED状态

accept函数返回时即从未完成队列到已完成队列转移一个连接. 该队列专门用于控制同时请求建立队列的客户端数量.

为什么是四次分手?
断开连接的时候, 我记得上学的时候, 还不太能理解, 或者觉得奇怪, 为什么要4次?
你想啊:

我们打电话, 我说我挂电话啦, 对方说好; 那么此次连接就被挂断了; 根本不会有对方再次说, 我也挂电话了, 我还给回复一下, 好的.

这不是很奇怪么? 难道电话不是全双工的连接通信么? (听的同时也是可以说的呀)
然而不同的是, TCP这种全双工是基于字节流的:
这就意味着,当主机1发出FIN报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2,它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回ACK报文段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了FIN报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。

也就是说, 如果不反复确认, 可能只是半关闭.
单方面关闭其实只是表面(发送FIN, 通常是应用层进行关闭的结果), 该方不再发送数据, 但是还是可以回应ACK的(ack是协议栈自动产生).

仅仅四次分手, 还不能保证可靠的关闭, 还是因为网络的不可靠和延迟特性, 还需要保证等待 2MSL.

即在 FIN_WAIT1(主动关闭一方重发FIN的时间), FIN_WAIT2(紧接着等待对方, 被动一方发FIN关闭连接的时间; FIN_WAIT2 其实是属于半关闭状态, 等待对端发送FIN包.),
之后在收到对方被动关闭的FIN后, 在 TIME_WAIT上等待 2MSL.

2MSL

MSL:报文段最大生存时间,它是任何报文段被丢弃前在网络内的最长时间。这其实是连接管理里面的内容, 由于经常被问到, 所以单独拿出来.

其实就是怕, 主动关闭方的最后一个ack(对对方的关闭进行ack回复)对方没有收到, 那么它没有收到ack肯定再次fin告知关闭.
所以我就多等一下, 即2MSL. (Time_wait状态持续 2MSL 时间)

实际上主动关闭的一方, 比如客户端, 每次收到 被动关闭方, 比如服务器的 FIN 包时,它会设置一个计时器(2msl时间), 对方重发 FIN , 我收到的同时重置计时器.

超时之后, 主动方还没有收到被动方再次重发的 FIN, 一般情况下, 主动方直接closed状态, 而被动方则永久无法再收到我的ack, 即再也无法关闭.

具体可以看下图:

仔细看上面这个图的状态流转. 除了保证可靠关闭(保证对方能手动最后一个ack)外, 还有一个作用: 保证本次发送的延迟&重复数据从网络中都消失(其实还是等待对方重发反馈).

2MSL 通常指定的是30秒, 1分钟, 2分钟(根据具体的实现或者配置), 标准一般是指定2分钟.
(其实选择RTO的时间即可, 但是这里采用2MSL还是比较可靠的)

2MSL期间(TIME_WAIT), 客户端(或者说主动关闭方)处的相应IP地址+端口号不能被新的连接使用(can’t bind local address: already in use), 直到2MSL结束.
(如果你能重新使用, 这是非常危险的, 因为新的连接拿到的包可能是旧的, 还没有断开的连接的包)
2MSL结束之后, 新的连接(客户端这边儿同样的IP+端口), 如果收到被延迟的数据包, 直接丢弃.

总结一句: 主动关闭方, 处于TIME_WAIT时间段, 其端口号不能被使用.

平静时间

主机重启的MSL时间内, 不能建立连接. (没多大用处, 但是当初规定它的是因为, 怕处于TIME_WAIT阶段的主机重启, 并且重启时间又在2MSL时间内, 怕它又立马建立连接.

TIME_WAIT过多

上面也已经在2MSL中说过了 TIME_WAIT 状态持续的必要性:
TIME_WAIT是TCP协议用以保证被重新分配的socket不会受到之前残留的延迟重发报文影响的机制,是必要的逻辑保证.

TIME_WAIT过多 (实际上由于短连接问题导致)
发起socket主动关闭的一方 socket将进入, 该状态将持续2个MSL(Max Segment Lifetime),在Windows下默认为4分钟,即240秒,TIME_WAIT状态下的socket不能被回收使用. 具体现象是对于一个处理大量短连接的服务器,如果是由服务器主动关闭客户端的连接,将导致服务器端存在大量的处于TIME_WAIT状态的socket, 甚至比处于Established状态下的socket多的多,严重影响服务器的处理能力,甚至耗尽可用的socket,停止服务.

即, 主动关闭的一方是服务器的话(一般是短连接), 就要注意该问题.
(如果短时间内(例如1s内)进行大量的短连接,客户端所在的操作系统的socket端口和句柄被用尽!)

短连接, 进行的时间比较短(整个连接+传输过程都非常短)

如果查看:

1
$ netstat -n | awk '/^tcp/ {++state[$NF]} END {for(key in state) print key,"/t",state[key]}'

修改一下内核参数, 对应文件/etc/sysctl.conf, 添加如下:

1
2
3
4
5
6
7
8
net.ipv4.tcp_fin_timeout = 30
net.ipv4.tcp_keepalive_time = 1200
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_max_tw_buckets = 5000

执行以下命令使配置生效: /sbin/sysctl -p.

具体参数, 说明如下:

  • net.ipv4.tcp_syncookies = 1 表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;
  • net.ipv4.tcp_tw_reuse = 1 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
  • net.ipv4.tcp_tw_recycle = 1 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
  • net.ipv4.tcp_fin_timeout = 30 表示如果套接字由本端要求关闭,这个参数决定了它保持在FIN-WAIT-2状态的时间。
  • net.ipv4.tcp_keepalive_time = 1200 表示当keepalive起用的时候,TCP发送keepalive消息的频度。缺省是2小时,改为20分钟。
  • net.ipv4.ip_local_port_range = 1024 65000 表示用于向外连接的端口范围。缺省情况下很小:32768到61000,改为1024到65000。
  • net.ipv4.tcp_max_syn_backlog = 8192 表示SYN队列的长度,默认为1024,加大队列长度为8192,可以容纳更多等待连接的网络连接数。
  • net.ipv4.tcp_max_tw_buckets = 5000 表示系统同时保持TIME_WAIT套接字的最大数量,如果超过这个数字,TIME_WAIT套接字将立刻被清除并打印警告信息。
    默认为180000,改为5000。

对于Apache、Nginx等服务器,上几行的参数可以很好地减少TIME_WAIT套接字数量,但是对于Squid,效果却不大。
此项参数可以控制TIME_WAIT套接字的最大数量,避免Squid服务器被大量的TIME_WAIT套接字拖死。

当然, 如果运行的程序有权限, 貌似也可以在网络代码中设置, 甚至可妒忌也能进行一些设置:

  • 客户端程序中设置 socket 的SO_LINGER选项
  • 客户端机器打开 tcp_tw_recycle 和tcp_timestamps选项
  • 客户端机器打开tcp_tw_reuse和tcp_timestamps选项
  • 客户端机器设置tcp_max_tw_buckets为一个很小的值

RTO

重传超时时间(RTO)
如果发送方等待一段时间后,还是没有收到 ACK 确认,就会启动超时重传。这个等待的时间被称为重传超时时间(RTO,Retransmission TimeOut)。

RTO 的值具体是多久呢? (即重传定时器)

RTO是根据RTT计算的, 也就是和RTT相关.

RTO 的值不是固定的,它是一个动态变化的时间, 这个时间总是略大于连接往返时间(RTT,Round Trip Time).
这个设定可以这样理解:“数据发送给对方,再返回到我这里,假设需要 10 秒,那我就等待 12秒,如果超过 12 秒,那估计就是回不来了”

RTT (往返时间,round trip time)是动态变化的,因为谁也不知道网络下一时刻是否拥堵, 而当前的 RTO 需要根据未来的 RTT 估算得出。

  • RTO 不能估算太大,否则会多等待太多时间;也不能太小,否则会因为网络突然变慢而将不该重传的数据进行重传。
  • RTO是基于RTT的, 但是RTT是根据网络状况而变化的.
  • RTO 有自己的估算公式,可以保证即使 RTT 波动较大,它的变化也不会太剧烈。

例如旧标准RFC的计算方法:

1
2
R <- R_old*percent + R_current*(1-percent);
RTO = R*2; //2的倍数也可, 方便计算机做移位计算

适用于变化很缓慢的RTT情况, 之后的标准改用平滑的方差+平滑的RTT去测量了(还是参考当前和旧的RTT, 但是引入方差做差值补偿), 具体公式略.

指数退让

观察连续重传之间不同的时间差, 它们取整后分别为1, 3, 6, 12, 24, 48和多个64. 当第一次发送后设置的超时时间其实为1.5秒(取整即为1), 之后就是1.52, 1.54 最高为64.
一般到48时还是满足指数关系的1.5*2^x, 而最大的尝试时间其实是可以指定参数的: tcp_ip_abort_interval, 默认为2分钟或者9分钟(根据内核实现不同而不同)

(简单的记着尝试重传的时间是递增了, 基本满足指数倍数的关系, 但有最大尝试时间限定)

(每个连接的RTT是单独测量的, 为每个连接都维护很多参数, 比方拥塞门限, RTT等, 都是很耗费服务器资源的; )
一旦连接关闭, 数据损失就太可惜了, 可以让路由维护(为路由条目保留)相关路径的RTT.

怎么确认丢包?

  • 超时了还没有回复 ack
  • 收到了重复确认
    我现在发的是K+2的包, 但是对方重复确认的却是K的包(即ack=k+1), 说明K+1它没有收到; 即某包没有到, 但是它后面的包都到了, 就会重复确认所丢失的包

状态流转图(重要)

状态流转图, 指TCP工作时的各个状态, 一般就记忆下面的这个图:

其他复杂的状态图, 结合上面这个图再看就比较清楚了.

但是这个图没有包含同时打开, 同时关闭的过程. (但是也足够了, 因为制造同时打开&同时关闭的几率太小)

以及

记住第一个图就够了, 同时打开和关闭的, 比较特殊, 也很少遇到.

7种计时器

一共有7个定时器, 但是其中4个比较重要:

  • 超时重传定时器
  • 坚持定时器
  • 保活定时器
  • 2MSL定时器

建立连接定时器
connection-establishment timer.
建立连接的过程中,在发送SYN时, 会启动一个定时器(默认应该是3秒),
如果SYN包丢失了, 那么3秒以后会重新发送SYN包的(当然还会启动一个新的定时器, 设置成6秒超时),

当然也不会一直没完没了的发SYN包, 在/proc/sys/net/ipv4/tcp_syn_retries 可以设置到底要重新发送几次SYN包.
建立连接的最长尝试时间, 75秒.(超过这个时间还是建立不起连接则放弃尝试)

重传定时器
retransmission timer.
重传定时器在 TCP 发送数据时设定, 在计时器超时后没有收到返回的确认ACK, 发送端就会重新发送队列中需要重传的报文段。
使用RTO重传计时器一般有如下规则:

  • 当TCP发送了位于发送队列最前端的报文段后就启动这个RTO计时器;
  • 如果队列为空则停止计时器,否则重启计时器;
  • 当计时器超时后,TCP会重传发送队列最前端的报文段;
  • 当一个或者多个报文段被累计确认后,这个(些)报文段会被清除出队列

重传计时器保证了接收端能够接收到丢失的报文段,继而保证了接收端交付给接收进程的数据始终的有序完整的。因为接收端永远不会把一个失序不完整的报文段交付给接收进程。

延迟应答定时器
delayed ACK timer.
延迟应答也被成为捎带ACK, 这个定时器是在延迟应答的时候使用的。

为什么要延迟应答呢? 延迟应答是为了提高网络传输的效率(吞吐量)。
举例说明, 比如服务端收到客户端的数据后, 不是立刻回ACK给客户端, 而是等一段时间(一般最大200ms),这样如果服务端要是有数据需要发给客户端,
那么这个ACK就和服务端的数据一起发给客户端了, 这样比立即回给客户端一个ACK节省了一个数据包。

持续定时器
persist timer, 又称坚持定时器.

我们已经知道TCP通过让接收方指明希望从发送方接收的数据字节数(即窗口大小)来进行流量控制。如果窗口大小为0会发生什么情况呢?
这将有效地阻止发送方传送数据,直到窗口变为非0为止。接收端窗口变为非0后,(发送端会定时定时探测)就会发送一个确认ACK指明需要的报文段序号以及窗口大小。

如果这个确认ACK丢失了,则双方就有可能因为等待对方而使连接终止:接收方等待接收数据(因为它已经向发送方通告了一个非0的窗口),
而发送方在等待允许它继续发送数据的窗口更新。为防止这种死锁情况的发生,发送方使用一个坚持定时器 (persist timer)来周期性地向接收方查询,
以便发现窗口是否已增大。这些从发送方发出的报文段称为窗口探查 (window probe).

TCP只确认哪些包含有数据的ACK报文段, 不对ACK报文段(仅仅是ACK的报文)进行确认.

会周期性的探测窗口是否变大(window probe), 大小为1个字节. 接收方收到是要回应ack的告知相关窗口大小的.

这个探测周期就是坚持定时器的间隔时间, 但是这个时间是变化的(具体和参数设定有关), 满足指数退让规则. (4.9, 4.9, 6, 12, 24, 48, 最多60秒)

保活定时器
keepalive timer.

如果客户端和服务端长时间没有数据交互(但是连接还是继续保持的),那么需要保活定时器来判断是否对端还活着.
一般需要知道对端是否活着, 其实是应用想知道, 也希望TCP提供这种能力, 但是TCP规范中并没有提出. RFC中提出不需要保活定时器的理由:

  • 短暂差错时, 保活探测会让其断开(其实只是短暂差错, 没必要断开的)
  • 耗费带宽(甚至是金钱, 流量)

为什么可以定期探测?
如果一边已经断掉了, 你发信息给他, 他一般会回复一个RST报文, 让发送端断掉连接.
这里的定期探测, 只是在完全空闲的时候, 才会进行探测(如果有数据交换, 那么保活定时器就会不断的被刷新). 但是这个其实很不实用,因为默认是2小时没有数据交互才探测,时间实在是太长了。

所以说 TCP中提供保活定时器, 是比较有争议的. 一般认为不应该由TCP来完成, 而应该由应用来完成定期探测任务.
但是TCP保活定时器也是工作的:

  • 客户机正常, 服务器可达; 保活定时器不工作(或者两小时之内都不会被复位, 即认为是正常的, 不需要保活定时器监测)
  • 客户机已经奔溃&正在重启, 那么客户机是不能响应服务器的探测的, 这个时候服务器会重传10次, 每次75秒左右, 即最长750秒后还没有收到客户器反馈, 那么就断开连接
  • 客户机已经完成重启(但原来连接肯定没有了), 服务器会收到客户机的RST报文, 即立即断开连接
  • 服务器奔溃了, 这种情况客户机也是不断探测10次, 直至750秒超时, 然后断开.

总结

开始探测到最终放弃探测, 尝试时间最长是750秒; 但是开始第一次探测距离上次正常发送报文的时间间隔是2小时.

如果你真的要确认对端是否活着, 那么应该自己实现心跳包,而不是依赖于这个保活定时器。

在TCP连接建立的时候指定了 SO_KEEPALIVE,保活定时器才会生效。

FIN_WAIT_2定时器
FIN_WAIT_2 timer
主动关闭的一端调用完close以后(即发FIN给被动关闭的一端, 并且收到其对FIN的确认ACK)则进入FIN_WAIT_2状态。
如果这个时候因为网络突然断掉、被动关闭的一段宕机等原因,导致主动关闭的一端不能收到被动关闭的一端发来的FIN,主动关闭的一段总不能一直傻等着,
占着资源不撒手吧?

这个时候就需要FIN_WAIT_2定时器出马了, 如果在该定时器超时的时候,还是没收到被动关闭一端发来的FIN,那么不好意思, 不等了, 直接释放这个链接。
FIN_WAIT_2定时器的时间可以从/proc/sys/net/ipv4/tcp_fin_timeout中查看和设置。

TIME_WAIT定时器
TIME_WAIT timer, 也叫 2MSL timer
TIME_WAIT是主动关闭连接的一端最后进入的状态, 而不是直接变成CLOSED的状态, 为什么呢?
第一个原因是万一被动关闭的一端在超时时间内没有收到最后一个ACK, 则会重发最后的FIN,2MSL(报文段最大生存时间)等待时间保证了重发的FIN会被主动关闭的一段收到且重新发送最后一个ACK;
另外一个原因是在2MSL等待时间时,任何迟到的报文段会被接收并丢弃,防止老的TCP连接的包在新的TCP连接里面出现。不可避免的,在这个2MSL等待时间内,不会建立同样(源IP, 源端口,目的IP,目的端口)的连接。

其他可以参考 2MSL.

心跳保活

网络编程中, 常见的内容.
如果是TCP层, 一般就是尝试探测10次, 最大尝试时间为750秒; 但是开始探测时间是上次正常交流后的2小时, 故而一般是根据应用层的需要, 自己再实现.

窗口

在tcp协议头里看到, 有一个 window size字段.

它和滑动窗口, 拥塞窗口是什么关系呢?
一语道破: 接收窗口以及发送窗口. (实际上发送端比较特殊, 既存在拥塞控制, 同时也是存在流量控制的)

这个window size, 报文中一般直接用win, 简单来说, 就是指通知窗口, 它告知发送端的缓存头, 尾(即左右边界)如何移动.
(其实移动的是发送端, 接收端只是告知我还能接收多大的字节)

先说说 窗口本身(缓存或者缓存队列).

TCP窗口

如果窗口过大,会导致接收方的缓存区数据溢出。这时候本该被接收的数据反而丢弃了,就会导致无意义的重传。
因此,窗口大小是一个可以改变的值,它由接收端主机控制,附加在 TCP 首部的“窗口大小”字段中。

TCP 协议中的窗口是指发送方窗口和接收方窗口的较小值.

如果数据包发出后,直至 ACK 确认返回以前,发送端都无法发送数据, 那么包的往返时间(RTT)越长,网络利用效率和通信性能就越低。
为了解决这个问题,TCP 使用了“窗口”这个概念(响应的一方采用 delayed ack, 一般都要延迟回复, 保证先到先回复)。窗口具有大小,
它表示无需等待确认应答就可以继续发送数据包的最大数量。(缓存或者缓存队列, 没有发送成功的, 根据ack判断,然后重发)

引入窗口的概念后,被发送的数据不能立刻丢弃,需要缓存起来以备将来需要重发(真正收到具体的 ack 才会出队)

比如窗口大小为 4 (K)时,数据发送的示意图如下:(无丢失时)

如果是数据包没有丢失,但是确认包丢失了呢?这就是窗口最擅长处理的问题了。
假设发送发收到的确认包中的 ACK 第一次是 1001,第二次是 4001。那么我们完全可以相信中间的两个包是成功被接收的。

因为如果有没接收到的包, 接收方是不会增加 ACK 的

即可以不必重新传递第1个数据包, 直接发送(收到ack 2001时就知道了即使ack 1001 没有收到也可以继续发送4001)

在这种情况下,如果不使用窗口,发送方就需要重传第1、3, 4个数据包,但是有了窗口的概念后,发送方就省略了两次重传。
(有时候能看到这样”隔一个报文段确认”的现象)

总结就是: 增大了连续发送的量, 像一个缓存池; 结合sequence机制, 保证可靠传输.

滑动窗口

滑动窗口其实是接收方的说法, 也就是说, 在TCP通信的响应字段里设置的win, 来控制下次发送方发送报文的字节数, 通俗说就是接收窗口, 通告窗口.
该机制常用于流量控制, 详细信息请参考下面的流量控制部分.

(单纯的窗口, 可以简单理解成缓冲区, 为了处理丢包问题; 而谈滑动窗口, 则更多的是说通知窗口)

拥塞窗口

类似的, 这其实是发送方的说法, 在请求字节序中, 发送方维持一个拥塞窗口cwnd( congestion window )的状态变量, 也就是相当于说它是附加项字段.

拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化. 发送方让自己的发送窗口等于拥塞窗口, 通俗说, 就是发送窗口.

拥塞是怎么产生的?

  • 大通道->小通道, 即高速链路到低速链路, 是处理不及时了, 就开始延迟(超时)或者直接丢包.
    (此时通道可能就变长了, RTT值变大了)
  • 多条链路汇聚也会引起拥塞.

这里也提醒了我们:

tcp协议中, 发送方不仅仅要看对方接收能力, 还要注意网络的拥塞状况, 减少丢包重传的概率.

通知窗口(window size)是接收方窗口的控制, 真正需要滑动的是发送方的窗口(但谈到滑动窗口, 一般都是在说接收端窗口); 拥塞窗口(cwnd)则是主动控制发送方的窗口大小.
发送方, 取两者其小, 并且单次发送还不能超过MSS.

也就是说, 发送端, 发送的包, 先要满足MSS要求, 再满足通知窗口要求, 还要注意拥塞窗口的限制.

糊涂窗口

糊涂窗口综合征, 这是在TCP/IP详解中谈到的问题, 本质还是频繁通知小窗口的问题:
为了避免频繁告知对端窗口大小(比如说, 窗口变大了, 但是窗口大小还是很小), 这个时候应该告诉对端窗口为0, 否则就会引起小数据传输.
具体的说, 在以下情况才进行通告真实窗口大小:

  • 接收端
    • 不通告小窗口(接收方窗口小于一个MSS, 或者窗口大小小于接收方缓存空间的一半, 不论实际有多少), 这些情况下, 通告为0;
  • 发送端
    • 满足下列条件才发送数据(发送满MSS的数据报文, 对端通知的窗口为其最大窗口一半, 发送的数据全部经过确认了)
      (即对端已经收到了, 一般禁用nagle算法, 因为nagle算法允许未被确认时发数据包(虽然最多只有一个))

总之是, 不要频繁通知, 避免小包传送.

流量控制

所谓流量控制就是让发送方的发送速率不要太快,要让接收方来得及接收。如果发送方把数据发送得过快,接收方可能会来不及接收,这就会造成数据的丢失。

也就是说, 流量控制是指实现对发送方的流量控制. 实现手段就是上满说的滑动窗口.

设A向B发送数据, 在连接建立时,B告诉了A:“我的接收窗口是 rwnd = 400 ”(这里的 rwnd 表示 receiver window) 。因此,发送方的发送窗口不能超过接收方给出的接收窗口的数值。

TCP的窗口单位是字节,不是报文段

假设每一个报文段为100字节长,而数据报文段序号的初始值设为1。大写ACK表示首部中确认位ACK,小写ack表示确认字段的值ack

见下图: 动态调整窗口 & 丢失重发

从图中可以看出,B进行了三次流量控制。第一次把窗口减少到 rwnd = 300 ,第二次又减到了 rwnd = 100 ,最后减到 rwnd = 0 ,即不允许发送方再发送数据了。
这种使发送方暂停发送的状态将持续到主机B重新发出一个新的窗口值为止(实际上会有一个persisence timer启动计时, 时间到开始探测B窗口设置)。

TCP为每一个连接设有一个持续计时器(persistence timer)。只要TCP连接的一方收到对方的零窗口通知,就启动持续计时器。若持续计时器设置的时间到期,就发送一个零窗口控测报文段(携1字节的数据),那么收到这个报文段的一方就重新设置持续计时器。

B 向 A 发送的三个报文段都设置了 ACK = 1 ,只有在 ACK=1 时确认号字段才有意义。

严格来说, 流量控制, 算作拥塞控制的一部分; 因为网络处理能力不足时, 也会要求调整发送方的流量.

拥塞控制

网络拥塞现象是指到达通信子网中某一部分的分组数量过多,使得该部分网络来不及处理,以致引起这部分乃至整个网络性能下降的现象,严重时甚至会导致网络通信业务陷入停顿,即出现死锁现象。
拥塞控制是处理网络拥塞现象的一种机制: 通过增减单次发送量逐步调整,使之逼近当前网络的承载量。.

先接着上面的拥塞窗口讲. 拥塞控制, 总的来说, 就是一系列对于拥塞窗口的控制流程或者算法.
主要涉及慢启动, 拥塞避免, 快重传, 快恢复, 拥塞窗口, RTT等内容.

原则

但不管算法或者流程怎么变, 它总有一个原则:

  • 只要网络没有出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去
  • 只要网络出现拥塞,拥塞窗口就减小一些,以减少注入到网络中的分组数

可以类比科目一或者科目四考试的时候, 红绿灯路口阻塞了, 不提倡前行(避免加重阻塞)

理解了该原则, 下面的 慢启动, 拥塞避免, 快重传, 快恢复等, 都是非常容易理解的. 不过有些机制&算法会引起麻烦, 实际开发中并不一定使用.

这些手段综合作用, 对拥塞窗口的影响, 大概是这样的:

其中算法部分涉及到对拥塞窗口, 网络阈值的控制.

算法中 慢启动, 拥塞控制, 需要对超时,三次重复确认的理解, 可以简单认为是tcp的一种机制.

慢启动

又称慢开始算法, 其核心就是逐步增大探测, 适当调整, 遇到严重拥塞(超时响应)cnwd=1;

实质上是降低一开始就发送过多的数据到网络上(防止这种情况的丢包, 而不是刻意针对网络上的低速链路, 也就是它是一个主动策略)

当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么就有可能引起网络拥塞,因为现在并不清楚网络的负荷情况。
因此,较好的方法是 先探测一下,即由小到大逐渐增大发送窗口,也就是说,由小到大逐渐增大拥塞窗口数值。

通常在刚刚开始发送报文段时,先把拥塞窗口 cwnd 设置为一个最大报文段MSS的数值, 而在每收到一个对新的报文段的确认后,把拥塞窗口翻倍.
用这样的方法逐步增大发送方的拥塞窗口 cwnd ,可以使分组注入到网络的速率更加合理, 其过程简述如下图:

可以看到开始的时候, 是呈现出指数级增长(但是上图没有描述到拥塞的情况), 因为这样增长下去, 肯定会有一个点到达网络的传输极限,

此时立马把发送窗口的极限(即发送超时时拥塞窗口的大小), 即阈值, 设置为超时时的一半(作为新阈值, threshold), 然后重复翻倍过程.

这就是慢启动的整个过程, 总结如下:

  • 如果指数增长极限了, 那么下次指数极限的前半段还是保持指数增长的方式逐步加大发送窗口, 但是后半段用线程增长的方式加大窗口.
  • 慢启动算法, 拥塞窗口一定是从1开始的; 仅仅探测到网络极限以及之后开始的前半段, 后半段就是拥塞避免.

其实安装字面意思也能理解, 慢启动, 就是慢慢开始嘛.

这里注意下, 由于拥塞避免中也会对阈值进行折半, 所以这里强调一下: 当探测到超时, 阀值=单次发送量÷2,单次发送量=1
(可以看到上图中, 除了最开始, 每次超时也会再次重新进行慢启动, 同时如果接收端处理不了, 发送终止窗口也会停止发送)

慢开始门限ssthresh的用法如下:

  • 当 cwnd < ssthresh 时,使用上述的慢开始算法。
  • 当 cwnd > ssthresh 时,停止使用慢开始算法而改用拥塞避免算法。
  • 当 cwnd = ssthresh 时,既可使用慢开始算法,也可使用拥塞控制避免算法

一般可以认为, cwnd >= ssthresh 即可以采用拥塞避免算法(快速重传&快速恢复)

(降低数据进入网络的速率, 但是指数增加窗口, 之后遇到超时(即已经达到了最大窗口, 缓存量), 或者严重网络阻塞, 或者到达门限阈值(门限之后, cwnd减半, 增加放缓), 就采用拥塞避免算法; 一般之后再超时引起拥塞, 还是进入慢启动; 只是少数分组丢失则采用拥塞避免)

拥塞避免

其实上面阐述的线性增长的部分就是拥塞避免算法, 它包含了快速重传和快速恢复的场景(根据具体情况选用)

让拥塞窗口cwnd缓慢地增大,即每经过一个 往返时间RTT(即收到对方ACK)就把发送方的拥塞窗口cwnd加1,而不是加倍。
这样拥塞窗口cwnd按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。
(拥塞避免是已经丢包了(即已经发生拥塞了), 它来处理丢包, 即快速恢复+快速重传; 它发包的数量也能增加, 但是是线性增加, RTT内, 最大增加1; 即如果一次发了5个报文, 每收到一个报文, cwnd的大小增加1/5, 5个都回来才增加1; 快速恢复即cwnd不再像慢启动那样从1开始, 而是直接从sshold开始, 从门限开始(或者具体来说就是门限+3, 因为现在已经收到了三个确认, 这也要算在窗口之内)–门限通常在之前慢启动阶段已经设置为拥塞值&最初门限的一半了)

无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认),就要把慢开始门限ssthresh设置为出现拥塞时的发送方窗口值的一半(但不能小于2)。

  • 慢启动中, 把拥塞窗口cwnd重新设置为1,执行慢开始算法;
  • 拥塞避免中, 把阈值设置为此时最大值的一半, cwnd起点也是从改值开始(图中是 一半 + 3)

即下图部分:

可以看到, 收到3个重复确认(简单认为拥塞开始阶段), 阈值减半, cwnd从一半+3开始. 然后又会线性增加,直至下一次出现三次重复确认应答或超时。

简单说区别:

  • 应答超时 : 慢启动, cnwd=1开始, 之后根据ack响应, 指数级增长.
  • 出现三次重复确认 : 拥塞避免, cnwd从门限值开始, 之后线性增长.

应答超时, 三次重复确认都是什么?
上面说了, 这两个内容是用来区分不同情况下, 到底是 慢启动还是拥塞避免的其中一个根据, 但是由此也引来了快重传, 快恢复.

先说应答超时, 如果发送方的一个分组或者多个分组都丢失了, 我接收方, 催了, 催了好几遍, 你发送方还没有重传, 说明啥? 说明我的ack你很可能就没有收到. 说明丢失的分组已经很多了, 说明网络现在已经严重拥塞了, 例如发送序号为0、10、20、30、40的5条长度为10字节的分组,其中序号30的丢了,则返回的确认是10、20、30、30。这才只有两条重复确认。然而刚刚说过,单次发送量往往大于3,所以超时更可能是因为不止一条分组或确认丢失而引起的,这说明网络比上一情况中的更加繁忙。那么咋办? 重新慢启动呀.

然后三次重复确认, 这个是快重传算法的一个规定(具体规范见下面的部分), 但是出现这个说明, 已经出现分组丢失了, 例如:
发送序号为0、10、20、30、40的5条长度为10字节的分组,其中序号20的丢了,则返回的确认是10、20、20、20。3个20就是重复的确认。
这个时候, 其实说明, 网络有拥塞,但是不严重. 言下之意, 请减缓cnwd的增长速度, 即采用拥塞避免呀.

总结:

收到重复确认其实说明有丢包, 但是不是普遍现象, 因为网络还可以反馈回来, 所以采用慢启动处理丢包即可;
但是如果有超时, 即包的相应反馈都收不到, 说明可能对方连请求包都没有拿到, 说明丢包严重&网络严重拥塞, 此时就应该用慢启动.

btw: 实际上, 快重传&快恢复是为拥塞避免算法服务的.

快重传

快重传算法首先要求接收方每收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时才进行捎带确认。

收到三次重复确认应答,进入“快速重发”阶段, 参考例子如下:

接收方收到了M1和M2后都分别发出了确认。现在假定接收方没有收到M3但接着收到了M4。
显然,接收方不能确认M4,因为M4是收到的失序报文段。根据 可靠传输原理,接收方可以什么都不做,也可以在适当时机发送一次对M2的确认。

但按照快重传算法的规定,接收方应及时发送对M2的重复确认,这样做可以让发送方及早知道报文段M3没有到达接收方。发送方接着发送了M5和M6。
接收方收到这两个报文后,也还要再次发出对M2的重复确认。这样,发送方共收到了 接收方的四个对M2的确认,其中后三个都是重复确认。

快重传算法还规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段M3,而不必 继续等待M3设置的重传计时器到期。

实际上, 就是敦促发送方, 而不是让发送方在RTO结束时才知道, 哦, 有分组丢失了…

由于发送方尽早重传未被确认的报文段,因此采用快重传后可以使整个网络吞吐量提高约20%。

快恢复

减半, 然后线性增长, 可以认为就是快速恢复的核心.

其过程有以下两个要点:

  • 当发送方连续收到三个重复确认,就执行“拥塞避免”算法,把门限ssthresh减半
  • 与慢开始不同之处是现在不执行慢开始算法(即拥塞窗口cwnd现在不设置为1),而是把cwnd值设置为 慢开始门限ssthresh减半后的数值
    然后开始执行拥塞避免算法(“加法增大”,),使拥塞窗口缓慢地线性增大

实际上, 会有快恢复是历史原因:
原来在遇到三次重复确认时, 也是采用慢开始重新来的, 即cnwd=1重新增长, 而有了拥塞避免时, 发现可以不用从1开始, 具体如下图:

看到了两个版本, 现在采用的是新版本的拥塞避免.
快速重传”算法、”快速恢复”算法,避免了当网络拥塞不够严重时采用”慢启动”算法而造成过大地减小发送窗口尺寸的现象。
不必从1开始, 并且每次增长是+1:

当”旧”数据包离开网络后,才能发送”新”数据包进入网络,即同一时刻在网络中传输的数据包数量是恒定的。
如果发送方收到一个重复的ACK,则认为已经有一个数据包离开了网络,于是将拥塞窗口加1。

(快速恢复主要还是指, 三次确认后, cwdn为阈值或者阈值+3)

具体的计算方法可能是: (快速恢复)
cwnd = cwnd_old + segsize*segsize/cwnd_old + segsize/8;

简单的认为是线性增加即可.

总结:

  • 拥塞严重, 使用慢启动; 不太严重, 但有丢包重传可以采用拥塞避免;
  • 快速重传是丢包后对方催促三次引发的(同时引发拥塞避免); 快速恢复则应对了丢包不是那么严重的场景的增长问题.

特殊情况

对于ICMP异常通知, 门限和cwnd的变化和具体的内核协议栈实现有关, 一般情况下直接忽略主机不可达(连接断了), 网络不可达的情况(认为其实短暂现象).
而源站路由抑制差错, 则采用慢启动(cwnd=1)处理.

改进

实际上是对网络传输能力&带宽&RTT, 和对方通知窗口window size的设置优化.

慢启动指数增长 cwnd 时, 一般很快就超过 window size, 所以这个时候就是接收方的流量控制在限制了.
(当然如果没有再此之前就达到了网络的阈值(网络上的低速链路吃不消了), 即路由器开始丢包, 即检测到丢包或者超时, 那么就会采用拥塞避免或者快速重传等措施了)

为了最大程度利用传送带宽, 即保证能够一直发送(收到确认前), 最好把开始发送的 window size, 即发送窗口设置为整个通道的大小.
可以把它想象成一个长方形, 为了整体利用该通道(长方形), 应该把window size设置为: RTT * Bandwidth. (发送第一个报文到收到至少为RTT时间, 这个时间里可以一直发, 来沾满整个通道)

其他改进来自网络wiki, 参考连接已经在下面给出了, 下面是原文摘录:

对传统TCP拥塞控制机制的发展及改进
(1)对慢启动的改进  

  慢启动(slowstart)算法通过逐渐增加cwnd的大小来探测可用的网络容量,防止连接开始时采用不合适的发送量导致网络拥塞。然而有时该算法也会浪费可用的网络容量,因为慢启动算法总是从cwnd=l开始,每收到一个ACK,cwnd增加l,对RTT时间长的网络,为使cwnd达到一个合适的值,需要花很长的时间,特别是网络实际容量很大时,会造成浪费。为此可采用大的初始窗口,大的初始窗口避免了延迟ACK机制下单个报文段初始窗口的等待超时问题,缩短了小TCP流的传输时间和大延迟链路上的慢启动时间。

  在慢启动阶段,在每个RTT时间内,cwnd增加一倍,这样当cwnd增加到一定的值时,就可能导致以网络能够处理的最大容量的2倍来发送数据,从而淹没网络。Hoe建议使用packet-pair算法和测量RTT来为ssthresh估计合适值,以此来适时地结束慢启动阶段。但是由于受各方面干扰,估算合理的ssthresh值并不容易,因此这个方法的效果是有限的。而Smooth-start较为平滑地从慢启动过渡到拥塞避免阶段,减少了报文段丢失和突发通讯量,提高了TCP拥塞控制的性能。

  (2)对重传与恢复的改进

  为了避免不必要的重传超时,有人提出了一种受限传输机制:如果接收方的广播窗口允许的话,发送方接收到一个或者两个重复的ACK(acknowledgment)后,继续传输新的数据报文段。受限的传输机制允许具有较小窗口的TCP连接进行错误恢复,而且避免了不必要的重传。

  有很多情况下,数据报文段并没有丢失,但TCP发送方可能会误判数据报文段丢失,然后调用拥塞控制规程减少拥塞窗口的大小。比如当重传定时器过早溢出时,发送方在重传数据报文段时不必要地减少了拥塞窗口,而这时并没有数据报文段丢失。如果是由于数据报文段的重新组织而不是数据报文段丢失,而导致3个重复的确认,同样会导致发送方不必要地在快速重传数据报文段后减少拥塞窗口。

  如果TCP的发送方在重传数据报文段一个RTT后发现接收方接收到了重传数据报文段的两个拷贝,则可以推断重传是不必要的。这时,TCP的发送方可以撤销对拥塞窗口的减少。发送方可以通过将慢启动门限增加到原始值,调用慢启动规程使拥塞窗口恢复原先值。除了恢复拥塞窗口,TCP发送方还可以调整重复确认门限或者重传超时参数来避免由于多次不必要的重传而浪费带宽。

  (3)对公平性的改进

  在拥塞避免阶段,如果没有发生丢包事件,则TCP发送方的cwnd在每个RTT时间内大约可以增加一个报文段大小,但这样会造成具有不同RTT时间或窗口尺寸的多个连接在瓶颈处对带宽竞争的不公平性,RTT时间或窗口小的连接,相应的cwnd增长速度也相对缓慢,所以只能得到很小一部分带宽。

  要解决上述问题,可以通过在路由器处使用公平队列和TCP友好缓存管理来进行控制以增加公平性。然而如没有路由器的参与,要增加公平性,就要求TCP发送端的拥塞控制进行相应的改变,在拥塞避免阶段使共享同一资源的各个TCP连接以相同速度发送数据,从而确保了各个连接间的公平性。

Nagle算法

这里算作对拥塞控制的一点补充吧. 从问题入手谈谈 delayed ack 和 nagle algorithm.

概述(思想)

在网络拥塞控制领域,有一个非常有名的算法叫做Nagle算法(Nagle algorithm),这是使用它的发明人John Nagle的名字来命名的,John Nagle在1984年首次用这个算法来尝试解决福特汽车公司的网络拥塞问题(RFC 896)。
该问题的具体描述是:如果我们的应用程序一次产生1个字节的数据,而这个1个字节数据又以网络数据包的形式发送到远端服务器,那么就很容易导致网络由于太多的数据包而过载。比如,当用户使用Telnet连接到远程服务器时,每一次击键操作就会产生1个字节数据,进而发送出去一个数据包,所以,在典型情况下,传送一个只拥有1个字节有效数据的数据包,却要发费40个字节长包头(即ip头20字节+tcp头20字节)的额外开销,这种有效载荷(payload)利用率极其低下的情况被统称之为愚蠢窗口症候群(Silly Window Syndrome)。可以看到,这种情况对于轻负载的网络来说,可能还可以接受,但是对于重负载的网络而言,就极有可能承载不了而轻易的发生拥塞瘫痪。
针对上面提到的这个状况,Nagle算法的改进在于:如果发送端欲多次发送包含少量字符的数据包(一般情况下,后面统一称长度小于MSS的数据包为小包,与此相对,称长度等于MSS的数据包为大包,为了某些对比说明,还有中包,即长度比小包长,但又不足一个MSS的包),则发送端会先将第一个小包发送出去,而将后面到达的少量字符数据都缓存起来而不立即发送,直到收到接收端对前一个数据包报文段的ACK确认、或当前字符属于紧急数据,或者积攒到了一定数量的数据(比如缓存的字符数据已经达到数据包报文段的最大长度)等多种情况才将其组成一个较大的数据包发送出去。

通俗来说, 就是解决愚蠢的连续小包发送问题; 其实你完全可以应用层先把包组织好, 即在协议层面就优化好.

详述

TCP/IP协议中,无论发送多少数据,总是要在数据前面加上协议头,同时,对方接收到数据,也需要发送ACK表示确认。为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据。(一个连接会设置MSS参数,因此,TCP/IP希望每次都能够以MSS尺寸的数据块来发送数据)。Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。基本定义是: 任意时刻,最多只能有一个未被确认的小段。
所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。

举个例子,一开始client端调用socket的write操作将一个int型数据(称为A块)写入到网络中,由于此时连接是空闲的(也就是说还没有未被确认的小段),因此这个int型数据会被马上发送到server端,接着,client端又调用write操作写入一个int型数据(简称B块),这个时候,A块的ACK没有返回,所以可以认为已经存在了一个未被确认的小段,所以B块没有立即被发送,一直等待A块的ACK收到(大概40ms之后),B块才被发送。

但是, 第一次write之后,因为no unconfirmed data in pipe, 所以包直接发送出去了,随后server返回response,client读取之后第二次write,同样,包正常出去。因此,设置TCP_NODELAY和没设置,在我们的模式里是没有任何影响的。(注意区别)

这里, 就由 nagle 算法保证了, 仅有一个未确认的小块儿.

btw: 40ms是什么?(有说是200ms)
因为TCP/IP中不仅仅有Nagle算法(Nagle‘s Algorithm),还有一个ACK延迟机制(TCP Delayed Ack) 。当Server端收到数据之后,它并不会马上向client端发送ACK,而是会将ACK的发送延迟一段时间(假设为t),它希望在t时间内server端会向client端发送应答数据,这样ACK就能够和应答数据一起发送,就像是应答数据捎带着ACK过去。

意思说, 服务端最好不仅仅回复给客户端说我收到了, 最好把处理完的数据一起带回来呀

也就是如果一个 TCP 连接的一端启用了Nagle算法,而另一端启用了ACK延时机制,而发送的数据包又比较小,则可能会出现这样的情况:发送端在等待接收端对上一个packet的Ack才发送当前的packet,而接收端则正好延迟了此Ack的发送,那么这个正要被发送的packet就会同样被延迟。

当然Delayed Ack是有个超时机制的,而默认的超时正好就是40ms。 具体是40ms还是200ms需要根据内核协议栈确定(即操作系统相关).
相关内容不多阐述, 可以参考Winsock 200ms delay issue:
TCP will ACK every second packet immediately.(第二个packet,指连续收到两个数据包中的第二个)

回到nagle算法和delayed ack:

这边等你回复, 然后再发; 另一边偏偏延迟回复. 延迟增大了, 却没有提供吞吐量, 真的是太揪心了.

现代的 TCP/IP 协议栈实现,默认几乎都启用了这两个功能,那岂不每次都会触发这个延迟问题?事实不是那样的。
仅当协议的交互是发送端连续发送两个packet,然后立刻read的时候才会出现问题。
(上面遇到的问题, 最后的那个一个小包传输, 就被nagle算法和ack delayed搞的延迟发送了, 导致吞吐量下降)

此外delayed ack能保证先收到的包,先响应.(这在滑动窗口机制中强调过, 无需等待确认即可再发送)

总结:
delayed ack(确认收到的同时也想办法把响应数据一起传递回去, 明显的就是拿延迟换吞吐量, 减轻服务器压力; 最多等200ms),
nagle算法(一个TCP连接上, 最多只能有一个未被确认的小分组; 也就是严格控制了不断发送小包的情况, 积攒够了字节, 再发送或者另外一个情况发一个收到了确认, 再发第二个, 这样确保了发送时序; 但是该算法通常引起一些问题(比如延迟性要求比较高的系统, 要求你快速发送; 比如交互式响应的操作中, 先发送指令的第一个序列, 但是服务器收到又不立即回复, 又等了200ms, 才回复ack, 严重响应交互体验), 所以一般会设置参数禁用)

解决

1.优化协议
连续 write 小数据包,然后 read , 即write+read+write+read其实是一个不好的网络编程模式.

连续 write 其实应该在应用层合并成一次 write.

2.开启TCP_NODELAY
简单地说,这个选项的作用就是禁用Nagle算法,禁止后当然就不会有它引起的一系列问题了。使用setsockopt可以做到:

1
2
3
4
static void _set_tcp_nodelay(int fd) {  
int enable = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void*)&enable, sizeof(enable));
}

为什么禁用?
nagel算法有点儿类似udp中的TFTP协议, 非确认不发送, 但是传送效率还是太差了.

对于大数据, 这里用的是滑动窗口机制, 即没有收到回复前, 可以连续发送窗口大小的内容(提高了发送量).
之所以可以这样做是因为采用了窗口做缓存, 内容在缓存里面存有备份, 丢失了可以重传, 并且只有收到确认才从缓存队列里清除.
(它的确认机制也是保证滑动窗口可以实时的重要原因, 只有当收到了才会增加确认ack序列号;)
并且有时候也会采用”隔一个报文段确认” (一次性确认俩或者更多, 中间没有确认的, 用ack序列号推断; 因为没收到ack序列号不会增加的)

并且同样的报文, 可能由于发送方接收方代码, 网络拥塞程度等, 接收的时序也不一样.(有的包就是被延迟了很久才收到)

常用编程选项

可以直接查看 man 7 tcp, 这里拿出最常用的来说一说.

主要有:

  • TCP_CORK
  • TCP_NODELAY
  • TCP_DEFER_ACCEPT
  • TCP_KEEPCNT / TCP_KEEPIDLE / TCP_KEEPINTVL

TCP_NODELAY 和 TCP_CHORK选项 :
TCP_NODELAYTCP_CORK基本上控制了包的“Nagle化”,Nagle化在这里的含义是采用Nagle算法把较小的包组装为更大的帧。
TCP_NODELAY和TCP_CORK都禁掉了Nagle算法,只不过他们的行为不同而已。
TCP_NODELAY 不使用Nagle算法,不会将小包进行拼接成大包再进行发送,直接将小包发送出去,会使得小包时候用户体验非常好。
TCP_CORK: 当在传送大量数据的时候,为了提高TCP发送效率,可以设置TCP_CORK,CORK顾名思义,就是”塞子”的意思:
它会尽量在每次发送最大的数据量。当设置了TCP_CORK后,会有延迟200ms,当阻塞时间过后,数据就会自动传送。

TCP_DEFER_ACCEPT选项:
defer accept,从字面上理解是推迟accept,实际上是当接收到第一个数据之后,才会创建连接,三次握手完成,连接还没有建立。
对于像HTTP等非交互式的服务器,这个很有意义,可以用来防御空连接攻击(只是建立连接,但是不发送任何数据)。
使用方法如下:

1
2
val = 5;
setsockopt(srv_socket->fd, SOL_TCP, TCP_DEFER_ACCEPT, &val, sizeof(val));

里面 val 的单位是秒,注意如果打开这个功能,kernel 在 val 秒之内还没有收到数据,不会继续唤醒进程,而是直接丢弃连接。
如果服务器设置 TCP_DEFER_ACCEPT 选项后,服务器受到一个CONNECT请求后,三次握手之后,新的socket状态依然为SYN_RECV,而不是ESTABLISHED,操作系统不会Accept。
由于设置TCP_DEFER_ACCEPT选项之后,三次握手后状态没有达到ESTABLISHED,而是SYN_RECV。
这个时候,如果客户端一直没有发送”数据”报文,服务器将重传SYN/ACK报文,重传次数受net.ipv4.tcp_synack_retries参数控制,
达到重传次数之后,才会再次进行setsockopt中设置的超时值,因此会出现SYN_RECV生存时间比设置值大一些的情况。

SO_KEEPALIVE选项 :
通常这个选型关联: SO_KEEPALIVE, TCP_KEEPCNT, TCP_KEEPIDLE, TCP_KEEPINTVL

如果一方已经关闭或异常终止连接,而另一方却不知道,我们将这样的TCP连接称为半打开的, TCP通过保活定时器(KeepAlive)来检测半打开连接.

在高并发的网络服务器中,经常会出现漏掉socket的情况,对应的结果有一种情况就是出现大量的CLOSE_WAIT状态的连接(确认对端已经关闭了).
这个时候,可以通过设置 KEEPALIVE 选项来解决这个问题,当然还有其他的方法可以解决这个问题.

使用方法如下:

1
2
3
4
5
6
7
8
9
//Setting For KeepAlive
int keepalive = 1;
setsockopt(incomingsock,SOL_SOCKET,SO_KEEPALIVE,(void*)(&keepalive),(socklen_t)sizeof(keepalive));
int keepalive_time = 30;
setsockopt(incomingsock, IPPROTO_TCP, TCP_KEEPIDLE,(void*)(&keepalive_time),(socklen_t)sizeof(keepalive_time));
int keepalive_intvl = 3;
setsockopt(incomingsock, IPPROTO_TCP, TCP_KEEPINTVL,(void*)(&keepalive_intvl),(socklen_t)sizeof(keepalive_intvl));
int keepalive_probes= 3;
setsockopt(incomingsock, IPPROTO_TCP, TCP_KEEPCNT,(void*)(&keepalive_probes),(socklen_t)sizeof(keepalive_probes));

设置SO_KEEPALIVE选项来开启KEEPALIVE,然后通过TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT设置keepalive的开始时间、间隔、次数等参数。

当然,也可以通过设置 /proc/sys/net/ipv4/tcp_keepalive_timetcp_keepalive_intvltcp_keepalive_probes等内核参数来达到目的,
但是这样的话,会影响所有的socket,因此建议使用setsockopt设置。

TCP的未来

W.Richard Stevens的《TCP/IP详解》说的 TCP/IP的未来已经到来了.

  • TCP在高速环境下获得最大吞吐量 (一般总是大的数据包发送效率高; 但是考虑到跳数和带宽, 即空闲跳的等待时间, 发送效率上未必是大的包比较好)
    有人在做过实验, 当丢包率在5%时, 网络的吞吐量, 即传输性能会降低50%左右. (难以想象的恐怖, 并且是恶性循环)
  • TCP在高延迟带宽网络上的问题 (`bandwidth rtt`比较大, 即使最大窗口, 即TCP最大报文数65535字节, 可能也是比不上的, 这个时候可以考虑多个TCP连接或者窗口扩大选项, 即移位扩倍)
    RTT同时在多个连接上测量(借用时间戳). 如果带宽*RTT比窗口还大, 即吞吐率(最大传出速度)超过了处理速度, 也是浪费网络. 这个时候会把减少延迟, 适当限制吞吐率.
    窗口大小, 带宽, 都可能是瓶颈. (存储单位和传输速率单位是约8倍的关系, 不谈传输开销和分包等, 还要注意1000和1024的关系)
  • T/TCP 事务功能
    扩展TCP, 减少建立/终止的开销, 最好一个请求一个应答, 并且可以检测重复请求.
    TCP本身已经提供了很多可靠措施保证, 但是开销又太大, 如果用UDP, 则很多保证(超时, 重传, 拥塞避免)都需要在应用层重新做(重复时间). 这个时候最好需要一个能够处理足够多事物功能的运输层T/TCP.

加速三次握手的过程: 以前没有建立连接的还是进行三次握手, 然后传输; 如果之前已经建立过了连接, 那么直接通信数据交换(可以匹配是同一次连接).
(有点儿cookie和session的意思, 客户端带过去 cookie, 服务端这边缓存的session进行匹配查看是否之前已经建立过了连接)

实际上这个就是通过减少通信次数, 把原来可能需要三次握手, 4次分手的过程, 通过在更多的报文信息在一次连接过程中完成.
例如客户端过去的时候, 会携带客户端syn, 客户端数据, 客户端的fin, 客户端的cc(这个用来匹配是否是同一次连接); 而服务器相应的时候, 也是一次性全部相应这些东西(cc的响应就是回显); 之后客户端在进行服务端fin和syn确认. 丢包了, 仍旧可以利用重传机制. 本质上是减少通信次数, 然后携带更多信息. (这也是高速网络的好处, 也就是TCP的今天)
这是对于TCP事务性(即同一个连接任务)的最小修改(毕竟以前的超时重传, 拥塞避免它还是保留的)

缩短TIME_WAIT的时间:
这个以前谈过, 就是解决短时间内频繁大量小链接的影响, 让连接可以快速重用.

参考

  1. 《unp 卷1》
  2. 《Linux系统编程》
  3. 《unix高级编程》
  4. 《图解tcp-ip》
  5. 《TCP/IP协议详解》 卷1
  6. 《高效TCP/IP编程》
  7. http://wiki.mbalib.com/wiki/%E6%8B%A5%E5%A1%9E%E6%8E%A7%E5%88%B6 对传统TCP拥塞控制机制的发展及改进
  8. http://blog.csdn.net/dog250/article/details/53013410
  9. http://blog.csdn.net/yunhua_lee/article/details/8146830
  10. http://blog.csdn.net/hyman_yx/article/details/52065418
  11. http://www.choudan.net/2014/09/12/%E7%BD%91%E7%BB%9C%E5%AD%A6%E4%B9%A0%E7%82%B9%E6%BB%B4-%E4%B8%80.html delayed ack问题
  12. http://www.jianshu.com/p/d9edbba4035b
  13. http://blog.csdn.net/xautgaozhe/article/details/48242381
文章目录
  1. 1. 概览
  2. 2. 正文
    1. 2.1. 协议本身
    2. 2.2. 套接字模型
    3. 2.3. 连接管理
    4. 2.4. 2MSL
    5. 2.5. 平静时间
      1. 2.5.1. TIME_WAIT过多
    6. 2.6. RTO
    7. 2.7. 状态流转图(重要)
    8. 2.8. 7种计时器
    9. 2.9. 心跳保活
    10. 2.10. 窗口
      1. 2.10.1. TCP窗口
      2. 2.10.2. 滑动窗口
      3. 2.10.3. 拥塞窗口
      4. 2.10.4. 糊涂窗口
    11. 2.11. 流量控制
    12. 2.12. 拥塞控制
      1. 2.12.1. 原则
      2. 2.12.2. 慢启动
      3. 2.12.3. 拥塞避免
      4. 2.12.4. 快重传
      5. 2.12.5. 快恢复
      6. 2.12.6. 特殊情况
      7. 2.12.7. 改进
    13. 2.13. Nagle算法
      1. 2.13.1. 概述(思想)
      2. 2.13.2. 详述
      3. 2.13.3. 解决
    14. 2.14. 常用编程选项
    15. 2.15. TCP的未来
  3. 3. 参考
|