C10k问题简述

所谓c10k问题,指的是:服务器如何支持10k个并发连接,也就是concurrent 10000 connection(这也是c10k这个名字的由来)。由于硬件成本的大幅度降低和硬件技术的进步,如果一台服务器能够同时服务更多的客户端,那么也就意味着服务每一个客户端的成本大幅度降低。从这个角度来看,c10k问题显得非常有意义。

一、C10K问题由来

互联网的基础是网络通信,早期的互联网可以说是一个小群体的集合。互联网还不够普及,用户也不多,一台服务器同时在线100个用户,在当时已经算是大型应用了,所以并不存在 C10K 的难题。互联网的爆发期是在www网站、浏览器出现后。最早的互联网称之为Web1.0,大部分的使用场景是下载一个HTML页面,用户在浏览器中查看网页上的信息,这个时期也不存在C10K问题。

Web2.0时代到来后,就不同了。一方面是,互联网普及率大大提高了,用户群体几何倍增长。另一方面是,互联网不再是单纯地浏览www网页,逐渐开始进行交互,而且应用程序的逻辑也变得更复杂。从简单的表单提交,到即时通信和在线实时互动,C10K的问题才体现出来了。因为每一个用户都必须与服务器保持连接,才能进行实时数据交互。诸如Facebook这样的网站,同一时间的并发TCP连接很可能已经过亿。

早期的腾讯QQ也同样面临C10K问题,只不过他们是用了UDP这种原始的包交换协议来实现的,绕开了这个难题,当然过程肯定是痛苦的。如果当时有epoll技术,他们肯定会用TCP。众所周之,后来的手机QQ、微信都采用TCP协议。

实际上,当时也有异步模式,如:select/poll模型。这些技术都有一定的缺点:selelct最大不能超过1024;poll没有限制,但每次收到数据时,需要遍历每一个连接,查看哪个连接有数据请求。

这时候问题就来了,最初的服务器都是基于进程/线程模型的,新到来一个TCP连接,就需要分配1个进程(或者线程)。进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。如果是C10K,就要创建1万个进程,那么就单机而言,操作系统是无法承受的(往往出现效率低下、甚至完全瘫痪)。如果是采用分布式系统,维持1亿用户在线需要10万台服务器,成本巨大,也只有Facebook、Google、Apple等巨头,才有财力购买如此多的服务器。

基于上述考虑,如何突破单机性能局限,是高性能网络编程所必须要直面的问题。这些局限和问题,最早被Dan Kegel 进行了归纳和总结,并首次系统地分析和提出了解决方案。后来,这种普遍的网络现象和技术局限,都被大家称为 C10K 问题。

二、C10K问题的本质

C10K问题,本质上是操作系统的问题。对于Web1.0/2.0时代的操作系统而言, 传统的同步阻塞I/O模型都是一样的,处理的方式都是requests per second,并发10K和100的区别关键在于CPU。

创建的进程、线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞), 进程/线程上下文切换消耗大, 导致操作系统崩溃,这就是C10K问题的本质!

可见,解决C10K问题的关键就是:尽可能减少CPU等核心资源消耗,从而榨干单台服务器的性能,突破C10K问题所描述的瓶颈。

三、C10K问题的解决方案探讨

从网络编程技术的角度来说,主要思路为:

  1. 为每个连接分配一个独立的线程/进程。
  2. 同一个线程/进程同时处理多个连接(IO多路复用)

3.1 为每个连接分配一个独立的线程/进程

这一思路最为直接。但是,由于申请进程/线程会占用相当可观的系统资源,同时对于多进程/线程的管理会对系统造成压力,因此,这种方案不具备良好的可扩展性。

这一思路在服务器资源还没有富裕到足够程度的时候,是不可行的。即便资源足够富裕,效率也不够高。

总之,此思路技术实现会使得资源占用过多,可扩展性差,在实际应用中已被抛弃。

3.2 同一个线程/进程同时处理多个连接(IO多路复用)

IO多路复用,从技术实现上,又分很多种。我们逐一来看看下述各种实现方式的优劣。

实现方式1:循环逐个处理各个连接,每个连接对应一个 socket

循环逐个处理各个连接,每个连接对应一个 socket。当所有 socket 都有数据的时候,这种方法是可行的。但是,当应用读取某个 socket 的文件数据不 ready 的时候,整个应用会阻塞在这里,等待该文件句柄ready,即使别的文件句柄 ready,也无法往下处理。

实现小结:直接循环处理多个连接。

问题归纳:任一文件句柄的不成功会阻塞住整个应用。

实现方式2:使用select方法

使用select方法解决上面阻塞的问题,思路比较简单。在读取文件句柄之前,先查下它的状态,如果ready 了,就进行处理;如果不 ready, 就不进行处理;这不就解决了这个问题了嘛?于是,有了 select 方案。用一个 fd_set 结构体来告诉内核同时监控多个文件句柄,当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。之后,应用可以使用 FD_ISSET 来逐个查看,确定哪个文件句柄的状态发生了变化。这样做,小规模的连接问题不大,但当连接数很多(文件句柄个数很多)的时候,逐个检查状态就很慢了。因此,select 往往存在管理的句柄上限(FD_SETSIZE)。同时,在使用上,因为只有一个字段记录关注和发生事件,所以每次调用之前,要重新初始化fd_set结构体。

intselect(intnfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,structtimeval *timeout);

实现小结:有连接请求抵达了,再检查处理。
问题归纳:句柄上限+重复初始化+逐个排查所有文件句柄状态,效率不高。

实现方式3:使用poll方法

poll 主要解决 select 的前两个问题:

1.通过一个 pollfd 数组,向内核传递需要关注的事件,以消除文件句柄上限。

2.使用不同字段分别标注“关注事件和发生事件”,来避免重复初始化。

实现小结:设计新的数据结构,提高使用效率。
问题归纳:逐个排查所有文件句柄状态,效率不高。

实现方式4:使用epoll方法

既然“poll逐个排查所有文件句柄状态”效率不高,很自然的,在调用返回的时候,如果只给应用提供发生了状态变化(很可能是数据 ready)的文件句柄,进行排查的效率就高很多。epoll 采用了这种设计,适用于大规模的应用场景。实验表明:当文件句柄数目超过10之后,epoll 性能将优于 select 和 poll;当文件句柄数目达到 10K 的时候,epoll 已经超过 select 和 poll 两个数量级。

实现小结:只返回状态变化的文件句柄。
问题归纳:依赖特定平台(Linux)。

因为Linux是互联网企业中使用率最高的操作系统,所以Epoll就成为“C10K killer、高并发、高性能、异步非阻塞”这些技术的代名词了。FreeBSD推出了kqueue,Linux推出了epoll,Windows推出了IOCP,Solaris推出了/dev/poll。这些操作系统提供的功能,就是为了解决C10K问题。epoll技术的编程模型就是异步非阻塞回调,也可以叫做Reactor、事件驱动、事件轮循(EventLoop)。Nginx、libevent、node.js这些就是Epoll时代的产物。

实现方式5:使用libevent库

由于epoll,、kqueue、IOCP每个接口都有自己的特点,程序移植非常困难,所以需要对这些接口进行封装,以让它们易于使用和移植,其中libevent库就是其中之一。跨平台,封装底层平台的调用,提供统一的 API,但底层在不同平台上自动选择合适的调用。按照libevent的官方网站,libevent库提供了以下功能:当一个文件描述符的特定事件(如可读,可写或出错)发生了,或一个定时事件发生了,libevent就会自动执行用户指定的回调函数,来处理事件。目前,libevent已支持以下接口/dev/poll、kqueue、event ports、select、poll 和 epoll。Libevent的内部事件机制完全是基于所使用的接口的。因此,libevent非常容易移植,也使它的扩展性非常容易。目前,libevent已在以下操作系统中编译通过:Linux、BSD、Mac OS X、Solaris和Windows。使用libevent库进行开发非常简单,也很容易在各种unix平台上移植。一个简单的使用libevent库的程序如下:

C10k问题简述_第1张图片

四、引申讨论C10M问题

随着技术的演进,epoll已经可以较好地处理 C10K 问题。但是,如果要进一步的扩展,例如支持10M 规模的并发连接,原有的技术就无能为力了。那么,新的瓶颈在哪里呢?

从前面的演化过程中,我们可以看到,根本的思路是:要高效地去除阻塞,让CPU更多地处理核心任务所以,就千万级并发而言,内核不是解决方案,而是问题所在!

这意味着:

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

当连接很多时,首先需要大量的进程/线程来做事。同时,系统中的应用进程/线程可能大量地都处于 ready 状态,需要系统不断地进行快速切换,而我们知道系统上下文的切换是有代价的。虽然现在 Linux 系统的调度算法已经设计地很高效了,但对于10M这样大规模的场景,仍然力有不足。

所以我们面临的瓶颈有两个:一个是进程/线程作为处理单元,还是太厚重了;另一个是系统调度的代价太高了

很自然地,我们会想到,如果有一种更轻量级的进程/线程作为处理单元,而且它们的调度可以做到很快(最好不需要锁),那就完美了。

现在,这样的技术在某些语言中已经有了一些实现,它们就是coroutine(协程),或协作式例程。具体来说,Python、Lua 语言中的coroutine(协程)模型,Go语言中的goroutine(Go协程)模型,都是类似的一个概念。实际上,多种语言(甚至 C 语言)都可以实现类似的模型。

它们在实现上都是试图用一组少量的线程来实现多个任务,一旦某个任务阻塞,则可能用同一线程继续运行其他任务,避免大量上下文的切换每个协程所独占的系统资源往往只有栈部分。而且,各个协程之间的切换,往往是用户通过代码来显式指定的(跟各种callback 类似),不需要内核参与,可以很方便实地现异步。

这个技术本质上也是异步非阻塞技术,它是将事件回调进行了包装,让程序员看不到里面的事件循环。程序员就像写阻塞代码一样简单。比如调用 client->recv() 等待接收数据时,就像阻塞代码一样写。实际上是,底层库在执行recv时悄悄保存了一个状态,比如代码行数、局部变量的值。然后,就跳回到EventLoop中了。什么时候真的数据到来时,它再把刚才保存的代码行数、局部变量值取出来,又开始继续执行。

这就是协程的本质。协程是异步非阻塞的另外一种展现形式。Golang、Erlang、Lua协程都是这个模型。

4.1 同步阻塞

大家看完协程,是否感觉到:实际上,协程和同步阻塞是一样的。答案是正确的。所以,协程也叫做用户态进程/用户态线程。区别就在于:进程/线程是操作系统充当了EventLoop调度,而协程是应用程序自己用Epoll进行调度

协程的优点是:它比系统线程开销小。其缺点是:如果其中一个协程中有密集计算,其他的协程就不运行了。

操作系统进程的优点是:无论代码怎么写,所有进程都可以并发运行。其缺点是:开销大。

Erlang解决了协程密集计算的问题,它基于自行开发VM,并不执行机器码。即使存在密集计算的场景,VM发现某个协程执行时间过长,也可以进行中止切换。Golang由于是直接执行机器码,所以无法解决此问题。所以,Golang要求用户必须在密集计算的代码中,自行Yield

实际上,同步阻塞程序的性能并不差,它的效率很高,不会浪费资源。当进程发生阻塞后,操作系统会将它挂起,不会分配CPU。直到数据到达,才会重新分配CPU。只是进程开多了之后,多进程的副作用才明显,因为进程多了,互相切换开销太大。所以,如果一个服务器程序只有1000左右的并发连接,同步阻塞模式是最好的

4.2 异步回调和协程哪个性能好

协程虽然是用户态调度,实际上还是需要调度的。既然存在调度,就存在上下文切换。所以,协程虽然比操作系统进程性能要好,但总还是有额外消耗的。而异步回调是没有切换开销的,它等同于顺序执行代码。所以,异步回调程序的性能是要优于协程模型的性能

5、参考资料

[1] 为什么QQ用的是UDP协议而不是TCP协议?
[2] 移动端IM/推送系统的协议选型:UDP还是TCP?
[3] 高性能网络编程经典:《The C10K problem(英文)》[附件下载]
[4] 高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少
[5] 《The C10K problem (英文在线阅读、英文PDF版下载、中文译文)》
[6] 搜狗实验室技术交流文档《C10K问题探讨》(52im.net).pdf (350.83 KB)
[7] [通俗易懂]深入理解TCP协议(上):理论基础
[8] [通俗易懂]深入理解TCP协议(下):RTT、滑动窗口、拥塞处理
[9] 《TCP/IP详解 卷1:协议 (在线阅读版)》

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