本章主要讲我个人在多线程开发方面的一些粗浅经验。总结了一两种常用的线程模型,归纳了进程间通信与线程同步的最佳实践,以期用简单规范的方式开发功能正确、线程安全的多线程程序。本章假定读者已经有多线程编程的知识与经验(本书不是一篇入门教程)。
文中的"多线程服务器"是指运行在Linux操作系统上的独占式网络应用程序。硬件平台为Intel x86-64系列的多核CPU,单路或双路SMP服务器(每台机器一共拥有四个核或八个核,十几GB内存),机器之间用千兆以太网连接。这大概是目前民用PC服务器的主流配置。不考虑做分布式存储,只考虑分布式计算,系统的规模大约是几十台服务器到几百台服务器之间。
我将要谈的"网络应用程序"的基本功能可以简单归纳为"收到数据,算一算,再发出去"。在这个简化了的模型里,似乎看不出用多线程的必要,单线程应该也能做得很好。"为什么需要写多线程程序"这个问题容易引发口水战,我放到§ 3.5讨论。请允许我先假定"多线程编程"这一背景。
"服务器"这个词有时指程序,有时指进程,有时指硬件(无论虚拟的或真实的),请注意按上下文区分。另外,本书不考虑虚拟化的场景,当我说"两个进程不在同一台机器上"时,指的是逻辑上不在同一个操作系统里运行,虽然物理上可能位于同一机器虚拟出来的两台"虚拟机"上。
3.1 进程与线程
"进程(process)"是操作系统里最重要的两个概念之一(另一个是文件),粗略地讲,一个进程是"内存中正在运行的程序"。本书的进程指的是Linux操作系统通过fork()系统调用产生的那个东西,或者Windows下CreateProcess()的产物,不是Erlang里的那种"轻量级进程(Actor)"。
每个进程有自己独立的地址空间(address space),"在同一个进程"还是"不在同一个进程"是系统功能划分的重要决策点。《Erlang程序设计》[ERL]把"进程"比喻为"人",我觉得十分精当,为我们提供了一个思考的框架。
每个人有自己的记忆(memory),人与人通过谈话(消息传递)来交流,谈话既可以是面谈(同一台服务器),也可以在电话里谈(不同的服务器,有网络通信)。面谈和电话谈的区别在于,面谈可以立即知道对方是否死了(crash,SIGCHLD),而电话谈只能通过周期性的心跳来判断对方是否还活着。
有了这些比喻,设计分布式系统时可以采取"角色扮演",团队里的几个人各自扮演一个进程,人的角色由进程的代码决定(管登录的、管消息分发的、管买卖的等等)。每个人有自己的记忆,但不知道别人的记忆,要想知道别人的看法,只能通过交谈(暂不考虑共享内存这种IPC)。然后就可以思考:
● 容错 万一有人突然死了
● 扩容 新人中途加进来
● 负载均衡 把甲的活儿挪给乙做
● 退休 甲要修复bug,先别派新任务,等他做完手上的事情就把他重启等等各种场景,十分便利。
"线程"这个概念大概是在1993年以后才慢慢流行起来的,距今不到20年,比不得有40年光辉历史的Unix操作系统。线程的出现给Unix添了不少乱,很多C库函数(strtok()、ctime())不是线程安全的,需要重新定义(§ 4.2);signal的语意也大为复杂化。据我所知,最早支持多线程编程的(民用)操作系统是Solaris 2.2和Windows NT 3.1,它们均发布于1993年。随后在1995年,POSIX threads标准确立。
线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。如果多个进程大量共享内存,等于是把多进程程序当成多线程来写,掩耳盗铃。
"多线程"的价值,我认为是为了更好地发挥多核处理器(multi-cores)的效能。在单核时代,多线程没有多大价值。Alan Cox说过:"A computer is a state machine.Threads are for people who can't program state machines."(计算机是一台状态机。线程是给那些不能编写状态机程序的人准备的。)如果只有一块CPU、一个执行单元,那么确实如Alan Cox所说,按状态机的思路去写程序是最高效的,这正好也是下一节展示的编程模型。
3.2 单线程服务器的常用编程模型
UNP\]对此有很好的总结(第6章的IO模型、第30章的客户端/服务器设计范式),这里不再赘述。据我了解,在高性能的网络程序中,使用得最为广泛的恐怕要数"non-blocking IO+IO multiplexing"(非阻塞I/O+I/O多路复用)这种模型,即Reactor模式\[注1:[http://www.cs.wustl.edu/-schmidt/PDF/Reactor1-93.pdf](http://www.cs.wustl.edu/-schmidt/PDF/Reactor1-93.pdf "http://www.cs.wustl.edu/-schmidt/PDF/Reactor1-93.pdf"), Reactor2-93.pdf,Reactor.pdf\],我知道的有:
● lighttpd,单线程服务器。(Nginx与之类似,每个工作进程有一个event loop。)
● libevent,libev。
● ACE,Poco C++libraries。
● Java NIO,包括Apache Mina和Netty。
● POE(Perl)。
● Twisted(Python)。
相反,Boost.Asio和Windows I/O Completion Ports实现了Proactor模式\[注2:[https://www.cs.wustl.edu/-schmidt/PDF/proactor.pdf](https://www.cs.wustl.edu/-schmidt/PDF/proactor.pdf "https://www.cs.wustl.edu/-schmidt/PDF/proactor.pdf")\],应用面似乎要窄一些。此外,ACE也实现了Proactor模式。
在"non-blocking IO+IO multiplexing"(非阻塞I/O+I/O多路复用)这种模型中,程序的基本结构是一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑:
```cpp
//代码仅为示意,没有完整考虑各种情况
while(!done)
{
int timeout_ms = max(1000, getNextTimedCallback());
int retval = ::poll(fds, nfds, timeout_ms);
if(retval < 0) {
//处理错误,回调用户的error handler
}
else {
//处理到期的timers,回调用户的timer handler
if(retval > 0) {
//处理IO事件,回调用户的IO event handler
}
}
}
```
这里select(2)/poll(2)有伸缩性方面的不足,Linux下可替换为epoll(4),其他操作系统也有对应的高性能替代品\[注3:[https://www.kegel.com/c10k.html](https://www.kegel.com/c10k.html "https://www.kegel.com/c10k.html") C10K问题\]。
Reactor模型的优点很明显,编程不难,效率也不错。不仅可以用于读写socket,连接的建立(connect(2)/accept(2))甚至DNS解析\[注4:gethostbyname(3)是阻寒的,对陌生域名解析的耗时可长达数秒。\]都可以用非阻塞方式进行,以提高并发度和吞吐量(throughput),对于IO密集的应用是个不错的选择。lighttpd就是这样,它内部的fdevent结构十分精妙,值得学习。
基于事件驱动的编程模型也有其本质的缺点,它要求事件回调函数必须是非阻塞的。对于涉及网络IO的请求响应式协议,它容易割裂业务逻辑,使其散布于多个回调函数之中,相对不容易理解和维护。现代的语言有一些应对方法(例如coroutine),但是本书只关注C++这种传统语言,因此就不展开讨论了。
### 3.3 多线程服务器的常用编程模型
这方面我能找到的文献\[注5:[https://cs.uwaterloo.ca/\~brecht/pubs.html](https://cs.uwaterloo.ca/~brecht/pubs.html "https://cs.uwaterloo.ca/~brecht/pubs.html") [https://inria.hal.science/file/index/docid/674475/filename/paper.pdf](https://inria.hal.science/file/index/docid/674475/filename/paper.pdf "https://inria.hal.science/file/index/docid/674475/filename/paper.pdf")\]不多,大概有这么几种(见§ 6.6更详细的讨论):
**1.每个请求创建一个线程,使用阻塞式IO操作**。在Java 1.4引入NIO之前,这是Java网络编程的推荐做法。可惜伸缩性不佳。
**2.使用线程池,同样使用阻塞式IO操作**。与第1种相比,这是提高性能的措施。
**3.使用non-blocking IO+IO multiplexing** (非阻塞IO + IO多路复用)。即Java NIO的方式。
**4.Leader/Follower等高级模式**。在默认情况下,我会使用第3种,即non-blocking IO+one loop per thread模式来编写多线程C++网络服务程序。
在默认情况下,我会使用第3种,即non-blocking IO+one loop per thread(非阻塞IO+每线程单循环)模式来编写多线程C++网络服务程序。
-----------------------------分隔线------------------------------
笔记:
```cpp
//BlockingQueue_test.cpp
#include "muduo/base/BlockingQueue.h"
#include "muduo/base/CountDownLatch.h"
#include "muduo/base/Thread.h"
#include
3b主进程+woker进程,如果必须绑定到一个TCP port,比如httpd+fastcgi
● 模式2是被很多人所鄙视的,认为多线程程序难写,而且与模式3相比并没有什么优势。
● 模式4更是千夫所指,它不但没有结合2和3的优点,反而汇聚了二者的缺点。
本文主要想讨论的是模式2和模式3b的优劣,即:什么时候一个服务器程序应该是多线程的。从功能上讲,没有什么是多线程能做到而单线程做不到的,反之亦然,都是状态机嘛(我很高兴看到反例)。从性能上讲,无论是IO bound还是CPU bound的服务,多线程都没有什么优势。
Paul E.McKenney在《Is Parallel Programming Hard,And,If So,What Can You Do About It?》[注17:http://kernel.org/pub/linux/kernel/people/paulmck/perfbook/perfbook.html]第3.5节指出,"As a rough rule of thumb,use the simplest tool that will get the job done."比方说,使用速率为50MB/s的数据压缩库、在进程创建销毁的开销是800µs、线程创建销毁的开销是50µs的前提下,考虑如何执行压缩任务:
● 如果要偶尔压缩1GB的文本文件,预计运行时间是20s,那么起一个进程去做是合理的,因为进程启动和销毁的开销远远小于实际任务的耗时。
● 如果要经常压缩500kB的文本数据,预计运行时间是10ms,那么每次都起进程似乎有点浪费了,可以每次单独起一个线程去做。
● 如果要频繁压缩10kB的文本数据,预计运行时间是200µs,那么每次起线程似乎也很浪费,不如直接在当前线程搞定。也可以用一个线程池,每次把压缩任务交给线程池,避免阻塞当前线程(特别要避免阻塞IO线程)。
由此可见,多线程并不是万灵丹(silver bullet),它有适用的场合。那么究竟什么时候该用多线程?在回答这个问题之前,我先谈谈必须用单线程的场合。
3.5.1 必须用单线程的场合
据我所知,有两种场合必须使用单线程:
1.程序可能会fork(2);
2.限制程序的CPU占用率。
只有单线程程序能 fork(2)根据后面 § 4.9 的分析,一个设计为可能调用fork(2)的程序必须是单线程的,比如后面§ 3.5.3中提到的"看门狗进程"。多线程程序不是不能调用fork(2),而是这么做会遇到很多麻烦,我想不出做的理由。
一个程序fork(2)之后一般有两种行为:
1.立刻执行exec(),变身为另一个程序。例如shell和inetd;又比如lighttpd fork()出子进程,然后运行fastcgi程序。或者集群中运行在计算节点上的负责启动job的守护进程(即我所谓的"看门狗进程")。
2.不调用exec(),继续运行当前程序。要么通过共享的文件描述符与父进程通信,协同完成任务;要么接过父进程传来的文件描述符,独立完成工作,例如20世纪80年代的Web服务器NCSA httpd。
这些行为中,我认为只有"看门狗进程"必须坚持单线程,其他的均可替换为多线程程序(从功能上讲)。
单线程程序能限制程序的CPU占用率 这个很容易理解,比如在一个8核的服务器上,一个单线程程序即便发生busy-wait(无论是因为bug,还是因为overload),占满1个core,其CPU使用率也只有12.5%.[注18:本书中,当句尾是百分号"%"时,句号改用句点".",以避免与千分号"%0"相混淆。]在这种最坏的情况下,系统还是有87.5%的计算资源可供其他服务进程使用。
因此对于一些辅助性的程序,如果它必须和主要服务进程运行在同一台机器的话(比如它要监控其他服务进程的状态),那么做成单线程的能避免过分抢夺系统的计算资源。比方说如果要把生产服务器上的日志文件压缩后备份到NFS上,那么应该使用普通单线程压缩工具(gzip/bzip2)。它们对系统造成的影响较小,在8核服务器上最多占满1个core。如果有人为了"提高速度",开启了多线程压缩或者同时起多个进程来压缩多个日志文件,有可能造成的结果是非关键任务耗尽了CPU资源,正常客户的请求响应变慢。这是我们不愿意看到的。
3.5.2 单线程程序的优缺点
从编程的角度,单线程程序的优势无须赘言:简单。程序的结构一般如§ 3.2所言,是一个基于IO multiplexing的event loop。或者如云风所言[注19:http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html],直接用阻塞IO。event loop的典型代码框架见§ 3.2。
Event loop有一个明显的缺点,它是非抢占的(non-preemptive)。假设事件a的优先级高于事件b,处理事件a需要1ms,处理事件b需要10ms。如果事件b稍早于a发生,那么当事件a到来时,程序已经离开了poll(2)调用,并开始处理事件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,一次的输入不过200字节(32-bit整数),CPU的运算时间却能长达几分钟。对于这种应用,模式3a是最适合的,能发挥多核的优势,程序也简单。
也就是说,无论任何一方早早地先到达瓶颈,多线程程序都没啥优势。
说到这里,可能已经有读者不耐烦了:你讲了这么多,都在说单线程的好处,那么多线程究竟有什么用?
3.5.3 适用多线程程序的场景
我认为多线程的适用场景是:提高响应速度,让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里,不同类别的事件之间相互影响。
这些条件比较抽象,这里举两个具体的(虽然是虚构的)例子。
假设要管理一个Linux服务器机群,这个机群里有8个计算节点,1个控制节点。机器的配置都是一样的,双路四核CPU,千兆网互联。现在需要编写一个简单的机群管理软件(参考LLNL的SLURM [注20:https://computing.llnl.gov/linux/slurm/]),这个软件由3个程序组成:
1.运行在控制节点上的master,这个程序监视并控制整个机群的状态。
2.运行在每个计算节点上的slave,负责启动和终止job,并监控本机的资源。[注21:slave的实现要点见http://www.slideshare.net/chenshuo/zurg-part-1.]
3.供最终用户使用的client命令行工具,用于提交job。
根据前面的分析,slave是个"看门狗进程",它会启动别的job进程,因此必须是个单线程程序。另外它不应该占用太多的CPU资源,这也适合单线程模型。master应该是个模式2的多线程程序:
● 它独占一台8核的机器,如果用模型1,等于浪费了87.5%的CPU资源。
● 整个机群的状态应该能完全放在内存中,这些状态是共享且可变的。如果用模式3,那么进程之间的状态同步会成大问题。而如果大量使用共享内存,则等于是掩耳盗铃,是披着多进程外衣的多线程程序。因为一个进程一旦在临界区内阻塞或crash,其他进程会全部死锁。
● 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用多线程方式编写是自然且高效的。
再举一个TCP聊天服务器的例子,这里的"聊天"不完全指人与人聊天,也可能是机器与机器"聊天"。这种服务的特点是并发连接之间有数据交换,从一个连接收到的数据要转发给其他多个连接。因此我们不能按模式3的做法,把多个连接分到多个进程中分别处理(这会带来复杂的进程间通信),而只能用模式1或者模式2。如果纯粹只有数据交换,那么我想模式1也能工作得很好,因为现在的CPU足够快,单线程应付几百个连接不在话下。
如果功能进一步复杂化,加上关键字过滤、黑名单、防灌水等等功能,甚至要给聊天内容自动加上相关连接,每一项功能都会占用CPU资源。这时就要考虑模式2了,因为单个CPU的处理能力显得捉襟见肘,顺序处理导致消息转发的延迟增加。这时我们考虑把空闲的多个CPU利用起来,自然的做法是把连接分散到多个线程上,例如按round-robin的方式把1000个客户连接分配到4个IO线程上。这样充分利用多核加速。具体的例子见§ 6.6的方案9,以及p.260的实现。
线程的分类
据我的经验,一个多线程服务程序中的线程大致可分为3类:
1.IO线程,这类线程的主循环是IO multiplexing,阻塞地等在select/poll/epoll_wait系统调用上。这类线程也处理定时事件。当然它的功能不止IO,有些简单计算也可以放入其中,比如消息的编码或解码。
2.计算线程,这类线程的主循环是blocking queue,阻塞地等在condition variable上。这类线程一般位于thread pool中。这种线程通常不涉及IO,一般要避免任何阻塞操作。
3.第三方库所用的线程,比如logging,又比如database connection。
服务器程序一般不会频繁地启动和终止线程。甚至,在我写过的程序里,create thread只在程序启动的时候调用,在服务运行期间是不调用的。
在多核时代,要想充分发挥CPU性能,多线程编程是不可避免的,"鸵鸟算法"不是办法。在学会多线程编程之前,我也一直认为单线程服务程序才是王道。在接触多线程编程之后,经过一段时间的训练和适应,我已能比较自如地编写正确且足够高效的多线程程序。学习多线程编程还有一个好处,即训练异步思维,提高分析并发事件的能力。这对设计分布式系统帮助巨大,因为运行在多台机器上的服务进程本质上是异步的。熟悉多线程编程的话,很容易就能发现分布式系统在消息和事件处理方面的race condition。
3.6"多线程服务器的适用场合"例释与答疑
《多线程服务器的适用场合》一文在博客[注22:http://blog.csdn.net/Solstice/article/details/5334243]登出之后,有热心读者提出质疑,我自己也觉得原文没有把道理说通、说透,下面用一些实例来解答读者的疑问。为方便阅读,本节以问答体呈现。以下"连接、端口"均指TCP协议。
1.Linux能同时启动多少个线程?
对于32-bit Linux,一个进程的地址空间是4GiB,其中用户态能访问3GiB左右,而一个线程的默认栈(stack)大小是10MB,心算可知,一个进程大约最多能同时启动300个线程。如果不改线程的调用栈大小的话,300左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库等等)同样要占用内存(地址空间)。
对于64-bit系统,线程数目可大大增加,具体数字我没有测试过,因为我在实际项目中一台机器上最多只用到过几十个用户线程,其中大部分还是空闲的。
下面的第2问关于线程数目的讨论以32-bit Linux为例。
2.多线程能提高并发度吗?
如果指的是"并发连接数",则不能。
由问题1可知,假如单纯采用thread per connection的模型,那么并发连接数最多300,这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千乃至上万,甚至几万)。所谓"基于事件",指的是用IO multiplexing event loop的编程模型,又称Reactor模式,在前文中已有介绍。
那么采用前文中推荐的one loop per thread呢?至少不逊于单线程程序。实际上单个event loop处理1万个并发长连接并不罕见,一个multi-loop的多线程程序应该能轻松支持5万并发链接。
**小结:**thread per connection不适合高并发场合,其scalability不佳。one loop per thread的并发度足够大,且与CPU数目成正比。
3.多线程能提高吞吐量吗?
对于计算密集型服务,不能。
假设有一个耗时的计算服务,用单线程算需要0.8s。在一台8核的机器上,我们可以启动8个线程一起对外服务(如果内存够用,启动8个进程也一样)。这样完成单个计算仍然要0.8s,但是由于这些进程的计算可以同时进行,理想情况下吞吐量可以从单线程的1.25qps(query per second)上升到10qps。(实际情况可能要打个八折------如果不是打对折的话。)
假如改用并行算法,用8个核一起算,理论上如果完全并行,加速比高达8,那么计算时间是0.1s,吞吐量还是10qps,但是首次请求的响应时间却降低了很多。实际上根据Amdahl's law,即便算法的并行度高达95%,8核的加速比也只有6,计算时间为0.133s,这样会造成吞吐量下降为7.5qps。不过以此为代价,换得响应时间的提升,在有些应用场合也是值得的。
再举一个例子,如果要在一台8核机器上压缩100个1GB的文本文件,每个core的处理能力为200MB/s。那么"每次起8个进程,每个进程压缩1个文件"与"依次压缩每个文件,每个文件用8个线程并行压缩"这两种方式的总耗时相当,因为CPU都是满载的。但是第2种方式能较快地拿到第一个压缩完的文件,也就是首次响应的延时更小。
这也回答了问题4。
如果用thread per request的模型,每个客户请求用一个线程去处理,那么当并发请求数大于某个临界值T′时,吞吐量反而会下降,因为线程多了以后上下文切换的开销也随之增加(分析与数据请见《A Design Framework for Highly Concurrent Systems》[注23:by Matt Welsh et al. http://www.cs.berkeley.edu/~culler/papers/events.pdf
])。thread per request是最简单的使用线程的方式,编程最容易,简单地把多线程程序当成一堆串行程序,用同步的方式顺序编程,比如在Java Servlet 2.x中,一次页面请求由一个函数HttpServlet.service(HttpServletRequest req,HttpServletResponse resp)同步地完成。
为了在并发请求数很高时也能保持稳定的吞吐量,我们可以用线程池,线程池的大小应该满足"阻抗匹配原则",见问题7。
线程池也不是万能的,如果响应一次请求需要做比较多的计算(比如计算的时间占整个response time的1/5强),那么用线程池是合理的,能简化编程。如果在一次请求响应中,主要时间是在等待IO,那么为了进一步提高吞吐量,往往要用其他编程模型,比如Proactor,见问题8。
4.多线程能降低响应时间吗?
如果设计合理,充分利用多核资源的话,可以。在突发(burst)请求时效果尤为明显。
例1:多线程处理输入 以memcached服务端为例。memcached一次请求响应大概可以分为3步:
1.读取并解析客户端输入;
2.操作hashtable;
3.返回客户端。
在单线程模式下,这3步是串行执行的。在启用多线程模式时,它会启用多个输入线程(默认是4个),并在建立连接时按round-robin法把新连接分派给其中一个输入线程,这正好是我说的one loop per thread模型。这样一来,第1步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第2步用了全局锁,还是单线程的,这可算是一个值得继续改进的地方。
比如,有两个用户同时发出了请求,这两个用户的连接正好分配在两个IO线程上,那么两个请求的第1步操作可以在两个线程上并行执行,然后汇总到第2步串行执行,这样总的响应时间比完全串行执行要短一些(在"读取并解析"所占的比重较大的时候,效果更为明显)。请继续看下面这个例子。
例2:多线程分担负载 假设我们要做一个求解Sudoku的服务[注24:见笔者的博客《谈谈数独》( http://blog.csdn.net/Solstice/article/details/2096209)。
],这个服务程序在9981端口接受请求,输入为一行81个数字(待填数字用0表示),输出为填好之后的81个数字(1~9),如果无解,输出"NO\r\n"。
由于输入格式很简单,用单个线程做IO就行了。先假设每次求解的计算用时为10ms,用前面的方法计算,单线程程序能达到的吞吐量上限为100qps;在8核机器上,如果用线程池来做计算,能达到的吞吐量上限为800qps。下面我们看看多线程如何降低响应时间。
假设1个用户在极短的时间内发出了10个请求,如果用单线程"来一个处理一个"的模型,这些reqs会排在队列里依次处理(这个队列是操作系统的TCP缓冲区,不是程序里自己的任务队列)。在不考虑网络延迟的情况下,第1个请求的响应时间是10ms;第2个请求要等第1个算完了才能获得CPU资源,它等了10ms,算了10ms,响应时间是20ms;依此类推,第10个请求的响应时间为100ms;这10个请求的平均响应时间为55ms。
如果Sudoku服务在每个请求到达时开始计时,会发现每个请求都是10ms响应时间;而从用户的观点来看,10个请求的平均响应时间为55ms,请读者想想为什么会有这个差异。
下面改用多线程:1个IO线程,8个计算线程(线程池)。二者之间用Block-ingQueue沟通。同样是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库能帮我们做到这一点,见第5章。(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线程。Netty就使用了这个办法来进一步降低延迟。
以上都仅讨论了"打一枪就跑"的情况,如果是一问一答,比如从memcached取一个值,那么"重叠IO"并不能降低响应时间,因为你无论如何要等memcached的回复。这时我们可以用别的方式来提高并发度,见问题8。(虽然不能降低响应时间,但也不要浪费线程在空等上。)
以上的例子也说明,BlockingQueue是构建多线程程序的利器。另见§ 12.8.3。
6.为什么第三方库往往要用自己的线程?
event loop模型没有标准实现。如果自己写代码,尽可以按所用Reactor的推荐方式来编程。但是第三方库不一定能很好地适应并融入这个event loop framework,有时需要用线程来做一些串并转换。比方说检测串口上的数据到达可以用文件描述符的可读事件,因此可以方便地融入event loop。但是检测串口上的某些控制信号(例如DCD)只能用轮询(ioctl(fd,TIOCMGET,&flags))或阻塞等待(ioctl(fd,TIOCMIWAIT,TIOCM_CAR));要想融入event loop,需要单独起一个线程来查询串口信号翻转,再转换为文件描述符的读写事件(可以通过pipe(2))。
对于Java,这个问题还好办一些,因为thread pool在Java里有标准实现,叫ExecutorService。如果第三方库支持线程池,那么它可以和主程序共享一个ExecutorService,而不是自己创建一堆线程。(比如在初始化时传入主程序的obj。)对于C++,情况麻烦得多,Reactor和thread pool都没有标准库。
例1:libmemcached只支持同步操作 libmemcached支持所谓的"非阻塞操作",但没有暴露一个能被select/poll/epoll的file describer,它的memcached_fetch始终会阻塞。它号称memcached_set可以是非阻塞的,实际意思是不必等待结果返回,但实际上这个函数会阻塞地调用write(2),仍可能阻塞在网络IO上。
如果在我们的Reactor event handler里调用了libmemcached的函数,那么latency就堪忧了。如果想继续用libmemcached,我们可以为它做一次线程封装,按问题5例2的办法,用额外的线程专门做memcached的IO,而程序主体还是Reactor。甚至可以把memcached的"数据就绪"作为一个event,注入我们的event loop中,以进一步提高并发度。(例子留待问题8讲。)
万幸的是,memcached的协议非常简单,大不了可以自己写一个基于Reactor的客户端,但是数据库客户端就没那么幸运了。
例2:MySQL的官方C API不支持异步操作 MySQL的官方客户端[注25:非官方的 libdrizzle 似乎支持异步操作,见 https://github.com/chaoslawful/drizzle-nginx-module]只支持同步操作,对于UPDATE/INSERT/DELETE之类只要行为不管结果的操作(如果代码需要得知其执行结果,则另当别论),我们可以用一个单独的线程来做,以降低服务线程的延迟。可仿照前面memcached_set的例子,不再赘言。麻烦的是SELECT,如果要把它也异步化,就得动用更复杂的模式了,见问题8。
相比之下,PostgreSQL的C客户端libpq的设计要好得多,我们可以用PQsend-Query()来发起一次查询,然后用标准的select/poll/epoll来等待PQsocket。如果有数据可读,那么用PQconsumeInput处理之,并用PQisBusy判断查询结果是否已就绪。最后用PQgetResult来获取结果。借助这套异步API,我们可以很容易地为libpq写一套wrapper,使之融入程序所用的event loop模型中。
7.什么是线程池大小的阻抗匹配原则?
我在前文中提到"阻抗匹配原则",这里大致讲一讲。
如果池中线程在执行任务时,密集计算所占的时间比重为P(0<P ≤1),而系统一共有C个CPU,为了让这C个CPU跑满而又不过载,线程池大小的经验公式T=C/P。T是个hint,考虑到P值的估计不是很准确,T的最佳值可以上下浮动50%.这个经验公式的原理很简单,T个线程,每个线程占用P的CPU时间,如果刚好占满C个CPU,那么必有T×P=C。下面验证一下边界条件的正确性。
假设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 pool ,还有别的non-trivial多线程编程模型吗?
有,Proactor。如果一次请求响应中要和别的进程打多次交道,那么Proactor模型往往能做到更高的并发度。当然,代价是代码变得支离破碎,难以理解。
这里举HTTP proxy为例,一次HTTP proxy的请求如果没有命中本地cache,那么它多半会:
1.解析域名(不要小看这一步,对于一个陌生的域名,解析可能要花几秒的时间);
2.建立连接;
3.发送HTTP请求;
4.等待对方回应;
5.把结果返回给客户。
这5步中跟2个server发生了3次round-trip,每次都可能花几百毫秒:
1.向DNS问域名,等待回复;
2.向对方的HTTP服务器发起连接,等待TCP三路握手完成;
3.向对方发送HTTP request,等待对方response。
而实际上HTTP proxy本身的运算量不大,如果用线程池,池中线程的数目会很庞大,不利于操作系统的管理调度。
这时我们有两个解决思路:
1.把"域名已解析"、"连接已建立"、"对方已完成响应"做成event,继续按照Reactor的方式来编程。这样一来,每次客户请求就不能用一个函数从头到尾执行完成,而要分成多个阶段,并且要管理好请求的状态("目前到了第几步?")。
2.用回调函数,让系统来把任务串起来。比如收到用户请求,如果没有命中本地缓存,那么需要执行:
a.立刻发起异步的DNS解析startDNSResolve(),告诉系统在解析完之后调用DNSResolved()函数;
b.在DNSResolved()中,发起TCP连接请求,告诉系统在连接建立之后调用connectionEstablished();
c.在connectionEstablished()中发送HTTP request,告诉系统在收到响应之后调用httpResponsed();
d.最后,在httpResponsed()里把结果返回给客户。
.NET大量采用的BeginInvoke/EndInvoke操作也是这个编程模式。当然,对于不熟悉这种编程方式的人,代码会显得很难看。有关Proactor模式的例子可参看Boost.Asio的文档,这里不再多说。
Proactor模式依赖操作系统或库来高效地调度这些子任务,每个子任务都不会阻塞,因此能用比较少的线程达到很高的IO并发度。
Proactor能提高吞吐,但不能降低延迟,所以我没有深入研究。另外,在没有语言直接支持的情况下[注26:有的语言能通过库扩展,例如 http://jscex.info/zh-cn/],Proactor模式让代码非常破碎,在C++中使用Proactor是很痛苦的。因此最好在"线程"很廉价的语言中使用这种方式,这时runtime往往会屏蔽细节,程序用单线程阻塞IO的方式来处理TCP连接。
9.模式2和模式3a该如何取舍?
§ 3.5中提到,模式2是一个多线程的进程,模式3a是多个相同的单线程进程。
我认为,在其他条件相同的情况下,可以根据工作集(work set)的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小。
如果工作集较大,那么就用多线程,避免CPU cache换入换出,影响性能;否则,就用单线程多进程,享受单线程编程的便利。举例来说
● 如果程序有一个较大的本地cache,用于缓存一些基础参考数据(in-memory look-up table),几乎每次请求都会访问cache,那么多线程更适合一些,因为可以避免每个进程都自己保留一份cache,增加内存使用。
● memcached这个内存消耗大户用多线程服务端就比在同一台机器上运行多个memcached instance要好。(但是如果你在16GiB内存的机器上运行32-bit memcached,那么此时多instance是必需的。)
● 求解Sudoku用不了多大内存。如果单线程编程更方便的话,可以用单线程多进程来做。再在前面加一个单线程的load balancer,仿lighttpd+fastcgi的成例。
线程不能减少工作量,即不能减少CPU时间。如果解决一个问题需要执行一亿条指令(这个数字不大,不要被吓到),那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核上的执行情况,我们能让工期提早结束。这听上去像统筹方法,其实也确实是统筹方法。