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

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 ACEPoco C++ librariesQT 待查)

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 (或者叫 Reactor),用于处理读写和定时事件(无论周期性的还是单次的),代码框架跟第 2 节一样。

这种方式的好处是:

l 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。

l 可以很方便地在线程间调配负载。

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)

blocking_queue<boost::function<void()> > taskQueue;  // 线程安全的阻塞队列

void worker_thread()

{

  while (!quit) {

    boost::function<void()> task = taskQueue.take();  // this blocks

    task();  // 在产品代码中需要考虑异常处理

  }

}

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

启动容量为 N 的线程池:

int N = num_of_computing_threads;

for (int i = 0; i < N; ++i) {

  create_thread(&worker_thread);  // 伪代码:启动线程

}

使用起来也很简单:

boost::function<void()> task = boost::bind(&Foo::calc, this);

taskQueue.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 mutex2 condition variables),健壮性要高得多。如果不想自己实现,用现成的库更好。(我没有用过免费的库,这里就不乱推荐了,有兴趣的同学可以试试 Intel Threading Building Blocks 里的 concurrent_queue<T>。)

归纳

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

l event loop 用作 non-blocking IO 和定时器。

l thread pool 用来做计算,具体可以是任务队列或消费者-生产者队列。

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

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

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

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

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

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

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

posted @ 2011-11-12 16:01  玩玩乐乐  阅读(688)  评论(0编辑  收藏  举报