千万级并发实现的秘密:内核不是解决方案,而是问题所在

1ZT:千万级并发实现的秘密:内核不是解决方案,而是问题所在!

摘要:C10K问题让我们意识到:当并发连接达到10K时,选择不同的解决方案,笔记本性能可能会超过16核服务器。对于C10K问题,我们或绕过,或克服;然而随着并发逐渐增多,在这个后10K的时代里,你是否有想过如何去克服C10M。

既然我们已经解决了C10K并发连接问题,应该如何提高水平支持千万级并发连接?你可能会说不可能。不,现在系统已经在用你可能不熟悉甚至激进的方式支持千万级别的并发连接。

要知道它是如何做到的,我们首先要了解ErrataSecurity的CEORobertGraham,以及他在Shmoocon2013大会上的“无稽之谈”——C10MDefendingTheInternetAtScale。

Robert用一种我以前从未听说的方式来很巧妙地解释了这个问题。他首先介绍了一点有关Unix的历史,Unix的设计初衷并不是一般的服务器操作系统,而是电话网络的控制系统。由于是实际传送数据的电话网络,所以在控制层和数据层之间有明确的界限。问题是我们现在根本不应该使用Unix服务器作为数据层的一部分。正如设计只运行一个应用程序的服务器内核,肯定和设计多用户的服务器内核是不同的。

也就是他所说的——关键要理解内核不是解决办法,内核是问题所在。

这意味着:

不要让内核执行所有繁重的任务。将数据包处理,内存管理,处理器调度等任务从内核转移到应用程序高效地完成。让Linux只处理控制层,数据层完全交给应用程序来处理。

最终就是要设计这样一个系统,该系统可以处理千万级别的并发连接,它在200个时钟周期内处理数据包,在14万个时钟周期内处理应用程序逻辑。由于一次主存储器访问就要花费300个时钟周期,所以这是最大限度的减少代码和缓存丢失的关键。

面向数据层的系统可以每秒处理1千万个数据包,面向控制层的系统,每秒只能处理1百万个数据包。

这似乎很极端,请记住一句老话:可扩展性是专业化的。为了做好一些事情,你不能把性能问题外包给操作系统来解决,你必须自己做。

现在,让我们学习Robert如何创建一个能够处理千万级别并发连接的系统。

C10K问题——最近十年

十年前,工程师处理C10K可扩展性问题时,尽量避免服务器处理超过1万个的并发连接。通过改进操作系统内核以及用事件驱动服务器(如Nginx和Node)代替线程服务器(Apache),这个问题已经被解决。人们用十年的时间从Apache转移到可扩展服务器,在近几年,可扩展服务器的采用率增长得更快了。

Apache的问题

Apache的问题在于服务器的性能会随着连接数的增多而变差

关键点:性能和可扩展性并不是一回事。当人们谈论规模时,他们往往是在谈论性能,但是规模和性能是不同的,比如Apache。

持续几秒的短期连接,比如快速事务,如果每秒处理1000个事务,只有约1000个并发连接到服务器。

事务延长到10秒,要维持每秒1000个事务,必须打开1万个并发连接。这种情况下:尽管你不顾DoS攻击,Apache也会性能陡降;同时大量的下载操作也会使Apache崩溃。

如果每秒处理的连接从5千增加到1万,你会怎么做?比方说,你升级硬件并且提高处理器速度到原来的2倍。发生了什么?你得到两倍的性能,但你没有得到两倍的处理规模。每秒处理的连接可能只达到了6000。你继续提高速度,情况也没有改善。甚至16倍的性能时,仍然不能处理1万个并发连接。所以说性能和可扩展性是不一样的。

问题在于Apache会创建一个CGI进程,然后关闭,这个步骤并没有扩展。

为什么呢?内核使用的O(N^2)算法使服务器无法处理1万个并发连接。

内核中的两个基本问题:

连接数=线程数/进程数。当一个数据包进来,内核会遍历其所有进程以决定由哪个进程来处理这个数据包。

连接数=选择数/轮询次数(单线程)。同样的可扩展性问题,每个包都要走一遭列表上所有的socket。

解决方法:改进内核使其在常数时间内查找。

使线程切换时间与线程数量无关。

使用一个新的可扩展epoll()/IOCompletionPort常数时间去做socket查询。

因为线程调度并没有得到扩展,所以服务器大规模对socket使用epoll方法,这样就导致需要使用异步编程模式,而这些编程模式正是Nginx和Node类型服务器具有的;所以当从Apache迁移到Nginx和Node类型服务器时,即使在一个配置较低的服务器上增加连接数,性能也不会突降;所以在10K连接时,一台笔记本电脑的速度甚至超过了16核的服务器。

C10M问题——未来十年

不远的将来,服务器将要处理数百万的并发连接。IPv6协议下,每个服务器的潜在连接数都是数以百万级的,所以处理规模需要升级。

如IDS/IPS这类应用程序需要支持这种规模,因为它们连接到一个服务器骨干网。其他例子:DNS根服务器,TOR节点,互联网Nmap,视频流,银行,CarrierNAT,VoIPPBX,负载均衡器,网页缓存,防火墙,电子邮件接收,垃圾邮件过滤。

通常人们将互联网规模问题归根于应用程序而不是服务器,因为他们卖的是硬件+软件。你买设备,并将其应用到你的数据中心。这些设备可能包含一块Intel主板或网络处理器以及用来加密和检测数据包的专用芯片等。

截至2013年2月,40gpbs,32-cores,256gigsRAM的X86服务器在Newegg网站上的报价是5000美元。该服务器可以处理1万个以上的并发连接,如果它们不能,那是因为你选择了错误的软件,而不是底层硬件的问题。这个硬件可以很容易地扩展到1千万个并发连接。

10M的并发连接挑战意味着什么:

1千万的并发连接数

100万个连接/秒——每个连接以这个速率持续约10秒

10GB/秒的连接——快速连接到互联网。

1千万个数据包/秒——据估计目前的服务器每秒处理50K的数据包,以后会更多。过去服务器每秒可以处理100K的中断,并且每一个数据包都产生中断。

10微秒的延迟——可扩展服务器也许可以处理这个规模,但延迟可能会飙升。

10微秒的抖动——限制最大延迟

并发10核技术——软件应支持更多核的服务器。通常情况下,软件能轻松扩展到四核。服务器可以扩展到更多核,因此需要重写软件,以支持更多核的服务器。

我们所学的是Unix而不是网络编程

很多程序员通过W.RichardStevens所著的《Unix网络编程》学习网络编程技术。问题是,这本书是关于Unix的,而不只是网络编程。它告诉你,让Unix做所有繁重的工作,你只需要在Unix的上层写一个小服务器。但内核规模不够,解决的办法是尽可能将业务移动到内核之外,并且自己处理所有繁重的业务。

这方面有影响的一个例子是Apache每个连接线程的模型。这意味着线程调度程序根据将要到来的数据确定接下来调用哪一个read()函数,也就是把线程调度系统当作数据包调度系统来用。(我真的很喜欢这一点,从来没有想过这样的说法)。

Nginx宣称,它不把线程调度当作数据包调度程序,而是自己进行数据包调度。使用select找到socket,我们知道数据来了,就可以立即读取并处理数据,数据也不会堵塞。

经验:让Unix处理网络堆栈,但之后的业务由你来处理。

怎样编写规模较大的软件?

如何改变你的软件,使其规模化?许多只提升硬件性能去支撑项目扩展的经验都是错误的,我们需要知道性能的实际情况。

要达到到更高的水平,需要解决的问题如下:

数据包的可扩展性

多核的可扩展性

内存的可扩展性

实现数据包可扩展——编写自己的个性化驱动来绕过堆栈

数据包的问题是它们需经Unix内核的处理。网络堆栈复杂缓慢,数据包最好直接到达应用程序,而非经过操作系统处理之后。

做到这一点的方法是编写自己的驱动程序。所有驱动程序将数据包直接发送到应用程序,而不是通过堆栈。你可以找到这种驱动程序:PF_RING,NETMAP,IntelDPDK(数据层开发套件)。Intel不是开源的,但有很多相关的技术支持。

速度有多快?Intel的基准是在一个相当轻量级的服务器上,每秒处理8000万个数据包(每个数据包200个时钟周期)。这也是通过用户模式。将数据包向上传递,使用用户模式,处理完毕后再返回。Linux每秒处理的数据包个数不超过百万个,将UDP数据包提高到用户模式,再次出去。客户驱动程序和Linux的性能比是80:1。

对于每秒1000万个数据包的目标,如果200个时钟周期被用来获取数据包,将留下1400个时钟周期实现类似DNS/IDS的功能。

通过PF_RING得到的是原始数据包,所以你必须做你的TCP堆栈。人们所做的是用户模式栈。Intel有现成的可扩展TCP堆栈

多核的可扩展性

多核可扩展性不同于多线程可扩展性。我们都熟知这个理念:处理器的速度并没有变快,我们只是靠增加数量来达到目的。

大多数的代码都未实现4核以上的并行。当我们添加更多内核时,下降的不仅仅是性能等级,处理速度可能也会变得越来越慢,这是软件的问题。我们希望软件的提高速度同内核的增加接近线性正相关。

多线程编程不同于多核编程

多线程

每个CPU内核中不止一个线程

用锁来协调线程(通过系统调用)

每个线程有不同的任务

多核

每个CPU内核中只有一个线程

当两个线程/内核访问同一个数据时,不能停下来互相等待

同一个任务的不同线程

要解决的问题是怎样将一个应用程序分布到多个内核中去

Unix中的锁在内核实现。4内核使用锁的情况是大多数软件开始等待其他线程解锁。因此,增加内核所获得的收益远远低于等待中的性能损耗。

我们需要这样一个架构,它更像高速公路而不是红绿灯控制的十字路口,无需等待,每个人都以自己的节奏行进,尽可能节省开销。

解决方案:

在每个核心中保存数据结构,然后聚合的对数据进行读取。

原子性。CPU支持可以通过C语言调用的指令,保证原子性,避免冲突发生。开销很大,所以不要处处使用。

无锁的数据结构。线程无需等待即可访问,在不同的架构下都是复杂的工作,请不要自己做。

线程模型,即流水线与工作线程模型。这不只是同步的问题,而是你的线程如何架构。

处理器关联。告诉操作系统优先使用前两个内核,然后设置线程运行在哪一个内核上,你也可以通过中断到达这个目的。所以,CPU由你来控制而不是Linux。

内存的可扩展性

如果你有20G的RAM,假设每次连接占用2K的内存,如果你还有20M的三级缓存,缓存中会没有数据。数据转移到主存中处理花费300个时钟周期,此时CPU没有做任何事情。

每个数据包要有1400个时钟周期(DNS/IDS的功能)和200个时钟周期(获取数据包)的开销,每个数据包我们只有4个高速缓存缺失,这是一个问题。

联合定位数据

不要通过指针在满内存乱放数据。每次你跟踪一个指针,都会是一个高速缓存缺失:[hashpointer]->[TaskControlBlock]->[Socket]->[App],这是四个高速缓存缺失。

保持所有的数据在一个内存块:[TCB|socket|APP]。给所有块预分配内存,将高速缓存缺失从4减少到1。

分页

32GB的数据需占用64MB的分页表,不适合都存储在高速缓存。所以存在两个高速缓存缺失——分页表和它所指向的数据。这是开发可扩展的软件不能忽略的细节。

解决方案:压缩数据,使用有很多内存访问的高速缓存架构,而不是二叉搜索树

NUMA架构加倍了主存访问时间。内存可能不在本地socket,而是另一个socket上。

内存池

启动时立即预先分配所有的内存

在对象,线程和socket的基础上进行分配。

超线程

每个网络处理器最多可以运行4个线程,英特尔只能运行2个。

在适当的情况下,我们还需要掩盖延时,比如内存访问中一个线程在等待另一个全速的线程。

大内存页

减小页表规模。从一开始就预留内存,让你的应用程序管理内存。

总结

网卡

问题:通过内核工作效率不高

解决方案:使用自己的驱动程序并管理它们,使适配器远离操作系统。

CPU

问题:使用传统的内核方法来协调你的应用程序是行不通的。

解决方案:Linux管理前两个CPU,你的应用程序管理其余的CPU。中断只发生在你允许的CPU上。

内存

问题:内存需要特别关注,以求高效。

解决方案:在系统启动时就分配大部分内存给你管理的大内存页

控制层交给Linux,应用程序管理数据。应用程序与内核之间没有交互,没有线程调度,没有系统调用,没有中断,什么都没有。

然而,你有的是在Linux上运行的代码,你可以正常调试,这不是某种怪异的硬件系统,需要特定的工程师。你需要定制的硬件在数据层提升性能,但是必须是在你熟悉的编程和开发环境上进行。

相关推荐