这篇文章原本是前一篇博客《多线程服务器的常用编程模型》(以下简称《常用模型》)计划中的一节,今天终于写完了。
“服务器开发”包罗万象,本文所指的“服务器开发”的含义请见《常用模型》一文,一句话形容是:跑在多核机器上的 Linux 用户态的没有用户界面的长期运行的网络应用程序。“长期运行”的意思不是指程序 7x24 不重启,而是程序不会因为无事可做而退出,它会等着下一个请求的到来。例如 wget 不是长期运行的,httpd 是长期运行的。
正名
与前文相同,本文的“进程”指的是 fork() 系统调用的产物。“线程”指的是 pthread_create() 的产物,而且我指的 pthreads 是 NPTL 的,每个线程由 clone() 产生,对应一个内核的 task_struct。本文所用的开发语言是 C++,运行环境为 Linux。
首先,一个由多台机器组成的分布式系统必然是多进程的(字面意义上),因为进程不能跨 OS 边界。在这个前提下,我们把目光集中到一台机器,一台拥有至少 4 个核的普通服务器。如果要在一台多核机器上提供一种服务或执行一个任务,可用的模式有:
运行一个单线程的进程
运行一个多线程的进程
运行多个单线程的进程
运行多个多线程的进程
这些模式之间的比较已经是老生常谈,简单地总结:
模式 1 是不可伸缩的 (scalable),不能发挥多核机器的计算能力;
模式 3 是目前公认的主流模式。它有两种子模式:
3a 简单地把模式 1 中的进程运行多份,如果能用多个 tcp port 对外提供服务的话;
3b 主进程+woker进程,如果必须绑定到一个 tcp port,比如 httpd+fastcgi。
模式 2 是很多人鄙视的,认为多线程程序难写,而且不比模式 3 有什么优势;
模式 4 更是千夫所指,它不但没有结合 2 和 3 的优点,反而汇聚了二者的缺点。
本文主要想讨论的是模式 2 和模式 3b 的优劣,即:什么时候一个服务器程序应该是多线程的。
从功能上讲,没有什么是多线程能做到而单线程做不到的,反之亦然,都是状态机嘛(我很高兴看到反例)。从性能上讲,无论是 IO bound 还是 CPU bound 的服务,多线程都没有什么优势。那么究竟为什么要用多线程?
在回答这个问题之前,我先谈谈必须用必须用单线程的场合。
必须用单线程的场合
据我所知,有两种场合必须使用单线程:
程序可能会 fork()
限制程序的 CPU 占用率
先说 fork(),我在《Linux 新增系统调用的启示》中提到:
fork() 一般不能在多线程程序中调用,因为 Linux 的 fork() 只克隆当前线程的 thread of control,不克隆其他线程。也就是说不能一下子 fork() 出一个和父进程一样的多线程子进程,Linux 也没有 forkall() 这样的系统调用。forkall() 其实也是很难办的(从语意上),因为其他线程可能等在 condition variable 上,可能阻塞在系统调用上,可能等着 mutex 以跨入临界区,还可能在密集的计算中,这些都不好全盘搬到子进程里。
更为糟糕的是,如果在 fork() 的一瞬间某个别的线程 a 已经获取了 mutex,由于 fork() 出的新进程里没有这个“线程a”,那么这个 mutex 永远也不会释放,新的进程就不能再获取那个 mutex,否则会死锁。(这一点仅为推测,还没有做实验,不排除 fork() 会释放所有 mutex 的可能。)
综上,一个设计为可能调用 fork() 的程序必须是单线程的,比如我在《启示》一文中提到的“看门狗进程”。多线程程序不是不能调用 fork(),而是这么做会遇到很多麻烦,我想不出做的理由。
一个程序 fork() 之后一般有两种行为:
立刻执行 exec(),变身为另一个程序。例如 shell 和 inetd;又比如 lighttpd fork() 出子进程,然后运行 fastcgi 程序。或者集群中运行在计算节点上的负责启动 job 的守护进程(即我所谓的“看门狗进程”)。
不调用 exec(),继续运行当前程序。要么通过共享的文件描述符与父进程通信,协同完成任务;要么接过父进程传来的文件描述符,独立完成工作,例如 80 年代的 web 服务器 NCSA httpd。
这些行为中,我认为只有“看门狗进程”必须坚持单线程,其他的均可替换为多线程程序(从功能上讲)。
单线程程序能限制程序的 CPU 占用率。
这个很容易理解,比如在一个 8-core 的主机上,一个单线程程序即便发生 busy-wait(无论是因为 bug 还是因为 overload),其 CPU 使用率也只有 12.5%,即占满 1 个 core。在这种最坏的情况下,系统还是有 87.5% 的计算资源可供其他服务进程使用。
因此对于一些辅助性的程序,如果它必须和主要功能进程运行在同一台机器的话(比如它要监控其他服务进程的状态),那么做成单线程的能避免过分抢夺系统的计算资源。
基于进程的分布式系统设计
《常用模型》一文提到,分布式系统的软件设计和功能划分一般应该以“进程”为单位。我提倡用多线程,并不是说把整个系统放到一个进程里实现,而是指功能划分之后,在实现每一类服务进程时,在必要时可以借助多线程来提高性能。对于整个分布式系统,要做到能 scale out,即享受增加机器带来的好处。
对于上层的应用而言,每个进程的代码量控制在 10 万行 C++ 以下,这不包括现成的 library 的代码量。这样每个进程都能被一个脑子完全理解,不会出现混乱。(其实我更想说 5 万行。)
这里推荐一篇 Google 的好文《Introduction to Distributed System Design》。其中点睛之笔是:分布式系统设计,是 design for failure。
本文继续讨论一个服务进程什么时候应该用多线程,先说说单线程的优势。
单线程程序的优势
从编程的角度,单线程程序的优势无需赘言:简单。程序的结构一般如《常用模型》所言,是一个基于 IO multiplexing 的 event loop。或者如云风所言,直接用阻塞 IO。
event loop 的典型代码框架是:
while (!done) {
int retval = ::poll(fds, nfds, timeout_ms);
if (retval < 0) {
处理错误
} else {
处理到期的 timers
if (retval > 0) {
处理 IO 事件
}
}
}
event loop 有一个明显的缺点,它是非抢占的(non-preemptive)。假设事件 a 的优先级高于事件 b,处理事件 a 需要 1ms,处理事件 b 需要 10ms。如果事件 b 稍早于 a 发生,那么当事件 a 到来时,程序已经离开了 poll() 调用开始处理事件 b。事件 a 要等上 10ms 才有机会被处理,总的响应时间为 11ms。这等于发生了优先级反转。
这可缺点可以用多线程来克服,这也是多线程的主要优势。
多线程程序有性能优势吗?
前面我说,无论是 IO bound 还是 CPU bound 的服务,多线程都没有什么绝对意义上的性能优势。这里详细阐述一下这句话的意思。
这句话是说,如果用很少的 CPU 负载就能让的 IO 跑满,或者用很少的 IO 流量就能让 CPU 跑满,那么多线程没啥用处。举例来说:
对于静态 web 服务器,或者 ftp 服务器,CPU 的负载较轻,主要瓶颈在磁盘 IO 和网络 IO。这时候往往一个单线程的程序(模式 1)就能撑满 IO。用多线程并不能提高吞吐量,因为 IO 硬件容量已经饱和了。同理,这时增加 CPU 数目也不能提高吞吐量。
CPU 跑满的情况比较少见,这里我只好虚构一个例子。假设有一个服务,它的输入是 n 个整数,问能否从中选出 m 个整数,使其和为 0 (这里 n < 100, m > 0)。这是著名的 subset sum 问题,是 NP-Complete 的。对于这样一个“服务”,哪怕很小的 n 值也会让 CPU 算死,比如 n = 30,一次的输入不过 120 字节(32-bit 整数),CPU 的运算时间可能长达几分钟。对于这种应用,模式 3a 是最适合的,能发挥多核的优势,程序也简单。
也就是说,无论任何一方早早地先到达瓶颈,多线程程序都没啥优势。
说到这里,可能已经有读者不耐烦了:你讲了这么多,都在说单线程的好处,那么多线程究竟有什么用?
适用多线程程序的场景
我认为多线程的适用场景是:提高响应速度,让 IO 和“计算”相互重叠,降低 latency。
虽然多线程不能提高绝对性能,但能提高平均响应性能。
一个程序要做成多线程的,大致要满足:
有多个 CPU 可用。单核机器上多线程的优势不明显。
线程间有共享数据。如果没有共享数据,用模型 3b 就行。虽然我们应该把线程间的共享数据降到最低,但不代表没有;
共享的数据是可以修改的,而不是静态的常量表。如果数据不能修改,那么可以在进程间用 shared memory,模式 3 就能胜任;
提供非均质的服务。即,事件的响应有优先级差异,我们可以用专门的线程来处理优先级高的事件。防止优先级反转;
latency 和 throughput 同样重要,不是逻辑简单的 IO bound 或 CPU bound 程序;
利用异步操作。比如 logging。无论往磁盘写 log file,还是往 log server 发送消息都不应该阻塞 critical path;
能 scale up。一个好的多线程程序应该能享受增加 CPU 数目带来的好处,目前主流是 8 核,很快就会用到 16 核的机器了。
具有可预测的性能。随着负载增加,性能缓慢下降,超过某个临界点之后急速下降。线程数目一般不随负载变化。
多线程能有效地划分责任与功能,让每个线程的逻辑比较简单,任务单一,便于编码。而不是把所有逻辑都塞到一个 event loop 里,就像 Win32 SDK 程序那样。
这些条件比较抽象,这里举一个具体的(虽然是虚构的)例子。
假设要管理一个 Linux 服务器机群,这个机群里有 8 个计算节点,1 个控制节点。机器的配置都是一样的,双路四核 CPU,千兆网互联。现在需要编写一个简单的机群管理软件(参考 LLNL 的 SLURM),这个软件由三个程序组成:
运行在控制节点上的 master,这个程序监视并控制整个机群的状态。
运在每个计算节点上的 slave,负责启动和终止 job,并监控本机的资源。
给最终用户的 client 命令行工具,用于提交 job。
根据前面的分析,slave 是个“看门狗进程”,它会启动别的 job 进程,因此必须是个单线程程序。另外它不应该占用太多的 CPU 资源,这也适合单线程模型。
master 应该是个模式 2 的多线程程序:
它独占一台 8 核的机器,如果用模型 1,等于浪费了 87.5% 的 CPU 资源。
整个机群的状态应该能完全放在内存中,这些状态是共享且可变的。如果用模式 3,那么进程之间的状态同步会成大问题。而如果大量使用共享内存,等于是掩耳盗铃,披着多进程外衣的多线程程序。
master 的主要性能指标不是 throughput,而是 latency,即尽快地响应各种事件。它几乎不会出现把 IO 或 CPU 跑满的情况。
master 监控的事件有优先级区别,一个程序正常运行结束和异常崩溃的处理优先级不同,计算节点的磁盘满了和机箱温度过高这两种报警条件的优先级也不同。如果用单线程,可能会出现优先级反转。
假设 master 和每个 slave 之间用一个 TCP 连接,那么 master 采用 2 个或 4 个 IO 线程来处理 8 个 TCP connections 能有效地降低延迟。
master 要异步的往本地硬盘写 log,这要求 logging library 有自己的 IO 线程。
master 有可能要读写数据库,那么数据库连接这个第三方 library 可能有自己的线程,并回调 master 的代码。
master 要服务于多个 clients,用多线程也能降低客户响应时间。也就是说它可以再用 2 个 IO 线程专门处理和 clients 的通信。
master 还可以提供一个 monitor 接口,用来广播 (pushing) 机群的状态,这样用户不用主动轮询 (polling)。这个功能如果用单独的线程来做,会比较容易实现,不会搞乱其他主要功能。
master 一共开了 10 个线程:
4 个用于和 slaves 通信的 IO 线程
1 个 logging 线程
1 个数据库 IO 线程
2 个和 clients 通信的 IO 线程
1 个主线程,用于做些背景工作,比如 job 调度
1 个 pushing 线程,用于主动广播机群的状态
虽然线程数目略多于 core 数目,但是这些线程很多时候都是空闲的,可以依赖 OS 的进程调度来保证可控的延迟。
综上所述,master 用多线程方式编写是自然且高效的。
线程的分类
据我的经验,一个多线程服务程序中的线程大致可分为 3 类:
IO 线程,这类线程的的主循环是 io multiplexing,等在 select/poll/epoll 系统调用上。这类线程也处理定时事件。当然它的功能不止 IO,有些计算也可以放入其中。
计算线程,这类线程的主循环是 blocking queue,等在 condition variable 上。这类线程一般位于 thread pool 中。
第三方库所用的线程,比如 logging,又比如 database connection。
服务器程序一般不会频繁地启动和终止线程。甚至,在我写过的程序里,create thread 只在程序启动的时候调用,在服务运行期间是不调用的。
在多核时代,多线程编程是不可避免的,“鸵鸟算法”不是办法。
续:《多线程服务器的适用场合》例释与答疑
上文《多线程服务器的适用场合》(以下简称《适用场合》)一文在博客登出之后,有热心读者提出质疑,我自己也觉得原文没有把道理说通说透,这篇文章试图用一些实例来解答读者的疑问。我本来打算修改原文,但是考虑到已经读过的读者不一定会注意到文章的变动,干脆另写一篇。为方便阅读,本文以问答体呈现。这篇文章可能会反复修改扩充,请注意上面的版本号。
本文所说的“多线程服务器”的定义与前文一样,同时参见《多线程服务器的常用编程模型》(以下简称《常用模型》)一文的详细界定,以下“连接、端口”均指 TCP 协议。
1. Linux 能同时启动多少个线程?
对于 32-bit Linux,一个进程的地址空间是 4G,其中用户态能访问 3G 左右,而一个线程的默认栈 (stack) 大小是 10M,心算可知,一个进程大约最多能同时启动 300 个线程。如果不改线程的调用栈大小的话,300 左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库、等等)同样要占用内存(地址空间)。
对于 64-bit 系统,线程数目可大大增加,具体数字我没有测试,因为我实际用不到那么多线程。
以下的关于线程数目的讨论以 32-bit Linux 为例。
2. 多线程能提高并发度吗?
如果指的是“并发连接数”,不能。
由问题 1 可知,假如单纯采用 thread per connection 的模型,那么并发连接数最多 300,这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千上万,甚至几万)。所谓“基于事件”,指的是用 IO multiplexing event loop 的编程模型,又称 Reactor 模式,在《常用模型》一文中已有介绍。
那么采用《常用模型》一文中推荐的 event loop per thread 呢?至少不逊于单线程程序。
小结:thread per connection 不适合高并发场合,其 scalability 不佳。event loop per thread 的并发度不比单线程程序差。
3. 多线程能提高吞吐量吗?
对于计算密集型服务,不能。
假设有一个耗时的计算服务,用单线程算需要 0.8s。在一台 8 核的机器上,我们可以启动 8 个线程一起对外服务(如果内存够用,启动 8 个进程也一样)。这样完成单个计算仍然要 0.8s,但是由于这些进程的计算可以同时进行,理想情况下吞吐量可以从单线程的 1.25cps (calc per second) 上升到 10cps。(实际情况可能要打个八折——如果不是打对折的话。)
假如改用并行算法,用 8 个核一起算,理论上如果完全并行,加速比高达 8,那么计算时间是 0.1s,吞吐量还是 10cps,但是首次请求的响应时间却降低了很多。实际上根据 Amdahl's law,即便算法的并行度高达 95%,8 核的加速比也只有 6,计算时间为 0.133s,这样会造成吞吐量下降为 7.5cps。不过以此为代价,换得响应时间的提升,在有些应用场合也是值得的。
这也回答了问题 4。
如果用 thread per request 的模型,每个客户请求用一个线程去处理,那么当并发请求数大于某个临界值 T’ 时,吞吐量反而会下降,因为线程多了以后上下文切换的开销也随之增加(分析与数据请见《A Design Framework for Highly Concurrent Systems》 by Matt Welsh et al.)。thread per request 是最简单的使用线程的方式,编程最容易,简单地把多线程程序当成一堆串行程序,用同步的方式顺序编程,比如 Java Servlet 中,一次页面请求由一个函数 HttpServlet#service(HttpServletRequest req, HttpServletResponse resp) 同步地完成。
为了在并发请求数很高时也能保持稳定的吞吐量,我们可以用线程池,线程池的大小应该满足“阻抗匹配原则”,见问题 7。
线程池也不是万能的,如果响应一次请求需要做比较多的计算(比如计算的时间占整个 response time 的 1/5 强),那么用线程池是合理的,能简化编程。如果一次请求响应中,thread 主要是在等待 IO,那么为了进一步提高吞吐,往往要用其它编程模型,比如 Proactor,见问题 8。
4. 多线程能降低响应时间吗?
如果设计合理,充分利用多核资源的话,可以。在突发 (burst) 请求时效果尤为明显。
例1: 多线程处理输入。
以 memcached 服务端为例。memcached 一次请求响应大概可以分为 3 步:
读取并解析客户端输入
操作 hashtable
返回客户端
在单线程模式下,这 3 步是串行执行的。在启用多线程模式时,它会启用多个输入线程(默认是 4 个),并在建立连接时按 round-robin 法把新连接分派给其中一个输入线程,这正好是我说的 event loop per thread 模型。这样一来,第 1 步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第 2 步用了全局锁,还是单线程的,这可算是一个值得继续改进的地方。
比如,有两个用户同时发出了请求,这两个用户的连接正好分配在两个 IO 线程上,那么两个请求的第 1 步操作可以在两个线程上并行执行,然后汇总到第 2 步串行执行,这样总的响应时间比完全串行执行要短一些(在“读取并解析”所占的比重较大的时候,效果更为明显)。请继续看下面这个例子。
例2: 多线程分担负载。
假设我们要做一个求解 Sudoku 的服务(见《谈谈数独》),这个服务程序在 9981 端口接受请求,输入为一行 81 个数字(待填数字用 0 表示),输出为填好之后的 81 个数字 (1 ~ 9),如果无解,输出 “NO/r/n”。
由于输入格式很简单,用单个线程做 IO 就行了。先假设每次求解的计算用时 10ms,用前面的方法计算,单线程程序能达到的吞吐量上限为 100req/s,在 8 核机器上,如果用线程池来做计算,能达到的吞吐量上限为 800req/s。下面我们看看多线程如何降低响应时间。
假设 1 个用户在极短的时间内发出了 10 个请求,如果用单线程“来一个处理一个”的模型,这些 reqs 会排在队列里依次处理(这个队列是操作系统的 TCP 缓冲区,不是程序里自己的任务队列)。在不考虑网络延迟的情况下,第 1 个请求的响应时间是 10ms;第 2 个请求要等第 1 个算完了才能获得 CPU 资源,它等了 10ms,算了 10ms,响应时间是 20ms;依次类推,第 10 个请求的响应时间为 100ms;10个请求的平均响应时间为 55ms。
如果 Sudoku 服务在每个请求到达时开始计时,会发现每个请求都是 10ms 响应时间,而从用户的观点,10 个请求的平均响应时间为 55ms,请读者想想为什么会有这个差异。
下面改用多线程:1 个 IO 线程,8 个计算线程(线程池)。二者之间用 BlockingQueue 沟通。同样是 10 个并发请求,第 1 个请求被分配到计算线程1,第 2 个请求被分配到计算线程 2,以此类推,直到第 8 个请求被第 8 个计算线程承担。第 9 和第 10 号请求会等在 BlockingQueue 里,直到有计算线程回到空闲状态才能被处理。(请注意,这里的分配实际上是由操作系统来做,操作系统会从处于 Waiting 状态的线程里挑一个,不一定是 round-robin 的。)
这样一来,前 8 个请求的响应时间差不多都是 10ms,后 2 个请求属于第二批,其响应时间大约会是 20ms,总的平均响应时间是 12ms。可以看出比单线程快了不少。
由于每道 Sudoku 题目的难度不一,对于简单的题目,可能 1ms 就能算出来,复杂的题目最多用 10ms。那么线程池方案的优势就更明显,它能有效地降低“简单任务被复杂任务压住”的出现概率。
以上举的都是计算密集的例子,即线程在响应一次请求时不会等待 IO,下面谈谈更复杂的情况。
5. 多线程程序如何让 IO 和“计算”相互重叠,降低 latency?
基本思路是,把 IO 操作(通常是写操作)通过 BlockingQueue 交给别的线程去做,自己不必等待。
例1: logging
在多线程服务器程序中,日志 (logging) 至关重要,本例仅考虑写 log file 的情况,不考虑 log server。
在一次请求响应中,可能要写多条日志消息,而如果用同步的方式写文件(fprintf 或 fwrite),多半会降低性能,因为:
文件操作一般比较慢,服务线程会等在 IO 上,让 CPU 闲置,增加响应时间。
就算有 buffer,还是不灵。多个线程一起写,为了不至于把 buffer 写错乱,往往要加锁。这会让服务线程互相等待,降低并发度。(同时用多个 log 文件不是办法,除非你有多个磁盘,且保证 log files 分散在不同的磁盘上,否则还是受到磁盘 IO 瓶颈制约。)
解决办法是单独用一个 logging 线程,负责写磁盘文件,通过一个或多个 BlockingQueue 对外提供接口。别的线程要写日志的时候,先把消息(字符串)准备好,然后往 queue 里一塞就行,基本不用等待。这样服务线程的计算就和 logging 线程的磁盘 IO 相互重叠,降低了服务线程的响应时间。
尽管 logging 很重要,但它不是程序的主要逻辑,因此对程序的结构影响越小越好,最好能简单到如同一条 printf 语句,且不用担心其他性能开销,而一个好的多线程异步 logging 库能帮我们做到这一点。(Apache 的 log4cxx 和 log4j 都支持 AsyncAppender 这种异步 logging 方式。)
例2: memcached 客户端
假设我们用 memcached 来保存用户最后发帖的时间,那么每次响应用户发帖的请求时,程序里要去设置一下 memcached 里的值。这一步如果用同步 IO,会增加延迟。
对于“设置一个值”这样的 write-only idempotent 操作,我们其实不用等 memcached 返回操作结果,这里也不用在乎 set 操作失败,那么可以借助多线程来降低响应延迟。比方说我们可以写一个多线程版的 memcached 的客户端,对于 set 操作,调用方只要把 key 和 value 准备好,调用一下 asyncSet() 函数,把数据往 BlockingQueue 上一放就能立即返回,延迟很小。剩下的时就留给 memcached 客户端的线程去操心,而服务线程不受阻碍。
其实所有的网络写操作都可以这么异步地做,不过这也有一个缺点,那就是每次 asyncWrite 都要在线程间传递数据,其实如果 TCP 缓冲区是空的,我们可以在本线程写完,不用劳烦专门的 IO 线程。Jboss 的 Netty 就使用了这个办法来进一步降低延迟。
以上都仅讨论了“打一枪就跑”的情况,如果是一问一答,比如从 memcached 取一个值,那么“重叠 IO”并不能降低响应时间,因为你无论如何要等 memcached 的回复。这时我们可以用别的方式来提高并发度,见问题8。(虽然不能降低响应时间,但也不要浪费线程在空等上,对吧)
另外以上的例子也说明,BlockingQueue 是构建多线程程序的利器。
6. 为什么第三方库往往要用自己的线程?
往往因为 event loop 模型没有标准实现。如果自己写代码,尽可以按所用 Reactor 的推荐方式来编程,但是第三方库不一定能很好地适应并融入这个 event loop framework。有时需要用线程来做一些串并转换。
对于 Java,这个问题还好办一些,因为 thread pool 在 Java 里有标准实现,叫 ExecutorService。如果第三方库支持线程池,那么它可以和主程序共享一个 ExecutorService ,而不是自己创建一堆线程。(比如在初始化时传入主程序的 obj。)对于 C++,情况麻烦得多,Reactor 和 Thread pool 都没有标准库。
例1:libmemcached 只支持同步操作
libmemcached 支持所谓的“非阻塞操作”,但没有暴露一个能被 select/poll/epoll 的 file describer,它的 memcached_fetch 始终会阻塞。它号称 memcached_set 可以是非阻塞的,实际意思是不必等待结果返回,但实际上这个函数会同步地调用 write(),仍可能阻塞在网络 IO 上。
如果在我们的 reactor event handler 里调用了 libmemcached 的函数,那么 latency 就堪忧了。如果想继续用 libmemcached,我们可以为它做一次线程封装,按问题 5 例 2 的办法,同额外的线程专门做 memcached 的 IO,而程序主体还是 reactor。甚至可以把 memcached “数据就绪”作为一个 event,注入到我们的 event loop 中,以进一步提高并发度。(例子留待问题 8 讲)
万幸的是,memcached 的协议非常简单,大不了可以自己写一个基于 reactor 的客户端,但是数据库客户端就没那么幸运了。
例2:MySQL 的官方 C API 不支持异步操作
MySQL 的客户端只支持同步操作,对于 UPDATE/INSERT/DELETE 之类只要行为不管结果的操作(如果代码需要得知其执行结果则另当别论),我们可以用一个单独的线程来做,以降低服务线程的延迟。可仿照前面 memcached_set 的例子,不再赘言。麻烦的是 SELECT,如果要把它也异步化,就得动用更复杂的模式了,见问题 8。
相比之下,PostgreSQL 的 C 客户端 libpq 的设计要好得多,我们可以用 PQsendQuery() 来发起一次查询,然后用标准的 select/poll/epoll 来等待 PQsocket,如果有数据可读,那么用 PQconsumeInput 处理之,并用 PQisBusy 判断查询结果是否已就绪,最后用 PQgetResult 来获取结果。借助这套异步 API,我们可以很容易地为 libpq 写一套 wrapper,使之融入到程序所用的 reactor 模型中。
7. 什么是线程池大小的阻抗匹配原则?
我在《常用模型》中提到“阻抗匹配原则”,这里大致讲一讲。
如果池中线程在执行任务时,密集计算所占的时间比重为 P (0 < P <= 1),而系统一共有 C 个 CPU,为了让这 C 个 CPU 跑满而又不过载,线程池大小的经验公式 T = C/P。(T 是个 hint,考虑到 P 值的估计不是很准确,T 的最佳值可以上下浮动 50%。)
以后我再讲这个经验公式是怎么来的,先验证边界条件的正确性。
假设 C = 8, P = 1.0,线程池的任务完全是密集计算,那么 T = 8。只要 8 个活动线程就能让 8 个 CPU 饱和,再多也没用,因为 CPU 资源已经耗光了。
假设 C = 8, P = 0.5,线程池的任务有一半是计算,有一半等在 IO 上,那么 T = 16。考虑操作系统能灵活合理地调度 sleeping/writing/running 线程,那么大概 16 个“50% 繁忙的线程”能让 8 个 CPU 忙个不停。启动更多的线程并不能提高吞吐量,反而因为增加上下文切换的开销而降低性能。
如果 P < 0.2,这个公式就不适用了,T 可以取一个固定值,比如 5*C。
另外,公式里的 C 不一定是 CPU 总数,可以是“分配给这项任务的 CPU 数目”,比如在 8 核机器上分出 4 个核来做一项任务,那么 C=4。
8. 除了你推荐的 reactor + thread poll,还有别的 non-trivial 多线程编程模型吗?
有,Proactor。
如果一次请求响应中要和别的进程打多次交道,那么 proactor 模型往往能做到更高的并发度。当然,代价是代码变得支离破碎,难以理解。
这里举 http proxy 为例,一次 http proxy 的请求如果没有命中本地 cache,那么它多半会:
解析域名 (不要小看这一步,对于一个陌生的域名,解析可能要花半秒钟)
建立连接
发送 HTTP 请求
等待对方回应
把结果返回客户
这 5 步里边跟 2 个 server 发生了 3 次 round-trip:
向 DNS 问域名,等待回复;
向对方 http 服务器发起连接,等待 TCP 三路握手完成;
向对方发送 http request,等待对方 response。
而实际上 http proxy 本身的运算量不大,如果用线程池,池中线程的数目会很庞大,不利于操作系统管理调度。
这时我们有两个解决思路:
把“域名已解析”,“连接已建立”,“对方已完成响应”做成 event,继续按照 Reactor 的方式来编程。这样一来,每次客户请求就不能用一个函数从头到尾执行完成,而要分成多个阶段,并且要管理好请求的状态(“目前到了第几步?”)。
用回调函数,让系统来把任务串起来。比如收到用户请求,如果没有命中本地 cache,立刻发起异步的 DNS 解析 startDNSResolve(),告诉系统在解析完之后调用 DNSResolved() 函数;在 DNSResolved() 中,发起连接,告诉系统在连接建立之后调用 connectionEstablished();在 connectionEstablished() 中发送 http request,告诉系统在收到响应之后调用 httpResponsed();最后,在 httpResponsed() 里把结果返回给客户。.NET 大量采用的 Begin/End 操作也是这个编程模式。当然,对于不熟悉这种编程方式的人,代码会显得很难看。Proactor 模式的例子可看 boost::asio 的文档,这里不再多说。
Proactor 模式依赖操作系统或库来高效地调度这些子任务,每个子任务都不会阻塞,因此能用比较少的线程达到很高的 IO 并发度。
Proactor 能提高吞吐,但不能降低延迟,所以我没有深入研究。
9. 模式 2 和模式 3a 该如何取舍?
这里的“模式”不是 pattern,而是 model,不巧它们的中译是一样的。《适用场合》中提到,模式 2 是一个多线程的进程,模式 3a 是多个相同的单线程进程。
我认为,在其他条件相同的情况下,可以根据工作集 (work set) 的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小。
如果工作集较大,那么就用多线程,避免 CPU cache 换入换出,影响性能;否则,就用单线程多进程,享受单线程编程的便利。
例如,memcached 这个内存消耗大户用多线程服务端就比在同一台机器上运行多个 memcached instance 要好。(除非你在 16G 内存的机器上运行 32-bit memcached,那么多 instance 是必须的。)
又例如,求解 Sudoku 用不了多大内存,如果单线程编程更方便的话,可以用单线程多进程来做。再在前面加一个单线程的 load balancer,仿 lighttpd + fastcgi 的成例。
线程不能减少工作量,即不能减少 CPU 时间。如果解决一个问题需要执行一亿条指令(这个数字不大,不要被吓到),那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核上的执行情况,我们能让工期提早结束。这听上去像统筹方法,确实也正是统筹方法。