多线程服务器的常用编程模型
陈硕(giantchen_AT_gmail)
Blog.csdn.net/Solstice
2009Feb12
建议阅读本文 PDF 版下载:http://files.cppblog.com/Solstice/multithreaded_server.pdf
本文主要讲我个人在多线程开发方面的一些粗浅经验。总结了一两种常用的线程模型,归纳了进程间通讯与线程同步的最佳实践,以期用简单规范的方式开发多线程程序。
文中的“多线程服务器”是指运行在Linux操作系统上的独占式网络应用程序。硬件平台为Intelx64系列的多核CPU,单路或双路SMP服务器(每台机器一共拥有四个核或八个核,十几GB内存),机器之间用百兆或千兆以太网连接。这大概是目前民用PC服务器的主流配置。
本文不涉及Windows系统,不涉及人机交互界面(无论命令行或图形);不考虑文件读写(往磁盘写log除外),不考虑数据库操作,不考虑Web应用;不考虑低端的单核主机或嵌入式系统,不考虑手持式设备,不考虑专门的网络设备,不考虑高端的>=32核Unix主机;只考虑TCP,不考虑UDP,也不考虑除了局域网络之外的其他数据收发方式(例如串并口、USB口、数据采集板卡、实时控制等)。
有了以上这么多限制,那么我将要谈的“网络应用程序”的基本功能可以归纳为“收到数据,算一算,再发出去”。在这个简化了的模型里,似乎看不出用多线程的必要,单线程应该也能做得很好。“为什么需要写多线程程序”这个问题容易引发口水战,我放到另一篇博客里讨论。请允许我先假定“多线程编程”这一背景。
“服务器”这个词有时指程序,有时指进程,有时指硬件(无论虚拟的或真实的),请注意按上下文区分。另外,本文不考虑虚拟化的场景,当我说“两个进程不在同一台机器上”,指的是逻辑上不在同一个操作系统里运行,虽然物理上可能位于同一机器虚拟出来的两台“虚拟机”上。
本文假定读者已经有多线程编程的知识与经验,这不是一篇入门教程。
本文承蒙MiloYip先生审读,在此深表谢意。当然,文中任何错误责任均在我。
目录
封装MutexLock、MutexLockGuard和Condition 11
“进程/process”是操作里最重要的两个概念之一(另一个是文件),粗略地讲,一个进程是“内存中正在运行的程序”。本文的进程指的是Linux操作系统通过fork()系统调用产生的那个东西,或者Windows下CreateProcess()的产物,不是Erlang里的那种轻量级进程。
每个进程有自己独立的地址空间(addressspace),“在同一个进程”还是“不在同一个进程”是系统功能划分的重要决策点。Erlang书把“进程”比喻为“人”,我觉得十分精当,为我们提供了一个思考的框架。
每个人有自己的记忆(memory),人与人通过谈话(消息传递)来交流,谈话既可以是面谈(同一台服务器),也可以在电话里谈(不同的服务器,有网络通信)。面谈和电话谈的区别在于,面谈可以立即知道对方死否死了(crash,SIGCHLD),而电话谈只能通过周期性的心跳来判断对方是否还活着。
有了这些比喻,设计分布式系统时可以采取“角色扮演”,团队里的几个人各自扮演一个进程,人的角色由进程的代码决定(管登陆的、管消息分发的、管买卖的等等)。每个人有自己的记忆,但不知道别人的记忆,要想知道别人的看法,只能通过交谈。(暂不考虑共享内存这种IPC。)然后就可以思考容错(万一有人突然死了)、扩容(新人中途加进来)、负载均衡(把a的活儿挪給b做)、退休(a要修复bug,先别给他派新活儿,等他做完手上的事情就把他重启)等等各种场景,十分便利。
“线程”这个概念大概是在1993年以后才慢慢流行起来的,距今不过十余年,比不得有40年光辉历史的Unix操作系统。线程的出现给Unix添了不少乱,很多C库函数(strtok(),ctime())不是线程安全的,需要重新定义;signal的语意也大为复杂化。据我所知,最早支持多线程编程的(民用)操作系统是Solaris2.2和WindowsNT3.1,它们均发布于1993年。随后在1995年,POSIXthreads标准确立。
线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。如果多个进程大量共享内存,等于是把多进程程序当成多线程来写,掩耳盗铃。
“多线程”的价值,我认为是为了更好地发挥对称多路处理(SMP)的效能。在SMP之前,多线程没有多大价值。AlanCox说过Acomputerisastatemachine.Threadsareforpeoplewhocan'tprogramstatemachines.(计算机是一台状态机。线程是给那些不能编写状态机程序的人准备的。)如果只有一个执行单元,一个CPU,那么确实如AlanCox所说,按状态机的思路去写程序是最高效的,这正好也是下一节展示的编程模型。
UNP3e对此有很好的总结(第6章:IO模型,第30章:客户端/服务器设计范式),这里不再赘述。据我了解,在高性能的网络程序中,使用得最为广泛的恐怕要数“non-blockingIO+IOmultiplexing”这种模型,即Reactor模式,我知道的有:
llighttpd,单线程服务器。(nginx估计与之类似,待查)
llibevent/libev
lACE,PocoC++libraries(QT待查)
lJavaNIO(Selector/SelectableChannel),ApacheMina,Netty(Java)
lPOE(Perl)
lTwisted(Python)
相反,boost::asio和WindowsI/OCompletionPorts实现了Proactor模式,应用面似乎要窄一些。当然,ACE也实现了Proactor模式,不表。
在“non-blockingIO+IOmultiplexing”这种模型下,程序的基本结构是一个事件循环(eventloop):(代码仅为示意,没有完整考虑各种情况)
while(!done)
{
inttimeout_ms=max(1000,getNextTimedCallback());
intretval=::poll(fds,nfds,timeout_ms);
if(retval<0){
处理错误
}else{
处理到期的timers
if(retval>0){
处理IO事件
}
}
}
当然,select(2)/poll(2)有很多不足,Linux下可替换为epoll,其他操作系统也有对应的高性能替代品(搜c10kproblem)。
Reactor模型的优点很明显,编程简单,效率也不错。不仅网络读写可以用,连接的建立(connect/accept)甚至DNS解析都可以用非阻塞方式进行,以提高并发度和吞吐量(throughput)。对于IO密集的应用是个不错的选择,Lighttpd即是这样,它内部的fdevent结构十分精妙,值得学习。(这里且不考虑用阻塞IO这种次优的方案。)
当然,实现一个优质的Reactor不是那么容易,我也没有用过坊间开源的库,这里就不推荐了。
这方面我能找到的文献不多,大概有这么几种:
1.每个请求创建一个线程,使用阻塞式IO操作。在Java1.4引入NIO之前,这是Java网络编程的推荐做法。可惜伸缩性不佳。
2.使用线程池,同样使用阻塞式IO操作。与1相比,这是提高性能的措施。
3.使用non-blockingIO+IOmultiplexing。即JavaNIO的方式。
4.Leader/Follower等高级模式
在默认情况下,我会使用第3种,即non-blockingIO+oneloopperthread模式。
http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#THREADS_AND_COROUTINES
此种模型下,程序里的每个IO线程有一个eventloop(或者叫Reactor),用于处理读写和定时事件(无论周期性的还是单次的),代码框架跟第2节一样。
这种方式的好处是:
l线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。
l可以很方便地在线程间调配负载。
eventloop代表了线程的主循环,需要让哪个线程干活,就把timer或IOchannel(TCPconnection)注册到那个线程的loop里即可。对实时性有要求的connection可以单独用一个线程;数据量大的connection可以独占一个线程,并把数据处理任务分摊到另几个线程中;其他次要的辅助性connections可以共享一个线程。
对于non-trivial的服务端程序,一般会采用non-blockingIO+IOmultiplexing,每个connection/acceptor都会注册到某个Reactor上,程序里有多个Reactor,每个线程至多有一个Reactor。
多线程程序对Reactor提出了更高的要求,那就是“线程安全”。要允许一个线程往别的线程的loop里塞东西,这个loop必须得是线程安全的。
不过,对于没有IO光有计算任务的线程,使用eventloop有点浪费,我会用有一种补充方案,即用blockingqueue实现的任务队列(TaskQueue):
blocking_queue<boost::function<void()>>taskQueue;//线程安全的阻塞队列
voidworker_thread()
{
while(!quit){
boost::function<void()>task=taskQueue.take();//thisblocks
task();//在产品代码中需要考虑异常处理
}
}
用这种方式实现线程池特别容易:
启动容量为N的线程池:
intN=num_of_computing_threads;
for(inti=0;i<N;++i){
create_thread(&worker_thread);//伪代码:启动线程
}
使用起来也很简单:
boost::function<void()>task=boost::bind(&Foo::calc,this);
taskQueue.post(task);
上面十几行代码就实现了一个简单的固定数目的线程池,功能大概相当于Java5的ThreadPoolExecutor的某种“配置”。当然,在真实的项目中,这些代码都应该封装到一个class中,而不是使用全局对象。另外需要注意一点:Foo对象的生命期,我的另一篇博客《当析构函数遇到多线程——C++中线程安全的对象回调》详细讨论了这个问题
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx
除了任务队列,还可以用blocking_queue<T>实现数据的消费者-生产者队列,即T的是数据类型而非函数对象,queue的消费者(s)从中拿到数据进行处理。这样做比taskqueue更加specific一些。
blocking_queue<T>是多线程编程的利器,它的实现可参照Java5util.concurrent里的(Array|Linked)BlockingQueue,通常C++可以用deque来做底层的容器。Java5里的代码可读性很高,代码的基本结构和教科书一致(1个mutex,2个conditionvariables),健壮性要高得多。如果不想自己实现,用现成的库更好。(我没有用过免费的库,这里就不乱推荐了,有兴趣的同学可以试试IntelThreadingBuildingBlocks里的concurrent_queue<T>。)
总结起来,我推荐的多线程服务端编程模式为:eventloopperthread+threadpool。
leventloop用作non-blockingIO和定时器。
lthreadpool用来做计算,具体可以是任务队列或消费者-生产者队列。
以这种方式写服务器程序,需要一个优质的基于Reactor模式的网络库来支撑,我只用过in-house的产品,无从比较并推荐市面上常见的C++网络库,抱歉。
程序里具体用几个loop、线程池的大小等参数需要根据应用来设定,基本的原则是“阻抗匹配”,使得CPU和IO都能高效地运作,具体的考虑点容我以后再谈。
这里没有谈线程的退出,留待下一篇blog“多线程编程反模式”探讨。
此外,程序里或许还有个别执行特殊任务的线程,比如logging,这对应用程序来说基本是不可见的,但是在分配资源(CPU和IO)的时候要算进去,以免高估了系统的容量。
Linux下进程间通信(IPC)的方式数不胜数,光UNPv2列出的就有:pipe、FIFO、POSIX消息队列、共享内存、信号(signals)等等,更不必说Sockets了。同步原语(synchronizationprimitives)也很多,互斥器(mutex)、条件变量(conditionvariable)、读写锁(reader-writerlock)、文件锁(Recordlocking)、信号量(Semaphore)等等。
如何选择呢?根据我的个人经验,贵精不贵多,认真挑选三四样东西就能完全满足我的工作需要,而且每样我都能用得很熟,,不容易犯错。
进程间通信我首选Sockets(主要指TCP,我没有用过UDP,也不考虑Unixdomain协议),其最大的好处在于:可以跨主机,具有伸缩性。反正都是多进程了,如果一台机器处理能力不够,很自然地就能用多台机器来处理。把进程分散到同一局域网的多台机器上,程序改改host:port配置就能继续用。相反,前面列出的其他IPC都不能跨机器(比如共享内存效率最高,但再怎么着也不能高效地共享两台机器的内存),限制了scalability。
在编程上,TCPsockets和pipe都是一个文件描述符,用来收发字节流,都可以read/write/fcntl/select/poll等。不同的是,TCP是双向的,pipe是单向的(Linux),进程间双向通讯还得开两个文件描述符,不方便;而且进程要有父子关系才能用pipe,这些都限制了pipe的使用。在收发字节流这一通讯模型下,没有比sockets/TCP更自然的IPC了。当然,pipe也有一个经典应用场景,那就是写Reactor/Selector时用来异步唤醒select(或等价的poll/epoll)调用(SunJVM在Linux就是这么做的)。
TCPport是由一个进程独占,且操作系统会自动回收(listeningport和已建立连接的TCPsocket都是文件描述符,在进程结束时操作系统会关闭所有文件描述符)。这说明,即使程序意外退出,也不会给系统留下垃圾,程序重启之后能比较容易地恢复,而不需要重启操作系统(用跨进程的mutex就有这个风险)。还有一个好处,既然port是独占的,那么可以防止程序重复启动(后面那个进程抢不到port,自然就没法工作了),造成意料之外的结果。
两个进程通过TCP通信,如果一个崩溃了,操作系统会关闭连接,这样另一个进程几乎立刻就能感知,可以快速failover。当然,应用层的心跳也是必不可少的,我以后在讲服务端的日期与时间处理的时候还会谈到心跳协议的设计。
与其他IPC相比,TCP协议的一个自然好处是“可记录可重现”,tcpdump/Wireshark是解决两个进程间协议/状态争端的好帮手。
另外,如果网络库带“连接重试”功能的话,我们可以不要求系统里的进程以特定的顺序启动,任何一个进程都能单独重启,这对开发牢靠的分布式系统意义重大。
使用TCP这种字节流(bytestream)方式通信,会有marshal/unmarshal的开销,这要求我们选用合适的消息格式,准确地说是wireformat。这将是我下一篇blog的主题,目前我推荐GoogleProtocolBuffers。
有人或许会说,具体问题具体分析,如果两个进程在同一台机器,就用共享内存,否则就用TCP,比如MSSQLServer就同时支持这两种通信方式。我问,是否值得为那么一点性能提升而让代码的复杂度大大增加呢?TCP是字节流协议,只能顺序读取,有写缓冲;共享内存是消息协议,a进程填好一块内存让b进程来读,基本是“停等”方式。要把这两种方式揉到一个程序里,需要建一个抽象层,封装两种IPC。这会带来不透明性,并且增加测试的复杂度,而且万一通信的某一方崩溃,状态reconcile也会比sockets麻烦。为我所不取。再说了,你舍得让几万块买来的SQLServer和你的程序分享机器资源吗?产品里的数据库服务器往往是独立的高配置服务器,一般不会同时运行其他占资源的程序。
TCP本身是个数据流协议,除了直接使用它来通信,还可以在此之上构建RPC/REST/SOAP之类的上层通信协议,这超过了本文的范围。另外,除了点对点的通信之外,应用级的广播协议也是非常有用的,可以方便地构建可观可控的分布式系统。
本文不具体讲Reactor方式下的网络编程,其实这里边有很多值得注意的地方,比如带backoff的retryconnecting,用优先队列来组织timer等等,留作以后分析吧。
线程同步的四项原则,按重要性排列:
1.首要原则是尽量最低限度地共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑immutable对象;实在不行才暴露可修改的对象,并用同步措施来充分保护它。
2.其次是使用高级的并发编程构件,如TaskQueue、Producer-ConsumerQueue、CountDownLatch等等;
3.最后不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,偶尔用一用读写锁;
4.不自己编写lock-free代码,不去凭空猜测“哪种做法性能会更好”,比如spinlockvs.mutex。
前面两条很容易理解,这里着重讲一下第3条:底层同步原语的使用。
互斥器(mutex)恐怕是使用得最多的同步原语,粗略地说,它保护了临界区,一个时刻最多只能有一个线程在临界区内活动。(请注意,我谈的是pthreads里的mutex,不是Windows里的重量级跨进程Mutex。)单独使用mutex时,我们主要为了保护共享数据。我个人的原则是:
l用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作。
l只用非递归的mutex(即不可重入的mutex)。
l不手工调用lock()和unlock()函数,一切交给栈上的Guard对象的构造和析构函数负责,Guard对象的生命期正好等于临界区(分析对象在什么时候析构是C++程序员的基本功)。这样我们保证在同一个函数里加锁和解锁,避免在foo()里加锁,然后跑到bar()里解锁。
l在每次构造Guard对象的时候,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁(deadlock)。由于Guard对象是栈上对象,看函数调用栈就能分析用锁的情况,非常便利。
次要原则有:
l不使用跨进程的mutex,进程间通信只用TCPsockets。
l加锁解锁在同一个线程,线程a不能去unlock线程b已经锁住的mutex。(RAII自动保证)
l别忘了解锁。(RAII自动保证)
l不重复解锁。(RAII自动保证)
l必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错
用RAII封装这几个操作是通行的做法,这几乎是C++的标准实践,后面我会给出具体的代码示例,相信大家都已经写过或用过类似的代码了。Java里的synchronized语句和C#的using语句也有类似的效果,即保证锁的生效期间等于一个作用域,不会因异常而忘记解锁。
Mutex恐怕是最简单的同步原语,安照上面的几条原则,几乎不可能用错。我自己从来没有违背过这些原则,编码时出现问题都很快能招到并修复。
谈谈我坚持使用非递归的互斥器的个人想法。
Mutex分为递归(recursive)和非递归(non-recursive)两种,这是POSIX的叫法,另外的名字是可重入(Reentrant)与非可重入。这两种mutex作为线程间(inter-thread)的同步工具时没有区别,它们的惟一区别在于:同一个线程可以重复对recursivemutex加锁,但是不能重复对non-recursivemutex加锁。
首选非递归mutex,绝对不是为了性能,而是为了体现设计意图。non-recursive和recursive的性能差别其实不大,因为少用一个计数器,前者略快一点点而已。在同一个线程里多次对non-recursivemutex加锁会立刻导致死锁,我认为这是它的优点,能帮助我们思考代码对锁的期求,并且及早(在编码阶段)发现问题。
毫无疑问recursivemutex使用起来要方便一些,因为不用考虑一个线程会自己把自己给锁死了,我猜这也是Java和Windows默认提供recursivemutex的原因。(Java语言自带的intrinsiclock是可重入的,它的concurrent库里提供ReentrantLock,Windows的CRITICAL_SECTION也是可重入的。似乎它们都不提供轻量级的non-recursivemutex。)
正因为它方便,recursivemutex可能会隐藏代码里的一些问题。典型情况是你以为拿到一个锁就能修改对象了,没想到外层代码已经拿到了锁,正在修改(或读取)同一个对象呢。具体的例子:
std::vector<Foo>foos;
MutexLockmutex;
voidpost(constFoo&f)
{
MutexLockGuardlock(mutex);
foos.push_back(f);
}
voidtraverse()
{
MutexLockGuardlock(mutex);
for(autoit=foos.begin();it!=foos.end();++it){//用了0x新写法
it->doit();
}
}
post()加锁,然后修改foos对象;traverse()加锁,然后遍历foos数组。将来有一天,Foo::doit()间接调用了post()(这在逻辑上是错误的),那么会很有戏剧性的:
1.Mutex是非递归的,于是死锁了。
2.Mutex是递归的,由于push_back可能(但不总是)导致vector迭代器失效,程序偶尔会crash。
这时候就能体现non-recursive的优越性:把程序的逻辑错误暴露出来。死锁比较容易debug,把各个线程的调用栈打出来((gdb)threadapplyallbt),只要每个函数不是特别长,很容易看出来是怎么死的。(另一方面支持了函数不要写过长。)或者可以用PTHREAD_MUTEX_ERRORCHECK一下子就能找到错误(前提是MutexLock带debug选项。)
程序反正要死,不如死得有意义一点,让验尸官的日子好过些。
如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:
1.跟原来的函数同名,函数加锁,转而调用第2个函数。
2.给函数名加上后缀WithLockHold,不加锁,把原来的函数体搬过来。
就像这样:
voidpost(constFoo&f)
{
MutexLockGuardlock(mutex);
postWithLockHold(f);//不用担心开销,编译器会自动内联的
}
//引入这个函数是为了体现代码作者的意图,尽管push_back通常可以手动内联
voidpostWithLockHold(constFoo&f)
{
foos.push_back(f);
}
这有可能出现两个问题(感谢水木网友ilovecpp提出):a)误用了加锁版本,死锁了。b)误用了不加锁版本,数据损坏了。
对于a),仿造前面的办法能比较容易地排错。对于b),如果pthreads提供isLocked()就好办,可以写成:
voidpostWithLockHold(constFoo&f)
{
assert(mutex.isLocked());//目前只是一个愿望
//...
}
另外,WithLockHold这个显眼的后缀也让程序中的误用容易暴露出来。
C++没有annotation,不能像Java那样给method或field标上@GuardedBy注解,需要程序员自己小心在意。虽然这里的办法不能一劳永逸地解决全部多线程错误,但能帮上一点是一点了。
我还没有遇到过需要使用recursivemutex的情况,我想将来遇到了都可以借助wrapper改用non-recursivemutex,代码只会更清晰。
===回到正题===
本文这里只谈了mutex本身的正确使用,在C++里多线程编程还会遇到其他很多racecondition,请参考拙作《当析构函数遇到多线程——C++中线程安全的对象回调》
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx。请注意这里的class命名与那篇文章有所不同。我现在认为MutexLock和MutexLockGuard是更好的名称。
性能注脚:Linux的pthreadsmutex采用futex实现,不必每次加锁解锁都陷入系统调用,效率不错。Windows的CRITICAL_SECTION也是类似。
条件变量(conditionvariable)顾名思义是一个或多个线程等待某个布尔表达式为真,即等待别的线程“唤醒”它。条件变量的学名叫管程(monitor)。JavaObject内置的wait(),notify(),notifyAll()即是条件变量(它们以容易用错著称)。条件变量只有一种正确使用的方式,对于wait()端:
1.必须与mutex一起使用,该布尔表达式的读写需受此mutex保护
2.在mutex已上锁的时候才能调用wait()
3.把判断布尔条件和wait()放到while循环中
写成代码是:
MutexLockmutex;
Conditioncond(mutex);
std::deque<int>queue;
intdequeue()
{
MutexLockGuardlock(mutex);
while(queue.empty()){//必须用循环;必须在判断之后再wait()
cond.wait();//这一步会原子地unlockmutex并进入blocking,不会与enqueue死锁
}
assert(!queue.empty());
inttop=queue.front();
queue.pop_front();
returntop;
}
对于signal/broadcast端:
1.不一定要在mutex已上锁的情况下调用signal(理论上)
2.在signal之前一般要修改布尔表达式
3.修改布尔表达式通常要用mutex保护(至少用作fullmemorybarrier)
写成代码是:
voidenqueue(intx)
{
MutexLockGuardlock(mutex);
queue.push_back(x);
cond.notify();
}
上面的dequeue/enqueue实际上实现了一个简单的unboundedBlockingQueue。
条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高层的同步措施,如BlockingQueue或CountDownLatch。
读写锁(Reader-Writerlock),读写锁是个优秀的抽象,它明确区分了read和write两种行为。需要注意的是,readerlock是可重入的,writerlock是不可重入(包括不可提升readerlock)的。这正是我说它“优秀”的主要原因。
遇到并发读写,如果条件合适,我会用《借shared_ptr实现线程安全的copy-on-write》http://blog.csdn.net/Solstice/archive/2008/11/22/3351751.aspx介绍的办法,而不用读写锁。当然这不是绝对的。
信号量(Semaphore),我没有遇到过需要使用信号量的情况,无从谈及个人经验。
说一句大逆不道的话,如果程序里需要解决如“哲学家就餐”之类的复杂IPC问题,我认为应该首先考察几个设计,为什么线程之间会有如此复杂的资源争抢(一个线程要同时抢到两个资源,一个资源可以被两个线程争夺)?能不能把“想吃饭”这个事情专门交给一个为各位哲学家分派餐具的线程来做,然后每个哲学家等在一个简单的conditionvariable上,到时间了有人通知他去吃饭?从哲学上说,教科书上的解决方案是平权,每个哲学家有自己的线程,自己去拿筷子;我宁愿用集权的方式,用一个线程专门管餐具的分配,让其他哲学家线程拿个号等在食堂门口好了。这样不损失多少效率,却让程序简单很多。虽然Windows的WaitForMultipleObjects让这个问题trivial化,在Linux下正确模拟WaitForMultipleObjects不是普通程序员该干的。
本节把前面用到的MutexLock、MutexLockGuard、Conditionclasses的代码列出来,前面两个classes没多大难度,后面那个有点意思。
MutexLock封装临界区(Criticalsecion),这是一个简单的资源类,用RAII手法[CCS:13]封装互斥器的创建与销毁。临界区在Windows上是CRITICAL_SECTION,是可重入的;在Linux下是pthread_mutex_t<span style=
评论