千万级并发连接的秘密

 

既然我们已经解决了过万并发连接(C10K concurrent connection problem)的问题,现在如何升级到支持千万级的并发连接?你会说:“不可能”。不,现在,一些系统通过使用一些不广为人知的先进技术,已经能够提供千万级的并发连接。

为了明白这是如何实现,我们找到了Errata Security的CEO—— Robert Graham和他在Shmoocon 2013上精彩绝伦的演讲—— C10M Defending The Internet At Scale(译者注:FQ的同学可以去看看)。

Robert解决这个问题的方法如此技艺高超,此前我从未听说过。他首先讲了一段Unix的历史,他提到Unix系统最开始并不是设计成通用的服务器操作系统,而是设计成电话网络中的控制系统。电话网络中,控制面与数据面有明显的区分,数据传输是在电话网络中进行的。问题就在于我们现在把Unix服务器当成是用户面的部分来使用,这是不应该的。如果我们设计内核时是为了让每台服务器运行一个应用程序,那会与现在的多用户内核有巨大得差别。

所以,他说关键是在于明白:

·内核并不是解决方法,内核是问题所在

意思是:

·不要让内核做所有繁重的工作。将数据包处理,内存管理和线程调度等从内核中移出来,放到应用程序里,使其处理得更加高效。让Linux内核处理控制面,应用程序处理数据面。

这样,系统在处理上千万的并发连接时,200个时钟周期用于数据包处理,1400个时钟周期用于程序逻辑。由于内存访问要使用300个时钟周期,使用减少代码和减少cache丢失的方法进行设计也是关键所在。

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

如果这看起来很极端,那么记住一句老话:可扩展性是一门技术活。为了做出成功的系统,千万别把性能“外包”给操作系统。你必须亲自去完成。

现在,然我们看一下Robert是如何构建一个具备支持千万级别并发连接能力的系统……

C10K 问题 – 上一个十年

十年前,工程师们解决了C10K(concurrent 10,000)可扩展性问题。他们通过为内核打补丁、从多线程服务器(如Apache)迁移到事件驱动服务器(如Nginx和Node)。人们从Apache到可扩展服务器迁移了10年。在最近几年,我们看到了人们更快的采用可扩展服务器。

Apache的问题

·Apache的问题在于,随着连接的增多,性能愈发下降。

·关键点:性能与可扩展性是正交的。这两个是不同的概念。当人们讨论扩展时他们常常会说到性能,但是这两者间有着明显的区别。

·对于只持续几秒的短连接,我们称之为快事务(quick transaction),如果你能够处理1000TPS(Transaction Per Second),那么你的服务器只能支持1000的并发连接。

·如果事务的时长改为10秒,现在在1000TPS的情况下你能够支持10,000的连接。Apache的性能会急剧下降,即使这可能会触发了DoS攻击的检测。通过大量的下载就能使Apache宕机。

·如果你能够每秒处理5000连接,而你要怎么做才能够每秒处理10,000连接呢?假设你升级了硬件而且使处理器快了两倍。会发生什么?你能够获得两倍的性能,但是你不能获得两倍的扩展。也许你只能够处理每秒6000的连接。如果持续升级硬件,你会得到同样的结果。性能与可扩展性是不一样的。

·问题在于Apache会创建一个CGI进程然后杀掉他。这导致了其不可扩展。

·为什么?服务器不能处理10,000个并发连接是由于内核使用O(n)算法。

  ·内核中两个基本问题:

  1)  连接数=线程数/进程数。一个数据包进来,内核要遍历所有10,000个进程找到处理这个数据包的进程。

  2)  连接数=select数/poll数。同样的可扩展问题,每一个数据包都要遍历sockets列表。

  ·解决方法:为内核打上补丁,使其查找时间为常数。

  1)  现在无论线程数量多少,线程的切换时间是常数。

  2)  使用epoll()/IOCompeltionPort 可扩展的系统调用能够在常数时间查找socket。

·线程调度仍不能够扩展,所以服务器使用epoll的异步编程模型,在Node和Nginx中都体现了。即使一台较慢的服务器,增加连接数时性能不会急剧下降。10,000的连接,笔记本都能比16核的服务器快。

C10M问题 -- 下一个十年

在不远的未来,服务器将需要处理百万级别的并发连接。随着IPv6的普及,我们要开始下一个阶段的扩展,使得服务器支持的连接数达到百万。

·需要这样的可扩展性应用包括:IDS/IPS,因为他们连接到服务器的骨干。其他应用如DNS根服务器,TOR节点,因特网的Nmap,视频流,金融,NAT,Voip交换机,负载均衡服务器,web缓存,防火墙,邮件接收服务,垃圾邮件过滤等。

·其他遇到扩展问题的人包括设备供应商,因为他们销售软硬一体的设备。你购买这些设备直接放置到数据中心里使用。这些设备可能会有一块专门用于加密,数据包解析等的Intel主板或者网络处理器。

·在新蛋网上一台40gbps,32核,256G内存的X86服务器的价格只要5000美金。这样的服务器能够处理超过10,000的连接。如果不行的话,是因为你软件设计不好,并不是硬件的问题。这样的硬件可以很轻易扩展到千万的并发连接。

千万并发连接的挑战意味着什么:

1. 一千万并发连接数

2. 每秒一百万连接数 —— 每个连接持续时间大概是10秒

3. 每秒100亿比特 —— 因特网的快速链接

4. 每秒1千万个数据包 —— 预计,当前服务器每秒处理50,000个数据包,这将要提高一个层次。每个数据包会触发一次中断,而之前服务器每秒能处理100,000个中断。

5. 10毫秒的时延 —— 可扩展的服务器或许能够解决得了扩展问题,但是时延并不行。

6. 10毫秒的抖动 —— 限制最大时延

7. 10个CPU —— 软件将要扩展到多核的服务器。大多数软件只可以轻易扩展到4个核,由于服务器要扩展到更多核的服务器,所以软件也要重写做相应的支持。

我们了解到Unix并不是用于网络编程

·将近一代的程序员都学习过Richard Stevens编写的<<Unix Network Programming>>。问题在于这本书是关于Unix,而不仅仅是网络编程。他告诉你如何让Unix完成繁重的工作,而你也只在Unix上编写一个小小的服务器。但是内核并不是可扩展的。解决方法在于将繁重的工作从内核剥离,由自己完成。

·比如,考虑一下Apache每个连接一个线程的模型带来的影响。这意味着线程调度器根据收到的数据包来决定调用哪一个线程的read()。这等于是将线程调度器当做数据包调度器来使用。(我真的很喜欢这个模型,以前从来没想过这个方法。译者注:这是反话吧)

·而Nginx并不把线程调度器当做数据包调度器来使用。而是自己完成数据包调度。使用select来查找socket,一旦发现数据马上读取,这样就不会产生阻塞。

·Unix处理网络协议栈,而你处理所有其他的事情。

如何编写可扩展的软件

如何修改软件使其可扩展呢?许多关于硬件处理能力的经验估计都是错误的。我们要知道实际的性能能力是什么。

为了更进一步,我们要解决一下几个问题:

1. 数据包可扩展性

2. 多核可扩展性

3. 内存可扩展性

数据包扩展 —— 编写自定义的驱动旁路内核协议栈

·数据包的问题在于他们需要穿过Unix内核。内核协议栈既复杂又慢。数据包到达你的程序的路径应该更加直接。不要让操作系统来处理数据包。

·编写你自己的驱动方法是,驱动只需要将数据包发送给你的应用程序,而不要经过内核的协议栈。你可以找到的驱动:PF_RING, Netmap, Intel DPDK (数据包开发套件)。Intel是不开源的,但是其提供许多的支持。

·多快?Intel有一个基准,在一台轻量级服务器上每秒能处理8千万个数据包。这是在用户态实现的。数据包到达用户态,然后再发送出去。而使用Linux处理UDP的情况下,每秒只能达到1百万个数据包。自定义的驱动是Linux的80倍。

·为了实现每秒处理1千万个数据包,200个时钟周期用于获取数据包,剩余1400个时钟周期用于实现应用程序功能。

·通过PF_RING收到的原生数据包,你必须自己实现TCP协议栈。很多人已经实现了用户态的协议栈。比如Intel就提供了一个高性能可扩展的TCP协议栈。

多核的可扩展性

多核的扩展和多线程的扩展并不完全相同。我们知道,相比更快的处理器,获取更多的处理器要来得容易。

大多数的代码不能扩展超过4个核。当我们添加更多的核时,性能并没有不断提升。这是因为软件的问题。我们要使软件能够随着核数线性的扩展,要使软件通过增多核数来提高性能。

多线程编程并不是多核编程

·多线程:

  ·每个核超过一个线程

  ·线程通过锁来同步

  ·每个线程一个任务

·多核:

  ·每个核一个线程

  ·当两个线程访问同一个数据的时候,他们不能停下来等待对方

  ·所有的线程做同一个任务

·我们的问题是如何扩展应用程序到多个核

·在Unix中锁是在内核中实现。当核在等待线程释放锁时,内核开始占用我们的CPU资源。 我们需要的架构是像高速公路而不是有交通灯控制的十字路口。我们要使每个线程都按照自己的步伐在运行,而不是等待。

·解决方法:

  ·每个核一个数据结构。

  ·原子性。使用C语言中CPU支持的原子指令。保证原子性,而绝不要发生冲突。但随之而来的是昂贵的代价,所以不要到处使用。

  ·免锁的数据结构。线程间访问这些数据是不需要停止和等待。千万不要自己去实现,因为这样的数据结构跨平台的实现会非常复杂。

  ·线程模型。流水线和工作者线程模型。问题不仅仅在于同步,而且在于线程的设计。

  ·处理核附着。让操作系统使用前两个核。设置你的线程到其他处理核上。同样的方法可以用到中断上。这样你就可以独占这些CPU,而不受Linux内核影响。

内存扩展

·问题在于,如果你有20G的内存,假设每个连接使用2K,在你只有20M的L3缓存的情况下,没有任何连接数据是在缓存里的。这会消耗300个时钟周期用来到内存中取数据。

·仔细考虑我们每个数据包1400个时钟周期的处理预算。记住那200个时钟周期/每数据包的额外开销。我们只可以允许4次cache丢失,这是个问题。

·使数据放置到一起

  ·不要通过指针胡乱的引用内存里的数据。每一次你引用指针将会导致一次cache丢失:[hash pointer] -> [Task Control Block] -> [Socket] -> [App]。这就4次cache丢失。

  ·将数据放到同一个内存块里:[TCB|Socket|App]。通过预分配所有的内存块来保护内存。这会将cache丢失次数从4减到1。

·分页

  ·32G内存就需要64M的分页表,cache装不下。所以你会有两次cache丢失,一次是分页表,而另一次是页表所指的内存。这是我们考虑可扩展软件时不能忽略的细节。

  ·解决方法:压缩数据;使用高效的缓存数据结构替代有大量内存访问的二叉搜索树。

·NUMA架构使得内存访问时间加倍。

·内存池

  ·启动时一次预分配所有的内存。

  ·基于每一个对象,每一个线程,每一个套接字进行分配。

·超线程

  ·网络处理器上每个处理器可以跑4个线程,而Intel只可以跑2个。

  ·这可以掩盖时延,比如内存访问,因为当一个线程在等待时,另一个可全速执行。

·超大页

  ·减少分页表的大小。从一开始就保留内存,然后由应用程序来管理。

总结

·NIC

  ·问题:数据包通过内核协议栈,效率不高

  ·解决方法:编写自己的驱动来管理协议栈

·CPU

  ·问题:如果你使用传统的方法在内核上构建应用程序,这并不高效

  ·解决方法:赋予Linux前两个CPU,剩下的CPU由你的应用程序管理。这些CPU上不允许网卡中断。

·内存

  ·问题:需要仔细考虑如何使其更加高效

  ·解决方法:在系统启动时预分配大部分内存,由自己来管理。

控制面留给Linux,数据面跑在应用程序的代码里。他从不与内核交互,没有线程调度,没有系统调用,没有中断,什么都没有。

还有,你手上的是可以正常调试运行在Linux上的代码,而不是由特定工程师构造的一些奇怪的硬件系统。你可以通过熟悉的编程语言和开发环境,达到你期望的特定硬件处理数据的性能。

 

 [英文原文: The Secret To 10 Million Concurrent Connections -The Kernel Is The Problem, Not The Solution]

你可能感兴趣的:(并发)