This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="07 | 性能好,效率高的一对多通讯该如何实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/99/b9c73eae64c2fc4965eb3496960b5699.mp3"></audio>
你好,我是陶辉。从这一讲开始,我们将从单机进入网络层面的性能优化。
我们接触过的绝大多数通讯方式无论是面向连接的HTTP协议还是无连接的DNS协议都是一对一收发消息的。其实除了一对一还有一对多的通讯方式它在网络资源的利用上效率要比一对一高得多。这种一对多的通讯方式在局域网中有很广泛的应用常见的ARP欺骗、泛洪攻击等都是通过一对多通讯进行的。
当应用场景中用一对多代替一对一通讯时发送方性能会获得很大的提升整个局域网的效率也会提高。比如源主机的带宽只有1Gbps如果采用一对一的方式向100个客户端发送流媒体这100台主机的带宽之和不会超过1Gbps。但采用一对多传输时总带宽就可以达到100Gbps。
除了能提升性能以外,由于一对多通讯可同时向所有主机发送消息,这就在功能层面上可以替换许多人工操作。比如分布式系统的服务发现,使用人工配置既容易出错,速度也慢,而用广播就可以轻松实现自动化服务发现。
一对多通讯协议一直在发展在运营商的IPTV网络的视频直播中它就得到了广泛的应用。即使你暂时不会用到一对多这种方式也应当了解下它是怎么工作的熟悉它的工作原理后还能更深入地理解一对一通讯协议。
这一讲,我们就来学习如何实现一对多通讯。
## 广播是怎么实现的?
一对多通讯分为两种:对局域网内所有主机发送消息的叫做**广播**,而对部分主机发送消息的,则叫做**组播**。我们先来看一下广播是怎么实现的。
使用广播要改用UDP协议。可能你会问为什么不能使用最熟悉的TCP协议呢这要从TCP协议的分层说起。
1978年在TCP协议迭代了3个版本后才被Jon PostelIANA创始人提出违反了网络分层原则网络层和传输层耦合在一起很难扩展。于是在TCP的第4个迭代版本中把协议一分为二包括网络层IP协议和传输层TCP协议这也是今天的IP协议被称为IPv4的原因
<img src="https://static001.geekbang.org/resource/image/95/02/95d8fe24b6343fef34579f240696dd02.jpg" alt="">
当你访问Internet站点时IP协议会将数据通过网络设备穿越多个卫星、光纤等网络才能送到服务器。而网络设备天然就拥有广播能力当它在一个网络端口上收到主机发来的报文时可以向其他端口上的所有主机重发一遍这就是广播如下图所示
<img src="https://static001.geekbang.org/resource/image/ce/f0/ce45d52f5c87bcae9c4adae8056c21f0.jpg" alt="">
**虽然IP协议已经具有广播功能但实际编程中并不会直接使用IP协议发送广播因为它很难与进程关联起来。**
根据网络分层模型上层协议可以使用下层协议的功能所以传输层协议拥有IP协议的广播能力。同时传输层通过端口号把网络报文和进程关联在了一起就像TCP的80端口把HTTP消息与Nginx等Web Server关联在一起。
<img src="https://static001.geekbang.org/resource/image/13/ac/13d968cbacd229f242764557957d3bac.jpg" alt="">
然而传输层的TCP协议为了保证可靠性建立了逻辑上的连接概念由于一个连接上只能有两方所以TCP无法进行一对多通讯。而传输层的UDP协议无需建立连接所以我们常用UDP协议发送广播。
广播的性能高有两个原因:首先,交换机直接转发给接收方,要比从发送方到接收方的传输路径更短。其次,原本需要发送方复制多份报文再逐一发送至各个接受者的工作,被交换机完成了,这既分担了发送方的负载,也充分使用了整个网络的带宽。
那么交换机收到消息后怎么知道这是广播报文并转发给整个网络呢我们知道以太网中的数据链路层通过硬件的MAC地址来传播消息交换机就通过报文的MAC地址来确定是否需要广播。**当交换机收到目标MAC地址是ff:ff:ff:ff:ff:ff的报文时便知道这是一个广播报文**才会将它转发给局域网中的所有主机否则只会转发给MAC地址对应端口上的主机。
不过我们写代码时无法控制底层的MAC地址只能填写目标IP地址。什么样的目标IP地址会生成广播MAC地址呢**如果只是对所在子网进行广播那么使用受限广播地址255.255.255.255就可以了;如果局域网划分了多个子网,主机需要向其他子网广播,则需要正确地设置直接广播地址(路由器需要打开直接广播功能)。**
## 如何正确地设置直接广播IP地址
怎么设置直接广播的IP地址呢我们首先得了解IP地址的构成。
由于IP协议需要跨越多个网络工作所以IP地址被一分为二包括前边的网络ID和后边的主机ID其中**网络ID用于不同网络间的寻址而主机ID则用于在本地局域网内通讯。**
举个例子如果你的局域网IP地址是192.168.0.101那么网络ID就是192.168.0而主机ID则是101这里假定网络管理员没有继续划分C类子网
这是因为以192.168打头的IP地址被称为C类地址而C类地址中最后1个十进制数字代表主机ID。如果IP地址是172.16.20.227这就是B类地址此时172.16是网络ID而20.227才是主机ID。
所以IP地址的前缀数字不同主机ID的划分位置也不同。事实上IP地址一共被划分为A、B、C、D、E5个类别它的划分依据正是IP地址转换为二进制后用前4个比特位作为依据的。如果第1个比特位为0这就是A类地址它的网络ID是IP地址的第1到第8比特位而主机ID则是随后的24个比特位。如果局域网IP地址第1个数字是10这就是A类私有地址局域网中的地址不能在公网中使用统称为私有地址或者内网地址
<img src="https://static001.geekbang.org/resource/image/4f/e1/4f9c58b60404e2f874802b6a2ec207e1.jpg" alt="">
类似的若前2个比特位是10则是B类地址它的主机ID是后16位如果前3个比特位为110这就是C类地址它的主机ID是后8位。显然以192打头的地址前3位正是110所以这是C类地址。
<img src="https://static001.geekbang.org/resource/image/d6/5e/d67b7a52601f80027576c866cf80e25e.jpg" alt="">
此外还有D类组播地址和E类预留实验地址。
清楚了划分方法后再来看如何通过修改主机ID将IP地址改为直接广播地址。如果你留心观察IP地址会发现主机ID不会出现全0和全1这两种情况这是因为全0和全1有特殊用途其中全0特指它自己所以0.0.0.0可以指代本机IP而全1表示全部主机。
**所以主机ID的比特位全部设为1后就是广播地址。**比如192.168.0.101是C类地址把主机ID从101改为255后就可以用192.168.0.255发送广播了。
然而事情到这并没有完。一个A类网络可以容纳千万台主机B类网络则只能容纳6万多台主机C类网络则最多容纳254台主机。仅有的三类网络中主机数量差距太大而世界上存在各种规模的企业它们所需网络中的主机规模千差万别上述划分方式太过单一无法满足各类企业的需求**于是诞生了CIDR这种新的划分方式它通过子网掩码或者叫Netmask可以在任意的位置将IP地址拆分为网络ID和主机ID扩展了A、B、C三类网络的用法。**
当你查看主机的IP地址时就会看到其后跟着一个类似IP地址的子网掩码。子网掩码必须把它展开成二进制才能使用这样掩码前N位为1时就表示IP地址的前N位是网络ID而掩码后面剩余的位全是0表示IP地址对应的位是主机ID。
比如若192.168.0.101的子网掩码是255.255.255.192就表示IP地址的前26位是网络ID后6位是主机ID将主机ID置为全1后就得到了它的广播地址192.168.0.127,如下图所示:
<img src="https://static001.geekbang.org/resource/image/65/08/6586d4ec875f63b19993b78c7a11e808.jpg" alt="">
到这里我们设置好IP地址后再把socket句柄设置SO_BROADCAST属性就可以发送广播了。广播虽然有很多优点可是一旦被滥用很容易产生网络风暴所以路由器默认是不转发广播报文的。
## 用更精准的组播来做服务发现
当你用UDP广播来做分布式系统的服务发现会遇到这样一个问题若并非网络内的所有主机都属于分布式系统那么当指定了端口的UDP广播报文到达其他主机时会怎么样呢这些广播报文在这3个步骤后会被丢弃
- 第1步网卡设备收到报文后查看报文中的目标MAC地址是否与本机的MAC地址匹配如果不匹配就会丢弃。广播MAC地址默认匹配继续交由上层的IP协议栈处理
- 第2步IP协议栈查看目标IP地址是否为本机IP地址不匹配也会丢弃报文。上文介绍过的广播IP地址同样默认匹配交由传输层协议继续处理。
- 第3步传输层检查目标端口是否有进程在监听如果没有则丢弃报文反之则交付给进程处理。不属于集群的主机自然不会启动服务监听端口在这一步才会丢弃广播报文。
<img src="https://static001.geekbang.org/resource/image/1c/b3/1c8b6032474debdd2a4d4569a1752ab3.jpg" alt="">
可见对于不属于分布式集群的主机而言广播报文既占用了它们的带宽这3步协议栈的操作也消耗了CPU的计算力。有什么办法能缩小广播的范围消除它加在无关主机上的负载呢
组播可以做到。组播是一种“定向广播”它设定了一个虚拟组用组播IP来标识。这个虚拟组中可以包含多个主机的IP当向对应的组播IP发送消息时仅在这个组内的主机才能收到消息。
组播IP与常见的单播IP不同它是前文介绍过5类IP地址中的D类地址32位IP地址的前4位必须是1110因此组播IP地址的范围是从224.0.0.0到239.255.255.255。
当设置好组播IP地址后还要通过管理组播地址的IGMP协议Internet Group Management Protocol将主机IP地址添加进虚拟组中。编程语言提供的setsockopt函数就可以操作IGMP协议管理组播地址。比如使用参数IP_ADD_MEMBERSHIP就能够向虚拟组中增加IP而IP_DROP_MEMBERSHIP则可以从组中去除某个主机的IP。
[这里](https://github.com/russelltao/geektime-webprotocol/tree/master/python%E7%A4%BA%E4%BE%8B%E4%BB%A3%E7%A0%81)有一个可运行的python测试代码供你参考。如果你想进一步了解组播的细节可以观看[《Web协议详解与抓包实战》第117课](https://time.geekbang.org/course/detail/175-134405)。
组播相对于广播而言 除了能够更精准的管理组播范围还能够跨越多个网络工作。当然如果将多个网络中的IP加入同一虚拟组时需要涉及到的路由器都可以正确地处理这些IP地址且都能支持IGMP协议。
## 小结
最后我们对这一讲做一个总结。
由于一对多通讯能够充分利用整体网络的性能,而且通过交换机能够同时向许多主机发送消息,所以在局域网内有广泛的应用。
在TCP协议分层后IP协议天然就支持一对多通讯方式。TCP协议面向连接的特性使它放弃了一对多的通讯方式而UDP协议则继承了IP协议的这一功能。所以在一对多通讯场景中我们会选择UDP协议。
正确输入广播地址的前提是理解IP地址如何划分为网络ID和主机ID。当主机ID所有的比特位改为全1时IP地址就表示该网络下的所有主机这就是广播地址。当向广播地址发送UDP消息时网络中的所有主机都会收到。广播在局域网中有广泛的应用转换IP地址与MAC地址的ARP协议就是用广播实现的。
广播对无关的主机增加了不必要的负担而组播可以更精准地“定向”广播。组播地址也被称为D类地址它描述的虚拟组要通过IGMP协议管理。网络API中的setsockopt函数可以通过IGMP协议向虚拟组中添加或者删除IP地址。当路由器支持IGMP协议时组播就可以跨越多个网络实现更广泛的一对多通讯。
广播和组播能够充分地使用全网带宽,也通过交换机等网络设备分散了发送主机的负载。但它很难对每台接收主机提供定制化服务,这样可靠传输就很难实现。这使得它们在更关注及时性、对丢包不敏感的流媒体直播中更有应用前景。
这一讲我们介绍了许多网络概念,这些也是理解后续内容的基础。从下一讲开始,我们将进入更复杂的一对一通讯协议。
## 思考题
最后,请你思考下,你使用或者了解过哪些一对多的通讯协议?它们的优缺点,以及未来的发展方向又是什么?欢迎你留言与我探讨。
感谢阅读,如果你觉得今天学习的内容对你有帮助,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,102 @@
<audio id="audio" title="08 | 事件驱动C10M是如何实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/6a/0550ddef1be2a819b2704e790c3e916a.mp3"></audio>
你好,我是陶辉。
上一讲介绍了广播与组播这种一对多通讯方式,从这一讲开始,我们回到主流的一对一通讯方式。
早些年我们谈到高并发总是会提到C10K这是指服务器同时处理1万个TCP连接。随着服务器性能的提升近来我们更希望单台服务器的并发能力可以达到C10M也就是同时可以处理1千万个TCP连接。从C10K到C10M实现技术并没有本质变化都是用事件驱动和异步开发实现的。[[第5讲]](https://time.geekbang.org/column/article/233629) 介绍过的协程,也是依赖这二者实现高并发的。
做过异步开发的同学都知道处理基于TCP的应用层协议时一个请求的处理代码必须被拆分到多个回调函数中由异步框架在相应的事件生成时调用它们。这就是事件驱动方式它通过减少上下文切换次数实现了C10M级别的高并发。
不过做应用开发的同学往往不清楚什么叫做“事件”不了解处理HTTP请求的回调函数与事件间的关系。这样在高并发下当多个HTTP请求争抢执行时涉及资源分配、释放等重要工作的回调函数就可能在错误的时间被调用进而引发一系列问题。比如不同的回调函数对应不同的事件如果某个函数执行时间过长就会影响其他请求可能导致大量请求出现超时而处理失败。
这一讲我们就来介绍一下事件是怎样产生的它是如何驱动请求执行的多路复用技术是怎样协助实现异步开发的理解了这些你也就明白了这种事件驱动的解决方案知道了怎么样实现C10M。
## 事件是怎么产生的?
要了解“事件驱动”的运作机制,首先就要搞清楚到底什么是事件。这就需要你对网络原理有深入的理解了。
简单来说从网络中接收到一个报文就可能产生一个事件。如上一讲介绍过的UDP请求就是最简单的例子一个UDP请求通常仅由一个网络报文组成所以当收到一个UDP报文就意味着收到一个请求它会生成一个事件进而触发回调函数执行。
不过常见的HTTP等协议都是基于TCP实现的。由于TCP是一种面向字节流的协议HTTP请求的大小并不受限制当一个HTTP请求的大小超过TCP报文的最大长度时请求会被拆分到多个报文中运输在接收端的缓冲区中重组、排序。因此并不是每个到达的TCP报文都能生成事件的。
如果不理解事件和TCP报文的关系就没法准确地掌握处理HTTP请求的函数何时被调用。当然作为应用开发工程师我们无须在意实现细节只要了解TCP连接建立、关闭以及消息的发送和接收这四个场景中报文与事件间的关系就可以了。
事件并没有你想象中那么复杂它只有两种类型读事件与写事件其中读事件表示有到达的消息需要处理而写事件表示可以发送消息TCP连接的写缓冲区中有可用空间。我们先从三次握手建立连接说起这一过程会产生一读、一写两个事件。
由于TCP允许双向传输所以**建立连接时,会依次在连接的两个方向上建立通道。**主动发起连接的一方叫做客户端,被动监听端口等待连接的一方叫做服务器。
客户端首先发送SYN报文给服务器而服务器收到后回复ACK和SYN这里我们只需要知道产生事件的过程即可下一讲会详细介绍这两个报文的含义**当它们到达客户端时,双向连接中由客户端到服务器的通道就建立好了,此时客户端就已经可以发送请求了,因此客户端会产生写事件。**接着,**客户端发送ACK报文到达服务器后服务器上会产生读事件**因为进程原本在监听80等端口此时有新连接建立成功应当调用accept函数读取这个连接所以这是一个读事件。
<img src="https://static001.geekbang.org/resource/image/73/98/73b9d890c7087531b51180ada6e65f98.png" alt="">
在建立好的TCP连接上收发消息时读事件对应着接收到对方的消息这很好理解。写事件则稍微复杂些我们举个例子加以说明。假设要发送一个2MB的请求**当调用write函数发送时会先把内存中的数据拷贝到写缓冲区中后再发送到网卡上。**
为何要多此一举呢这是因为在对方没有明确表示收到前TCP会通过定时器重发写缓冲区中的数据保证消息能够到达对方。写缓冲区是有大小限制的我在[第10讲]中会详细介绍。这里假设写缓冲区只有1MB所以调用write发送2MB数据时write函数的返回值只有1MB表示写缓冲区已用尽。当收到对方发来的ACK报文后缓冲区中的数据才能释放就会产生写事件通知进程发送剩余的那1MB数据。
<img src="https://static001.geekbang.org/resource/image/c5/7a/c524965bee6407bd716c7dc33bdd437a.png" alt="">
如同建立连接需要双向建立一样关闭连接也需要双方各自关闭每个方向的通道。主动关闭的一方发送FIN报文到达被动方后内核自动回复ACK报文这表示从主动方到被动方的通道已经关闭。**但被动方到主动方的通道也需要关闭所以此时被动方会产生读事件提醒被动方调用close函数关闭连接。**
<img src="https://static001.geekbang.org/resource/image/b7/96/b73164fd504cc2574066f526ebee7596.png" alt="">
这样我们就清楚了TCP报文如何产生事件也明白回调函数何时执行了。然而同步代码拆分成多个异步函数成本并不低咱们手里拿着事件驱动这个锤子可不能看到什么都像是钉子。
什么样的代码值得基于事件来做拆分呢还得回到高性能这个最终目标上来。我们知道做性能优化一定要找出性能瓶颈针对瓶颈做优化性价比才最高。对于服务器来说对最慢的操作做异步化改造才能值回开发效率的损失。而服务里对资源的操作速度由快到慢依次是CPU、内存、磁盘和网络。CPU和内存的执行速度都是纳秒级的无须考虑事件驱动而磁盘和网络都可以采用事件驱动的异步方式处理。
相对而言网络不只速度慢而且波动很大既受制于连接对端的性能也受制于网络传输路径。把操作网络的同步API改为事件驱动的异步API收益最大。而磁盘特别是机械硬盘访问速度虽然不快但它最慢时也不过几十毫秒是可控的。而且目前磁盘异步IO技术参见[[第4讲]](https://time.geekbang.org/column/article/232676)还不成熟它绕过了PageCache性能损失很大。所以当下的事件驱动主要就是指网络事件。
## 该怎样处理网络事件?
有了网络事件的概念后,我们再来看用户态代码如何处理事件。
网络事件是由内核产生的进程该怎样获取到它们呢如epoll这样的多路复用技术可以帮我们做到。多路复用是通讯领域的词汇有些抽象但原理确很简单。
比如一条高速的光纤上允许多个用户用较低的网速同时通讯这就是多路复用。同样道理一个进程虽然任一时刻只能处理一个请求但处理每个请求产生的事件时若耗时控制在1毫秒以内这样1秒钟就可以处理数千个请求从更长的时间维度上看多个请求复用了一个进程也叫做多路复用或者叫做时分多路复用。我们熟知的epoll就是内核提供给用户态的多路复用接口进程可以通过它从内核中获取事件。
epoll是如何获取网络事件的呢最简单的方法就是在获取事件时把所有并发连接传给内核再由内核返回产生了事件的连接再处理这些连接对应的请求即可。epoll前的select等多路复用函数就是这么干的。
然而C10M意味着有一千万个连接若每个socket是4字节那么1千万连接就是40M字节。这样每收集一次事件就需要从用户态复制40M字节到内核态。而且高性能Server必须及时地处理网络事件所以每隔几十毫秒就要收集一次事件性能消耗巨大。
epoll为了降低性能消耗把获取事件拆分成两步。
- 第一步把需要监控的socket传给内核epoll_ctl函数它仅在连接建立等有限的时机调用
- 第二步收集事件epoll_wait函数便不用传递socket了这样就把socket的重复传递改为了一次传递降低了性能损耗。
由于网卡的处理能力有限千兆网卡下每秒只能接收100MB左右的数据如果每个请求约10KB那么每秒大概有1万个请求到达、10万个事件需要处理。这样即使每隔100毫秒收集一次事件调用epoll_wait每次也不过只有1万个事件100000 Event/s * 0.1s = 10000 Event/s需要处理只要保证处理一个事件的平均时间小于10微秒多核处理器可以做到100毫秒内就可以处理完这些事件100ms = 10us * 10000。 因此哪怕有1千万并发连接也能保证1万RPS的处理能力这就是epoll能在C10M下实现高吞吐量的原因。
进程获取到产生事件的socket后又该如何处理它呢这里的核心约束是处理任何一个事件的耗时都应该是微秒级或者毫秒级否则就会延误其他事件的处理不只降低了用户的体验而且会形成恶性循环。
我们知道为了应对网络的不确定性每个参与网络通讯的进程都会为请求设置超时时间。一旦某个socket上的事件迟迟不被处理当客户端的超时定时器触发时客户端往往会关闭连接并重发请求这会让服务器雪上加霜。
怎样保证处理一个事件的时间不会太长呢? 我们把处理事件的代码分为三类来看。
第一类是计算任务虽然内存、CPU的速度很快然而循环执行也可能耗时达到秒级。所以如果一定要引入需要密集计算才能完成的请求为了不阻碍其他事件的处理要么把这样的请求放在独立的线程中完成要么把请求的处理过程拆分成多段确保每段能够快速执行完同时每段执行完都要均等地处理其他事件这样通过放慢该请求的处理时间就保障了其他请求的及时处理。
第二类会读写磁盘由于磁盘的写入操作使用了PageCache的延迟写特性当write函数返回时只是复制到了内存中所以写入操作很快。磁盘的读取操作就比较慢了这时通常要把大文件的读取拆分成许多份每份仅有几十KB降低单次操作的耗时。
第三类是通过网络访问上游服务。与处理客户端请求相似我们必须使用非阻塞socket用事件驱动方式处理请求。需要注意的是许多网络服务提供的SDK都是基于阻塞socket实现的使用前必须先做完非阻塞改造。比如Memcached的官方SDK是用阻塞socket实现的Nginx如果直接使用该SDK访问它性能就会一落千丈。正确的访问方式是使用第三方提供的ngx_http_memcached_module模块它用非阻塞socket重新封装了SDK。
总之网络报文到达后内核就产生了读、写事件而epoll函数使得进程可以高效地收集到这些事件。接下来要确保在进程中处理每个事件的时间足够短才能及时地处理所有请求这个过程中既要避免阻塞socket的使用也要把耗时过长的操作拆成多份执行。最终通过快速、及时、均等地执行所有事件异步Server实现了高并发。
## 小结
最后我们对这一讲做个小结。异步服务改为从事件层面处理请求在epoll这样的多路复用机制协助下最终实现了C10M级别的高并发服务。
事件有很多种网络消息的传输既慢又不可控所以用网络事件驱动请求的性价比最高。这样就需要你了解TCP报文是如何产生事件的。
TCP连接建立时会在客户端产生写事件在服务器端产生读事件。连接关闭时则会在被动关闭端产生读事件。在连接上收发消息时也会产生事件其中发送消息前的写事件与内核分配的缓冲区有关。
清楚了事件与TCP报文的关系后可以用多路复用技术获取事件其中epoll是佼佼者它取消了收集事件时重复传递的大量socket参数给C10M的实现提供了基础。
你需要注意的是处理epoll收集到的事件时必须保证处理一个事件的平均时间在毫秒级以内。传统的阻塞socket是做不到的所以必须用非阻塞socket替换阻塞socket。如果事件的回调函数耗时过长也得拆分为多个耗时短的函数用多次事件比如定时器事件的触发来替代。
虽然我们有了上述的事件驱动方案但实现C10M还需要更谨慎地使用不过数百GB的服务器内存。关于如何降低内存的消耗可以关注[[第2讲]](https://time.geekbang.org/column/article/230221) 提到的内存池,[第11讲] 还会介绍如何减少连接缓冲区的空间占用。
这一讲我们介绍了事件驱动的总体方案但C10M需要高效的用心几乎所有服务器资源所以我们还得通过Linux更精细地控制TCP的行为接下来的3讲我们将深入Linux讨论如何优化TCP的性能。
## 思考题
最后留给你一个思考题需要CPU做密集计算的请求该如何拆分到事件驱动框架中呢欢迎你在留言区留言与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,159 @@
<audio id="audio" title="09 | 如何提升TCP三次握手的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/9e/726a6d37e4a50655c75344829823169e.mp3"></audio>
你好,我是陶辉。
上一讲我们提到TCP在三次握手建立连接、四次握手关闭连接时是怎样产生事件的这两个过程中TCP连接经历了复杂的状态变化既容易导致编程出错也有很大的优化空间。这一讲我们看看在Linux操作系统下如何优化TCP的三次握手流程提升握手速度。
TCP是一个可以双向传输的全双工协议所以需要经过三次握手才能建立连接。三次握手在一个HTTP请求中的平均时间占比在10%以上在网络状况不佳、高并发或者遭遇SYN泛洪攻击等场景中如果不能正确地调整三次握手中的参数就会对性能有很大的影响。
TCP协议是由操作系统实现的调整TCP必须通过操作系统提供的接口和工具这就需要理解Linux是怎样把三次握手中的状态暴露给我们以及通过哪些工具可以找到优化依据并通过哪些接口修改参数。
因此这一讲我们将介绍TCP握手过程中各状态的意义并以状态变化作为主线看看如何调整Linux参数才能提升握手的性能。
## 客户端的优化
客户端和服务器都可以针对三次握手优化性能。相对而言,主动发起连接的客户端优化相对简单一些,而服务器需要在监听端口上被动等待连接,并保存许多握手的中间状态,优化方法更为复杂一些。我们首先来看如何优化客户端。
三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输TCP协议的许多特性都是依赖序列号实现的比如流量控制、消息丢失后的重发等等这也是三次握手中的报文被称为SYN的原因因为SYN的全称就叫做Synchronize Sequence Numbers。
<img src="https://static001.geekbang.org/resource/image/c5/aa/c51d9f1604690ab1b69e7c4feb2f31aa.jpg" alt="">
三次握手虽然由操作系统实现但它通过连接状态把这一过程暴露给了我们我们来细看下过程中出现的3种状态的意义。客户端发送SYN开启了三次握手此时在客户端上用netstat命令后续查看连接状态都使用该命令可以看到**连接的状态是SYN_SENT**顾名思义就是把刚SYN发送出去
```
tcp 0 1 172.16.20.227:39198 129.28.56.36:81 SYN_SENT
```
客户端在等待服务器回复的ACK报文。正常情况下服务器会在几毫秒内返回ACK但如果客户端迟迟没有收到ACK会怎么样呢客户端会重发SYN**重试的次数由tcp_syn_retries参数控制**默认是6次
```
net.ipv4.tcp_syn_retries = 6
```
第1次重试发生在1秒钟后接着会以翻倍的方式在第2、4、8、16、32秒共做6次重试最后一次重试会等待64秒如果仍然没有返回ACK才会终止三次握手。所以总耗时是1+2+4+8+16+32+64=127秒超过2分钟。
如果这是一台有明确任务的服务器,你可以根据网络的稳定性和目标服务器的繁忙程度修改重试次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。
<img src="https://static001.geekbang.org/resource/image/a3/8f/a3c5e77a228478da2a6e707054043c8f.png" alt="">
## 服务器端的优化
当服务器收到SYN报文后服务器会立刻回复SYN+ACK报文既确认了客户端的序列号也把自己的序列号发给了对方。此时服务器端出现了新连接状态是SYN_RCVRCV是received的缩写。这个状态下服务器必须建立一个SYN半连接队列来维护未完成的握手信息当这个队列溢出后服务器将无法再建立新连接。
<img src="https://static001.geekbang.org/resource/image/c3/82/c361e672526ee5bb87d5f6b7ad169982.png" alt="">
新连接建立失败的原因有很多怎样获得由于队列已满而引发的失败次数呢netstat -s命令给出的统计结果中可以得到。
```
# netstat -s | grep &quot;SYNs to LISTEN&quot;
1192450 SYNs to LISTEN sockets dropped
```
这里给出的是队列溢出导致SYN被丢弃的个数。注意这是一个累计值如果数值在持续增加则应该调大SYN半连接队列。**修改队列大小的方法是设置Linux的tcp_max_syn_backlog 参数:**
```
net.ipv4.tcp_max_syn_backlog = 1024
```
如果SYN半连接队列已满只能丢弃连接吗并不是这样**开启syncookies功能就可以在不使用SYN队列的情况下成功建立连接。**syncookies是这么做的服务器根据当前状态计算出一个值放在己方发出的SYN+ACK报文中发出当客户端返回ACK报文时取出该值验证如果合法就认为连接建立成功如下图所示。
<img src="https://static001.geekbang.org/resource/image/0d/c0/0d963557347c149a6270d8102d83e0c0.png" alt="">
Linux下怎样开启syncookies功能呢修改tcp_syncookies参数即可其中值为0时表示关闭该功能2表示无条件开启功能而1则表示仅当SYN半连接队列放不下时再启用它。由于syncookie仅用于应对SYN泛洪攻击攻击者恶意构造大量的SYN报文发送给服务器造成SYN半连接队列溢出导致正常客户端的连接无法建立这种方式建立的连接许多TCP特性都无法使用。所以应当把tcp_syncookies设置为1仅在队列满时再启用。
```
net.ipv4.tcp_syncookies = 1
```
当客户端接收到服务器发来的SYN+ACK报文后就会回复ACK去通知服务器同时己方连接状态从SYN_SENT转换为ESTABLISHED表示连接建立成功。服务器端连接成功建立的时间还要再往后到它收到ACK后状态才变为ESTABLISHED。
如果服务器没有收到ACK就会一直重发SYN+ACK报文。当网络繁忙、不稳定时报文丢失就会变严重此时应该调大重发次数。反之则可以调小重发次数。**修改重发次数的方法是调整tcp_synack_retries参数**
```
net.ipv4.tcp_synack_retries = 5
```
tcp_synack_retries 的默认重试次数是5次与客户端重发SYN类似它的重试会经历1、2、4、8、16秒最后一次重试后等待32秒若仍然没有收到ACK才会关闭连接故共需要等待63秒。
服务器收到ACK后连接建立成功此时内核会把连接从SYN半连接队列中移出再移入accept队列等待进程调用accept函数时把连接取出来。如果进程不能及时地调用accept函数就会造成accept队列溢出最终导致建立好的TCP连接被丢弃。
实际上丢弃连接只是Linux的默认行为我们还可以选择向客户端发送RST复位报文告诉客户端连接已经建立失败。打开这一功能需要将tcp_abort_on_overflow参数设置为1。
```
net.ipv4.tcp_abort_on_overflow = 0
```
**通常情况下应当把tcp_abort_on_overflow设置为0因为这样更有利于应对突发流量。**举个例子当accept队列满导致服务器丢掉了ACK与此同时客户端的连接状态却是ESTABLISHED进程就在建立好的连接上发送请求。只要服务器没有为请求回复ACK请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成accept队列满那么当accept队列有空位时再次接收到的请求报文由于含有ACK仍然会触发服务器端成功建立连接。所以**tcp_abort_on_overflow设为0可以提高连接建立的成功率只有你非常肯定accept队列会长期溢出时才能设置为1以尽快通知客户端。**
那么怎样调整accept队列的长度呢**listen函数的backlog参数就可以设置accept队列的大小。事实上backlog参数还受限于Linux系统级的队列长度上限当然这个上限阈值也可以通过somaxconn参数修改。**
```
net.core.somaxconn = 128
```
当下各监听端口上的accept队列长度可以通过ss -ltn命令查看但accept队列长度是否需要调整该怎么判断呢还是通过netstat -s命令给出的统计结果可以看到究竟有多少个连接因为队列溢出而被丢弃。
```
# netstat -s | grep &quot;listen queue&quot;
14 times the listen queue of a socket overflowed
```
如果持续不断地有连接因为accept队列溢出被丢弃就应该调大backlog以及somaxconn参数。
## TFO技术如何绕过三次握手
以上我们只是在对三次握手的过程进行优化。接下来我们看看如何绕过三次握手发送数据。
三次握手建立连接造成的后果就是HTTP请求必须在一次RTTRound Trip Time从客户端到服务器一个往返的时间后才能发送Google对此做的统计显示三次握手消耗的时间在HTTP请求完成的时间占比在10%到30%之间。
<img src="https://static001.geekbang.org/resource/image/1b/a8/1b9d8f49d5a716470481657b07ae77a8.png" alt="">
因此Google提出了TCP fast open方案简称[TFO](https://tools.ietf.org/html/rfc7413)客户端可以在首个SYN报文中就携带请求这节省了1个RTT的时间。
接下来我们就来看看TFO具体是怎么实现的。
**为了让客户端在SYN报文中携带请求数据必须解决服务器的信任问题。**因为此时服务器的SYN报文还没有发给客户端客户端是否能够正常建立连接还未可知但此时服务器需要假定连接已经建立成功并把请求交付给进程去处理所以服务器必须能够信任这个客户端。
TFO到底怎样达成这一目的呢它把通讯分为两个阶段第一阶段为首次建立连接这时走正常的三次握手但在客户端的SYN报文会明确地告诉服务器它想使用TFO功能这样服务器会把客户端IP地址用只有自己知道的密钥加密比如AES加密算法作为Cookie携带在返回的SYN+ACK报文中客户端收到后会将Cookie缓存在本地。
之后如果客户端再次向服务器建立连接就可以在第一个SYN报文中携带请求数据同时还要附带缓存的Cookie。很显然这种通讯方式下不能再采用经典的“先connect再write请求”这种编程方法而要改用sendto或者sendmsg函数才能实现。
服务器收到后会用自己的密钥验证Cookie是否合法验证通过后连接才算建立成功再把请求交给进程处理同时给客户端返回SYN+ACK。虽然客户端收到后还会返回ACK但服务器不等收到ACK就可以发送HTTP响应了这就减少了握手带来的1个RTT的时间消耗。
<img src="https://static001.geekbang.org/resource/image/7a/c3/7ac29766ba8515eea5bb331fce6dc2c3.png" alt="">
当然为了防止SYN泛洪攻击服务器的TFO实现必须能够自动化地定时更新密钥。
Linux下怎么打开TFO功能呢这要通过tcp_fastopen参数。由于只有客户端和服务器同时支持时TFO功能才能使用**所以tcp_fastopen参数是按比特位控制的。其中第1个比特位为1时表示作为客户端时支持TFO第2个比特位为1时表示作为服务器时支持TFO**所以当tcp_fastopen的值为3时比特为0x11就表示完全支持TFO功能。
```
net.ipv4.tcp_fastopen = 3
```
## 小结
这一讲我们沿着三次握手的流程介绍了Linux系统的优化方法。
当客户端通过发送SYN发起握手时可以通过tcp_syn_retries控制重发次数。当服务器的SYN半连接队列溢出后SYN报文会丢失从而导致连接建立失败。我们可以通过netstat -s给出的统计结果判断队列长度是否合适进而通过tcp_max_syn_backlog参数调整队列的长度。服务器回复SYN+ACK报文的重试次数由tcp_synack_retries参数控制网络稳定时可以调小它。为了应对SYN泛洪攻击应将tcp_syncookies参数设置为1它仅在SYN队列满后开启syncookie功能保证连接成功建立。
服务器收到客户端返回的ACK后会把连接移入accept队列等待进程调用accept函数取出连接。如果accept队列溢出默认系统会丢弃ACK也可以通过tcp_abort_on_overflow参数用RST通知客户端连接建立失败。如果netstat统计信息显示大量的ACK被丢弃后可以通过listen函数的backlog参数和somaxconn系统参数提高队列上限。
TFO技术绕过三次握手使得HTTP请求减少了1个RTT的时间。Linux下可以通过tcp_fastopen参数开启该功能。
从这一讲可以看出虽然TCP是由操作系统实现的但Linux通过多种方式提供了修改TCP功能的接口供我们优化TCP的性能。下一讲我们再来探讨四次握手关闭连接时Linux怎样帮助我们优化其性能。
## 思考题
最后,留给你一个思考题,关于三次握手建立连接,你做过哪些优化?效果如何?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="10 | 如何提升TCP四次挥手的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/0e/0fd9ccef046f64c2131adb2e84135b0e.mp3"></audio>
你好,我是陶辉。
上一节课,我们介绍了建立连接时的优化方法,这一节课再来看四次挥手关闭连接时,如何优化性能。
close和shutdown函数都可以关闭连接但这两种方式关闭的连接不只功能上有差异控制它们的Linux参数也不相同。close函数会让连接变为孤儿连接shutdown函数则允许在半关闭的连接上长时间传输数据。TCP之所以具备这个功能是因为它是全双工协议但这也造成四次挥手非常复杂。
四次挥手中你可以用netstat命令观察到6种状态。其中你多半看到过TIME_WAIT状态。网上有许多文章介绍怎样减少TIME_WAIT状态连接的数量也有文章说TIME_WAIT状态是必不可少、不能优化掉的。这两种看似自相矛盾的观点之所以存在就在于优化连接关闭时不能仅基于主机端的视角还必须站在整个网络的层次上才能给出正确的解决方案。
Linux为四次挥手提供了很多控制参数有些参数的名称与含义并不相符。例如tcp_orphan_retries参数中有orphan孤儿却同时对非孤儿连接也生效。而且错误地配置这些参数不只无法针对高并发场景提升性能还会降低资源的使用效率甚至引发数据错误。
这一讲我们将基于四次挥手的流程介绍Linux下的优化方法。
## 四次挥手的流程
你想没想过,为什么建立连接是三次握手,而关闭连接需要四次挥手呢?
这是因为TCP不允许连接处于半打开状态时就单向传输数据所以在三次握手建立连接时服务器会把ACK和SYN放在一起发给客户端其中ACK用来打开客户端的发送通道SYN用来打开服务器的发送通道。这样原本的四次握手就降为三次握手了。
<img src="https://static001.geekbang.org/resource/image/74/51/74ac4e70ef719f19270c08201fb53a51.png" alt="">
但是当连接处于半关闭状态时TCP是允许单向传输数据的。为便于下文描述**接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。**当主动方关闭连接时被动方仍然可以在不调用close函数的状态下长时间发送数据此时连接处于半关闭状态。这一特性是TCP的双向通道互相独立所致却也使得关闭连接必须通过四次挥手才能做到。
**互联网中往往服务器才是主动关闭连接的一方。**这是因为HTTP消息是单向传输协议服务器接收完请求才能生成响应发送完响应后就会立刻关闭TCP连接这样及时释放了资源能够为更多的用户服务。
这就使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。
了解了这一点之后,我们再来看四次挥手的流程。
<img src="https://static001.geekbang.org/resource/image/e2/b7/e2ef1347b3b4590da431dc236d9239b7.png" alt="">
**其实四次挥手只涉及两种报文FIN和ACK。**FIN就是Finish结束连接的意思谁发出FIN报文就表示它将不再发送任何数据关闭这一方向的传输通道。ACK是Acknowledge确认的意思它用来通知对方你方的发送通道已经关闭。
当主动方关闭连接时会发送FIN报文此时主动方的连接状态由ESTABLISHED变为FIN_WAIT1。当被动方收到FIN报文后内核自动回复ACK报文连接状态由ESTABLISHED变为CLOSE_WAIT顾名思义它在等待进程调用close函数关闭连接。当主动方接收到这个ACK报文后连接状态由FIN_WAIT1变为FIN_WAIT2主动方的发送通道就关闭了。
再来看被动方的发送通道是如何关闭的。当被动方进入CLOSE_WAIT状态时进程的read函数会返回0这样开发人员就会有针对性地调用close函数进而触发内核发送FIN报文此时被动方连接的状态变为LAST_ACK。当主动方收到这个FIN报文时内核会自动回复ACK同时连接的状态由FIN_WAIT2变为TIME_WAITLinux系统下大约1分钟后TIME_WAIT状态的连接才会彻底关闭。而被动方收到ACK报文后连接就会关闭。
## 主动方的优化
关闭连接有多种方式比如进程异常退出时针对它打开的连接内核就会发送RST报文来关闭。RST的全称是Reset复位的意思它可以不走四次挥手强行关闭连接但当报文延迟或者重复传输时这种方式会导致数据错乱所以这是不得已而为之的关闭连接方案。
安全关闭连接的方式必须通过四次挥手它由进程调用close或者shutdown函数发起这二者都会向对方发送FIN报文shutdown参数须传入SHUT_WR或者SHUT_RDWR才会发送FIN区别在于close调用后哪怕对方在半关闭状态下发送的数据到达主动方进程也无法接收。
**此时这个连接叫做孤儿连接如果你用netstat -p命令会发现连接对应的进程名为空。而shutdown函数调用后即使连接进入了FIN_WAIT1或者FIN_WAIT2状态它也不是孤儿连接进程仍然可以继续接收数据。**关于孤儿连接的概念,下文调优参数时还会用到。
主动方发送FIN报文后连接就处于FIN_WAIT1状态下该状态通常应在数十毫秒内转为FIN_WAIT2。只有迟迟收不到对方返回的ACK时才能用netstat命令观察到FIN_WAIT1状态。此时**内核会定时重发FIN报文其中重发次数由tcp_orphan_retries参数控制**注意orphan虽然是孤儿的意思该参数却不只对孤儿连接有效事实上它对所有FIN_WAIT1状态下的连接都有效默认值是0特指8次
```
net.ipv4.tcp_orphan_retries = 0
```
如果FIN_WAIT1状态连接有很多你就需要考虑降低tcp_orphan_retries的值。当重试次数达到tcp_orphan_retries时连接就会直接关闭掉。
**对于正常情况来说调低tcp_orphan_retries已经够用但如果遇到恶意攻击FIN报文根本无法发送出去。**这是由TCP的2个特性导致的。
- 首先TCP必须保证报文是有序发送的FIN报文也不例外当发送缓冲区还有数据没发送时FIN报文也不能提前发送。
- 其次TCP有流控功能当接收方将接收窗口设为0时发送方就不能再发送数据。所以当攻击者下载大文件时就可以通过将接收窗口设为0导致FIN报文无法发送进而导致连接一直处于FIN_WAIT1状态。
解决这种问题的方案是调整tcp_max_orphans参数
```
net.ipv4.tcp_max_orphans = 16384
```
顾名思义,**tcp_max_orphans 定义了孤儿连接的最大数量。**当进程调用close函数关闭连接后无论该连接是在FIN_WAIT1状态还是确实关闭了这个连接都与该进程无关了它变成了孤儿连接。Linux系统为防止孤儿连接过多导致系统资源长期被占用就提供了tcp_max_orphans参数。如果孤儿连接数量大于它新增的孤儿连接将不再走四次挥手而是直接发送RST复位报文强制关闭。
当连接收到ACK进入FIN_WAIT2状态后就表示主动方的发送通道已经关闭接下来将等待对方发送FIN报文关闭对方的发送通道。这时**如果连接是用shutdown函数关闭的连接可以一直处于FIN_WAIT2状态。但对于close函数关闭的孤儿连接这个状态不可以持续太久而tcp_fin_timeout控制了这个状态下连接的持续时长。**
```
net.ipv4.tcp_fin_timeout = 60
```
它的默认值是60秒。这意味着对于孤儿连接如果60秒后还没有收到FIN报文连接就会直接关闭。这个60秒并不是拍脑袋决定的它与接下来介绍的TIME_WAIT状态的持续时间是相同的我们稍后再来回答60秒的由来。
TIME_WAIT是主动方四次挥手的最后一个状态。当收到被动方发来的FIN报文时主动方回复ACK表示确认对方的发送通道已经关闭连接随之进入TIME_WAIT状态等待60秒后关闭为什么呢我们必须站在整个网络的角度上才能回答这个问题。
TIME_WAIT状态的连接在主动方看来确实已经关闭了。然而被动方没有收到ACK报文前连接还处于LAST_ACK状态。如果这个ACK报文没有到达被动方被动方就会重发FIN报文。重发次数仍然由前面介绍过的tcp_orphan_retries参数控制。
如果主动方不保留TIME_WAIT状态会发生什么呢此时连接的端口恢复了自由身可以复用于新连接了。然而被动方的FIN报文可能再次到达这既可能是网络中的路由器重复发送也有可能是被动方没收到ACK时基于tcp_orphan_retries参数重发。这样**正常通讯的新连接就可能被重复发送的FIN报文误关闭。**保留TIME_WAIT状态就可以应付重发的FIN报文当然其他数据报文也有可能重发所以TIME_WAIT状态还能避免数据错乱。
我们再回过头来看看为什么TIME_WAIT状态要保持60秒呢这与孤儿连接FIN_WAIT2状态默认保留60秒的原理是一样的**因为这两个状态都需要保持2MSL时长。MSL全称是Maximum Segment Lifetime它定义了一个报文在网络中的最长生存时间**报文每经过一次路由器的转发IP头部的TTL字段就会减1减到0时报文就被丢弃这就限制了报文的最长存活时间
为什么是2 MSL的时长呢这其实是相当于至少允许报文丢失一次。比如若ACK在一个MSL内丢失这样被动方重发的FIN会在第2个MSL内到达TIME_WAIT状态的连接可以应对。为什么不是4或者8 MSL的时长呢你可以想象一个丢包率达到百分之一的糟糕网络连续两次丢包的概率只有万分之一这个概率实在是太小了忽略它比解决它更具性价比。
**因此TIME_WAIT和FIN_WAIT2状态的最大时长都是2 MSL由于在Linux系统中MSL的值固定为30秒所以它们都是60秒。**
虽然TIME_WAIT状态的存在是有必要的但它毕竟在消耗系统资源比如TIME_WAIT状态的端口就无法供新连接使用。怎样解决这个问题呢
**Linux提供了tcp_max_tw_buckets 参数当TIME_WAIT的连接数量超过该参数时新关闭的连接就不再经历TIME_WAIT而直接关闭。**
```
net.ipv4.tcp_max_tw_buckets = 5000
```
当服务器的并发连接增多时相应地同时处于TIME_WAIT状态的连接数量也会变多此时就应当调大tcp_max_tw_buckets参数减少不同连接间数据错乱的概率。
当然tcp_max_tw_buckets也不是越大越好毕竟内存和端口号都是有限的。有没有办法让新连接复用TIME_WAIT状态的端口呢如果服务器会主动向上游服务器发起连接的话就可以把tcp_tw_reuse参数设置为1它允许作为客户端的新连接在安全条件下使用TIME_WAIT状态下的端口。
```
net.ipv4.tcp_tw_reuse = 1
```
当然要想使tcp_tw_reuse生效还得把timestamps参数设置为1它满足安全复用的先决条件对方也要打开tcp_timestamps
```
net.ipv4.tcp_timestamps = 1
```
老版本的Linux还提供了tcp_tw_recycle参数它并不要求TIME_WAIT状态存在60秒很容易导致数据错乱不建议设置为1。
```
net.ipv4.tcp_tw_recycle = 0
```
所以在Linux 4.12版本后,直接取消了这一参数。
## 被动方的优化
当被动方收到FIN报文时就开启了被动方的四次挥手流程。内核自动回复ACK报文后连接就进入CLOSE_WAIT状态顾名思义它表示等待进程调用close函数关闭连接。
内核没有权力替代进程去关闭连接因为若主动方是通过shutdown关闭连接那么它就是想在半关闭连接上接收数据。**因此Linux并没有限制CLOSE_WAIT状态的持续时间。**
当然大多数应用程序并不使用shutdown函数关闭连接所以当你用netstat命令发现大量CLOSE_WAIT状态时要么是程序出现了Bugread函数返回0时忘记调用close函数关闭连接要么就是程序负载太高close函数所在的回调函数被延迟执行了。此时我们应当在应用代码层面解决问题。
由于CLOSE_WAIT状态下连接已经处于半关闭状态所以此时进程若要关闭连接只能调用close函数再调用shutdown关闭单向通道就没有意义了内核就会发出FIN报文关闭发送通道同时连接进入LAST_ACK状态等待主动方返回ACK来确认连接关闭。
如果迟迟等不到ACK内核就会重发FIN报文重发次数仍然由tcp_orphan_retries参数控制这与主动方重发FIN报文的优化策略一致。
至此,由一方主动发起四次挥手的流程就介绍完了。需要你注意的是,**如果被动方迅速调用close函数那么被动方的ACK和FIN有可能在一个报文中发送这样看起来四次挥手会变成三次挥手这只是一种特殊情况不用在意。**
我们再来看一种特例,如果连接双方同时关闭连接,会怎么样?
此时上面介绍过的优化策略仍然适用。两方发送FIN报文时都认为自己是主动方所以都进入了FIN_WAIT1状态FIN报文的重发次数仍由tcp_orphan_retries参数控制。
<img src="https://static001.geekbang.org/resource/image/04/52/043752a3957d36f4e3c82cd83d472452.png" alt="">
接下来双方在等待ACK报文的过程中都等来了FIN报文。这是一种新情况所以连接会进入一种叫做CLOSING的新状态它替代了FIN_WAIT2状态。此时内核回复ACK确认对方发送通道的关闭仅己方的FIN报文对应的ACK还没有收到。所以CLOSING状态与LAST_ACK状态下的连接很相似它会在适时重发FIN报文的情况下最终关闭。
## 小结
我们对这一讲的内容做个小结。
今天我们讲了四次挥手的流程,你需要根据主动方与被动方的连接状态变化来调整系统参数,使它在特定网络条件下更及时地释放资源。
四次挥手的主动方为了应对丢包允许在tcp_orphan_retries次数内重发FIN报文。当收到ACK报文连接就进入了FIN_WAIT2状态此时系统的行为依赖这是否为孤儿连接。
如果这是close函数关闭的孤儿连接那么在tcp_fin_timeout秒内没有收到对方的FIN报文连接就直接关闭反之shutdown函数关闭的连接则不受此限制。毕竟孤儿连接可能在重发次数内存在数分钟之久为了应对孤儿连接占用太多的资源tcp_max_orphans定义了最大孤儿连接的数量超过时连接就会直接释放。
当接收到FIN报文并返回ACK后主动方的连接进入TIME_WAIT状态。这一状态会持续1分钟为了防止TIME_WAIT状态占用太多的资源tcp_max_tw_buckets定义了最大数量超过时连接也会直接释放。当TIME_WAIT状态过多时还可以通过设置tcp_tw_reuse和tcp_timestamps为1 将TIME_WAIT状态的端口复用于作为客户端的新连接。
被动关闭的连接方应对非常简单它在回复ACK后就进入了CLOSE_WAIT状态等待进程调用close函数关闭连接。因此出现大量CLOSE_WAIT状态的连接时应当从应用程序中找问题。当被动方发送FIN报文后连接就进入LAST_ACK状态在未等来ACK时会在tcp_orphan_retries参数的控制下重发FIN报文。
至此TCP连接建立、关闭时的性能优化就介绍完了。下一讲我们将专注在TCP上传输数据时如何优化内存的使用效率。
## 思考题
最后给你留一个思考题。你知道关闭连接时的SO_LINGER选项吗它希望用四次挥手替代RST关闭连接的方式防止浏览器没有接收到完整的HTTP响应。请你思考一下SO_LINGER会怎么影响主动方连接的状态变化SO_LINGER上的超时时间是怎样与系统配置参数协作的欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/aa/a84702c6b4bca2042ce0b5d5ca0f68aa.mp3"></audio>
你好,我是陶辉。
我们在[[第8课]](https://time.geekbang.org/column/article/236921) 中讲了如何从C10K进一步到C10M不过这也意味着TCP占用的内存翻了一千倍服务器的内存资源会非常紧张。
如果你在Linux系统中用free命令查看内存占用情况会发现一栏叫做buff/cache它是系统内存似乎与应用进程无关。但每当进程新建一个TCP连接buff/cache中的内存都会上升4K左右。而且当连接传输数据时就远不止增加4K内存了。这样几十万并发连接就在进程内存外又增加了GB级别的系统内存消耗。
这是因为TCP连接是由内核维护的内核为每个连接建立的内存缓冲区既要为网络传输服务也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小就无法充分使用网络带宽TCP传输速度就会很慢如果连接的内存配置过大那么服务器内存会很快用尽新连接就无法建立成功。因此只有深入理解Linux下TCP内存的用途才能正确地配置内存大小。
这一讲我们就来看看Linux下的TCP缓冲区该如何修改才能在高并发下维持TCP的高速传输。
## 滑动窗口是怎样影响传输速度的?
我们知道TCP必须保证每一个报文都能够到达对方它采用的机制就是报文发出后必须收到接收方返回的ACK确认报文Acknowledge确认的意思。如果在一段时间内称为RTOretransmission timeout没有收到这个报文还得重新发送直到收到ACK为止。
**可见TCP报文发出去后并不能立刻从内存中删除因为重发时还需要用到它。**由于TCP是由内核实现的所以报文存放在内核缓冲区中这也是高并发下buff/cache内存增加很多的原因。
事实上,确认报文被收到的机制非常复杂,它受制于很多因素。我们先来看第一个因素,**速度**。
如果我们发送一个报文收到ACK确认后再发送下一个报文会有什么问题显然发送每个报文都需要经历一个RTT时延RTT的值可以用ping命令得到。要知道因为网络设备限制了报文的字节数所以每个报文的体积有限。
比如以太网报文最大只有1500字节而发送主机到接收主机间要经历多个广域网、局域网其中最小的设备决定了网络报文的最大字节数在TCP中这个值叫做MSSMaximum Segment Size它通常在1KB左右。如果RTT时延是10ms那么它们的传送速度最多只有1KB/10ms=100KB/s可见这种确认报文方式太影响传输速度了。
<img src="https://static001.geekbang.org/resource/image/8c/a8/8c97985f1ed742b458d0c00c3155aba8.png" alt="">
**提速的方式很简单,并行地批量发送报文,再批量确认报文即可。**比如发送一个100MB的文件如果MSS值为1KB那么需要发送约10万个报文。发送方大可以同时发送这10万个报文再等待它们的ACK确认。这样发送速度瞬间就达到100MB/10ms=10GB/s。
然而,这引出了另一个问题,接收方有那么强的处理能力吗?**接收方的处理能力**,这是影响确认机制的第二个因素(网络也没有这么强的处理能力,下一讲会介绍应对网络瓶颈的拥塞控制技术)。
<img src="https://static001.geekbang.org/resource/image/f9/85/f9e14ba29407da48bf55a7c24c7af585.png" alt="">
当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,网络效率非常低。怎么限制发送方的速度呢?
**接收方把它的处理能力告诉发送方,使其限制发送速度即可,这就是滑动窗口的由来。**接收方根据它的缓冲区可以计算出后续能够接收多少字节的报文这个数字叫做接收窗口。当内核接收到报文时必须用缓冲区存放它们这样剩余缓冲区空间变小接收窗口也就变小了当进程调用read函数后数据被读入了用户空间内核缓冲区就被清空这意味着主机可以接收更多的报文接收窗口就会变大。
因此接收窗口并不是恒定不变的那么怎么把时刻变化的窗口通知给发送方呢TCP报文头部中的窗口字段就可以起到通知的作用。
当发送方从报文中得到接收方的窗口大小时就明白了最多能发送多少字节的报文这个数字被称为发送方的发送窗口。如果不考虑下一讲将要介绍的拥塞控制发送方的发送窗口就是接收方的接收窗口由于报文有传输时延t1时刻的接收窗口在t2时刻才能到达发送端因此这两个窗口并不完全等价
<img src="https://static001.geekbang.org/resource/image/9a/f1/9a9385d4e5343285201e0242809c16f1.jpg" alt="">
从上图中可以看到窗口字段只有2个字节因此它最多能表达2<sup>16</sup> 即65535字节大小的窗口之所以不是65536是因为窗口可以为0此时叫做窗口关闭上一讲提到的关闭连接时让FIN报文发不出去以致于服务器的连接都处于FIN_WAIT1状态就是通过窗口关闭技术实现的这在RTT为10ms的网络中也只能到达6MB/s的最大速度在当今的高速网络中显然并不够用。
[RFC1323](https://tools.ietf.org/html/rfc1323) 定义了扩充窗口的方法但Linux中打开这一功能需要把tcp_window_scaling配置设为1此时窗口的最大值可以达到1GB2<sup>30</sup>)。
```
net.ipv4.tcp_window_scaling = 1
```
这样看来只要进程能及时地调用read函数读取数据并且接收缓冲区配置得足够大那么接收窗口就可以无限地放大发送方也就无限地提升发送速度。很显然这是不可能的因为网络的传输能力是有限的当发送方依据发送窗口发送超过网络处理能力的报文时路由器会直接丢弃这些报文。因此缓冲区的内存并不是越大越好。
## 带宽时延积如何确定最大传输速度?
缓冲区到底该设置为多大呢我们知道TCP的传输速度受制于发送窗口与接收窗口以及网络传输能力。其中两个窗口由缓冲区大小决定进程调用read函数是否及时也会影响它。如果缓冲区大小与网络传输能力匹配那么缓冲区的利用率就达到了最大值。
怎样计算出网络传输能力呢?带宽描述了网络传输能力,但它不能直接使用,因为它与窗口或者说缓冲区的计量单位不同。带宽是单位时间内的流量 它表达的是速度比如你家里的宽带100MB/s而窗口和缓冲区的单位是字节。当网络速度乘以时间才能得到字节数差的这个时间这就是网络时延。
当最大带宽是100MB/s、网络时延是10ms时这意味着客户端到服务器间的网络一共可以存放100MB/s * 0.01s = 1MB的字节。这个1MB是带宽与时延的乘积所以它就叫做带宽时延积缩写为BDPBandwidth Delay Product。这1MB字节存在于飞行中的TCP报文它们就在网络线路、路由器等网络设备上。如果飞行报文超过了1MB就一定会让网络过载最终导致丢包。
由于发送缓冲区决定了发送窗口的上限,而发送窗口又决定了已发送但未确认的飞行报文的上限,因此,发送缓冲区不能超过带宽时延积,因为超出的部分没有办法用于有效的网络传输,且飞行字节大于带宽时延积还会导致丢包;而且,缓冲区也不能小于带宽时延积,否则无法发挥出高速网络的价值。
## 怎样调整缓冲区去适配滑动窗口?
这么看来,我们只要把缓冲区设置为带宽时延积不就行了吗?**比如当我们做socket网络编程时通过设置socket的SO_SNDBUF属性就可以设定缓冲区的大小。**
然而,这并不是个好主意,因为不是每一个请求都能够达到最大传输速度,比如请求的体积太小时,在**慢启动**(下一讲会谈到)的影响下,未达到最大速度时请求就处理完了。再比如网络本身也会有波动,未必可以一直保持最大速度。
**因此,时刻让缓冲区保持最大,太过浪费内存了。**
到底该如何设置缓冲区呢?
我们可以使用Linux的**缓冲区动态调节功能**解决上述问题。其中缓冲区的调节范围是可以设置的。先来看发送缓冲区它的范围通过tcp_wmem配置
```
net.ipv4.tcp_wmem = 4096 16384 4194304
```
其中第1个数值是动态范围的下限第3个数值是动态范围的上限。而中间第2个数值则是初始默认值。
发送缓冲区完全根据需求自行调整。比如一旦发送出的数据被确认而且没有新的数据要发送就可以把发送缓冲区的内存释放掉。而接收缓冲区的调整就要复杂一些先来看设置接收缓冲区范围的tcp_rmem
```
net.ipv4.tcp_rmem = 4096 87380 6291456
```
它的数值与tcp_wmem类似第1、3个值是范围的下限和上限第2个值是初始默认值。发送缓冲区自动调节的依据是待发送的数据接收缓冲区由于只能被动地等待接收数据它该如何自动调整呢
**可以依据空闲系统内存的数量来调节接收窗口。**如果系统的空闲内存很多,就可以把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而对方的发送速度就会通过增加飞行报文来提升。反之,内存紧张时就会缩小缓冲区,这虽然会减慢速度,但可以保证更多的并发连接正常工作。
发送缓冲区的调节功能是自动开启的而接收缓冲区则需要配置tcp_moderate_rcvbuf为1来开启调节功能
```
net.ipv4.tcp_moderate_rcvbuf = 1
```
接收缓冲区调节时怎么判断空闲内存的多少呢这是通过tcp_mem配置完成的
```
net.ipv4.tcp_mem = 88560 118080 177120
```
tcp_mem的3个值是Linux判断系统内存是否紧张的依据。当TCP内存小于第1个值时不需要进行自动调节在第1和第2个值之间时内核开始调节接收缓冲区的大小大于第3个值时内核不再为TCP分配新内存此时新连接是无法建立的。
在高并发服务器中,为了兼顾网速与大量的并发连接,**我们应当保证缓冲区的动态调整上限达到带宽时延积而下限保持默认的4K不变即可。而对于内存紧张的服务而言调低默认值是提高并发的有效手段。**
同时如果这是网络IO型服务器那么**调大tcp_mem的上限可以让TCP连接使用更多的系统内存这有利于提升并发能力。**需要注意的是tcp_wmem和tcp_rmem的单位是字节而tcp_mem的单位是页面大小。而且**千万不要在socket上直接设置SO_SNDBUF或者SO_RCVBUF这样会关闭缓冲区的动态调整功能。**
## 小结
我们对这一讲的内容做个小结。
实现高并发服务时由于必须把大部分内存用在网络传输上所以除了关注应用内存的使用还必须关注TCP内核缓冲区的内存使用情况。
TCP使用ACK确认报文实现了可靠性又依赖滑动窗口既提升了发送速度也兼顾了接收方的处理能力。然而默认的滑动窗口最大只能到65KB要想提升发送速度必须提升滑动窗口的上限在Linux下是通过设置tcp_window_scaling为1做到的。
滑动窗口定义了飞行报文的最大字节数当它超过带宽时延积时就会发生丢包。而当它小于带宽时延积时就无法让TCP的传输速度达到网络允许的最大值。因此滑动窗口的设计必须参考带宽时延积。
内核缓冲区决定了滑动窗口的上限但我们不能通过socket的SO_SNFBUF等选项直接把缓冲区大小设置为带宽时延积因为TCP不会一直维持在最高速上过大的缓冲区会减少并发连接数。Linux带来的缓冲区自动调节功能非常有效我们应当把缓冲区的上限设置为带宽时延积。其中发送缓冲区的调节功能是自动打开的而接收缓冲区需要把tcp_moderate_rcvbuf设置为1来开启其中调节的依据根据tcp_mem而定。
这样高效地配置内存后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。这一讲我们谈了内核缓冲区对传输速度的影响,下一讲我们再来看如何调节发送速度以匹配不同的网络能力。
## 思考题
最后请你观察下Linux系统下连接建立时、发送接收数据时buff/cache内存的变动情况。用我们这一讲介绍的原理解释系统内存的变化现象。欢迎你在留言区与我沟通互动。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="12 | 如何调整TCP拥塞控制的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/4c/b79a03a9a6ba059cfe329e3f4ccccb4c.mp3"></audio>
你好,我是陶辉。
上一讲我们谈到接收主机的处理能力不足时是通过滑动窗口来减缓对方的发送速度。这一讲我们来看看当网络处理能力不足时又该如何优化TCP的性能。
如果你阅读过TCP协议相关的书籍一定看到过慢启动、拥塞控制等名词。这些概念似乎离应用开发者很远然而如果没有拥塞控制整个网络将会锁死所有消息都无法传输。
而且如果你在开发分布式集群中的高并发服务理解拥塞控制的工作原理就可以在内核的TCP层提升所有进程的网络性能。比如你可能听过2013年谷歌把初始拥塞窗口从3个MSS最大报文长度左右提升到10个MSS将Web站点的网络性能提升了10%以上而有些高速CDN站点甚至把初始拥塞窗口提升到70个MSS。
特别是近年来谷歌提出的BBR拥塞控制算法已经应用在高版本的Linux内核中从它在YouTube上的应用可以看到在高性能站点上网络时延有20%以上的降低,传输带宽也有提高。
Linux允许我们调整拥塞控制算法但是正确地设置参数还需要深入理解拥塞控制对TCP连接的影响。这一讲我们将沿着网络如何影响发送速度这条线看看如何调整Linux下的拥塞控制参数。
## 慢启动阶段如何调整初始拥塞窗口?
上一讲谈到,只要接收方的读缓冲区足够大,就可以通过报文中的接收窗口,要求对方更快地发送数据。然而,网络的传输速度是有限的,它会直接丢弃超过其处理能力的报文。而发送方只有在重传定时器超时后,才能发现超发的报文被网络丢弃了,发送速度提不上去。更为糟糕的是,如果网络中的每个连接都按照接收窗口尽可能地发送更多的报文时,就会形成恶性循环,最终超高的网络丢包率会使得每个连接都无法发送数据。
解决这一问题的方案叫做拥塞控制它包括4个阶段我们首先来看TCP连接刚建立时的慢启动阶段。由于TCP连接会穿越许多网络所以最初并不知道网络的传输能力为了避免发送超过网络负载的报文TCP只能先调低发送窗口关于发送窗口你可以参考[[第11讲]](https://time.geekbang.org/column/article/239176)),减少飞行中的报文来让发送速度变慢,这也是“慢启动”名字的由来。
让发送速度变慢是通过引入拥塞窗口全称为congestion window缩写为CWnd类似地接收窗口叫做rwnd发送窗口叫做swnd实现的它用于避免网络出现拥塞。上一讲我们说过如果不考虑网络拥塞发送窗口就等于对方的接收窗口而考虑了网络拥塞后发送窗口则应当是拥塞窗口与对方接收窗口的最小值
```
swnd = min(cwnd, rwnd)
```
这样,发送速度就综合考虑了接收方和网络的处理能力。
虽然窗口的计量单位是字节但为了方便理解通常我们用MSS作为描述窗口大小的单位其中MSS是TCP报文的最大长度。
如果初始拥塞窗口只有1个MSS当MSS是1KB而RTT时延是100ms时发送速度只有10KB/s。所以当没有发生拥塞时拥塞窗口必须快速扩大才能提高互联网的传输速度。因此慢启动阶段会以指数级扩大拥塞窗口扩大规则是这样的发送方每收到一个ACK确认报文拥塞窗口就增加1个MSS比如最初的初始拥塞窗口也称为initcwnd是1个MSS经过4个RTT就会变成16个MSS。
虽然指数级提升发送速度很快但互联网中的很多资源体积并不大多数场景下在传输速度没有达到最大时资源就已经下载完了。下图是2010年Google对Web对象大小的CDF累积分布统计大多数对象在10KB左右。
<img src="https://static001.geekbang.org/resource/image/1a/23/1a93f6622d30ef5a138b37ce7f94e323.png" alt="" title="图片来源《An Argument for Increasing TCP's Initial Contestion Window》">
这样当MSS是1KB时多数HTTP请求至少包含10个报文即使以指数级增加拥塞窗口也需要至少4个RTT才能传输完参见下图
<img src="https://static001.geekbang.org/resource/image/86/5c/865b14ad43c828fdf494b541bb810f5c.png" alt="">
因此2013年TCP的初始拥塞窗口调整到了10个MSS参见[RFC6928](https://tools.ietf.org/html/rfc6928)这样1个RTT内就可以传输10KB的请求。然而如果你需要传输的对象体积更大BDP带宽时延积很大时完全可以继续提高初始拥塞窗口的大小。下图是2014年、2017年全球主要CDN厂商初始拥塞窗口的变化可见随着网速的增加初始拥塞窗口也变得更大了。
[<img src="https://static001.geekbang.org/resource/image/20/d2/20bdd4477ed2d837d398a3b43020abd2.png" alt="" title="图片来源https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/">](https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/)
因此你可以根据网络状况和传输对象的大小调整初始拥塞窗口的大小。调整前先要清楚你的服务器现在的初始拥塞窗口是多大。你可以通过ss命令查看当前拥塞窗口
```
# ss -nli|fgrep cwnd
cubic rto:1000 mss:536 cwnd:10 segs_in:10621866 lastsnd:1716864402 lastrcv:1716864402 lastack:1716864402
```
再通过ip route change命令修改初始拥塞窗口
```
# ip route | while read r; do
ip route change $r initcwnd 10;
done
```
当然,更大的初始拥塞窗口以及指数级的提速,连接很快就会遭遇网络拥塞,从而导致慢启动阶段的结束。
## 出现网络拥塞时该怎么办?
以下3种场景都会导致慢启动阶段结束
1. 通过定时器明确探测到了丢包;
1. 拥塞窗口的增长到达了慢启动阈值ssthresh全称为slow start threshold也就是之前发现网络拥塞时的窗口大小
1. 接收到重复的ACK报文可能存在丢包。
我们先来看第1种场景在规定时间内没有收到ACK报文这说明报文丢失了网络出现了严重的拥塞必须先降低发送速度再进入拥塞避免阶段。不同的拥塞控制算法降低速度的幅度并不相同比如CUBIC算法会把拥塞窗口降为原先的0.8倍也就是发送速度降到0.8倍)。此时,我们知道了多大的窗口会导致拥塞,因此可以把慢启动阈值设为发生拥塞前的窗口大小。
再看第2种场景虽然还没有发生丢包但发送方已经达到了曾经发生网络拥塞的速度拥塞窗口达到了慢启动阈值接下来发生拥塞的概率很高所以进入**拥塞避免阶段,此时拥塞窗口不能再以指数方式增长,而是要以线性方式增长**。接下来拥塞窗口会以每个RTT增加1个MSS的方式代替慢启动阶段每收到1个ACK就增加1个MSS的方式。这里可能有同学会有疑问在第1种场景发生前慢启动阈值是多大呢事实上[RFC5681](https://tools.ietf.org/html/rfc5681#page-5) 建议最初的慢启动阈值尽可能的大这样才能在第1、3种场景里快速发现网络瓶颈。
第3种场景最为复杂。我们知道TCP传输的是字节流而“流”是天然有序的。因此当接收方收到不连续的报文时就可能发生报文丢失或者延迟等待发送方超时重发太花时间了为了缩短重发时间**快速重传算法便应运而生。**
当连续收到3个重复ACK时发送方便得到了网络发生拥塞的明确信号通过重复ACK报文的序号我们知道丢失了哪个报文这样不等待定时器的触发立刻重发丢失的报文可以让发送速度下降得慢一些这就是快速重传算法。
出现拥塞后,发送方会缩小拥塞窗口,再进入前面提到的拥塞避免阶段,用线性速度慢慢增加拥塞窗口。然而,**为了平滑地降低速度,发送方应当先进入快速恢复阶段,在失序报文到达接收方后,再进入拥塞避免阶段。**
那什么是快速恢复呢我们不妨把网络看成一个容器上一讲中说过它可以容纳BDP字节的报文每当接收方从网络中取出一个报文发送方就可以增加一个报文。当发送方接收到重复ACK时可以推断有失序报文离开了网络到达了接收方的缓冲区因此可以再多发送一个报文。如下图所示
<img src="https://static001.geekbang.org/resource/image/92/d9/92980476c93766887cc260f03c5d50d9.png" alt="">
这里你要注意第6个报文在慢启动阶段丢失接收方收到失序的第7个报文会触发快速重传算法它必须立刻返回ACK6。而发送方接收到第1个重复ACK6报文时就从慢启动进入了快速重传阶段**此刻的重复ACK不会扩大拥塞窗口。**当连续收到3个ACK6时发送方会重发报文6并把慢启动阈值和拥塞窗口都降到之前的一半3个MSS再进入快速恢复阶段。按照规则由于收到3个重复ACK所以拥塞窗口会增加3个MSS。之后收到的2个ACK让拥塞窗口增加到了8个MSS直到收到期待的ACK12发送方才会进入拥塞避免阶段。
慢启动、拥塞避免、快速重传、快速恢复共同构成了拥塞控制算法。Linux上提供了更改拥塞控制算法的配置你可以通过tcp_available_congestion_control配置查看内核支持的算法列表
```
net.ipv4.tcp_available_congestion_control = cubic reno
```
再通过tcp_congestion_control配置选择一个具体的拥塞控制算法
```
net.ipv4.tcp_congestion_control = cubic
```
但有件事你得清楚,拥塞控制是控制网络流量的算法,主机间会互相影响,在生产环境更改之前必须经过完善的测试。
## 基于测量的拥塞控制算法
上文介绍的是传统拥塞控制算法,它是以丢包作为判断拥塞的依据。然而,网络刚出现拥塞时并不会丢包,而真的出现丢包时,拥塞已经非常严重了。如下图所示,像路由器这样的网络设备,都会有缓冲队列应对突发的、超越处理能力的流量:
<img src="https://static001.geekbang.org/resource/image/47/85/4732f8f97aefcb26334f4e7d1d096185.png" alt="">
当缓冲队列为空时,传输速度最快。一旦队列开始积压,每个报文的传输时间需要增加排队时间,网速就变慢了。而当队列溢出时,才会出现丢包,基于丢包的拥塞控制算法在这个时间点进入拥塞避免阶段,显然太晚了。因为升高的网络时延降低了用户体验,而且从丢包到重发这段时间,带宽也会出现下降。
进行拥塞控制的最佳时间点,是缓冲队列刚出现积压的时刻,**此时,网络时延会增高,但带宽维持不变,这两个数值的变化可以给出明确的拥塞信号**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/2c/ca/2cbda9079294ed5da6617f0f0e83acca.png" alt="" title="图片来源网络传输速度_RTT与飞行报文的关系">
这种以测量带宽、时延来确定拥塞的方法在丢包率较高的网络中应用效果尤其好。2016年Google推出的BBR算法全称Bottleneck Bandwidth and Round-trip propagation time就是测量驱动的拥塞控制算法它在YouTube站点上应用后使得网络时延下降了20%以上传输带宽也有5%左右的提升。
当然,测量驱动的拥塞算法并没有那么简单,因为网络会波动,线路也会变化,算法必须及时地响应网络变化,这里不再展开算法细节,你可以在我的[这篇博客](https://www.taohui.pub/2019/08/07/%e4%b8%80%e6%96%87%e8%a7%a3%e9%87%8a%e6%b8%85%e6%a5%9agoogle-bbr%e6%8b%a5%e5%a1%9e%e6%8e%a7%e5%88%b6%e7%ae%97%e6%b3%95%e5%8e%9f%e7%90%86/)中找到BBR算法更详细的介绍。
Linux 4.9版本之后都支持BBR算法开启BBR算法仍然使用tcp_congestion_control配置
```
net.ipv4.tcp_congestion_control=bbr
```
## 小结
我们对这一讲的内容做个小结。
当TCP连接建立成功后拥塞控制算法就会发生作用首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口Linux上可以通过route ip change命令修改它。通常在带宽时延积较大的网络中应当调高初始拥塞窗口。
丢包以及重复的ACK都是明确的拥塞信号此时发送方就会调低拥塞窗口减速同时修正慢启动阈值。这样将来再次到达这个速度时就会自动进入拥塞避免阶段用线性速度代替慢启动阶段的指数速度提升窗口大小。
当然重复ACK意味着发送方可以提前重发丢失报文快速重传算法定义了这一行为。同时为了使得重发报文的过程中发送速度不至于出现断崖式下降TCP又定义了快速恢复算法发送方在报文重新变得有序后结束快速恢复进入拥塞避免阶段。
但以丢包作为网络拥塞的信号往往为时已晚于是以BBR算法为代表的测量型拥塞控制算法应运而生。当飞行中报文数量不变而网络时延升高时就说明网络中的缓冲队列出现了积压这是进行拥塞控制的最好时机。Linux高版本支持BBR算法你可以通过tcp_congestion_control配置更改拥塞控制算法。
## 思考题
最后,请你思考下,快速恢复阶段的拥塞窗口,在报文变得有序后反而会缩小,这是为什么?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="13 | 实战:单机如何实现管理百万主机的心跳服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/3b/5a3eeb1aa7620126176097d6fa78a23b.mp3"></audio>
你好,我是陶辉。
这一讲我们将结合前12讲以一个可管理百万主机集群的心跳服务作为实战案例看看所有高性能服务的设计思路。
首先解释下什么是心跳服务。集群中的主机如果宕机,那么管理服务必须及时发现,并做相应的容灾处理,比如将宕机主机的业务迁移到新的虚拟机上等等。怎么做到及时发现呢?可以要求每台主机定时上报心跳包,考虑到网络报文的延迟,如果管理服务在几个上报周期内未收到心跳,则认为主机宕机。当新主机加入集群后,心跳服务也可以及时识别并告知管理服务。
这就是心跳服务要解决的核心问题虽然很简单可是如果集群规模达到百万台虚拟机或者微服务进程这就不再简单了。多核CPU、内存使用效率、网络带宽时延积等都必须纳入你的考虑因为此时心跳包占用的网络带宽已经接近网卡上限仅调动一颗CPU的计算力去处理就会大量丢包百万级的对象、网络连接也很容易造成内存OOM。甚至判断宕机的算法都要重新设计降低时间复杂度后才能够应对超大集群的心跳管理。
解决这种集群规模下的性能问题,需要深入掌握底层基础知识,用系统化的全局思维去解决问题,这也是程序员薪资差异的重要分水岭。接下来,我们先实现更高效的核心算法,再设计高并发服务的架构,最后再来看传输层协议的选择。
## 如何设计更快的宕机判断算法?
通过心跳包找到宕机的主机需要一套算法比如用for循环做一次遍历找到停止上报心跳的主机就可以实现然而正如[[第 3 讲]](https://time.geekbang.org/column/article/232351) 所说,**当管理的对象数量级很大时,算法复杂度会严重影响程序性能**,遍历算法此时并不可取。我们先分析下这个算法的时间复杂度。
如果用红黑树这里用红黑树是因为它既支持遍历也可以实现对数时间复杂度的查询操作存放主机及其最近一次上报时间那么新主机上报心跳被发现的流程时间复杂度仅为O(logN)这是查询红黑树的成本。寻找宕机服务的流程需要对红黑树做全量遍历用当前时间去比较每个主机的上次心跳时间时间复杂度就是O(N)
如果业务对时间灵敏度要求很高就意味着需要频繁地执行O(N)级的遍历当N也就是主机数量很大时耗时就很可观了。而且寻找宕机服务和接收心跳包是两个流程如果它们都在单线程中执行那么寻找宕机服务的那段时间就不能接收心跳包会导致丢包如果使用多线程并发执行因为两个流程都需要操作红黑树所以要使用到互斥锁而当这两个流程争抢锁的频率很高时性能也会急剧下降。
**其实这个算法的根本问题在于,判断宕机的流程做了大量的重复工作。**比如主机每隔1秒上报一次心跳而考虑到网络可能丢包故5秒内失去心跳就认为宕机这种情况下如果主机A在第10秒时失去心跳那么第11、12、13、14这4秒对主机A的遍历都是多余的只有第15秒对主机A的遍历才有意义。于是每次遍历平均浪费了4/5的计算量。
如何设计快速的宕机判断算法呢?其实,这是一个从一堆主机中寻找宕机服务的信息题。**根据香农的理论,引入更多的信息,才能减少不确定性降低信息熵,从而减少计算量。就像心跳包间是有时间顺序的,上面的宕机判断算法显然忽略了接收到它们的顺序。**比如主机A的上次心跳包距现在4秒了而主机B距现在只有1秒显然不应同等对待。
于是我们引入存放心跳包的先入先出队列这就保存了心跳包的时序关系。新的心跳包进入队列尾部而老的心跳包则从队列首部退出这样寻找宕机服务时只要看队列首部最老的心跳包距现在是否超过5秒如果超过5秒就认定宕机同时把它取出队列否则证明队列中不存在宕机服务维持队列不变。
当然这里并没有解决如何发现新主机的问题。我们还需要一个能够执行高效查询的容器存放所有主机及其状态。红黑树虽然不慢但我们不再需要遍历容器所以可以选择更快的、查询时间复杂度为O(1)的哈希表存放主机信息(非标哈希表的实现参见[[第 3 讲]](https://time.geekbang.org/column/article/232351))。如下图所示。
<img src="https://static001.geekbang.org/resource/image/32/41/322fff00d232ebc1c85694babfb37541.png" alt="">
当然队列中的心跳包并不是只能从队首删除否则判断宕机流程的时间复杂度仍然是O(N)。实际上,每当收到心跳包时,如果对应主机的上一个心跳包还在队列中,那么可以直接把它从队列中删除。显然,计算在线主机何时宕机,只需要最新的心跳包,老的心跳包没有必要存在。因此,这个队列为每个主机仅保留最新的那个心跳包。如下图所示:
<img src="https://static001.geekbang.org/resource/image/97/94/97cae81094f213896989158cf9baf594.png" alt="">
这样判断宕机的速度会非常快它的计算量等于实际发生宕机的主机数量。同时接收心跳包并发现新主机的流程因为只需要做一次哈希表查询时间复杂度也只有O(1)。
<img src="https://static001.geekbang.org/resource/image/6b/d9/6ba31e4046ba0f578659bf423e4553d9.png" alt="">
这样,新算法通过**以空间换时间**的思想,虽然使用了更加占用空间的哈希表,并新增了有序队列容器,但将宕机和新主机发现这两个流程都优化到了常量级的时间复杂度。尤其是宕机流程的计算量非常小,它仅与实际宕机服务的数量有关,这就允许我们将宕机判断流程插入到心跳包的处理流程中,以微观上的分时任务实现宏观上的并发,同时也避免了对哈希表的加锁。
## 如何设计高并发架构?
有了核心算法,还需要充分利用服务器资源的架构,才能实现高并发。
一颗1GHZ主频的CPU意味着一秒钟只有10亿个时钟周期可以工作如果心跳服务每秒接收到100万心跳包就要求它必须在1000个时钟周期内处理完一个心跳包。这无法做到因为每一个汇编指令的执行需要多个时钟周期参见[CPI](https://en.wikipedia.org/wiki/Cycles_per_instruction)一条高级语言的语句又由多条汇编指令构成而中间件提供的反序列化等函数又需要很多条语句才能完成。另外内核从网卡上读取报文执行协议分析需要的时钟周期也要算到这1000个时钟周期里。
因此选择只用一颗CPU为核心的单线程开发模式一定会出现计算力不足不能及时接收报文从而使得缓冲区溢出的问题最终导致大量丢包。所以我们必须选择多线程或者多进程开发模式。多进程之间干扰更小但内存不是共享的数据同步较为困难因此案例中我们还是选择多线程开发模式。
使用多线程后我们需要解决3个问题。
第一是负载均衡我们应当把心跳包尽量均匀分配到不同的工作线程上处理。比如接收网络报文的线程基于主机名或者IP地址用哈希算法将心跳包分发给工作线程处理这样每个工作线程只处理特定主机的心跳相互间不会互相干扰从而可以无锁编程。
第二是多线程同步。分发线程与工作线程间可以采用生产者-消费者模型传递心跳包,然而多线程间传递数据要加锁,为了减少争抢锁对系统资源的消耗,需要做到以下两点:
- 由于工作线程多过分发线程(接收心跳包消耗的资源更少),所以每个工作线程都配发独立的缓冲队列及操作队列的互斥锁;
- 为避免线程执行主动切换,必须使用自旋锁,关于锁的选择你可以看[[第 6 讲]](https://time.geekbang.org/column/article/234548)。如下图所示:
<img src="https://static001.geekbang.org/resource/image/27/76/2726b6c9b73325583f8491a822a22476.png" alt="">
第三要解决CPU亲和性问题。从[[第 1 讲]](https://time.geekbang.org/column/article/232351) 我们可以看到CPU缓存对计算速度的影响很大如果线程频繁地切换CPU会导致缓存命中率下降降低性能此时将线程绑定到特定的CPU就是一个解决方案NUMA架构也会对CPU亲和性产生影响这里略过
这样通过上述的多线程架构就可以有效地使用CPU。当然除了CPU内存的使用效率也很重要。[[第2讲]](https://time.geekbang.org/column/article/230221) 中我们提到TCMalloc相比Linux默认的PtMalloc2内存池在多线程下分配小块内存的速度要快得多所以对于心跳服务应当改用TCMalloc申请内存。而且如果心跳包对象的格式已经固定你还可以建立一个心跳包资源池循环往复的使用这进一步减少了构造、销毁心跳包对象所消耗的计算力。
由于服务重启后一个心跳周期内就可以获得所有心跳包,所以并不需要将数据持久化到磁盘上。如果你想进一步了解磁盘优化,可以再看下[[第 4 讲]](https://time.geekbang.org/column/article/232676)。
## 如何选择心跳包网络协议?
最后我们再来看看心跳包的协议该选择TCP还是UDP实现。
网络报文的长度是受限的,[MTU](https://zh.wikipedia.org/wiki/%E6%9C%80%E5%A4%A7%E4%BC%A0%E8%BE%93%E5%8D%95%E5%85%83)Maximum Transmission Unit定义了最大值。比如以太网中MTU是1500字节如果TCP或者UDP试图传送大于1500字节的报文IP协议就会把报文拆分后再发到网络中并在接收方组装回原来的报文。然而IP协议并不擅长做这件事拆包组包的效率很低因此TCP协议宁愿自己拆包详见[[第 11 讲]](https://time.geekbang.org/column/article/239176))。
所以如果心跳包长度小于MTU那么UDP协议是最佳选择。如果心跳包长度大于MTU那么最好选择TCP协议面对复杂的TCP协议还需要解决以下问题。
首先一台服务器到底能同时建立多少TCP连接要回答这个问题得先从TCP四元组谈起它唯一确定一个TCP连接。TCP四元组分别是&lt;源IP、目的IP、源端口、目的端口&gt;其中前两者在IP头部中后两者在TCP头部中。
<img src="https://static001.geekbang.org/resource/image/e0/2f/e05b4dcffa30fe5ec5a3a85511f0db2f.png" alt="">
由于IPv4地址为4个字节参见[[第 7 讲]](https://time.geekbang.org/column/article/235302)、端口为2个字节所以当服务器IP地址和监听端口固定时并发连接数的上限则是2<sup>(32+16)</sup>
当然,这么高的并发连接需要很多条件,其中之一就是增加单个进程允许打开的最大句柄数(包括操作系统允许的最大句柄数/proc/sys/fs/file-nr因为Linux下每个连接都要用掉一个文件句柄。当然作为客户端的主机如果想用足2<sup>16</sup>&nbsp;端口还得修改ip_local_port_range配置扩大客户端的端口范围
```
net.ipv4.ip_local_port_range = 32768 60999
```
其次基于TCP协议实现百万级别的高并发必须使用基于事件驱动的全异步开发模式参见[[第 8 讲]](https://time.geekbang.org/column/article/236921)。而且TCP协议的默认配置并没有考虑高并发场景所以我们还得在以下4个方面优化TCP协议
1. 三次握手建立连接的过程需要优化,详见[[第 9 讲]](https://time.geekbang.org/column/article/237612)
1. 四次挥手关闭连接的过程也需要优化,详见[[第 10 讲]](https://time.geekbang.org/column/article/238388)
1. 依据网络带宽时延积重新设置TCP缓冲区详见[[第 11讲]](https://time.geekbang.org/column/article/239176)
1. 优化拥塞控制算法,详见[[第 12 讲]](https://time.geekbang.org/column/article/239621)。
最后还有一个问题需要我们考虑。网络中断时并没有任何信息通知服务器此时该如何发现并清理服务器上的这些僵死连接呢KeepAlive机制允许服务器定时向客户端探测连接是否存活。其中每隔tcp_keepalive_time秒执行一次探测。
```
net.ipv4.tcp_keepalive_time = 7200
```
每次探测的最大等待时间是tcp_keepalive_intvl 秒。
```
net.ipv4.tcp_keepalive_intvl = 75
```
超时后内核最多尝试tcp_keepalive_probes次仍然没有反应就会及时关闭连接。
```
net.ipv4.tcp_keepalive_probes = 9
```
当然如果在应用层通过心跳能及时清理僵死TCP连接效果会更好。
从上述优化方案可见TCP协议的高并发优化方案还是比较复杂的这也是享受TCP优势时我们必须要付出的代价。
## 小结
这一讲以我实践过的项目为案例,介绍了高并发服务的设计思路。
核心算法对性能的影响最大为了设计出高效的算法我们必须分析出时间复杂度充分寻找、利用已知信息减少算法的计算量。在心跳服务这个案例中利用好心跳包的时序就可以把计算宕机的时间复杂度从O(N) 降为O(1)。
有了好的算法还需要好的架构才能高效地调动系统资源。当摩尔定律在CPU频率上失效后CPU都在向多核发展所以高性能必须充分使用多核的计算力。此时我们需要谨慎设计多线程间的负载均衡和数据同步尽量减少访问共享资源带来的损耗。选择与业务场景匹配的内存池也很重要对于RPS上百万的服务来说申请内存的时间不再是一个忽略项。
选择网络协议时如果消息长度大于MTU那么选择TCP更有利但TCP解决了流控、可靠性等很多问题优化起来较为困难。对于不要求可靠传输长度通常不大的心跳包来说UDP协议通常是更好的选择。
## 思考题
最后,还是留给你点思考题。你遇到过心跳服务吗?它是怎么设计的?还有哪些优化空间?欢迎你在留言区与我探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。