多线程服务器

来源:互联网 发布:博看人文期刊数据库 编辑:程序博客网 时间:2024/06/06 02:58

http://www.cnblogs.com/Solstice/archive/2010/02/12/multithreaded_server.html

多线程服务器的常用编程模型

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

2010 Feb 12

本文 PDF 版下载: http://files.cppblog.com/Solstice/multithreaded_server.pdf

本文主要讲我个人在多线程开发方面的一些粗浅经验。总结了一两种常用的线程模型,归纳了进程间通讯与线程同步的最佳实践,以期用简单规范的方式开发多线程程序。

文中的“多线程服务器”是指运行在 Linux 操作系统上的独占式网络应用程序。硬件平台为 Intel x64 系列的多核 CPU,单路或双路 SMP 服务器(每台机器一共拥有四个核或八个核,十几 GB 内存),机器之间用百兆或千兆以太网连接。这大概是目前民用 PC 服务器的主流配置。

本文不涉及 Windows 系统,不涉及人机交互界面(无论命令行或图形);不考虑文件读写(往磁盘写 log 除外),不考虑数据库操作,不考虑 Web 应用;不考虑低端的单核主机或嵌入式系统,不考虑手持式设备,不考虑专门的网络设备,不考虑高端的 >=32 核 Unix 主机;只考虑 TCP,不考虑 UDP,也不考虑除了局域网络之外的其他数据收发方式(例如串并口、USB口、数据采集板卡、实时控制等)。

有了以上这么多限制,那么我将要谈的“网络应用程序”的基本功能可以归纳为“收到数据,算一算,再发出去”。在这个简化了的模型里,似乎看不出用多线程的必要,单线程应该也能做得很好。“为什么需要写多线程程序”这个问题容易引发口水战,我放到另一篇博客里讨论。请允许我先假定“多线程编程”这一背景。

“服务器”这个词有时指程序,有时指进程,有时指硬件(无论虚拟的或真实的),请注意按上下文区分。另外,本文不考虑虚拟化的场景,当我说“两个进程不在同一台机器上”,指的是逻辑上不在同一个操作系统里运行,虽然物理上可能位于同一机器虚拟出来的两台“虚拟机”上。

本文假定读者已经有多线程编程的知识与经验,这不是一篇入门教程。

本文承蒙 Milo Yip 先生审读,在此深表谢意。当然,文中任何错误责任均在我。

目  录

1 进程与线程 2

2 典型的单线程服务器编程模型 3

3 典型的多线程服务器的线程模型 3

One loop per thread 4

线程池 4

归纳 5

4 进程间通信与线程间通信 5

5 进程间通信 6

6 线程间同步 7

互斥器 (mutex) 7

跑题:非递归的 mutex 8

条件变量 10

读写锁与其他 11

封装 MutexLock、MutexLockGuard 和 Condition 11

线程安全的 Singleton 实现 14

归纳 15

7 总结 15

后文预览:Sleep 反模式 16

1 进程与线程

“进程/process”是操作里最重要的两个概念之一(另一个是文件),粗略地讲,一个进程是“内存中正在运行的程序”。本文的进程指的是 Linux 操作系统通过 fork() 系统调用产生的那个东西,或者 Windows 下 CreateProcess() 的产物,不是 Erlang 里的那种轻量级进程。

每个进程有自己独立的地址空间 (address space),“在同一个进程”还是“不在同一个进程”是系统功能划分的重要决策点。Erlang 书把“进程”比喻为“人”,我觉得十分精当,为我们提供了一个思考的框架。

每个人有自己的记忆 (memory),人与人通过谈话(消息传递)来交流,谈话既可以是面谈(同一台服务器),也可以在电话里谈(不同的服务器,有网络通信)。面谈和电话谈的区别在于,面谈可以立即知道对方死否死了(crash, SIGCHLD),而电话谈只能通过周期性的心跳来判断对方是否还活着。

有了这些比喻,设计分布式系统时可以采取“角色扮演”,团队里的几个人各自扮演一个进程,人的角色由进程的代码决定(管登陆的、管消息分发的、管买卖的等等)。每个人有自己的记忆,但不知道别人的记忆,要想知道别人的看法,只能通过交谈。(暂不考虑共享内存这种 IPC。)然后就可以思考容错(万一有人突然死了)、扩容(新人中途加进来)、负载均衡(把 a 的活儿挪給 b 做)、退休(a 要修复 bug,先别给他派新活儿,等他做完手上的事情就把他重启)等等各种场景,十分便利。

“线程”这个概念大概是在 1993 年以后才慢慢流行起来的,距今不过十余年,比不得有 40 年光辉历史的 Unix 操作系统。线程的出现给 Unix 添了不少乱,很多 C 库函数(strtok(), ctime())不是线程安全的,需要重新定义;signal 的语意也大为复杂化。据我所知,最早支持多线程编程的(民用)操作系统是 Solaris 2.2 和 Windows NT 3.1,它们均发布于 1993 年。随后在 1995 年,POSIX threads 标准确立。

线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。如果多个进程大量共享内存,等于是把多进程程序当成多线程来写,掩耳盗铃。

“多线程”的价值,我认为是为了更好地发挥对称多路处理 (SMP) 的效能。在 SMP 之前,多线程没有多大价值。Alan Cox 说过 A computer is a state machine. Threads are for people who can't program state machines. (计算机是一台状态机。线程是给那些不能编写状态机程序的人准备的。)如果只有一个执行单元,一个 CPU,那么确实如 Alan Cox 所说,按状态机的思路去写程序是最高效的,这正好也是下一节展示的编程模型。

2 典型的单线程服务器编程模型

UNP3e 对此有很好的总结(第 6 章:IO 模型,第 30 章:客户端/服务器设计范式),这里不再赘述。据我了解,在高性能的网络程序中,使用得最为广泛的恐怕要数“non-blocking IO + IO multiplexing”这种模型,即 Reactor 模式,我知道的有:

  • lighttpd,单线程服务器。(nginx 估计与之类似,待查)
  • libevent/libev
  • ACE,Poco C++ libraries(QT 待查)
  • Java NIO (Selector/SelectableChannel), Apache Mina, Netty (Java)
  • POE (Perl)
  • Twisted (Python)

相反,boost::asio 和 Windows I/O Completion Ports 实现了 Proactor 模式,应用面似乎要窄一些。当然,ACE 也实现了 Proactor 模式,不表。

在“non-blocking IO + IO multiplexing”这种模型下,程序的基本结构是一个事件循环 (event loop):(代码仅为示意,没有完整考虑各种情况)

view source
print?
01while (!done)
02{
03  int timeout_ms = max(1000, getNextTimedCallback());
04  int retval = ::poll(fds, nfds, timeout_ms);
05  if (retval < 0) {
06    处理错误
07  } else {
08    处理到期的 timers
09    if (retval > 0) {
10      处理 IO 事件
11    }
12  }
13}

当然,select(2)/poll(2) 有很多不足,Linux 下可替换为 epoll,其他操作系统也有对应的高性能替代品(搜 c10k problem)。

Reactor 模型的优点很明显,编程简单,效率也不错。不仅网络读写可以用,连接的建立(connect/accept)甚至 DNS 解析都可以用非阻塞方式进行,以提高并发度和吞吐量 (throughput)。对于 IO 密集的应用是个不错的选择,Lighttpd 即是这样,它内部的 fdevent 结构十分精妙,值得学习。(这里且不考虑用阻塞 IO 这种次优的方案。)

当然,实现一个优质的 Reactor 不是那么容易,我也没有用过坊间开源的库,这里就不推荐了。

3 典型的多线程服务器的线程模型

这方面我能找到的文献不多,大概有这么几种:

1. 每个请求创建一个线程,使用阻塞式 IO 操作。在 Java 1.4 引入 NIO 之前,这是 Java 网络编程的推荐做法。可惜伸缩性不佳。

2. 使用线程池,同样使用阻塞式 IO 操作。与 1 相比,这是提高性能的措施。

3. 使用 non-blocking IO + IO multiplexing。即 Java NIO 的方式。

4. Leader/Follower 等高级模式

在默认情况下,我会使用第 3 种,即 non-blocking IO + one loop per thread 模式。
http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#THREADS_AND_COROUTINES

One loop per thread

此种模型下,程序里的每个 IO 线程有一个 event loop (或者叫 Reactor),用于处理读写和定时事件(无论周期性的还是单次的),代码框架跟第 2 节一样。

这种方式的好处是:

  • 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。
  • 可以很方便地在线程间调配负载。

event loop 代表了线程的主循环,需要让哪个线程干活,就把 timer 或 IO channel (TCP connection) 注册到那个线程的 loop 里即可。对实时性有要求的 connection 可以单独用一个线程;数据量大的 connection 可以独占一个线程,并把数据处理任务分摊到另几个线程中;其他次要的辅助性 connections 可以共享一个线程。

对于 non-trivial 的服务端程序,一般会采用 non-blocking IO + IO multiplexing,每个 connection/acceptor 都会注册到某个 Reactor 上,程序里有多个 Reactor,每个线程至多有一个 Reactor。

多线程程序对 Reactor 提出了更高的要求,那就是“线程安全”。要允许一个线程往别的线程的 loop 里塞东西,这个 loop 必须得是线程安全的。

线程池

不过,对于没有 IO 光有计算任务的线程,使用 event loop 有点浪费,我会用有一种补充方案,即用 blocking queue 实现的任务队列(TaskQueue):

view source
print?
1blocking_queue<boost::function<void()> > taskQueue;  // 线程安全的阻塞队列
2  
3void worker_thread()
4{
5  while (!quit) {
6    boost::function<void()> task = taskQueue.take();  // this blocks
7    task();  // 在产品代码中需要考虑异常处理
8  }
9}

用这种方式实现线程池特别容易:

view source
print?
1// 启动容量为 N 的线程池:
2int N = num_of_computing_threads;
3for (int i = 0; i < N; ++i) {
4  create_thread(&worker_thread);  // 伪代码:启动线程
5}

使用起来也很简单:

view source
print?
1boost::function<void()> task = boost::bind(&Foo::calc, this);
2taskQueue.post(task);

上面十几行代码就实现了一个简单的固定数目的线程池,功能大概相当于 Java 5 的 ThreadPoolExecutor 的某种“配置”。当然,在真实的项目中,这些代码都应该封装到一个 class 中,而不是使用全局对象。另外需要注意一点:Foo 对象的生命期,我的另一篇博客《当析构函数遇到多线程——C++ 中线程安全的对象回调》详细讨论了这个问题
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx

除了任务队列,还可以用 blocking_queue<T> 实现数据的消费者-生产者队列,即 T 的是数据类型而非函数对象,queue 的消费者(s)从中拿到数据进行处理。这样做比 task queue 更加 specific 一些。

blocking_queue<T> 是多线程编程的利器,它的实现可参照 Java 5 util.concurrent 里的 (Array|Linked)BlockingQueue,通常 C++ 可以用 deque 来做底层的容器。Java 5 里的代码可读性很高,代码的基本结构和教科书一致(1 个 mutex,2 个 condition variables),健壮性要高得多。如果不想自己实现,用现成的库更好。(我没有用过免费的库,这里就不乱推荐了,有兴趣的同学可以试试 Intel Threading Building Blocks 里的 concurrent_queue<T>。)

归纳

总结起来,我推荐的多线程服务端编程模式为:event loop per thread + thread pool。

  • event loop 用作 non-blocking IO 和定时器。
  • thread pool 用来做计算,具体可以是任务队列或消费者-生产者队列。

以这种方式写服务器程序,需要一个优质的基于 Reactor 模式的网络库来支撑,我只用过 in-house 的产品,无从比较并推荐市面上常见的 C++ 网络库,抱歉。

程序里具体用几个 loop、线程池的大小等参数需要根据应用来设定,基本的原则是“阻抗匹配”,使得 CPU 和 IO 都能高效地运作,具体的考虑点容我以后再谈。

这里没有谈线程的退出,留待下一篇 blog“多线程编程反模式”探讨。

此外,程序里或许还有个别执行特殊任务的线程,比如 logging,这对应用程序来说基本是不可见的,但是在分配资源(CPU 和 IO)的时候要算进去,以免高估了系统的容量。

4 进程间通信与线程间通信

Linux 下进程间通信 (IPC) 的方式数不胜数,光 UNPv2 列出的就有:pipe、FIFO、POSIX 消息队列、共享内存、信号 (signals) 等等,更不必说 Sockets 了。同步原语 (synchronization primitives) 也很多,互斥器 (mutex)、条件变量 (condition variable)、读写锁 (reader-writer lock)、文件锁 (Record locking)、信号量 (Semaphore) 等等。

如何选择呢?根据我的个人经验,贵精不贵多,认真挑选三四样东西就能完全满足我的工作需要,而且每样我都能用得很熟,,不容易犯错。

5 进程间通信

进程间通信我首选 Sockets(主要指 TCP,我没有用过 UDP,也不考虑  Unix domain 协议),其最大的好处在于:可以跨主机,具有伸缩性。反正都是多进程了,如果一台机器处理能力不够,很自然地就能用多台机器来处理。把进程分散到同一局域网的多台机器上,程序改改 host:port 配置就能继续用。相反,前面列出的其他 IPC 都不能跨机器(比如共享内存效率最高,但再怎么着也不能高效地共享两台机器的内存),限制了 scalability。

在编程上,TCP sockets 和 pipe 都是一个文件描述符,用来收发字节流,都可以 read/write/fcntl/select/poll 等。不同的是,TCP 是双向的,pipe 是单向的 (Linux),进程间双向通讯还得开两个文件描述符,不方便;而且进程要有父子关系才能用 pipe,这些都限制了 pipe 的使用。在收发字节流这一通讯模型下,没有比 sockets/TCP 更自然的 IPC 了。当然,pipe 也有一个经典应用场景,那就是写 Reactor/Selector 时用来异步唤醒 select (或等价的 poll/epoll) 调用(Sun JVM 在 Linux 就是这么做的)。

TCP port 是由一个进程独占,且操作系统会自动回收(listening port 和已建立连接的 TCP socket 都是文件描述符,在进程结束时操作系统会关闭所有文件描述符)。这说明,即使程序意外退出,也不会给系统留下垃圾,程序重启之后能比较容易地恢复,而不需要重启操作系统(用跨进程的 mutex 就有这个风险)。还有一个好处,既然 port 是独占的,那么可以防止程序重复启动(后面那个进程抢不到 port,自然就没法工作了),造成意料之外的结果。

两个进程通过 TCP 通信,如果一个崩溃了,操作系统会关闭连接,这样另一个进程几乎立刻就能感知,可以快速 failover。当然,应用层的心跳也是必不可少的,我以后在讲服务端的日期与时间处理的时候还会谈到心跳协议的设计。

与其他 IPC 相比,TCP 协议的一个自然好处是“可记录可重现”,tcpdump/Wireshark 是解决两个进程间协议/状态争端的好帮手。

另外,如果网络库带“连接重试”功能的话,我们可以不要求系统里的进程以特定的顺序启动,任何一个进程都能单独重启,这对开发牢靠的分布式系统意义重大。

使用 TCP 这种字节流 (byte stream) 方式通信,会有 marshal/unmarshal 的开销,这要求我们选用合适的消息格式,准确地说是 wire format。这将是我下一篇 blog 的主题,目前我推荐 Google Protocol Buffers。

有人或许会说,具体问题具体分析,如果两个进程在同一台机器,就用共享内存,否则就用 TCP,比如 MS SQL Server 就同时支持这两种通信方式。我问,是否值得为那么一点性能提升而让代码的复杂度大大增加呢?TCP 是字节流协议,只能顺序读取,有写缓冲;共享内存是消息协议,a 进程填好一块内存让 b 进程来读,基本是“停等”方式。要把这两种方式揉到一个程序里,需要建一个抽象层,封装两种 IPC。这会带来不透明性,并且增加测试的复杂度,而且万一通信的某一方崩溃,状态 reconcile 也会比 sockets 麻烦。为我所不取。再说了,你舍得让几万块买来的 SQL Server 和你的程序分享机器资源吗?产品里的数据库服务器往往是独立的高配置服务器,一般不会同时运行其他占资源的程序。

TCP 本身是个数据流协议,除了直接使用它来通信,还可以在此之上构建 RPC/REST/SOAP 之类的上层通信协议,这超过了本文的范围。另外,除了点对点的通信之外,应用级的广播协议也是非常有用的,可以方便地构建可观可控的分布式系统。

本文不具体讲 Reactor 方式下的网络编程,其实这里边有很多值得注意的地方,比如带 back off 的 retry connecting,用优先队列来组织 timer 等等,留作以后分析吧。

6 线程间同步

线程同步的四项原则,按重要性排列:

1. 首要原则是尽量最低限度地共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑 immutable 对象;实在不行才暴露可修改的对象,并用同步措施来充分保护它。

2. 其次是使用高级的并发编程构件,如 TaskQueue、Producer-Consumer Queue、CountDownLatch 等等;

3. 最后不得已必须使用底层同步原语 (primitives) 时,只用非递归的互斥器和条件变量,偶尔用一用读写锁;

4. 不自己编写 lock-free 代码,不去凭空猜测“哪种做法性能会更好”,比如 spin lock vs. mutex。

前面两条很容易理解,这里着重讲一下第 3 条:底层同步原语的使用。

互斥器 (mutex)

互斥器 (mutex) 恐怕是使用得最多的同步原语,粗略地说,它保护了临界区,一个时刻最多只能有一个线程在临界区内活动。(请注意,我谈的是 pthreads 里的 mutex,不是 Windows 里的重量级跨进程 Mutex。)单独使用 mutex 时,我们主要为了保护共享数据。我个人的原则是:

  • 用 RAII 手法封装 mutex 的创建、销毁、加锁、解锁这四个操作。
  • 只用非递归的 mutex(即不可重入的 mutex)。
  • 不手工调用 lock() 和 unlock() 函数,一切交给栈上的 Guard 对象的构造和析构函数负责,Guard 对象的生命期正好等于临界区(分析对象在什么时候析构是 C++ 程序员的基本功)。这样我们保证在同一个函数里加锁和解锁,避免在 foo() 里加锁,然后跑到 bar() 里解锁。
  • 在每次构造 Guard 对象的时候,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁 (deadlock)。由于 Guard 对象是栈上对象,看函数调用栈就能分析用锁的情况,非常便利。

次要原则有:

  • 不使用跨进程的 mutex,进程间通信只用 TCP sockets。
  • 加锁解锁在同一个线程,线程 a 不能去 unlock 线程 b 已经锁住的 mutex。(RAII 自动保证)
  • 别忘了解锁。(RAII 自动保证)
  • 不重复解锁。(RAII 自动保证)
  • 必要的时候可以考虑用 PTHREAD_MUTEX_ERRORCHECK 来排错

用 RAII 封装这几个操作是通行的做法,这几乎是 C++ 的标准实践,后面我会给出具体的代码示例,相信大家都已经写过或用过类似的代码了。Java 里的 synchronized 语句和 C# 的 using 语句也有类似的效果,即保证锁的生效期间等于一个作用域,不会因异常而忘记解锁。

Mutex 恐怕是最简单的同步原语,安装上面的几条原则,几乎不可能用错。我自己从来没有违背过这些原则,编码时出现问题都很快能招到并修复。

跑题:非递归的 mutex

谈谈我坚持使用非递归的互斥器的个人想法。

Mutex 分为递归 (recursive) 和非递归(non-recursive)两种,这是 POSIX 的叫法,另外的名字是可重入 (Reentrant) 与非可重入。这两种 mutex 作为线程间 (inter-thread) 的同步工具时没有区别,它们的惟一区别在于:同一个线程可以重复对 recursive mutex 加锁,但是不能重复对 non-recursive mutex 加锁。

首选非递归 mutex,绝对不是为了性能,而是为了体现设计意图。non-recursive 和 recursive 的性能差别其实不大,因为少用一个计数器,前者略快一点点而已。在同一个线程里多次对 non-recursive mutex 加锁会立刻导致死锁,我认为这是它的优点,能帮助我们思考代码对锁的期求,并且及早(在编码阶段)发现问题。

毫无疑问 recursive mutex 使用起来要方便一些,因为不用考虑一个线程会自己把自己给锁死了,我猜这也是 Java 和 Windows 默认提供 recursive mutex 的原因。(Java 语言自带的 intrinsic lock 是可重入的,它的 concurrent 库里提供 ReentrantLock,Windows 的 CRITICAL_SECTION 也是可重入的。似乎它们都不提供轻量级的 non-recursive mutex。)

正因为它方便,recursive mutex 可能会隐藏代码里的一些问题。典型情况是你以为拿到一个锁就能修改对象了,没想到外层代码已经拿到了锁,正在修改(或读取)同一个对象呢。具体的例子:

view source
print?
01std::vector<Foo> foos;
02MutexLock mutex;
03  
04void post(const Foo& f)
05{
06  MutexLockGuard lock(mutex);
07  foos.push_back(f);
08}
09  
10void traverse()
11{
12  MutexLockGuard lock(mutex);
13  for (auto it = foos.begin(); it != foos.end(); ++it) { // 用了 0x 新写法
14    it->doit();
15  }
16}

post() 加锁,然后修改 foos 对象; traverse() 加锁,然后遍历 foos 数组。将来有一天,Foo::doit() 间接调用了 post() (这在逻辑上是错误的),那么会很有戏剧性的:

1. Mutex 是非递归的,于是死锁了。

2. Mutex 是递归的,由于 push_back 可能(但不总是)导致 vector 迭代器失效,程序偶尔会 crash。

这时候就能体现 non-recursive 的优越性:把程序的逻辑错误暴露出来。死锁比较容易 debug,把各个线程的调用栈打出来((gdb) thread apply all bt),只要每个函数不是特别长,很容易看出来是怎么死的。(另一方面支持了函数不要写过长。)或者可以用 PTHREAD_MUTEX_ERRORCHECK 一下子就能找到错误(前提是 MutexLock 带 debug 选项。)

程序反正要死,不如死得有意义一点,让验尸官的日子好过些。

如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:

1. 跟原来的函数同名,函数加锁,转而调用第 2 个函数。

2. 给函数名加上后缀 WithLockHold,不加锁,把原来的函数体搬过来。

就像这样:

view source
print?
01void post(const Foo& f)
02{
03  MutexLockGuard lock(mutex);
04  postWithLockHold(f);  // 不用担心开销,编译器会自动内联的
05}
06  
07// 引入这个函数是为了体现代码作者的意图,尽管 push_back 通常可以手动内联
08void postWithLockHold(const Foo& f)
09{
10  foos.push_back(f);
11}

这有可能出现两个问题(感谢水木网友 ilovecpp 提出):a) 误用了加锁版本,死锁了。b) 误用了不加锁版本,数据损坏了。

对于 a),仿造前面的办法能比较容易地排错。对于 b),如果 pthreads 提供 isLocked() 就好办,可以写成:

view source
print?
1void postWithLockHold(const Foo& f)
2{
3  assert(mutex.isLocked());  // 目前只是一个愿望
4  // ...
5}

另外,WithLockHold 这个显眼的后缀也让程序中的误用容易暴露出来。

C++ 没有 annotation,不能像 Java 那样给 method 或 field 标上 @GuardedBy 注解,需要程序员自己小心在意。虽然这里的办法不能一劳永逸地解决全部多线程错误,但能帮上一点是一点了。

我还没有遇到过需要使用 recursive mutex 的情况,我想将来遇到了都可以借助 wrapper 改用 non-recursive mutex,代码只会更清晰。

=== 回到正题 ===

本文这里只谈了 mutex 本身的正确使用,在 C++ 里多线程编程还会遇到其他很多 race condition,请参考拙作《当析构函数遇到多线程——C++ 中线程安全的对象回调》
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx 。请注意这里的 class 命名与那篇文章有所不同。我现在认为 MutexLock 和 MutexLockGuard 是更好的名称。

性能注脚:Linux 的 pthreads mutex 采用 futex 实现,不必每次加锁解锁都陷入系统调用,效率不错。Windows 的 CRITICAL_SECTION 也是类似。

条件变量

条件变量 (condition variable) 顾名思义是一个或多个线程等待某个布尔表达式为真,即等待别的线程“唤醒”它。条件变量的学名叫管程 (monitor)。Java Object 内置的 wait(), notify(), notifyAll() 即是条件变量(它们以容易用错著称)。条件变量只有一种正确使用的方式,对于 wait() 端:

1. 必须与 mutex 一起使用,该布尔表达式的读写需受此 mutex 保护

2. 在 mutex 已上锁的时候才能调用 wait()

3. 把判断布尔条件和 wait() 放到 while 循环中

写成代码是:

view source
print?
01MutexLock mutex;
02Condition cond(mutex);
03std::deque<int> queue;
04  
05int dequeue()
06{
07  MutexLockGuard lock(mutex);
08  while (queue.empty()) {  // 必须用循环;必须在判断之后再 wait()
09    cond.wait(); // 这一步会原子地 unlock mutex 并进入 blocking,不会与 enqueue 死锁
10  }
11  assert(!queue.empty());
12  int top = queue.front();
13  queue.pop_front();
14  return top;
15}

对于 signal/broadcast 端:

1. 不一定要在 mutex 已上锁的情况下调用 signal (理论上)

2. 在 signal 之前一般要修改布尔表达式

3. 修改布尔表达式通常要用 mutex 保护(至少用作 full memory barrier)

写成代码是:

view source
print?
1void enqueue(int x)
2{
3  MutexLockGuard lock(mutex);
4  queue.push_back(x);
5  cond.notify();
6}

上面的 dequeue/enqueue 实际上实现了一个简单的 unbounded BlockingQueue。

条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高层的同步措施,如 BlockingQueue 或 CountDownLatch。

读写锁与其他

读写锁 (Reader-Writer lock),读写锁是个优秀的抽象,它明确区分了 read 和 write 两种行为。需要注意的是,reader lock 是可重入的,writer lock 是不可重入(包括不可提升 reader lock)的。这正是我说它“优秀”的主要原因。

遇到并发读写,如果条件合适,我会用《借 shared_ptr 实现线程安全的 copy-on-write》http://blog.csdn.net/Solstice/archive/2008/11/22/3351751.aspx 介绍的办法,而不用读写锁。当然这不是绝对的。

信号量 (Semaphore),我没有遇到过需要使用信号量的情况,无从谈及个人经验。

说一句大逆不道的话,如果程序里需要解决如“哲学家就餐”之类的复杂 IPC 问题,我认为应该首先考察几个设计,为什么线程之间会有如此复杂的资源争抢(一个线程要同时抢到两个资源,一个资源可以被两个线程争夺)?能不能把“想吃饭”这个事情专门交给一个为各位哲学家分派餐具的线程来做,然后每个哲学家等在一个简单的 condition variable 上,到时间了有人通知他去吃饭?从哲学上说,教科书上的解决方案是平权,每个哲学家有自己的线程,自己去拿筷子;我宁愿用集权的方式,用一个线程专门管餐具的分配,让其他哲学家线程拿个号等在食堂门口好了。这样不损失多少效率,却让程序简单很多。虽然 Windows 的 WaitForMultipleObjects 让这个问题 trivial 化,在 Linux 下正确模拟 WaitForMultipleObjects 不是普通程序员该干的。

封装 MutexLock、MutexLockGuard 和 Condition

本节把前面用到的 MutexLock、MutexLockGuard、Condition classes 的代码列出来,前面两个 classes 没多大难度,后面那个有点意思。

MutexLock 封装临界区(Critical secion),这是一个简单的资源类,用 RAII 手法 [CCS:13]封装互斥器的创建与销毁。临界区在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默认是不可重入的。MutexLock 一般是别的 class 的数据成员。

MutexLockGuard 封装临界区的进入和退出,即加锁和解锁。MutexLockGuard 一般是个栈上对象,它的作用域刚好等于临界区域。

这两个 classes 应该能在纸上默写出来,没有太多需要解释的:

view source
print?
01#include <pthread.h>
02#include <boost/noncopyable.hpp>
03  
04class MutexLock : boost::noncopyable
05{
06 public:
07  MutexLock()  // 为了节省版面,单行函数都没有正确缩进
08  { pthread_mutex_init(&mutex_, NULL); }
09  
10  ~MutexLock()
11  { pthread_mutex_destroy(&mutex_); }
12  
13  void lock()  // 程序一般不主动调用
14  { pthread_mutex_lock(&mutex_); }
15  
16  void unlock()  // 程序一般不主动调用
17  { pthread_mutex_unlock(&mutex_); }
18  
19  pthread_mutex_t* getPthreadMutex()  // 仅供 Condition 调用,严禁自己调用
20  { return &mutex_; }
21  
22 private:
23  pthread_mutex_t mutex_;
24};
25  
26class MutexLockGuard : boost::noncopyable
27{
28 public:
29  explicit MutexLockGuard(MutexLock& mutex) : mutex_(mutex)
30  { mutex_.lock(); }
31  
32  ~MutexLockGuard()
33  { mutex_.unlock(); }
34  
35 private:
36  MutexLock& mutex_;
37};
38  
39#define MutexLockGuard(x) static_assert(false, "missing mutex guard var name")

注意代码的最后一行定义了一个宏,这个宏的作用是防止程序里出现如下错误:

view source
print?
1void doit()
2{
3  MutexLockGuard(mutex);  // 没有变量名,产生一个临时对象又马上销毁了,没有锁住临界区
4  // 正确写法是 MutexLockGuard lock(mutex);
5  
6  // 临界区
7}

这里 MutexLock 没有提供 trylock() 函数,因为我没有用过它,我想不出什么时候程序需要“试着去锁一锁”,或许我写过的代码太简单了。

我见过有人把 MutexLockGuard 写成 template,我没有这么做是因为它的模板类型参数只有 MutexLock 一种可能,没有必要随意增加灵活性,于是我人肉把模板具现化 (instantiate) 了。此外一种更激进的写法是,把 lock/unlock 放到 private 区,然后把 Guard 设为 MutexLock 的 friend,我认为在注释里告知程序员即可,另外 check-in 之前的 code review 也很容易发现误用的情况 (grep getPthreadMutex)。

这段代码没有达到工业强度:a) Mutex 创建为 PTHREAD_MUTEX_DEFAULT 类型,而不是我们预想的 PTHREAD_MUTEX_NORMAL 类型(实际上这二者很可能是等同的),严格的做法是用 mutexattr 来显示指定 mutex 的类型。b) 没有检查返回值。这里不能用 assert 检查返回值,因为 assert 在 release build 里是空语句。我们检查返回值的意义在于防止 ENOMEM 之类的资源不足情况,这一般只可能在负载很重的产品程序中出现。一旦出现这种错误,程序必须立刻清理现场并主动退出,否则会莫名其妙地崩溃,给事后调查造成困难。这里我们需要 non-debug 的 assert,或许 google-glog 的 CHECK() 是个不错的思路。

以上两点改进留作练习。

Condition class 的实现有点意思。

Pthreads condition variable 允许在 wait() 的时候指定 mutex,但是我想不出什么理由一个 condition variable 会和不同的 mutex 配合使用。Java 的 intrinsic condition 和 Conditon class 都不支持这么做,因此我觉得可以放弃这一灵活性,老老实实一对一好了。相反 boost::thread 的 condition_varianle 是在 wait 的时候指定 mutex,请参观其同步原语的庞杂设计:

  • Concept 有四种 Lockable, TimedLockable, SharedLockable, UpgradeLockable.
  • Lock 有五六种: lock_guard, unique_lock, shared_lock, upgrade_lock, upgrade_to_unique_lock, scoped_try_lock.
  • Mutex 有七种:mutex, try_mutex, timed_mutex, recursive_mutex, recursive_try_mutex, recursive_timed_mutex, shared_mutex.

恕我愚钝,见到 boost::thread 这样如 Rube Goldberg Machine 一样“灵活”的库我只得三揖绕道而行。这些 class 名字也很无厘头,为什么不老老实实用 reader_writer_lock 这样的通俗名字呢?非得增加精神负担,自己发明新名字。我不愿为这样的灵活性付出代价,宁愿自己做几个简简单单的一看就明白的 classes 来用,这种简单的几行代码的轮子造造也无妨。提供灵活性固然是本事,然而在不需要灵活性的地方把代码写死,更需要大智慧。

下面这个 Condition 简单地封装了 pthread cond var,用起来也容易,见本节前面的例子。这里我用 notify/notifyAll 作为函数名,因为 signal 有别的含义,C++ 里的 signal/slot,C 里的 signal handler 等等。就别 overload 这个术语了。

view source
print?
01class Condition : boost::noncopyable
02{
03 public:
04  Condition(MutexLock& mutex) : mutex_(mutex)
05  { pthread_cond_init(&pcond_, NULL); }
06  
07  ~Condition()
08  { pthread_cond_destroy(&pcond_); }
09  
10  void wait()
11  { pthread_cond_wait(&pcond_, mutex_.getPthreadMutex()); }
12  
13  void notify()
14  { pthread_cond_signal(&pcond_); }
15  
16  void notifyAll()
17  { pthread_cond_broadcast(&pcond_); }
18  
19 private:
20  MutexLock& mutex_;
21  pthread_cond_t pcond_;
22};

如果一个 class 要包含 MutexLock 和 Condition,请注意它们的声明顺序和初始化顺序,mutex_ 应先于 condition_ 构造,并作为后者的构造参数:

view source
print?
01class CountDownLatch
02{
03 public:
04  CountDownLatch(int count)
05   : count_(count),
06     mutex_(),
07     condition_(mutex_)
08  { }
09  
10 private:
11  int count_;
12  MutexLock mutex_;  // 顺序很重要
13  Condition condition_;
14};

请允许我再次强调,虽然本节花了大量篇幅介绍如何正确使用 mutex 和 condition variable,但并不代表我鼓励到处使用它们。这两者都是非常底层的同步原语,主要用来实现更高级的并发编程工具,一个多线程程序里如果大量使用 mutex 和 condition variable 来同步,基本跟用铅笔刀锯大树(孟岩语)没啥区别。

在程序里使用 pthreads 库有一个额外的好处:分析工具认得它们,懂得其语意。线程分析工具如 Intel Thread Checker 和 Valgrind-Helgrind 等能识别 pthreads 调用,并依据 happens-before 关系 [Lamport 1978] 分析程序有无 data race。

线程安全的 Singleton 实现

研究 Signleton 的线程安全实现的历史你会发现很多有意思的事情,一度人们认为 Double checked locking 是王道,兼顾了效率与正确性。后来有神牛指出由于乱序执行的影响,DCL 是靠不住的。(这个又让我想起了 SQL 注入,十年前用字符串拼接出 SQL 语句是 Web 开发的通行做法,直到有一天有人利用这个漏洞越权获得并修改网站数据,人们才幡然醒悟,赶紧修补。)Java 开发者还算幸运,可以借助内部静态类的装载来实现。C++ 就比较惨,要么次次锁,要么 eager initialize、或者动用 memory barrier 这样的大杀器( http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf )。接下来 Java 5 修订了内存模型,并增强了 volatile 的语义,这下 DCL (with volatile) 又是安全的了。然而 C++ 的内存模型还在修订中,C++ 的 volatile 目前还不能(将来也难说)保证 DCL 的正确性(只在 VS2005+ 上有效)。

其实没那么麻烦,在实践中用 pthread once 就行:

view source
print?
01#include <pthread.h>
02  
03template<typename T>
04class Singleton : boost::noncopyable
05{
06 public:
07  static T& instance()
08  {
09    pthread_once(&ponce_, &Singleton::init);
10    return *value_;
11  }
12  
13  static void init()
14  {
15    value_ = new T();
16  }
17  
18 private:
19  static pthread_once_t ponce_;
20  static T* value_;
21};
22  
23template<typename T>
24pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;
25  
26template<typename T>
27T* Singleton<T>::value_ = NULL;

上面这个 Singleton 没有任何花哨的技巧,用 pthread_once_t 来保证 lazy-initialization 的线程安全。使用方法也很简单:

Foo& foo = Singleton<Foo>::instance();

当然,这个 Singleton 没有考虑对象的销毁,在服务器程序里,这不是一个问题,因为当程序退出的时候自然就释放所有资源了(前提是程序里不使用不能由操作系统自动关闭的资源,比如跨进程的 Mutex)。另外,这个 Singleton 只能调用默认构造函数,如果用户想要指定 T 的构造方式,我们可以用模板特化 (template specialization) 技术来提供一个定制点,这需要引入另一层间接。

归纳
  • 进程间通信首选 TCP sockets
  • 线程同步的四项原则
  • 使用互斥器的条件变量的惯用手法 (idiom),关键是 RAII

用好这几样东西,基本上能应付多线程服务端开发的各种场合,只是或许有人会觉得性能没有发挥到极致。我认为,先把程序写正确了,再考虑性能优化,这在多线程下任然成立。让一个正确的程序变快,远比“让一个快的程序变正确”容易得多。

7 总结

在现代的多核计算背景下,线程是不可避免的。尽管一定程度上可以通过 framework 来屏蔽,让你感觉像是在写单线程程序,比如 Java Servlet。了解 under the hood 发生了什么对于编写这种程序也会有帮助。

多线程编程是一项重要的个人技能,不能因为它难就本能地排斥,现在的软件开发比起 10 年 20 年前已经难了不知道多少倍。掌握多线程编程,才能更理智地选择用还是不用多线程,因为你能预估多线程实现的难度与收益,在一开始做出正确的选择。要知道把一个单线程程序改成多线程的,往往比重头实现一个多线程的程序更难。

掌握同步原语和它们的适用场合时多线程编程的基本功。以我的经验,熟练使用文中提到的同步原语,就能比较容易地编写线程安全的程序。本文没有考虑 signal 对多线程编程的影响,Unix 的 signal 在多线程下的行为比较复杂,一般要靠底层的网络库 (如 Reactor) 加以屏蔽,避免干扰上层应用程序的开发。

通篇来看,“效率”并不是我的主要考虑点,a) TCP 不是效率最高的 IPC,b) 我提倡正确加锁而不是自己编写 lock-free 算法(使用原子操作除外)。在程序的复杂度和性能之前取得平衡,并经考虑未来两三年扩容的可能(无论是 CPU 变快、核数变多,还是机器数量增加,网络升级)。下一篇“多线程编程的反模式”会考察伸缩性方面的常见错误,我认为在分布式系统中,伸缩性 (scalability) 比单机的性能优化更值得投入精力。

这篇文章记录了我目前对多线程编程的理解,用文中介绍的手法,我能解决自己面临的全部多线程编程任务。如果文章的观点与您不合,比如您使用了我没有推荐使用的技术或手法(共享内存、信号量等等),只要您理由充分,但行无妨。

这篇文章本来还有两节“多线程编程的反模式”与“多线程的应用场景”,考虑到字数已经超过一万了,且听下回分解吧 :-)

后文预览:Sleep 反模式

我认为 sleep 只能出现在测试代码中,比如写单元测试的时候。(涉及时间的单元测试不那么好写,短的如一两秒钟可以用 sleep,长的如一小时一天得想其他办法,比如把算法提出来并把时间注入进去。)产品代码中线程的等待可分为两种:一种是无所事事的时候(要么等在 select/poll/epoll 上。要么等在 condition variable 上,等待 BlockingQueue /CountDownLatch 亦可归入此类),一种是等着进入临界区(等在 mutex 上)以便继续处理。在程序的正常执行中,如果需要等待一段时间,应该往 event loop 里注册一个 timer,然后在 timer 的回调函数里接着干活,因为线程是个珍贵的共享资源,不能轻易浪费。如果多线程的安全性和效率要靠代码主动调用 sleep 来保证,这是设计出了问题。等待一个事件发生,正确的做法是用 select 或 condition variable 或(更理想地)高层同步工具。当然,在 GUI 编程中会有主动让出 CPU 的做法,比如调用 sleep(0) 来实现 yield。

 

#9楼 2010-02-15 23:10 双鱼座      

流模式或者停等模式不重要,重要的是通信协议的实现。我个人觉得本机进程间通信用共享内存;跨主机才用TCP更合理。最大的好处是不必分析进程间通信的宽度和频度。无法评估到底能节省多少性能。对于Windows来说,Socket是一个Handle、ShareMem也是一个Handle,完全可以很轻松地实现连接的虚拟化。   回复  引用  查看    

#10楼 2010-02-25 14:40 韦恩卑鄙 v-zhewg @waynebaby      

RPC 抽象出来成为Channel 不同情况 换不同的 Channel 就变成.net remoting了   回复  引用  查看    

 

、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、、

 

http://www.cnblogs.com/Solstice/archive/2010/02/28/why_multithreading.html

 

多线程服务器的适用场合

陈硕 (giantchen_AT_gmail)

www.cnblogs.com/Solstice

2010 Feb 28

这篇文章原本是前一篇博客《多线程服务器的常用编程模型》(以下简称《常用模型》)计划中的一节,今天终于写完了。

“服务器开发”包罗万象,本文所指的“服务器开发”的含义请见《常用模型》一文,一句话形容是:跑在多核机器上的 Linux 用户态的没有用户界面的长期运行的网络应用程序。“长期运行”的意思不是指程序 7x24 不重启,而是程序不会因为无事可做而退出,它会等着下一个请求的到来。例如 wget 不是长期运行的,httpd 是长期运行的。

正名

与前文相同,本文的“进程”指的是 fork() 系统调用的产物。“线程”指的是 pthread_create() 的产物,而且我指的 pthreads 是 NPTL 的,每个线程由 clone() 产生,对应一个内核的 task_struct。本文所用的开发语言是 C++,运行环境为 Linux。

首先,一个由多台机器组成的分布式系统必然是多进程的(字面意义上),因为进程不能跨 OS 边界。在这个前提下,我们把目光集中到一台机器,一台拥有至少 4 个核的普通服务器。如果要在一台多核机器上提供一种服务或执行一个任务,可用的模式有:

  1. 运行一个单线程的进程
  2. 运行一个多线程的进程
  3. 运行多个单线程的进程
  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 的服务,多线程都没有什么优势。那么究竟为什么要用多线程?

在回答这个问题之前,我先谈谈必须用必须用单线程的场合。

必须用单线程的场合

据我所知,有两种场合必须使用单线程:

  1. 程序可能会 fork()
  2. 限制程序的 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() 之后一般有两种行为:

  1. 立刻执行 exec(),变身为另一个程序。例如 shell 和 inetd;又比如 lighttpd fork() 出子进程,然后运行 fastcgi 程序。或者集群中运行在计算节点上的负责启动 job 的守护进程(即我所谓的“看门狗进程”)。
  2. 不调用 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 跑满,那么多线程没啥用处。举例来说:

  1. 对于静态 web 服务器,或者 ftp 服务器,CPU 的负载较轻,主要瓶颈在磁盘 IO 和网络 IO。这时候往往一个单线程的程序(模式 1)就能撑满 IO。用多线程并不能提高吞吐量,因为 IO 硬件容量已经饱和了。同理,这时增加 CPU 数目也不能提高吞吐量。
  2. 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 类:

  1. IO 线程,这类线程的的主循环是 io multiplexing,等在 select/poll/epoll 系统调用上。这类线程也处理定时事件。当然它的功能不止 IO,有些计算也可以放入其中。
  2. 计算线程,这类线程的主循环是 blocking queue,等在 condition variable 上。这类线程一般位于 thread pool 中。
  3. 第三方库所用的线程,比如 logging,又比如 database connection。

服务器程序一般不会频繁地启动和终止线程。甚至,在我写过的程序里,create thread 只在程序启动的时候调用,在服务运行期间是不调用的。

 

在多核时代,多线程编程是不可避免的,“鸵鸟算法”不是办法。

原创粉丝点击