返回介绍

4.2 C10K 问题

发布于 2023-05-19 13:36:37 字数 15567 浏览 0 评论 0 收藏 0

几年前,我去参加驾照更新的讲座1,讲师大叔三令五申“开车不要想当然”。所谓“开车想当然”,就是抱着主观的想当然的心态去开车,比如总认为“那个路口不会有车出来吧”、“那个行人会在车道前面停下来吧”之类的。这就是我们在 2-5 节中讲过的“正常化偏见”的一个例子。作为对策,我们应该提倡这样的开车方式,即提醒自己“那个路口可能会有车出来”、“行人可能会突然窜出来”等。

1 在日本更新驾照有效期时,必须参加相应的交通讲座,根据驾驶者在上一有效期时间段内有无违反交通法规等情况,讲座分为不同的类型,时间从 30 分钟到 2 小时不等。

在编程中也会发生完全相同的状况,比如“这个数据不会超过 16 比特的范围吧”、“这个程序不会用到公元 2000 年以后吧”等。这种想法正是导致 10 年前千年虫问题的根源。人类这种生物,仿佛从诞生之初就抱有对自己有利的主观看法。即便是现在,世界上依然因为“想当然编程”而不断引发各种各样的 bug,包括我自己在内,这真是让人头疼。

何为 C10K 问题

C10K 问题可能也是这种“想当然编程”的副产品。所谓 C10K 问题,就是 Client 10000 Problem,即“在同时连接到服务器的客户端数量超过 10000 个的环境中,即便硬件性能足够,依然无法正常提供服务”这样一个问题。

这个问题的发生,有很多背景,主要的背景如下:

· 由于互联网的普及导致连接客户端数量增加

· keep-alive 等连接保持技术的普及

前者纯粹是因为互联网用户数量的增加,导致热门网站的访问者增加,也就意味着连接数上限的增加。

更大的问题在于后者。在使用套接字(socket)的网络连接中,不能忽视第一次建立连接所需要的开销。在 HTTP 访问中,如果对一个一个的小数据传输请求每次都进行套接字连接,当访问数增加时,反复连接所需要的开销是相当大的。

为了避免这种浪费,从 HTTP1.1 开始,对同一台服务器产生的多个请求,都通过相同的套接字连接来完成,这就是 keep-alive 技术。

近年来,在网络聊天室等应用中为了提高实时性,出现了一种新的技术,即通过利用 keep-alive 所保持的套接字,由服务器向客户端推送消息,如 Comet,这样的技术往往需要很多的并发连接数。

在 Comet 中,客户端先向服务器发起一个请求,并在收到服务器响应显示页面之后,用 JavaScript 等手段监听该套接字上发送过来的数据。此后,当发生聊天室中有新消息之类的“事件”时,服务器就会对所有客户端一起发送响应数据(图 1)。

图 1 以网络聊天室为例对比抓取型和推送型

以往的 HTTP 聊天应用都是用抓取型方式来实现的,即以“用户发言”时、“按下刷新按钮”时或者“每隔一定时间”为触发条件,由客户端向服务器进行轮询。这种方式的缺点是,当聊天室中的其他人发言时,不会马上反映到客户端上,因此缺乏实时性。

相对地,Comet 以比较简单的方式实现了高实时性的推送型服务,但是它也有缺点,那就是更多的并发连接对服务器造成的负荷。用 Comet 来提供服务的情况下,会比抓取型方式更早遇到 C10K 问题,从而导致服务缺乏可扩展性。Comet 可以说是以可扩展性为代价来换取实时性的一种做法吧。

C10K 问题所引发的“想当然”

在安全领域有一个“最弱连接”2(Weakest link)的说法。如果往两端用力拉一条由很多环(连接)组成的锁链,其中最脆弱的一个连接会先断掉。因此,锁链整体的强度取决于其中最脆弱的一环。安全问题也是一样,整体的强度取决于其中最脆弱的部分。

2 Weakest link 在中文里一般称为“短板效应”,即一个由很多块木板围城的木桶能装多少水,取决于其中最短的一块木板的长度。

C10K 问题的情况也很相似。由于一台服务器同时应付超过一万个并发连接的情况,以前几乎从未设想过,因此实际运作起来就会遇到很多“想当然编程”所引发的结果。在构成服务的要素中,哪怕只有一个要素没有考虑到超过一万个客户端的情况,这个要素就会成为“最弱连接”,从而导致问题的发生。

下面我们来看看引发 C10K 问题的元凶——历史上一些著名的“想当然”吧。

同时工作的进程数不会有那么多吧。

出于历史原因,UNIX 的进程 ID 是一个带符号的 16 位整数。也就是说,一台计算机上同时存在的进程无法超过 32767 个。实际上,各种服务的运行还需要创建一些后台进程,因此应用程序可以创建的进程数量比这个数字还要小一些。

不过,现在用 16 位整数作为进程 ID 的操作系统越来越少了。比如我手边的 Linux 系统就是用带符号的 32 位整数来作为进程 ID 的。

虽然由数据类型所带来的进程数上限几乎不存在了,不过允许无限地创建进程也会带来很大的危害3,因此进程数的上限是可以在内核参数中进行设置的。看一下手边的 Linux 系统,其进程数上限被设定为 48353。

3 如果允许无限制地创建进程的话,那些能够不断产生子进程的程序就会带来持续扩大的危害。(原书注)

现代操作系统的进程数上限都是在内核参数中设置的,但我们会在后面要讲的内存开销的问题中提到,如果进程数随着并发连接数等比例增加的话,是无法处理大量的并发连接的。这时候就需要像事件驱动模型(event driven model)等软件架构层面的优化了。

而且,Linux 等系统中的进程数上限,实际上也意味着整个系统中运行的线程数的上限,因此为每个并发连接启动一个线程的程序也存在同样的上限。

内存的容量足够用来处理所创建的进程和线程的数量吧。

进程和线程的创建都需要消耗一定的内存。如果一个程序为每一个连接都分配一个进程或者线程的话,对状态的管理就可以相对简化,程序也会比较易懂,但问题则在于内存的开销。虽然程序的空间等可以通过操作系统的功能进行共享,但变量空间和栈空间是无法共享的,因此这部分内存的开销是无法避免的。此外,每次创建一个线程,作为栈空间,一般也会产生 1MB 到 2MB 左右的内存开销。

当然,操作系统都具备虚拟内存功能,即便分配出比计算机中安装的内存(物理内存)容量还要多的空间,也不会立刻造成停止响应。然而,超出物理内存的部分,是要写入访问速度只有 DRAM 千分之一左右的磁盘上的,因此一旦分配的内存超过物理内存的容量,性能就会发生难以置信的明显下滑。

当大量的进程导致内存开销超过物理内存容量时,每次进行进程切换都不得不产生磁盘访问,这样一来,消耗的时间太长导致操作系统整体陷入一种几乎停止响应的状态,这样的情况被称为抖动(thrashing)。

不过,计算机中安装的内存容量也在不断攀升。几年前在服务器中配备 2GB 左右的内存是常见的做法,但现在,一般的服务器中配置 8GB 内存也不算罕见了。随着操作系统 64 位化的快速发展,也许在不久的将来,为每个并发连接都分配一个进程或线程的简单模型,也足够应付一万个客户端了。但到了那个时候,说不定还会产生如 C1000K 问题之类的情况吧。

同时打开的文件描述符的数量不会有那么多吧。

所谓文件描述符(file descriptor),就是用来表示输入输出对象的整数,例如打开的文件以及网络通信用的套接字等。文件描述符的数量也是有限制的,在 Linux 中默认状态下,一个进程所能打开的文件描述符的最大数量是 1024 个。

如果程序的结构需要在一个进程中对很多文件描述符进行操作,就要考虑到系统对于文件描述符数量的限制。根据需要,必须将设置改为比默认的 1024 更大的值。

在 UNIX 系操作系统中,单个进程的限制可以通过 setrlimit 系统调用进行设置。系统全局上限也可以设置,但设置的方法因操作系统而异。

或者我们也可以考虑用这样一种方式,将每 1000 个并发连接分配给一个进程,这样一来一万个连接只要 10 个进程就够了,即便使用默认设置,也不会到达文件描述符的上限的。

要对多个文件描述符进行监视,用 select 系统调用就足够了吧。

正如上面所说的,“一个连接对应一个进程 / 线程”这样的程序虽然很简单,但在内存开销等方面存在问题,于是我们需要在一个进程中不使用单独的线程来处理多个连接。在这种情况下,如果不做任何检查就直接对套接字进行读取的话,在等待套接字收到数据的过程中,程序整体的运行就会被中断。

用单线程来处理多个连接的时候,像这种等待输入时的运行中断(被称为阻塞)是致命的。为了避免阻塞,在读取数据前必须先检查文件描述符中的输入是否已经到达并可用。

于是,在 UNIX 系操作系统中,对多个文件描述符,可以使用一个叫做 select 的系统调用来监视它们是否处于可供读写的状态。select 系统调用将要监视的文件描述符存入一个 fd_set 结构体,并设定一个超时时间,对它们的状态进行监视。当指定的文件描述符变为可读、可写、发生异常等状态,或者经过指定的超时时间时,该调用就会返回。之后,通过检查 fd_set,就可以得知在指定的文件描述符之中,发生了怎样的状态变化(图 2)。

#define NSOCKS 2
int sock[NSOCKS], maxfd; ←---sock[1]、sock[2]……中存入要监视的socket。maxfd中存入最大的文件描述符
fd_set readfds;
struct timeval tv;
int i, n;

FD_ZERO(&readfds); ←---fd_set初始化
for (i=0; i<NSOCKS; i++) {
  FD_SET(sock[i], &readfds);
}

tv.tv_sec = 2; ←---2秒超时
tv.tv_usec = 0;

n = select(maxfd+1, &readfds, NULL, NULL, &tv); ←---调用select,这次只监视read。关于返回值n:负数–出错,0–超时,正数–状态发生变化的fd数量

if (n < 0) {  /* 出错 */ 
  perror(NULL);
  exit(0);
}
if (n == 0) { /* 超时 */
  puts("timeout");
  exit(0);
}
else {        /* 成功 */
  for (i=0; i<NSOCKS; i++) {
    if (FD_ISSET(sock[i], &fds)) {
      do_something(sock[i]);
    }
  }
}
---
图 2 select 系统调用的使用示例(节选)

然而,如果考虑到在发生 C10K 问题这样需要处理大量并发连接的情况,使用 select 系统调用就会存在一些问题。首先,select 系统调用能够监视的文件描述符数量是有上限的,这个上限定义在宏 FD_SETSIZE 中,虽然因操作系统而异,但一般是在 1024 个左右。即便通过 setrlimit 提高了每个进程中的文件描述符上限,也并不意味着 select 系统调用的限制能够得到改善,这一点特别需要注意。

select 系统调用的另一个问题在于,在调用 select 时,作为参数的 fd_set 结构体会被修改。select 系统调用通过 fd_set 结构体接收要监视的文件描述符,为了标记出实际上发生状态变化的文件描述符,会将相应的 fd_set 进行改写。于是,为了通过 fd_set 得知到底哪些文件描述符已经处于可用状态,必须每次都将监视中的文件描述符全部检查一遍。

虽然单独每次的开销都很小,但通过 select 系统调用进行监视的操作非常频繁。当需要监视的文件描述符越来越多时,这种小开销累积起来,也会引发大问题。

为了避免这样的问题,在可能会遇到 C10K 问题的应用程序中尽量不使用 select 系统调用。为此,可以使用 epoll、kqueue 等其他(更好的)用于监视文件描述符的 API,或者可以使用非阻塞 I/O。再或者,也可以不去刻意避免使用 select 系统调用,而是将一个进程所处理的连接数控制在 select 的上限以下。

使用 epoll 功能

很遗憾,如果不通过 select 系统调用来实现对多个文件描述符的监视,那么各种操作系统就没有一个统一的方法。例如 FreeBSD 等系统中有 kqueue,Solariszh 则是 /dev/poll,Linux 中则是用被称为 epoll 的功能。把这些功能全都介绍一遍实在是太难了,我们就来看看 Linux 中提供的 epoll 这个功能吧。

epoll 功能是由 epoll_create、epoll_ctl 和 epoll_wait 这三个系统调用构成的。用法是先通过 epoll_create 系统调用创建监视描述符,该描述符用于代表要监视的文件描述符,然后通过 epoll_ctl 将监视描述符进行注册,再通过 epoll_wait 进行实际的监视。运用 epoll 的程序节选如图 3 所示。和 select 系统调用相比,epoll 的优点如下:

· 要监视的 fd 数量没有限制

· 内核会记忆要监视的 fd,无需每次都进行初始化

· 只返回产生事件的 fd 的信息,因此无需遍历所有的 fd

通过这样的机制,使得无谓的复制和循环操作大幅减少,从而在应付大量连接的情况下,性能能够得到改善。

实际上,和使用 select 系统调用的 Apache 1.x 相比,使用 epoll 和 kqueue 等新的事件监视 API 的 Apache 2.0,仅在这一点上性能就提升了约 20% ~ 30%。

int epfd; ←---①首先创建用于epoll的fd,MAX_EVENTS为要监视的fd的最大数量

if ((epfd = epoll_create(MAX_EVENTS)) < 0) { ←---epoll用fd创建失败
------------------------------------------------------------------------------------
  exit(1);
}

struct epoll_event event; ←---②将要监视的fd添加到epoll,根据要监视的数量进行循环
int sock;

memset(&event, 0, sizeof(event)); ←---初始化epoll_event结构体
ev.events = EPOLLIN; ←---对读取进行监视
ev.data.fd = sock;

if (epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &event) < 0) { ←---将socket添加到epoll。fd添加失败

  exit(1);
}
------------------------------------------------------------------------------------
int n, i; ←---③通过epoll_wait进行监视
struct epoll_event events[MAX_EVENTS];

while (1) {
  /* epoll_wait的参数
     第一个:epoll用的fd
     第二个:epoll_event结构体数组
     第三个:epoll_event数组的大小
     第四个:timeout时间(毫秒)
     超时时间为负数表示永远等待 */
  n = epoll_wait(epfd, events, MAX_EVENTS, -1);

  if (n < 0) { ←---监视失败
    exit(1);
  }
  for (i = 0; i < n; i++) { ←---对每个fd的处理
    do_something_on_event(events[i])
  }
}
close(epfd); ←---用一般的close来关闭epoll的fd
图 3 epoll_create 的 3 段示例程序

使用 libev 框架

即便我们都知道 epoll 和 kqueue 更加先进,但它们都只能在 Linux 或 BSD 等特定平台上才能使用,这一点让人十分苦恼。因为 UNIX 系平台的一个好处,就是稍稍用心一点就可以(比较)容易地写出具备跨平台兼容性的程序。

于是,一些框架便出现了,它们可以将平台相关的部分隐藏起来,实现对文件描述符的监视。在这些框架之中,我们来为大家介绍一下 libev 和 EventMachine。

libev 是一个提供高性能事件循环功能的库,在 Debian 中提供了 libev-dev 包。libev 是通过在 loop 结构体中设定一个回调函数,当发生事件(可读 / 可写,或者经过一定时间)时,回调函数就会被调用。图 4 展示了 libev 大概的用法。由于代码中加了很多注释,因此大家应该不难对 libev 的用法有个大致的理解。

1      /* 首先包含<ev.h> */
2      #include <ev.h>
3
4      /* 其他头文件 */
5      #include <stdio.h>
6      #include <sys/types.h>
7      #include <sys/socket.h>
8
9      ev_io srvsock_watcher;
10     ev_timer timeout_watcher;
11
12     /* 读取socket的回调函数 */
13     static void
14     sock_cb(struct ev_loop *loop, ev_io *w, int revents)
15     {
16       /* 读取socket处理 */
17       /* 省略do_socket_read的实现部分 */
18       /* 到达EOF则返回EOF */
19       if (do_socket_read(w->fd) == EOF) {
20         ev_io_stop(w);    /* 停止监视 */
21         close(w->fd);     /* 关闭fd */
22         free(w);          /* 释放ev_io */
23       }
24     }
25
26     /* 服务器socket的回调函数 */
27     static void
28     sv_cb(struct ev_loop *loop, ev_io *w, int revents)
29     {
30       struct sockaddr_storage buf;
31       ev_io *sock_watcher;
32       int s;
33
34       /* 接受客户端socket连接 */
35       s = accept(w->fd, &buf, sizeof(buf));
36       if (s < 0) return;
37
38       /* 开始监视客户端socket */
39       sock_watcher = malloc(sizeof(ev_io));
40       ev_io_init(sock_watcher, sock_cb, s, EV_READ);
41       ev_io_start(loop, sock_watcher);
42     }
43
44     /* 超时的回调函数 */
45     /* 事件循环60秒后调用 */
46     static void
47     timeout_cb(struct ev_loop *loop, ev_timer *w, int revents)
48     {
49       puts("timeout");
50       /* 结束所有的事件循环 */
51       ev_unloop(loop, EVUNLOOP_ALL);
52     }
53
54     int
55     main(void)
56     {
57       /* 获取事件循环结构体 */
58       /* 一般用default就可以了 */
59       struct ev_loop *loop = ev_default_loop(0);
60
61       /* 服务器socket的获取处理 */
62       /* 篇幅所限,省略get_server_socket的实现部分 */
63       /* socket, bind, 执行socket、bind、listen等*/
64       int s = get_server_socket();
65
66       /* 开始监视服务器socket */
67       ev_io_init(&srvsock_watcher, sv_cb, s, EV_READ);
68       ev_io_start(loop, &srvsock_watcher);
69
70       /* 设置超时时间(60秒) */
71       ev_timer_init(&timeout_watcher, timeout_cb, 60.0, 0.0);
72       ev_timer_start(loop, &timeout_watcher);
73
74       /* 事件循环体 */
75       ev_loop(loop, 0);
76
77       /* unloop被调用时结束事件循环 */
78       return 0;
79     }
图 4 libev 的用法

程序基本上就是对用于监视的对象 watcher 进行初始化,然后添加到事件循环结构体(第 66 ~ 72 行),最后调用事件循环(第 75 行)就可以了。接下来,每次发生事件时,就会自动调用 watcher 中设定的回调函数(第 12 ~ 52 行 )。在服务器套接字的回调函数(第 27 ~ 42 行)中,会将已接受的来自客户端连接的套接字添加到事件循环中去。

在这个例子中,只涉及了输出输出以及超时事件,实际上 libev 能够监视表 1 所示的所有这些种类的事件。

表1 libev可监视的事件一览

事 件 名

行 为

ev_io

输入输出

ev_timer

相对时间(n 秒后)

ev_periodic

绝对时间

ev_stat

文件属性的变化

ev_signal

信号

ev_child

子进程的变化

ev_idle

闲置

libev 可以根据不同的平台自动使用 epoll、kqueue、/dev/poll 等事件监视 API。如果这些 API 都不可用,则会使用 select 系统调用。使用了 libev,就可以在监视并发连接时无需担心移植性了。

在使用像 libev 这样的事件驱动库时,必须要注意回调函数不能发生阻塞。由于事件循环是在单线程下工作的,因此在回调函数执行过程中,是无法对别的事件进行处理的。不仅是 libev,在所有事件驱动架构的程序中,都必须尽快结束回调的处理。如果一项工作需要花费很多时间,则可以将其转发给其他进程 / 线程来完成。

使用 EventMachine

刚刚我们做了很多底层编程的工作,例题也是用 C 语言来写的。不过,仔细想想的话,正如一开始所讲过的那样,C10K 问题的本质其实是“明明硬件性能足够,但因来自客户端的并发连接过多导致处理产生破绽”。既然我们完全可以不那么在意 CPU 的性能,那是不是用 Ruby 也能够应对 C10K 问题呢?

答案是肯定的。实际上,用 Ruby 开发能应付大量并发连接的程序并不难,支持这一功能的框架也已经有了。下面我们来介绍一种 Ruby 中应对 C10K 问题的事件驱动框架——EventMachine。用 Ruby 的软件包管理系统 RubyGems 就可以很轻松地完成 EventMachine 的安装:

$ sudo gem install eventmachine
我们用 EventMachine 来实现了一个 Echo(回声)服务器,它的功能就是将写入 socket 的数据原原本本返回来,程序如图 5 所示。图 4 中运用 libev 编写的程序足足 有 80 行,这还是在省略了本质的处理部分的情况下,而图 5 的程序完整版也只需要 20 行。由此大家也可以感受到 Ruby 颇高的表达能力了吧。

1  require 'eventmachine'
2
3  module EchoServer
4    def post_init
5      puts "-- someone connected to the echo server!"
6    end
7
8    def receive_data data
9      send_data data
10     close_connection if data =~ /quit/i
11   end
12
13   def unbind
14     puts "-- someone disconnected from the echo server!"
15   end
16 end
17
18 EventMachine::run {
19   EventMachine::start_server "127.0.0.1", 8081, EchoServer
20   }
图 5 运用 EventMachine 编写的 Echo 服务器

在 EventMachine 中,回调是以 Ruby 模块的形式进行定义的。在图 5 的例子中,EchoServer 模块扮演了这个角色。这个模块中重写了几个方法,从而实现了回调,也就是一种 Template Method 设计模式吧。实现回调的方法如表 2 所示。

表2 EventMachine的回调方法

方 法 名

调用条件

目 的

post_init

socket连接后

初始化连接

receive_data(data)

数据接收后

读取数据

unbind

连接终止后

终止处理

connection_completed

连接完成时

初始化客户端连接

ssl_handshake_completed

SSL 连接时

SSL

ssl_verify_peer

SSL 连接时

SSL 节点验证

proxy_target_unbound

proxy 关闭时

转发目标

同样是事件驱动框架,但 libev 和 EventMachine 在功能上却有很大的不同。

libev 的目的只是提供最基本的事件监视功能,而在套接字连接、内存管理等方面还需要用户自己来操心。同时,它能够支持定时器、信号、子进程状态变化等各种事件。libev 是用于 C 语言的库,虽然程序可能会变得很繁琐,但却拥有可以应付各种状况的灵活性。

另一方面,EventMachine 提供了多并发网络连接处理方面的丰富功能。从图 5 的程序中应该也可以看出,由于它对套接字连接、数据读取都提供了相应的支持,因此在网络编程方面可以节约大量的代码,但相对来说,它所支持的事件种类只有输入输出和定时器。

作为 C 语言的库,libev 的功能专注于对事件的监视;而作为面向 Ruby 的框架,EventMachine 则支持包括服务器、客户端的网络连接和输入输出,甚至是 SSL 加密。这也许也反映了两种编程语言性格之间的差异吧。

其实,关于 libev 和 EventMachine 是否真的能够处理大量并发连接,最好是做个性能测试,但以我手上简陋的环境来说,恐怕无法尝试一万个客户端的连接,也不可能为了这个实验准备一万台笔记本电脑吧。而且,要进行可扩展性的实验,还是需要准备一个专门的网络环境才行。不过话说回来,libev 和 EventMachine 都已经在各种服务中拥有一些应用实例,应该不会存在非常极端的性能上的问题吧4

4 也有人对 EventMachine 的性能和功能表示不满,于是又开发出了一个新的异步 I/O 库——Celluloid.io。(原书注)

小结

在 libev、EventMachine 等事件驱动框架中,如何尽量缩短回调的执行时间是非常重要的,因为在回调中如果发生输入输出等待(阻塞),在大量并发连接的情况下是致命的。于是,在输入输出上如何避免阻塞(非阻塞 I/O)就显得愈发重要。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文