高性能网络编程 - The C10K problem 以及 网络编程技术角度的解决思路

2023-11-09 10:56:15 浏览数 (2)


C10K

英文地址: http://www.kegel.com/c10k.html

中文地址: https://www.oschina.net/translate/c10k

C10K是指单机1万网络并发连接和数据处理能力。 C10M是指单机1000万网络并发连接和数据处理能力


C10K的由来

众所周知,互联网的基础是网络通信,早期的互联网规模有限,只涉及小规模用户群体。在这个阶段,互联网的用户数量相对较少,一台服务器同时在线100个用户已经算是大规模应用了,因此并没有面临所谓的C10K难题。

互联网的爆发期可以追溯到出现www网站、浏览器和雅虎等平台之后。早期互联网被称为Web1.0时代,主要用于下载HTML页面,用户通过浏览器查看网页上的信息,而不需要进行复杂的实时交互。在这个时期,并不需要解决C10K问题。

然而,随着Web2.0时代的到来,情况发生了变化。一方面,互联网的普及率大幅提高,用户数量呈几何级增长。另一方面,互联网不再局限于简单的网页浏览,而是逐渐变为互动的平台,应用程序的逻辑也变得更加复杂,从简单的表单提交发展到了实时通信和在线实时互动。正是在这个背景下,C10K问题开始显现出来。因为每个用户都需要与服务器保持TCP连接以进行实时数据交互。

早期的腾讯QQ也遇到了C10K问题,不过他们采用了UDP这种原始的包交换协议,绕过了这一难题,尽管过程相当具有挑战性。如果当时已经有epoll技术,他们很可能会选择使用TCP。众所周知,后来的手机QQ和微信都采用了TCP协议。

实际上,当时也有一些异步模型,例如select和poll模型,但它们都存在一些限制。比如,select模型的最大连接数不能超过1024,而poll没有这个限制,但每次需要遍历每个连接以检查哪个连接有数据请求。

这时候出现了一个关键问题:最初的服务器基于进程/线程模型,每当有新的TCP连接时,就需要分配一个新的进程或线程。然而,进程是操作系统中最昂贵的资源之一,因此一台机器无法同时创建大量进程。如果要应对C10K问题,可能需要创建上万个进程,这对单台机器来说是难以承受的,效率低下甚至可能导致系统崩溃。如果采用分布式系统,要维护上亿用户的在线连接可能需要上万台服务器,成本巨大 。

鉴于上述情况,如何突破单机性能的限制成为高性能网络编程不得不面对的问题。这些限制和问题最早由Dan Kegel总结和归纳,他首次系统地分析并提出了解决方案。后来,这些普遍存在的网络现象和技术限制都被广泛称为C10K问题。


C10K问题在技术层面的典型体现

C10K问题的主要特点在于,当程序设计不够精良时,其性能与连接数量和机器性能之间的关系通常表现为非线性

举例来说,如果没有考虑C10K问题,一个经典基于select的程序在旧服务器上可以良好地处理1000个并发连接,但在性能翻倍的新服务器上却常常无法处理2000个并发连接。这是因为在策略不当的情况下,大量操作的开销与当前连接数n成线性相关,导致单个任务的资源消耗与当前连接数之间呈线性关系,即O(n)。对于服务程序来说,需要同时处理数以万计的套接字I/O操作,累积下来的资源开销相当可观,这显然会导致系统吞吐量无法与机器性能相匹配。

这便是C10K问题在技术层面的典型体现。这也解释了为什么大多数开发人员可以相对容易地从功能上实现相同的功能,但一旦应用到高并发场景中,初级开发人员和高级开发人员的技术实现会产生截然不同的实际应用效果。

因此,一些缺乏大规模并发实践经验的技术从业者,例如那些开发即时通讯应用等网络应用的人,往往在没有经受检验和考验的情况下声称其应用能够在单台服务器上支持上万、上十万甚至上百万的用户,这种说法常常难以经受实际验证。


C10K问题的本质

C10K问题本质上与操作系统的设计和性能密切相关。在Web1.0和Web2.0时代,传统的同步阻塞I/O模型在操作系统中普遍存在,处理方式都以"requests per second"为标准,而区分处理10K并发和100并发的关键在于CPU性能。

当创建大量进程和线程时,会导致数据频繁拷贝(包括缓存I/O、内核将数据从内核空间拷贝到用户进程空间等),并且进程/线程上下文切换的成本较高。这导致操作系统面临巨大的挑战,可能因此崩溃,这正是C10K问题的本质所在。

因此,解决C10K问题的关键在于尽可能减少对CPU等核心计算资源的消耗,从而充分发挥单台服务器的性能,以突破C10K问题所描述的性能瓶颈。这可以通过采用异步I/O模型、事件驱动编程、使用高效的多路复用技术如epoll等,以及优化操作系统内核等方式来实现。通过这些方法,可以显著提高服务器的吞吐量,减少资源消耗,从而解决C10K问题。


C10K解决思路

要解决这一问题,从纯网络编程技术角度看,主要思路有两个

  • 对于每个连接处理分配一个独立的进程/线程
  • 用同一进程/线程来同时处理若干连接

思路一:每个进程/线程处理一个连接

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

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

总之,此思路技术实现会使得资源占用过多,可扩展性差。


思路二:每个进程/线程同时处理多个连接(IO多路复用)

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

● 实现方式1:直接循环处理多个连接

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

实现小结:直接循环处理多个连接。 问题归纳:任一文件句柄的不成功会阻塞住整个应用。


● 实现方式2: select

select要解决上面阻塞的问题,思路很简单,如果我在读取文件句柄之前,先查下它的状态,ready 了就进行处理,不 ready 就不进行处理,这不就解决了这个问题了嘛?

于是有了 select 方案。用一个 fd_set 结构体来告诉内核同时监控多个文件句柄,当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。之后应用可以使用 FD_ISSET 来逐个查看是哪个文件句柄的状态发生了变化。这样做,小规模的连接问题不大,但当连接数很多(文件句柄个数很多)的时候,逐个检查状态就很慢了。

因此,select 往往存在管理的句柄上限(FD_SETSIZE)。同时,在使用上,因为只有一个字段记录关注和发生事件,每次调用之前要重新初始化 fd_set 结构体。

代码语言:javascript复制
intselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

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


● 实现方式3: poll

poll 主要解决 select 的前两个问题:通过一个 pollfd 数组向内核传递需要关注的事件消除文件句柄上限,同时使用不同字段分别标注关注事件和发生事件,来避免重复初始化。

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


● 实现方式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库的程序如下:

0 人点赞