陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
2010 Feb 28
这篇文章原本是前一篇博客《多线程服务器的常用编程模型》(以下简称《常用模型》)计划中的一节,今天终于写完了。
“服务器开发”包罗万象,本文所指的“服务器开发”的含义请见《常用模型》一文,一句话形容是:跑在多核机器上的 Linux 用户态的没有用户界面的长期运行的网络应用程序。“长期运行”的意思不是指程序 7x24 不重启,而是程序不会因为无事可做而退出,它会等着下一个请求的到来。例如 wget 不是长期运行的,httpd 是长期运行的。
与前文相同,本文的“进程”指的是 fork() 系统调用的产物。“线程”指的是 pthread_create() 的产物,而且我指的 pthreads 是 NPTL 的,每个线程由 clone() 产生,对应一个内核的 task_struct。本文所用的开发语言是 C++,运行环境为 Linux。
首先,一个由多台机器组成的分布式系统必然是多进程的(字面意义上),因为进程不能跨 OS 边界。在这个前提下,我们把目光集中到一台机器,一台拥有至少 4 个核的普通服务器。如果要在一台多核机器上提供一种服务或执行一个任务,可用的模式有:
这些模式之间的比较已经是老生常谈,简单地总结:
本文主要想讨论的是模式 2 和模式 3b 的优劣,即:什么时候一个服务器程序应该是多线程的。
从功能上讲,没有什么是多线程能做到而单线程做不到的,反之亦然,都是状态机嘛(我很高兴看到反例)。从性能上讲,无论是 IO bound 还是 CPU bound 的服务,多线程都没有什么优势。那么究竟为什么要用多线程?
在回答这个问题之前,我先谈谈必须用必须用单线程的场合。
据我所知,有两种场合必须使用单线程:
先说 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() 之后一般有两种行为:
这些行为中,我认为只有“看门狗进程”必须坚持单线程,其他的均可替换为多线程程序(从功能上讲)。
单线程程序能限制程序的 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 跑满,那么多线程没啥用处。举例来说:
也就是说,无论任何一方早早地先到达瓶颈,多线程程序都没啥优势。
说到这里,可能已经有读者不耐烦了:你讲了这么多,都在说单线程的好处,那么多线程究竟有什么用?
我认为多线程的适用场景是:提高响应速度,让 IO 和“计算”相互重叠,降低 latency。
虽然多线程不能提高绝对性能,但能提高平均响应性能。
一个程序要做成多线程的,大致要满足:
这些条件比较抽象,这里举一个具体的(虽然是虚构的)例子。
假设要管理一个 Linux 服务器机群,这个机群里有 8 个计算节点,1 个控制节点。机器的配置都是一样的,双路四核 CPU,千兆网互联。现在需要编写一个简单的机群管理软件(参考 LLNL 的 SLURM),这个软件由三个程序组成:
根据前面的分析,slave 是个“看门狗进程”,它会启动别的 job 进程,因此必须是个单线程程序。另外它不应该占用太多的 CPU 资源,这也适合单线程模型。
master 应该是个模式 2 的多线程程序:
综上所述,master 用多线程方式编写是自然且高效的。
据我的经验,一个多线程服务程序中的线程大致可分为 3 类:
服务器程序一般不会频繁地启动和终止线程。甚至,在我写过的程序里,create thread 只在程序启动的时候调用,在服务运行期间是不调用的。
在多核时代,多线程编程是不可避免的,“鸵鸟算法”不是办法。