多线程服务器的常用编程模型
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
2009 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 先生审读,在此深表谢意。当然,文中任何错误责任均在我。
目 录
封装 MutexLock、MutexLockGuard 和 Condition 11
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 模式,我知道的有:
l lighttpd,单线程服务器。(nginx 估计与之类似,待查)
l libevent/libev
l ACE,Poco C++ libraries(QT 待查)
l Java NIO (Selector/SelectableChannel), Apache Mina, Netty (Java)
l POE (Perl)
l Twisted (Python)
相反,boost::asio 和 Windows I/O Completion Ports 实现了 Proactor 模式,应用面似乎要窄一些。当然,ACE 也实现了 Proactor 模式,不表。
在“non-blocking IO + IO multiplexing”这种模型下,程序的基本结构是一个事件循环 (event loop):(代码仅为示意,没有完整考虑各种情况)
while (!done)
{
int timeout_ms = max(1000, getNextTimedCallback());
int retval = ::poll(fds, nfds, timeout_ms);
if (retval < 0) {
处理错误
} else {
处理到期的 timers
if (retval > 0) {
处理 IO 事件
}
}
}
当然,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 (或者叫