现在我们已经攻克了C10K问题,那么我们如何升级并支持1000万并发连接?你会说这是不可能的。不是这样的,现在的系统正在使用一些他们可能不熟悉的激进技术来提供 1000 万个并发连接。为了了解它是如何完成的,我们求助于 Errata Security 首席执行官 Robert Graham,以及他在 Shmoocon 2013 上题为C10M Defending The Internet At Scale的精彩演讲。
Robert以一种巧妙的方式来阐述我以前从未听说过的问题。他从一些历史开始,讲述了 Unix 最初为何被设计为电话网络的控制系统,而不是通用服务器操作系统。实际传输数据的是电话网络,因此控制平面和数据平面之间应该有明确的分离。我们现在正在使用Unix服务作为数据平面的一部分,但是我们不应该这么做。如果我们设计一个内核来处理每台服务器一个应用程序,那么我们的设计将与多用户内核的设计有很大不同。这是为什么Robert说关键是要理解:内核不是解决方法,而是阻碍。这意味着不要让内核做非常繁重的工作。应将数据包处理内存管理处理器调度从内核中取出并放入应用程序中,以便可以高效地完成这些工作。让内核处理控制平面,让应用程序处理数据平面。如此一个系统可以管理千万并发连接,200个时钟周期用于处理数据包,14万时钟周期用于处理应用逻辑。由于主内存访问需要 300 个时钟周期,因此设计中最重要的是最小化代码和减少缓存未命中。使用面向数据平面的系统,您每秒可以处理 1000 万个数据包。使用面向控制平面的系统,每秒只能收到 100 万个数据包。如果这看起来很极端,请记住一句老话:可扩展性就是专门化。为了做一些伟大的事情,你不能将性能外包给操作系统。你必须自己做。现在,让我们了解 Robert 如何创建一个能够处理 1000 万个并发连接的系统…


C10K 问题 - 过去十年

在过去的10年,工程师们谈论C10K规模的问题,目标是使服务器处理超过10000个并发连接。这个问题是通过修复操作系统内核并从 Apache 等线程服务器转向 Nginx 和 Node 等事件驱动服务器来解决的。这个过程花了十年时间,人们已经从 Apache 转向可扩展的服务器。在最近几年中,我们看到可扩展服务器的采用速度更快。

Apache存在的问题

  • Apache的问题是连接越多,性能越差
  • 关键点:性能和可扩展性是正交的概念,它们并不意味着同一件事。当人们谈论扩展时,他们通常指的是性能,但扩展和性能之间是有区别的。正如我们将在Apache中看到的那样
  • 对于持续几秒钟的短期连接(例如快速事务),如果您执行 1000 TPS,那么您将只有大约 1000 个到服务器的并发连接。
  • 将事务长度更改为 10 秒,现在在 1000 TPS 下,您将打开 10K 个连接。 Apache 的性能急剧下降,但这会让您容易受到 DoS 攻击。只要进行大量下载,Apache就会崩溃。
  • 如果您现在每秒处理 5,000 个连接,而您想处理 10K,您会怎么做?假设您升级了硬件并将处理器速度提高了一倍。会发生什么?您得到了双倍的性能,但是没有得到双倍的处理规模。大概仅能到达每秒6k连接的规模。果你继续加倍,同样的事情也会发生。 即使 16 倍,性能很棒,但仍然没有达到 10K 连接数。性能与可扩展性是不同。
  • 其中的问题是 Apache 会fork一个 CGI 进程,使用结束后kill。这无法拓展。
  • 为什么?由于内核中使用了 O(n2n^2) 算法,服务器无法处理 10K 并发连接。
    • 在内核中的两个基本问题:
      • 连接=线程/进程。当数据包进入时,它会遍历内核中的所有 10K 进程来找出哪个应该处理该数据包
      • 连接=select/poll(单线程)。同样的可扩展性问题。每个数据包都必须遍历套接字列表。
    • 解决方案:修复内核以在常数时间内进行查找
      • 现在,无论线程数量如何,线程都会在恒定时间上下文切换
      • 新的可扩展 epoll()/IOCompletionPort 使用常数时间套接字查找
  • 线程调度仍然无法扩展,因此服务器使用带套接字的 epoll 进行扩展,这导致了 Node 和 Nginx 中体现的异步编程模型。
  • 这将软件转移到了不同​​的性能图。即使服务器速度较慢,当您添加更多连接时,性能也不会急剧下降。在 10K 连接数下,笔记本电脑甚至比 16 核服务器还要快。

C10M 问题 - 下一个十年

在不久的将来,服务器将需要处理数百万个并发连接。对于 IPV6,每台服务器的潜在连接数为数百万,因此我们需要进入下一个级别的可扩展性。需要这种可扩展性的应用程序示例:IDS/IPS,因为它们连接到服务器主干网。其他示例:DNS 根服务器、TOR 节点、互联网 Nmap、视频流、银行、运营商 NAT、VoIP PBX、负载均衡器、Web 缓存、防火墙、电子邮件接收、垃圾邮件过滤。通常看到互联网规模问题的人更倾向于使用设备提供商而非服务商,因为他们在销售硬件+软件。您购买设备并将其插入您的数据中心。这些设备可能包含英特尔主板或网络处理器以及用于加密、数据包检查等的专用芯片。截至 2013 年 2 月,X86 在 Newegg 上的价格为 5000 美元,40gpbs、32 核、256gigs RAM。服务器可以进行超过 10K 的连接。如果他们不能,那是因为您在软件方面做出了错误的选择。问题不在于底层硬件。该硬件可以轻松扩展到 1000 万个并发连接。

10M 并发连接面临的挑战

  1. 1000万并发连接数
  2. 100 万个连接/秒 - 以大约每10秒一个连接的持续速率维持这种并发性
  3. 10 gbits/秒 连接 - 服务器需要有快速的互联网连接,以支持这种数据传输速率
  4. 每秒 1000 万个数据包 - 当前的服务器可能只能处理每秒50,000个数据包,而这个挑战要求提升到更高的水平。过去,服务器能够处理每秒100,000个中断,每个数据包都会引起中断
  5. 10 微秒延迟 - 可扩展的服务器可以处理这个规​​模,但延迟会激增
  6. 10 微秒抖动 - 限制最大延迟
  7. 10 个一致的 CPU 核心 - 软件应扩展到更多数量的核心。通常,软件只能轻松扩展到四个核心。服务器可以扩展到更多核心,因此需要重写软件以支持更大核心的机器

我们从Unix学到的不是网络编程

第一代程序员通过阅读 W. Richard Stevens 的《Unix 网络编程》来学习网络编程。但是这本书是关于Unix的,不仅仅是网络编程。这本书告诉我们让OS做所有繁重的工作,你仅需要在Unix上写小型服务器即可。但内核无法扩展。解决方案是把繁重的工作移出内核并自己完成。考虑Apache的每个连接一个线程模型的影响,这意味着线程调度器决定下一个调用哪个read(),这取决于哪个数据先到达。你实际上是在使用线程调度系统作为数据包调度系统。(我以前从未这样想过,真的很喜欢这个观点)。
Nginx说,不要使用线程调度作为数据包调度器。自己做数据包调度。使用select找到套接字,我们知道它有数据,所以我们可以立即读取而不会阻塞,然后处理数据。教训:让Unix处理网络协议栈,但之后需要你来处理一切。

如何编写可拓展的软件

要达到下一个水平,我们需要解决的问题包括:

  • 数据包可扩展性
  • 多核可扩展性
  • 内存可扩展性

数据包扩展性 - 编写自定义驱动程序以绕过堆栈

数据包的问题在于它们需要经过Unix内核。网络协议栈复杂且慢。数据包到达应用程序的路径需要更直接。不要让操作系统处理数据包。解决方式是写你自己的驱动。驱动程序所做的就是将数据包发送到您的应用程序,而不是通过堆栈。您可以找到一些现有的驱动程序:PF_RING、Netmap、Intel DPDK(数据平面开发套件)。英特尔是闭源的,但有很多支持。这些驱动到底有多快?英特尔有一个基准测试,在相当轻量级的服务器上每秒处理 8000 万个数据包(每个数据包 200 个时钟周期)。这还是通过用户模式完成的。数据包上升到用户模式,然后再下降以便发送出去。Linux在将UDP数据包提升到用户模式并再次发送出去时,每秒不会超过100万个数据包。自己实现驱动程序与Linux的性能之比是80比1。对于每秒 1000 万个数据包的目标,如果使用 200 个时钟周期来获取数据包,则剩下的 1400 个时钟周期用来实现类似 DNS/IDS 的功能。使用 PF_RING,您将获得原始数据包,因此您必须执行 TCP 堆栈。人们正在做用户模式堆栈。对于英特尔来说,有一个可用的 TCP 堆栈,可以提供真正可扩展的性能。

多核可拓展性

多核可拓展不等同于多线程可拓展。我们都知道处理器不会变得更快,但我们的处理器数量却越来越多。大多数代码都不会扩展到超过 4 个核心。当我们添加更多核心时,不仅性能会趋于平稳,而且随着我们添加更多核心,我们的速度会变得越来越慢。那是因为软件写得不好。当我们添加更多内核时,我们需要软件来几乎线性扩展。随着我们添加更多核心,希望变得更快。

多线程编码不是多核编码

  • 多线程
    • 每个 CPU 核心有多个线程
    • 通过锁来协调线程(系统调用)
    • 每个线程执行不同的任务
  • 多核
    • 每个 CPU 核心一个线程
    • 当两个线程/核心访问相同的数据时,它们无需停止并互相等待
    • 所有线程都是同一任务的一部分
  • 我们的问题是如何将一个应用程序分布到多个核心上。
  • 在Unix中,锁是在内核中实现的。当使用四个核心时,大多数软件开始等待其他线程释放锁。因此,内核开始消耗比你从更多CPU中获得的性能还要多的性能。
  • 我们需要的是一种更像高速公路而不是由红绿灯控制的交叉路口的架构。我们希望没有等待,每个人都以自己的节奏继续,尽可能减少开销。
  • 解决方案
    • 每个核心保持自己的数据结构。然后在聚合时读取所有计数器。
    • 原子操作。CPU支持的指令,可以从C语言调用。保证是原子的,不会冲突。开销大,所以不能在所有事情上都使用它。
    • 无锁数据结构。线程可以访问且永远不会相互等待。不要自己实现,因为在不同架构之间工作非常复杂。
    • 线程模型。流水线模型 vs. 工作者线程模型。问题不仅在于同步,还在于如何设计线程架构。
    • 处理器亲和性。告诉操作系统使用前两个核心。然后设置线程在哪些核心上运行。你也可以对中断做同样的事情。这样你就拥有这些CPU,而不是Linux。

内存可拓展性

  • 问题是,如果您有 20gigs RAM,假设每个连接使用 2k,并且只有 20meg L3 缓存,则所有数据都不会在缓存中。访问主内存需要 300 个时钟周期,此时 CPU 不执行任何操作。
  • 考虑一下每个数据包 1400 个时钟周期预算。其中 200 个时钟用于包开销。每个数据包只有 4 次缓存未命中的机会,这是一个问题。
  • 数据同址化
    • 不要通过指针将数据分散到内存各处。每次你跟踪一个指针都会导致缓存未命中:[哈希指针] -> [任务控制块] -> [套接字] -> [应用程序]。这会导致四次缓存未命中。
    • 将所有数据放在一个内存块中:[任务控制块 | 套接字 | 应用程序]。通过预先分配所有块来预留内存。这将缓存未命中的次数从4次减少到1次。
  • 内存页
    • 32gig 的内存需要 64MB 的分页表,这不适合缓存。因此,您有两次缓存未命中,一次针对分页表,另一次针对它所指向的内容。对于可扩展的软件来说,这是我们不能忽视的细节。
    • 解决方案:使用高效缓存结构而不是具有大量内存访问的二叉搜索树
    • NUMA 架构使主存访问时间加倍。内存可能不在本地socket上,而是在另一个socket上。
  • 内存池
    • 启动时一次性预分配所有内存。
    • 基于每个对象、每个线程和每个套接字进行分配。
  • 超线程
    • 网络程序每个处理器最多可以运行 4 个线程,而英特尔只有 2 个。
    • 例如,这会掩盖内存访问的延迟,因为当一个线程等待时,另一个线程会全速运行。
  • 大页
    • 减小页表大小。从一开始就预留内存,然后您的应用程序管理内存。

总结

  • 网卡
    • 问题:通过内核进行数据传输效果不佳。
    • 解决方案:通过使用你自己的驱动程序,将适配器从操作系统中分离出来,自行管理。
  • CPU
    • 问题:如果使用传统的内核方法来协调应用程序,效果不好。
    • 解决方案:将前两个CPU分配给Linux,剩下的CPU由你的应用程序管理。那些你不允许的CPU上不会发生中断。
  • 内存
    • 问题:需要特别注意才能很好地工作。
    • 解决方案:在系统启动时,分配大部分内存为你管理的大页内存。控制平面留给Linux,对于数据平面,什么都不做。数据平面在应用程序代码中运行。它从不与内核交互。没有线程调度,没有系统调用,没有中断,什么都没有。你拥有的是在 Linux 上运行的代码,你可以正常调试,它不是你需要定制工程师的某种奇怪的硬件系统。您可以获得数据平面所期望的定制硬件的性能,但需要您熟悉编程和开发环境。

相关文章