在进入这个话题之前先说说通用和专业之间的区别。
举个很好的例子,好比我们个人,绝大部分的人都是“通用”的,而只有极少部分的人是“专业”的。通用的人主要目标是活下去,即在最坏的条件下如何活下去,而专业的人目标在于在特定领域内将能量发挥到极致,这时考虑的是最好的条件,简单点说,通用的人什么都做,衣食住行必须一样不落下,而专业的人只需要做好一件事,其它的事他未必搞得定。
本来就是闲聊,所以也就没有列什么提纲,这里再来说说TCP拥塞控制。已经跑了30多年的基于AIMD的Reno及其变体CUBIC被证明是优秀的,很多人搞不清楚为什么它是优秀的,难道BBR不是更好吗?不!Reno/CUBIC之优秀说的是,即使在最坏的网络环境中也能保证拥塞控制起作用,维持公平性,这背后的哲学是,资源匮乏时,不患寡而患不均,因为幂律无法维持能量总量的稳定性,而在资源充盈时,则放开让幂律起作用,所谓让一部分人先富起来。这在数学上,AIMD的收敛模型可以证明这个问题,这里就不再展开,展开的话就会涉及到控制论的问题了。
那么Reno/CUBIC作为一个通用的拥塞控制算法当然是绝佳的了,至于说BBR,姑且认为它的1.0版本是B4网络上专业的拥塞控制算法吧,然而直到目前,也没能从数学上证明BBR在最坏的网络环境下能维持公平,甚至都不能证明它和其它算法配合得有多好,所以说虽然在性能上BBR可以说在绝大多数场景下表现不错,但由于其没有通用性的特征,故而CUBIC还是有一定市场。
刚刚扯到了人和TCP,现在来看看操作系统。这里主要来看看宏内核的Linux。首先Linux就是一个通用的操作系统,它保证的是,即使CPU再垃圾,内存再少,它也能做一个名副其实的多任务现代操作系统跑多个进程。所谓的现代操作系统其含义包含了两种善意的欺骗,在空间维度上,操作系统的虚拟地址空间机制让每一个进程都认为自己独占了内存,在时间维度上,操作系统的时间片调度机制让每一个进程都认为自己独享了CPU时间,本质上,Linux把这两件事做好就OK了,至于说别的,像I/O啦,像TCP/IP啦,这些都是进程的事情。
但是貌似Linux内核代理了这些本不该由它管的事情,比如磁盘I/O是内核处理的,文件系统也是内核的一个子系统,TCP/IP协议栈就别说,也是内核的一个子系统,这是为什么?
答案在于外围硬件与CPU架构之间的不兼容,所以必须由操作系统内核来提供软件兼容层适配二者,此话怎讲?前面我提到,现代操作系统在时间和空间维度上提供了两类假象,作为一个可用的系统,除了CPU,内存以及操作系统之外,外设是必不可少的,不然如何接收输入和寄送输出,问题就在这里,输入和输出外设并无法提供上述两类假象,以磁盘为例,它永远就是那么个磁盘,进程1为它设置了一个status,然后系统切换进程2运行,进程2看到的磁盘状态就是进程1刚刚给它设置的,进程之间互相闯地方这肯定会乱套,并且违背了现代操作系统提供的隔离虚拟机模型,因此必须由操作系统出面协调干涉。这最终形成了内核文件系统,内核协议栈这种架构。从初衷上看,操作系统着实只是代理实现这些,这显然并非它的本职,然而,直到目前,虽然IOMMU机制可以提供I/O的空间假象,但是对于时间维度,即便是有了一些硬件指令有实现I/O时间片的意思,但大体上依然是操作系统的调度子系统在代理。
无论如何,现实就是这个样子,几十年来,这种机制工作的非常好,作为通用操作系统,最关键的是这种机制在以往条件艰苦恶劣的情况依然可以发挥作用。不过正如穷惯了的人就算发财也依然会存钱而不是投资一样,这种机制在当前高性能服务器设计领域,会不会已经成为阻碍可扩展性阻碍性能的掣肘之制呢?如果是,有没有什么办法可以改变这个现实。
解决方案呼之即来,即不能把单独任务包装成独立的操作系统进程任其去调度,而应该让独立的进程主动去处理每一个任务。
我们来看一个案例,即Nginx之于Apache。
我们知道Apache的prefork这种mpm,其它的也差不多。这是典型的甩锅给操作系统的行为。对于实现者,只需要去实现一个进程,然后处理单个HTTP Request即可,收到一个新连接就跑这么一个进程,同时处理两个连接就跑两个这样的进程,至于别的,管它呢,让调度器去管理吧,如果说一个连接的优先级大于另一个连接,Apache的做法显然是为高优先级连接的处理进程设定一个高的调度优先级,依然是交给调度器去处理…这种方案显然是不可扩展的,因为Apache的可扩展性受制于调度器的可扩展性。
再看Nginx。和Apache不同,Nginx使用固定的进程处理所有这些事,自己处理连接调度,自己处理每一个Request。具体的描述,可以看一下我写的这篇:
网络服务的两种处理模型(Nginx为什么比Apache好):https://blog.csdn.net/dog250/article/details/78994710
再来看另一个案例,即TCP/IP协议栈。
看看现状是什么。刚才说了,操作系统本不该实现TCP/IP协议栈的,只是不得已而代理实现,现在我可以再进一步说这事,不得已确实不得已,在硬件网卡层面确实需要操作系统来代理,但是难道不是把数据包扔到进程隔离的内存(BufferRing?嗯,是的!)中就OK了吗,接下来的事情就是数据包的协议栈处理了,这完全可以交给用户态进程啊。这个问法问Apache的时候,就有了Nginx,我们试着问下Apache:难道不是把Connection放到一个队列里就OK了吗?接下来的事情就是让一个进程去处理这个队列,干嘛还要搞那么多个进程…这意味着对于TCP/IP协议栈,有着某种用户态的解决方案。确实,不卖关子,netmap,DPDK这些都是。
我们来看下操作系统内核实现的TCP/IP协议栈为什么不好。微观上说,Linux内核协议栈扩展性不好,多核处理数据包特别是小包时pps/核数的曲线上凸跌落,大致阻碍线性扩展的因素无非中断,锁,软中断唤醒用户进程/切换,以及这些导致的Cache污染。但这些说再多也都是各个点,即便你各个击破了也未必能有什么质的飞跃。如果我们把这种传统实现的内核态TCP/IP协议栈看作是古老的方法,那么用古老的方法去处理当代的高性能高扩展时尚的话,实际上已经成一场高潮迭起的杂技表演,不管是网卡厂商,还是Linux社区,均为这场杂技表演增加了很多看点,随便举几个例子:
- Intel网卡RSS
- Intel网卡Interrupt Mode
- Intel网卡Interrupt Delay
- Intel网卡DCA
- 网卡各种Offload
- Linux NAPI
- Linux Busy Polling
- Linux RPS/RFS
- Linux中断线程化
- …
有了这些,说实话Linux内核协议栈已经工作地相当不错,但这些都属于技艺展示。肖像画家失业是因为照相机出现了,所以在新的东西面前,技艺是不堪一击的,如果没有照相机,也就不会出现立体主义,便不会有格尔尼卡这种作品。所以说,当这些优化技巧,优化组件越来越多的时候,应该从架构上颠覆旧方法的时刻就不远了。
那么内核协议栈的问题到底在哪里?很简单,和Apache的问题一样,只是不在一个层次而已。那就是中断!归根结底内核协议栈对数据包的处理还是依赖了操作系统的调度器。
一次中断来到,协议栈收包软中断便可能在任意上下文运行,我们宏观上讲,中断本身就是一次任务切换,即便是使能了单独的中断stack,即便是中断线程化,即便收包软中断全部在softirqd上下文执行,所有这一切都免不了一次任务切换,我们知道切换意味着什么,我也也知道在softirq的最上层,还会触发另一次调度,即wakeup用户态的处理进程,总而言之,涉及到数据包处理时,操作系统内核的调度子系统完全投入,最终的目标无非就是为了处理一个个单独的数据包!进程切换的代价是高昂的,现场保存,负载均衡,cache污染…由于中断的到来是不可控的,因此由这个中断所引发的一系列调度和切换就是不可控的,之所以敢这么折腾调度器,就是因为现代操作系统内核在设计上是闭环的,你怎么折腾它也是金刚不坏,它确实不会崩溃,它能工作,但也仅此而已。再次重申,通用操作系统大部分时间工作在恶劣环境下而不崩溃的just fine状态,而不是工作在精益求精的满血状态。
中断的问题在于,实时优先于吞吐,想要大吞吐,必然要轮询。我前面的文章写过,对于个人实时比吞吐重要,只有商人才会在乎吞吐,但又能如何,我们服务的就是商人,毕竟服务器是要卖钱获取收益的。
现在让我们看看正确的做法是什么。
不管是mtcp还是腾讯的F-Stack这种用户态协议栈实现,都代表了一种新势力,它们均是某种完全可用的协议栈实现,并且也都是开源的。但我想表达的不是为这些概念或者说产品做宣传,我想表达的是它们下层的东西,即一种新的架构。这种架构在概念上非常简单。以下步骤即可:
- 网卡的BufferRing被mmap到用户态进程;
- 网卡只管把数据包放入一个BufferRing,操作写标;
- 用户态进程循环读取BufferRing,操作读标;
- 用户态进程处理读取到的每一个packet。
实际的实现可能会有比较复杂,但大致如此。我们发现这是似曾相识的,没错,Nginx就是这样的架构,只是多了一个epoll的通知。看看Nginx如何在并发连接数上秒掉Apache,就知道用户态协议栈如何在pps上秒掉内核协议栈了。总之,两种情况下,罪魁祸首都是调度,而解决方案均是不能把单独任务包装成独立的操作系统进程任其去调度,而应该让独立的进程主动去处理每一个任务。
要去买小龙虾了,最后,简单说一下DPDK。
嗯,很多人都是DPDK粉,所以不能得罪。但DPDK毕竟是一个产品而不是一个作品,相对而言,我更加喜欢netmap,我自己玩的mtcp就是使能了netmap而不是DPDK。
DPDK的问题在于,为了把性能发挥到极致,采用了一种走火入魔的方法,这并不是一个大厂的风范。它会把处理进程和CPU做强绑定,然后Busy polling,这个CPU基本做不了别的什么事,这是一种粗狂型的优化方案,此外,大部分时候你之所以看到DPDK比netmap表现好,请不要忽略Intel自家的DCA,即直接缓存访问。DCA猛的很呐,你要是知道Cache miss的代价基本也就知道DCA的收益了。我们知道协议栈处理中最频繁的处理就是包头的处理,而我们知道,经过精心的设计,一直到TCP层,包头基本不占什么空间,因此DCA便可以通过旁路把包头提前送入Cache,这样在CPU处理时,就会Cache hit!厉害吧,软硬结合!
无关的内容,想到了,就说下。
- Reno:并行连接数量无关,冲突就腰斩降窗
- CUBIC:RTT无关,冲突就缩放
- BBR:缓存无关,…
Reno/CUBIC无限可扩展,BBR则不行。