4.2 C10K 问题
几年前,我去参加驾照更新的讲座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 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论