聊聊原子变量、锁、内存屏障那点事,Linux 环境下多线程 C/C++ 程序的内存问题调试
突然想聊聊这个话题,是因为知乎上的一个问题多次出现在了我的Timeline里:请问,多个线程可以读一个变量,只有一个线程可以对这个变量进行写,到底要不要加锁?可惜的是很多高票答案语焉不详,甚至有所错漏。所以我想在这篇文章里斗胆聊聊这个水挺深的问题。受限于个人水平,文章若有错漏,还望读者不吝赐教。
首先约定,由于CPU的架构和设计浩如烟海,本文站在工程师的角度,只谈IA32/AMD64(x86-64)架构,不讨论其他架构的细节和差异。并且文章中主要引用Intel的文档予以佐证,不关注AMD在实现细节上的差异。
众所周知,当一个执行中的程序的数据被多个执行流并发访问的时候,就会涉及到同步(Synchronization)的问题。同步的目的是保证不同执行流对共享数据并发操作的一致性。早在单核时代,使用锁或者原子变量就很容易达成这一目的。甚至因为CPU的一些访存特性,对某些内存对齐数据的读或写也具有原子的特性。
比如,在《Intel® 64 and IA-32 Architectures Software Developer’s Manual》的第三卷System Programming Guide的Chapter 8 Multiple-Processor Management里,就给出了这样的说明:
也就是说,有些内存对齐的数据的访问在CPU层面就是原子进行的(注意这里说的只是单次的读或者写,类似普通变量i的i++操作不止一次内存访问)。此时,环形队列(Ring buffer)这种数据结构在某些架构的单核CPU上,只有一个Reader和一个Writer的情况下是不需要额外同步措施的。原因就是read_index
和writer_index
的写操作在满足对齐内存访问的情况下是原子的,不需要额外的同步措施。注意这里我加粗了单核CPU这个关键字,那么到了多核心处理器的今天,该操作就不是原子了吗?不,依旧是原子的,但是出现了其他的干扰因素迫使可能需要额外的同步措施才能保证原本无锁代码的正确运行。
首先是现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储,并充分复用寄存器。但是编译器对数据的依赖关系判断只能在单执行流内,无法判断其他执行流对竞争数据的依赖关系。就拿无锁环形队列来说,如果Writer做的是先放置数据,再更新索引的行为。如果索引先于数据更新,Reader就有可能会因为判断索引已更新而读到脏数据。
那禁止编译器对该类变量的优化,解决了编译期的重排序就没事了吗?不,CPU还有乱序执行(Out-of-Order Execution)的特性。流水线(Pipeline)和乱序执行是现代CPU基本都具有的特性。机器指令在流水线中经历取指、译码、执行、访存、写回等操作。为了CPU的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理器次序(Process Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,即满足As-if-Serial特性。显然,这里的不影响语义依旧只能是保证指令间的显式因果关系,无法保证隐式因果关系。即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。从此单核时代CPU的Self-Consistent特性在多核时代已不存在,多核CPU作为一个整体看,不再满足Self-Consistent特性。
简单总结一下,如果不做多余的防护措施,单核时代的无锁环形队列在多核CPU中,一个CPU核心上的Writer写入数据,更新index后。另一个CPU核心上的Reader依靠这个index来判断数据是否写入的方式不一定可靠。index有可能先于数据被写入,从而导致Reader读到脏数据。
所有的麻烦到这里就结束了吗?当然不,还有Cache的问题。前文提到的都是顺序一致性(Sequential Consistency)的问题,没有涉及Cache一致性(Cache Coherence)的问题。虽然说一般情况下程序员只需要关注顺序一致性即可,但是区分清楚这两个概念也能更好的解释内存屏障(Memory Barrier)。
开始提到Cache一致性协议之前,先介绍两个名词:
- Load/Read CPU读操作,是指将内存数据加载到寄存器的过程
- Store/Write CPU写操作,是指将寄存器数据写回主存的过程
现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成:
由于Cache的容量很小,一般都是充分的利用局部性原理,按行/块来和主存进行批量数据交换,以提升数据的访问效率。以前写过一篇《浅析x86架构中cache的组织结构》,这里不再赘述。既然各个核心之间有独立的Cache存储器,那么这些存储器之间的数据同步就是个比较复杂的事情。缓存数据的一致性由缓存一致性协议保证。这里比较经典的当属MESI协议。Intel的处理器使用从MESI中演化出的MESIF协议,而AMD使用MOESI协议。缓存一致性协议的细节超出了本文的讨论范围,有兴趣的读者可以自行研究。
传统的MESI协议中有两个行为的执行成本比较大。一个是将某个Cache Line标记为Invalid状态,另一个是当某Cache Line当前状态为Invalid时写入新的数据。所以CPU通过Store Buffer和Invalidate Queue组件来降低这类操作的延时。如图:
当一个核心在Invalid状态进行写入时,首先会给其它CPU核发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。然后异步在某个时刻真正的写入到Cache Line中。当前CPU核如果要读Cache Line中的数据,需要先扫描Store Buffer之后再读取Cache Line(Store-Buffer Forwarding)。但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache Line之后才会触发失效操作。而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态。和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。当然这里的Store Buffer和Invalidate Queue的说法是针对一般的SMP架构来说的,不涉及具体架构。事实上除了Store Buffer和Load Buffer,流水线为了实现并行处理,还有Line Fill Buffer/Write Combining Buffer 等组件,参考文献8-10给出了相关的资料可以进一步阅读。
好了,问题背景描述的差不多了,下面该解决方案登场了。
编译器优化乱序和CPU执行乱序的问题可以分别使用优化屏障 (Optimization Barrier)和内存屏障 (Memory Barrier)这两个机制来解决:
优化屏障 (Optimization Barrier):避免编译器的重排序优化操作,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。这就保证了编译时期的优化不会影响到实际代码逻辑顺序。
IA-32/AMD64架构上,在Linux下常用的GCC编译器上,优化屏障定义为(linux kernel, include/linux/compiler-gcc.h):
1
|
/* The "volatile" is due to gcc bugs */
|
优化屏障告知编译器:
- 内存信息已经修改,屏障后的寄存器的值必须从内存中重新获取
- 必须按照代码顺序产生汇编代码,不得越过屏障
C/C++的volatile关键字也能起到优化限制的作用,但是和Java中的volatile(Java 5之后)不同,C/C++中的volatile不提供任何防止乱序的功能,也并不保证访存的原子性。
内存屏障 (Memory Barrier)分为写屏障(Store Barrier)、读屏障(Load Barrier)和全屏障(Full Barrier),其作用有两个:
- 防止指令之间的重排序
- 保证数据的可见性
关于第一点,关于指令重排,这里不考虑架构的话,Load和Store两种操作会有Load-Store、Store-Load、Load-Load、Store-Store这四种可能的乱序结果。 上文提到的三种屏障则是限制这些不同乱序的机制。
关于第二点。写屏障会阻塞直到把Store Buffer中的数据刷到Cache中;读屏障会阻塞直到Invalid Queue中的消息执行完毕。以此来保证核间各级数据的一致性。
这里要强调,内存屏障解决的只是顺序一致性的问题,不解决Cache一致性的问题(这是Cache一致性协议的责任,也不需要程序员关注)。Store Buffer和Load Buffer等组件是属于流水线的一部分,和Cache无关。这里一定要区分清楚这两点,Cache一致性协议只是保证了Cache一致性(Cache Coherence),但是不关注顺序一致性(Sequential Consistency)的问题。比如,一个处理器对某变量A的写入操作仅比另一个处理器对A的读取操作提前很短的一点时间,那就不一定能确保该读取操作会返回新写入的值。这个新写入的值多久之后能确保被读取操作读取到,这是内存一致性模型(Memory Consistency Models)要讨论的问题。
完全的确保顺序一致性需要很大的代价,不仅限制编译器的优化,也限制了CPU的执行效率。为了更好地挖掘硬件的并行能力,现代的CPU多半都是介于两者之间,即所谓的宽松的内存一致性模型(Relaxed Memory Consistency Models)。不同的架构在重排上有各自的尺度,在严格排序和自由排序之间会有各自的偏向。偏向严格排序的一边,称之为强模型(Strong Model),而偏向于自由排序的一边,称之为弱模型(Weak Model)。AMD64架构是強模型:
特别地,早先时候,AMD64架构也会有Load-Load乱序发生(Memory Ordering in Modern Microprocessors, PaulE.McKenney, 2006)。
注意这里的IA-64(Intanium Processor Family)是弱模型,它和Intel® 64不是一回事。后者是从AMD交叉授权来的,源头就是AMD64架构。这里不讨论历史,只需要知道平时说的x86-64/x64就是指的AMD64架构即可。
《Intel® 64 and IA-32 Architectures Software Developer’s Manual》有如下的阐述:
简单翻译一下:
- 读操作之间不能重新排序
- 写操作不能跟旧的读操作排序
- 主存写操作不能跟其他的写操作排序,但是以下情况除外:
- 带有CLFLUSH(失效缓存)指令的写操作
- 带有non-temporal move指令的流存储(写入)(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, 和 MOVNTPD,都是SSE/SSE2扩展的指令)
- 字符串操作(REP STOSD等)
- 不同内存地址的读可以与较早的写排序,同一地址的情况除外
- 对I/O指令、锁指令、序列化指令的读写不能重排序
- 读不能越过较早的读屏障指令(LFENCE)或者全屏障指令(MFENCE)
- 写不能越过较早的读屏障指令(LFENCE)、写屏障指令(SFENCE)和全屏障指令(MFENCE)
- 读屏障指令(LFENCE)不能越过较早的读
- 写屏障指令(SFENCE)不能越过较早的写
- 全屏障指令(MFENCE)不能越过较早的读和写
在多处理器的情况下,单处理器内部的内存访问排序仍然依照以上的原则,并且规定处理器与处理器之间遵循如下的原则:
- 某个处理器的全部写操作以同样的顺序被其它处理器观察到
- 不同处理器之间的写操作不重排序
- 排序遵循逻辑上的因果关系
- 第三方总是观察到一致的写操作顺序
那么上文提到的四种可能的乱序在AMD64下明确说明不会有Load-Load乱序、Load-Store乱序,明确会出现Store-Load乱序,Store-Store乱序除了几种例外的情况也不会出现。参考文献5中给出了在Linux下重现出Store-Load乱序的代码,有兴趣的读者可以自行测试。
但是内存一致性模型不仅仅是没有指令重排就会保证一致的。但是如果仅仅只考虑指令重排,完全按照该规则来思考,就会遇到违反直觉的事情。特别的,在对写缓存的同步处理上,AMD64内存访问模型的 Intra-Processor Forwarding Is Allowed这个特性比较要命:
只考虑指令重排的话,AMD64架构既然不会有Load-Load重排的,r2=r4=0就不可能会出现,但是实际的结果是违反直觉的。出现这个现象的原因就是Intel对Store Buffer的处理上,Store Buffer的修改对其他CPU核心是不可见的。Processor 0对_x的修改缓存在了Processor 0的Store Buffer中,还未提交到L1 Cache,自然也不会失效掉Processor 1的L1 Cache中的相关行。Processor 1对_y的修改同理。
对于以上问题,AMD64提供了三个内存屏障指令来解决:
sfence指令为写屏障(Store Barrier),作用是:
- 保证了sfence前后Store指令的顺序,防止Store重排序
- 通过刷新Store Buffer保证sfence之前的Store要指令对全局可见
lfence指令读屏障(Load Barrier),作用是:
- 保证了lfence前后的Load指令的顺序,防止Load重排序
- 刷新Load Buffer
mfence指令全屏障(Full Barrier),作用是:
- 保证了mfence前后的Store和Load指令的顺序,防止Store和Load重排序
- 保证了mfence之后的Store指令全局可见之前,mfence之前的Store指令要先全局可见
如前文所说,AMD64架构上是不存在Load-Load重排的,但是当一个CPU核心收到其他CPU核心失效Cache Line的消息后,立即回复给对方一个应答信号。但是此时并没有立即失效掉Cache Line,而是将其包装成一个结构投递到自身的Load Buffer里。AMD64架构上不存在Load-Load重排并不意味着流水线真的就一条一条执行Load指令。在保证两个CPU核看到的Store顺序一致的情况下,是允许Load乱序的。比如连续的两个访存指令,指令1 Cache Miss,指令2 Cache Hit,实际上指令2是不会真的等待指令1的Load完成整个Cache替换过程后才执行的。实际流水线的实现中,Load先是乱序执行,然后有一个Load-ordering-Buffer(Load Buffer)的结构,在Load Commit之前检测冲突,Load过的地址是否又被其他CPU核心写过(没有存在失效信息)。只要没有冲突,这种乱序就是安全的。如果发生冲突,这种乱序就违反x86要求,需要被取消并Flush流水线。而上文提到的lfence指令会刷新Load Buffer,保证当前CPU核心立即读取到最新的数据。
另外, 除了显式的内存屏障指令,有些指令也会造成指令保序的效果,比如I/O操作的指令、exch等原子交换的指令,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。
说了这么多,环形队列(Ring buffer)在IA-32/AMD64架构上到底怎么实现才能保证安全?Linux Kernel里的KFIFO的实现可以拿来参考(include/linux/kfifo.h):
1
|
unsigned int __kfifo_put(struct kfifo *fifo, const unsigned char *buffer, unsigned int len)
|
代码中的smp_wmb()
、smp_rmb()
和smp_mb()
在AMD64架构上分别对应sfence
、lfence
、mfence
指令。但是Linux Kernel的代码要兼容所有的SMP架构,还要考虑很多弱内存模型的架构。所以这里的内存同步操作很多,但是不一定在AMD64上是必要的。当然,如果要考虑跨平台跨架构的代码,这样做是最保险的(另外Linux Kernel 4.0上KFIFO这个数据结构变化很大,内存同步操作也仅剩下smp_wmb()
,这个还没顾得上研究)。
如果IA-32/AMD64架构下,Ring Buffer如果要实现单Reader和单Writer不需要内存同步,需要满足哪些特性呢?
以下面的定义为例:
1
|
struct ring_buffer {
|
首先,read_index
和write_index
的写入操作必须是原子的,这就要求这两个变量本身在P6 Family及以后的CPU上至少是不能跨Cache行的。同时如果是32-bit的变量则P6之前的CPU还要保持32-bit字节对齐,如果是64-bit变量在IA-32上无法保障(IA-32下64bit的变量Store操作不是原子的)。另外,为了避免False Sharing,这两个变量最好按照Cache行对齐,即:
1
|
struct ring_buffer {
|
然后在入队和出队的地方插入编译屏障禁止掉编译器优化,根据Intel的文档,就能保证不会出现乱序问题:
主存写操作不能跟其他的写操作排序,但是以下情况除外:
带有CLFLUSH(失效缓存)指令的写操作
带有non-temporal move指令的流存储(写入)(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, 和 MOVNTPD,都是SSE/SSE2扩展的指令)
字符串操作(REP STOSD等)
在多处理器的情况下,单处理器内部的内存访问排序仍然依照以上的原则,并且规定处理器与处理器之间遵循如下的原则:
- 某个处理器的全部写操作以同样的顺序被其它处理器观察到
- 第三方总是观察到一致的写操作顺序
至于串操作,对buffer的修改可能是memcpy之类的操作,而对index的操作是普通赋值。memcpy在某些库中的实现使用了串操作指令又会怎样?会导致Store操作乱序吗?Intel有如下的说明:
所以不担心index的修改出现在rep:stosd之前。但是这样做是有这样的前提的,即Reader和Writer当前的修改不需要立即被对方知晓,即允许一段时间内的“不一致”。否则,必然需要内存屏障来确保修改操作全局一致。
以上的结论很容易引起口水仗,所以这里再次强调该结论只是在AMD64架构下,且不考虑可移植性的情况下成立。但是,按照我个人看法,这几个屏障指令不见得在所有Intel的CPU上都是有意义的,甚至有些屏障指令在Intel某些CPU上没有该屏障本身的语义。比如lfence本意是限制Load重排,然而AMD64就没有Load-Load乱序(内存可见性另说)。这几个屏障指令更像是Intel提供给软件开发者的一个Interface,在需要加屏障的地方让开发者加吧。至于实际上需不需要,CPU本身会判断,如果不需要的话直接由CPU直接NOP掉即可。这也是一种长远的考虑,那你问我在AMD64架构的CPU上写代码的时候,需要强一致的时候加不加屏障?那当时是要加的。按照Interface写代码是最保险的,万一Intel以后出一个采用弱一致模型的CPU(替被市场淘汰的IA-64默哀三分钟),遗留代码出点问题就不好了。
下面说说锁和原子变量。对于数据竞争(Data Races)的情况,最简单和最常见的场景就是使用Mutex了,包括并不限于互斥锁、自旋锁、读写锁等。拿互斥锁来说,除了保护临界区只能有一个执行流之外,还有其他的作用。这里要引入宽松的内存一致性模型(Relaxed Memory Consistency Models)中的Release Consistency模型[6]来解释,这个模型包含了同步操作Acquire和Release:
- Acquire: 在此操作后的所有读写操作必然发生在Acquire这个动作之后
- Release: 在此操作前的所有读写操作必然发生在Release这个动作之前
要注意的是Acquire和Release都只保证了一半的顺序:
- 对于Acquire来说,并没保证Acquire前的读写操作不会发生在Acquire动作之后
- 对于Release来说,并没保证Release后的读写操作不会发生在Release动作之前
因此Acquire和Release的组合便形成了内存屏障。
Mutex的Lock操作暗含了Acquire语义,Unlock暗含了Release语义。这里是脱离架构在讨论的,在具体的平台上如果Load和Store操作暗含Acquire和Release语义的话自然保证一致,否则可以是相关的内存屏障指令。所以Mutex不仅会保证执行的序列化,同时也保证了访存的一致性。与之类似,平台提供的原子变量除了保证内存操作原子之外,也会保证访存的一致性。
GCC提供了Built-in的原子操作函数可以使用,GCC 4以后的版本也提供了Built-in的屏障函数__sync_synchronize()
,这个屏障函数既是编译屏障又是内存屏障,代码插入这个函数的地方会被安插一条mfence
指令。不过GCC 4.4以上才支持mfence
,这个问题的讨论(bug?)在这里,Patch在这里。
实际上无锁的代码仅仅是不需要显式的Mutex来完成,但是存在数据竞争(Data Races)的情况下也会涉及到同步(Synchronization)的问题。从某种意义上来讲,所谓的无锁,仅仅只是颗粒度特别小的“锁”罢了,从代码层面上逐渐降低级别到CPU的指令级别而已,总会在某个层级上付出等待的代价,除非逻辑上彼此完全无关。另外,Lockfree
和Lockless
是两个概念,但这个话题太大,我个人尚且拿捏不住,就此打住。至于工程上,普通的程序员老老实实的用Mutex就好了,普通的计数类场景用原子变量也无可厚非。诸如无锁队列这种能明确证明其正确性的数据结构在一些场合也是很有价值的,用用无妨(但是多说一句,CAS这种乐观锁在核数很多的时候不见得高效,竞争太厉害的时候总体消耗很可能超出普通的锁)。但是如果不能做到在任何时候都能想明白顺序一致性的话,还是老老实实的用Mutex吧,否则造成的麻烦可比提升的这一点点效率折腾多了。
最后,讨论这些问题的文章太多了,各路说法到处飞,我也不敢保证这篇文章的说法全部正确,但至少我觉得是可以自圆其说的。如果你觉得哪里的描述有问题,不妨一起讨论,我们一起纠正这些错误的观点。
文章的撰写过程中参考了若干资料,下面列出的参考的资料和文章中,个别文章我只是“部分同意”原作者的观点,因为引用了作者部分说法,所以一并列出。这不代表我完全同意原作者观点,具体细节请读者自行判断(有了冲突,自然是以Intel最新文档的说法为准)。
参考文献
[1] Intel® 64 and IA-32 Architectures Software Developer’s Manual, https://software.intel.com/en-us/articles/intel-sdm
[2] Memory Barriers/Fences, https://mechanical-sympathy.blogspot.jp/2011/07/memory-barriersfences.html
[3] Memory Barriers: a Hardware View for Software Hackers, Paul E. McKenney, Linux Technology Center, IBM Beaverton, https://www.researchgate.net/publication/228824849_Memory_Barriers_a_Hardware_View_for_Software_Hackers
[4] 为什么程序员需要关心顺序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence)?, http://www.parallellabs.com/2010/03/06/why-should-programmer-care-about-sequential-consistency-rather-than-cache-coherence/
[5] 一个关于Memory Reordering的实验, http://blog.csdn.net/yxc135/article/details/11747995
[6] 计算机体系结构:量化研究方法, (美)亨尼西 等著, 机械工业出版社, 2012-1-1
[7] http://stackoverflow.com/questions/23603304/java-8-unsafe-xxxfence-instructions
[8] Intel Sandy Bridge Configuration, http://www.7-cpu.com/cpu/SandyBridge.html
[9] Intel’s Haswell CPU Microarchitecture, http://www.realworldtech.com/haswell-cpu/5/
[10] Write Combining, http://mechanical-sympathy.blogspot.com/2011/07/write-combining.html
[11] Memory ordering, https://en.wikipedia.org/wiki/Memory_ordering
Linux 环境下多线程 C/C++ 程序的内存问题调试
背景
很久以前写过一篇调试内存错误的文章《用gdb配合内核转储文件瞬间定位段错误》,这是最基本的C/C++程序内存错误的定位方法。简单的coredump问题一般都是普通的内存访问错误,通过这个方法都很容易定位到相关的代码bug并修复。
但是在具体实践中,往往会有一些复杂的内存访问错误,尤其是多线程环境下的C/C++程序。因为其内存的分配、释放与访问经常会牵扯到多个线程,容易引入复杂而难以定位的内存错误,导致程序在执行过程中错误的访问内存而被操作系统结束掉。
这篇文章介绍一些常见的内存错误和调试的步骤和方法,以及一些多线程程序避免内存问题的实践经验。
常见的内存错误举例
C/C++程序被称之为系统编程语言,往往编译成操作系统直接支持的可执行文件格式。C/C++语言本身没有垃圾回收机制,内存的动态分配与释放需要程序自行控制,对内存的访问也没有语言级别的校验和保护。出现内存访问错误后,进程多半会直接被操作系统结束掉。少部分情况因为访存地址合法,会对数据造成破坏(悬垂指针或者野指针),一般会在运行一段时间后才因为异常退出。这时候触发错误导致进程退出的代码位置往往不是”案发的第一现场“,给调试工作带来了更大的难度。
常见的内存访问错误有以下几种:
- 写内存越界(MO,Memory Overrun)
- 读写非法内存,本质上也属于内存越界(IMR / IMW, Invalid Memory Read / Invalid Memory Write)
- 栈溢出,也属于内存越界的一种(SO,Stack Overflow)
- 访问未初始化内存(AUM,Access Uninitialized Memory)
- 释放内存的参数为非法值(Wild Free)
- 内存释放两次(DF,Double Free)
- 函数访问指向被调用函数的栈内内存的指针(UaR,Use after Return)
- 内存释放后使用(UaF,Use after Free)
- 内存泄露(ML,Memory Leak)
上面这些都是一些抽象程度比较高的错误描述,具体到C/C++语言上面,会有更具体的错误,例如:
- 读取未初始化过的变量
- 野指针/悬垂指针读写
- 错误的指针类型转换
- 从已分配内存块的尾部进行读/写(数组等类型读写越界)
- 不匹配地使用 malloc/new/new[] 和 free/delete/delete[]
内存问题定位步骤
问题重现
第一步是问题重现。只要是可以稳定重现的bug都是很好解决的。开启Linux coredump,如果能稳定重现几次问题的话,就可以转到第二步了。如果是难以重现的bug,就要想办法模拟现场来制造coredump了。譬如完整的回归测试,完整的压力测试往往都是有效的。
如果测试case并没有覆盖到可以重现出问题的场景,或者是诸如线下没问题,线上必coredump的情况,可以在线上进行模拟。模拟方法通常都是搭建测试环境,使用tcpcopy
等工具在线上引流到测试机器进行压测,如果常规流量达不到重现标准,可以对流量进行放大。若线上搭建环境测试有困难,可以对线上流量抓包,然后在线下重放(tcpdump
、tcpreplay
和tcprewrite
等工具)。
这一步之后,一般情况下都能增大重现的概率。如果还难以重现,往往都是一些代码本身的竞态条件(Race Condition)造成的,一般需要在引流测试的同时对CPU或者IO加压,以增大资源竞争的概率来增加问题复现的概率。甚至有些问题是出现网络抖动等情况下,需要模拟弱网络的环境(Linux 2.6内核以上有netem
模块,可以模拟低带宽、传输延迟、丢包等情况,使用tc
这个工具就可以设置netem
的工作模式)。
至此,我们认为问题可以较容易复现且收集了足够多的coredump样本了。
gdb + coredump文件 + code review
有了足够多的样本后,就是gdb载入观察了,常用的命令有查看调用栈的bt
,查看线程、局部变量、寄存器等信息的info
等,使用bt
打出调用栈后,f [n]
切换到相应的调用层查看变量的值。配合代码review就能解决绝大多数普通的内存问题。如果说触发coredump的位置已经不是”案发的第一现场“,就需要用print
和x
等命令查看触发内存错误的指针值以及指针所在内存区域前后若干范围的值,往往会留下”杀手“代码的一些蛛丝马迹。此时的可能性一般有以下几种:
- 野指针
- 指针所在内存被其他代码非法修改(越界或者其他野指针误伤)
- 释放内存的参数为非法值(Wild Free),也可能是上一条原因导致
-
悬垂指针
- 内存释放后使用(UaF,Use after Free)
- 内存释放两次(DF,Double Free),第二次释放导致coredump
这类问题一般较难定位,尤其是野指针,某次内存的越界读写可能要在很久之后才会暴露出来。一般的调查手段难以奏效,需要上一些内存检查工具来辅助查找问题。
内存问题检查工具
C/C++代码的内存访问检查工具有很多,从非代码侵入式的工具到需要重新编译源程序的工具库都有。每个工具都有自己的一些检查的侧重点,不同的情况要选择不同的工具。如果难以判断问题来源,可以用的工具逐个尝试也是一种办法。后文中会逐一解读这些内存检查工具,并给出使用的方法和测试demo程序。
二分法 + code review
如果以上的方法都难以奏效的话,就只有最原始的方法了,在历史提交里通过二分法定位出问题提交,逐行进行代码review分析,推测所有的关联数据结构和多线程可能造成的静态条件。这是最后的方法了,把开发们全部关到小黑屋里,结合收集的coredump文件,画出数据结构关联图,专心解决问题。
常见的内存问题检查工具
glibc MALLOC_CHECK_
较新版本的glibc本身(其实准确讲glibc的内存分配的部分叫PtMalloc,本文用glibc指代PtMalloc)就有一些简单的内存检查或者保护的机制,环境变量里定义了MALLOC_CHECK_
检查宏的情况下对一些诸如 double free 的问题都能直接识别定位出来。支持的值有:
- 0 - 不产生错误信息,也不中止这个程序
- 1 - 产生错误信息,但是不中止这个程序
- 2 - 不产生错误信息,但是中止这个程序
- 3 - 产生错误信息,并中止这个程序
在我的机器上默认并没有设置这个环境变量,但是默认的行为是配置3的行为。下面举几个例子,比如这样的代码:
1
|
|
执行后直接崩溃并提示 double free or corruption 的错误:
另外释放无效的指针也会有相应的错误,例如下面的代码:
1
|
|
执行后崩溃并提示 free(): invalid pointer 的错误:
原理想来也很简单,实际上malloc(3)
分配的内存会比用户实际申请的长度大一点,在返回给用户代码的指针位置的前面有一个固定大小的结构,放置着该块内存的长度、属性和管理的数据结构。试想调用free(3)
的时候,并没有传入指针的长度,因为内存的长度记录在这个前置的管理结构里。那么只要在这个结构里放置一个校验的字段,标识出这块内存的状态是未分配,已分配还是已经释放。在调用free(3)
的时候,回退指定的长度来检查这个字段,就能识别出double free
或者invalid point
等错误了。如果很巧合的是,释放的错误指针前面的数据正好满足这个校验,就会导致glibc错误的执行释放,导致glibc管理内存的结果破坏掉。理想的结果是就地崩溃,否则执行下去,崩溃的位置就不可预计了。
但是对于申请内存的越界访问,比如如下的代码就无能为力了。
1
|
|
对于这种内存的访问错误,可以使用下面的工具。
Electric Fence(LD_PRELOAD=/usr/lib64/libefence.so)
Electric Fence 是一个内存调试库,原理是采用Linux的虚拟内存机制来保护动态分配的内存,在申请的内存的位置放置只读的哨兵页,在程序越界读写时直接coredump退出。具体信息可以参考维基百科的介绍:Electric Fence.
使用的方式很简单,直接在编译的命令行添加-lefence
来链接该库即可(红帽系的Linux用yum安装ElectricFence库即可)。或者可以利用Linux动态链接库的PRELOAD机制来使用LD_PRELOAD
宏来预先载入libefence.so进行内存保护。
执行刚才程序的结果如下:
运行后产生了core文件,gdb载入看看:
这样就检测出更多的内存访问错误了。
Electric Fence 的缺点也很明显,因为对内存做保护使用了mprotect(2)
等API,这个API对内存设置只读等属性要求内存页必须是4K对齐的(本质上是Intel CPU的页属性设置的要求),所以内存使用率较低的程序可以用该库进行检查,但是内存使用率很高的程序在使用过程中会造成内存暴涨而不可用。另外实践中发现,使用该库后程序性能下降极其厉害,说百倍都不夸张,所以可用性不是很高。但是检查一些简单程序的内存访问还是很易用的。
Dmalloc
Dmalloc 类似Electric Fence,支持十多种操作系统,相比Electric Fence在性能上有较大提高。但是使用的话要求包含库的头文件后重新编译程序,略有点不便。其官方网站上有详细的功能说明和文档,本文就不照搬了。
Valgrind
Valgrind 是一套Linux下的仿真调试工具的集合。Valgrind 由内核以及基于内核的其他调试工具组成。内核类似于一个框架。它模拟了一个CPU环境,并提供服务给其他工具。而其他工具则类似于插件,利用内核提供的服务完成各种特定的内存调试任务。
Valgrind包括如下一些工具:
- Memcheck:这是valgrind应用最广泛的工具,一个重量级的内存检查器,能够发现开发中绝大多数内存错误使用情况,比如:使用未初始化的内存,使用已经释放了的内存,内存访问越界等。
- Callgrind:它主要用来检查程序中函数调用过程中出现的问题。
- Cachegrind:它主要用来检查程序中缓存使用出现的问题。
- Helgrind:它主要用来检查多线程程序中出现的竞争问题。
- Massif:它主要用来检查程序中堆栈使用中出现的问题。
- Extension:可以利用core提供的功能,自己编写特定的内存调试工具。
这里只举一个使用的例子。将上文的代码修改为:
1
|
|
Valgrind检查不需要重新编译程序,直接载入运行。结果如下:
运行结果指出了代码第7行有1个越界的写,最后退出的时候有20 bytes的内存没有释放掉,这块内存是在代码第6行申请的(内存泄露)。Valgrind对程序运行的效率也有一些影响,但是实测比 Electric Fence 要强很多。
关于Valgrind的文章实在是太多了,本文重复一遍也没什么意义。所以Valgrind的使用参考官方文档或者网上的教程即可,本文不再赘述。
gcc 命令行参数 -fsanitize=address -fno-omit-frame-pointer
新版本的gcc(gcc49)提供了很好的内存访问检查机制,实践中发现对性能的影响居然比Valgrind小很多。在实践中 Electric Fence 和 Valgrind 严重影响了程序的性能,难以触发内存访问问题,而gcc的-fsanitize=address
编译参数解决了大问题,唯一的缺点是gcc高版本才支持,而实践中,生成环境的代码都是老版本编译器编译的。
闲话不表,重新编译和运行程序,结果如下:
输出的结果也明确表示了越界的问题代码的位置在第7行,并且给出了越界位置的内存数据(红色部分)。另外第6行申请的内存泄露了。
实践中发现新版本gcc的这个功能很给力,性能衰减比Valgrind都小(但是还是很明显的慢),基本上编译后的服务组件是可以勉强跑压测的。添加gcc的检查参数,编译后完整的跑一遍单元测试和性能测试,就能发现内存访问的错误。
mprotect 或 gdb 内存断点(注意Linux地址空间随机化)
如果以上的工具都没办法解决的问题,多半都是在高并发情况下出现的一些竞态条件引起的。如果在编码阶段没有从理论上处理好多线程竞争的竞态条件,就给后期的调试埋下了很深的坑。多线程编程经验后面再说,这里先讨论如果真到了这一步怎么办。
既然内存检查工具无法重现问题,多半都是这些工具对测试程序造成的性能衰减引起的(先确认是否覆盖了所有的case和代码执行路径,是否执行过了会触发问题的代码路径)。那么这时候一般有两个方法,第一是寄希望于代码的静态扫描工具,这个谷歌下有很多,这里不讨论;另一个就是本节要说的:mprotect 或 gdb 内存断点。既然问题只有在高并发且性能不衰减的情况下触发,那么采用的手段就不能影响或者基本上不影响程序的性能。
如果程序每次core的位置都很固定或者位置相对固定的话,可以使用mprotect(2)
系统调用:
1
|
|
具体的参数和demo可以man 2 mprotect
查看,这里不照搬man文档了。
mprotect(2)
的限制也很明显,需要页对齐的地址(因为Intel对页属性设置的限制)。所以分配内存时就比较麻烦一点,需要合理的计算位置,将出现问题时会导致被破坏的地址囊括在保护范围,又不能影响其他代码正常执行。由于这个限制,会对mprotect(2)
的使用场景有一些限制,但是对于固定会越界的代码位置来说,计算好数据位置,使得越界后第一个字节的内存起始的内存页就处于写保护中就可以了。随后像man文档的例子一样注册SIGSEGV
信号的处理函数即可,这里可以用backtrace(3)
和backtrace_symbols(3)
等函数来打出调用栈,轻松找过越界的罪魁祸首。
最后是看似最原始最简单,但是依旧很给力的gdb内存断点。gdb调试支持对内存位置设置修改断点,不用自己很麻烦的设置内存保护和信号处理函数。而且gdb的内存断点不像直接用mprotect(2)
有那么多限制(简单的翻了下代码,gdb用的是Intel CPU的调试寄存器实现,照着Intel文档写一个也没有多困难)。
下面演示下简单的使用方法,先看一段简单示例代码:
1
|
|
另一个线程越界访问了buffer区域,下面用gdb的内存断点测试下,结果如下:
注意多线程调试的话要决定是都让gdb锁定调度,另外如果要监视子进程,运行程序前还要设置set follow-fork-mode child
属性。如果是自己编码实现调试的话,关系到内存地址的一些操作,建议启动程序前禁用掉Linux的地址空间随机化机制(ASLR,Address space layout randomization),避免麻烦(gdb自己会设置,不需要关注)。ASLR是防御缓存区溢出的保护措施,关闭的方法很简单:
1
|
sudo sysctl -w kernel.randomize_va_space=0
|
总结和经验
内存定位工具使用经验
一般情况下遇到了内存泄露(ML,Memory Leak)
的问题,简单看看最近新增代码的内存申请与释放部分,大多数能看出来。如果看不出来,直接用Valgrind运行检测, 很容易检查出来。另外遇到了不匹配地使用 malloc/new/new[] 和 free/delete/delete[]
的问题,Valgrind也很容易检查出来。使用二方库和三方库的时候,很容易犯错误,如果遇到了库内申请的内存需要用户代码自己释放的情况(个人反感这种设计),一定要认真阅读文档,必要时查看源代码确认。
内存泄露(ML,Memory Leak)
的问题至今还没有遇到Valgrind检查不出来的情况,道听途说过有人遇到过高并发下出现泄漏的情况,用Valgrind拖慢了程序,查不出来。这时候可以重载下全局的malloc / free函数,申请和释放内存的时候打印函数和返回地址(用异步日志库),运行一段时间后写代码处理日志,找到泄漏点即可。
若是coredump的问题,用gdb载入coredump文件和代码先行分析,一般能解决。如果解决不了直接上Valgrind跑跑看。如果Valgrind检查不出来,Electric Fence一般是不用试的。可以直接上DMalloc了,重新编译程序后测试。如果再不行,推荐gcc的-fsanitize=address -fno-omit-frame-pointer
参数编译再运行测试。如果这些简单无脑的办法解决不了,就踏踏实实的分析代码,重现出足够多的coredump样本分析蛛丝马迹。然后用mprotect(2)
保护相关的内存,设置SIGSEGV
信号的处理函数。最后,有些越界之类的问题没那么复杂, gdb载入后设置内存断点,很容易就能找出罪魁祸首,很少需要自己用mprotect(2)
之类的手段。所以玩好gdb就能解决很多问题了。
多线程编程经验
多线程编程是个很大的话题,这篇文章不准备细说,只给出一点参考建议。首先是教材,C/C++的多线程编程的经典教材很少,近些年也就一本《C++ Concurrency in Action(中文版:C++并发编程实战)》还算能看,但是说实话也不怎么样。倒是Java领域有很多并发编程的大作,比如《Java Concurrency In Practice(中文版:Java并发编程实践)》很不错。另外Java的concurrent库值得一读,我自己很多C++的并发的数据结构就是照抄concurrent翻译成C++的。C++程序员反过来向Java学习并发多少有点讽刺,C++11标准明确了内存模型之后,希望在并发领域能诞生些大作吧。
接着说说经验,并发的代码不是那么容易写的,如果不能准确的判断出竞态条件的话,不建议去写并发的代码。否则绝对是给自己找不自在。如果避免不了,一定要有完整的理论学习之后再上手,多分析多思考,多读优秀的并发实现(C++没有代表性的代码的话就去读Java的concurrent库,学着改成C++版的)。除此之外,C++代码尽可能的避免在线程间共享对象(单例除外),尽可能的使用成熟的并发模式和数据结构减少直接的对象共享,比如ConcurrentHashMap、NonBlockingQueue、BlockingQueue、CountDownLatch等。
C++在线程间共享对象有很多麻烦,尤其是析构函数面临的一系列竞态条件。详细的描述可以围观陈硕的大作《当析构函数遇到多线程》。shared_ptr
这种引用技术型智能指针成为主流用法还需要时间,但是可以预见的是会被更多的人接受。至于shared_ptr
和weak_ptr
的使用难度,最多几个小时认真学习和实践就能掌握。
最后,总结一句最精炼的C/C++多线程编程经验,那就是——别用C++写并发代码,工程中最好就别用C++这个语言。
用 gdb 配合内核转储文件瞬间定位段错误
前几天在写一个使用Huffman算法的文本压缩程序时被“段错误”折磨了好长时间。因为自己向来对内存的使用保持着“克勤克俭”的作风,所以总是被此类错误折磨的焦头难额。C语言的内存管理本来就是一个繁琐的工作,写代码时略有不慎便会出现诸如“段错误(吐核)”的运行时崩溃。
其实段错误是操作系统的一个内存保护机制,一般情况下某程序尝试访问其许可范围之外的内存空间时便会触发内核的“一般保护性异常”,内核便会向程序发送一个SIGSEGV(11)信号(无效的内存引用),而SIGSEGV信号默认handler的动作便是在终端上打印出名为“段错误”的出错信息,并产生Core(内核转储)文件,最后结束掉当前犯错的程序。
段错误的成因大致有以下几种:
- 程序访问了系统数据区,尤其是往系统保护的内存地址写数据。比如尝试对NULL指针进行解引用或者对其指向的内存写入数据(但是不见得所有的指针越界都会触发“段错误”);
- 内存访问越界(数组越界等);
- 无限的递归(导致栈溢出);
- 对malloc / calloc申请的堆内存二次释放(可能与glibc库版本有关);
- 由于操作系统的段保护机制,如果由于缓冲区溢出等错误导致对某段内存的非法访问也会触发;
另外还有一些大家平时不大注意的地方会导致段错误,例如使用标准库函数fclose对一个打开的文件关闭了多次也会导致段错误,同时终端可能会输出很多关于运行时库错误的信息。因为对使用malloc族函数申请的堆内存释放第二次的时候会触发段错误,所以我猜测fclose触发段错误的原因可能是对文件指针FILE *指向的内存二次释放时触发的段错误。而Valgrind检测的结果基本上证明了我的猜测,fclose引发了堆异常,错误被定位到了free函数。
1
|
==5715== Invalid free() / delete / delete[] / realloc()
|
Valgrind是一款用于内存调试、内存泄漏检测以及性能分析的软件开发工具。遗憾的是它只能检测到堆里的内存泄漏和越界访问,对于栈里的内存访问错误爱莫能助(如果你对这里堆栈等概念有疑问,请参阅百度百科词条“堆栈”,至于为什么不推荐维基百科…因为关于这个词条,百度百科的资料更全一些)。关于Valgrind的具体使用方法超出了本文讨论范围,有兴趣的读者请自行Google。另外,关于“段错误”的介绍不再赘述,毕竟我们现在讨论的重点不是“段错误”的前世今生。
言归正传,我们前面提到,当一个程序出现内存异常访问后会触发内核的“一般保护性异常”,内核会向程序发送一个SIGSEGV(11)信号(无效的内存引用),而SIGSEGV信号的默认handler的动作便是在终端上打印出名为“段错误”的出错信息,并产生Core(内核转储)文件,最后结束掉当前犯错的程序。重点在这里,那个所谓的Core(内核转储)文件是什么东西呢?通过查阅man文档(man 5 core)我们得知了在程序崩溃时,它一般会在目录下生成一个core文件。core文件是该程序在内存中的映象(同时还会有一些调试信息包含在内)。而某些系统默认设置是不生成core文件的,我们可以在终端下输入ulimit -a 命令查看设置。
1
|
hurley@hurley-fedora ~$ ulimit -a
|
可以看到我的当前设置(fedora 16)把core文件大小被限制为0了(不生成core文件)。我们可以在终端下执行ulimit -c 1024修改限制(我的系统在重启后该设置又被重置为0,所以每次调试前都要设置,不知道为什么…)。
设置好了以后我们来制造一个会触发“段错误”的程序吧…
代码很简单:
1
|
|
很显然,编译运行后“段错误(吐核)”
1
|
hurley@hurley-fedora
|
我们使用gcc重新编译,这次要加上 -g 和 -rdynamic参数,-g我们都知道是加入调试信息,那 -rdynamic呢?它的作用是用来通知链接器将所有符号添加到动态符号表中(具体请查阅 man文档,关于链接这块的知识除了经典的《Linkers and loaders》之外,国产的《程序员的自我修养——链接、装载与库》也值得一读)。
1
|
hurley@hurley-fedora
|
我们看到了程序所在目录下生成了一个名为core.6864的内核转储文件,就是它了。如果你没有找到这个文件,那么请往上翻找找我关于ulimit的说明。
接下来我们用gdb开始调试,命令行如下,注意最后要加上那个内核转储文件。
1
|
hurley@hurley-fedora segment-test$ gdb ./test core.6864
|
我们什么也没有做,gdb就定位到了这个*p = ‘a’; 触发了异常,并且明确的告诉了我们这行代码位于test.c的第26行,main函数里,同时gdb告诉我们程序接收到了11号信号退出,原因是段错误。
难不成就这么简单吗?是的,就这么简单。实际的使用中我发现有时候不需要内核转储文件gdb也能直接定位到错误点,这一点比起vc那个调试器来毫不逊色。
这篇文章到这里就告一段落了,貌似关键内容就这么一点。好吧,其实段错误的处理并不复杂,之前的纠结完全是因为自己没有掌握方法罢了…
如果你也被数不尽的“段错误”所纠结着 ,希望这篇文章能帮到你。如果你觉得这篇文章太水了……好吧,我承认,它确实很水…
当析构函数遇到多线程 ── C++ 中线程安全的对象回调
陈硕 (giantchen_AT_gmail)
本文 PDF 下载: http://www.cppblog.com/Files/Solstice/dtor_meets_mt.pdf
摘要
编写线程安全的类不是难事,用同步原语保护内部状态即可。但是对象的生与死不能由对象自身拥有的互斥器来保护。如何保证即将析构对象 x 的时候,不会有另一个线程正在调用 x 的成员函数?或者说,如何保证在执行 x 的成员函数期间,对象 x 不会在另一个线程被析构?如何避免这种竞态条件是 C++ 多线程编程面临的基本问题,可以借助 boost 的 shared_ptr 和 weak_ptr 完美解决。这也是实现线程安全的 Observer 模式的必备技术。
本文源自我在 2009 年 12 月上海 C++ 技术大会的一场演讲《当析构函数遇到多线程》,内容略有增删。原始 PPT 可从 http://download.csdn.net/source/1982430 下载。
本文读者应具有 C++ 多线程编程经验,熟悉互斥器、竞态条件等概念,了解智能指针,知道 Observer 设计模式。
目录
1 多线程下的对象生命期管理................................................................................... 2
一个线程安全的 Counter 示例............................................................................ 3
7 插曲:系统地避免各种指针错误........................................................................... 10
与其他面向对象语言不同,C++ 要求程序员自己管理对象的生命期,这在多线程环境下显得尤为困难。当一个对象能被多个线程同时看到,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件:
- 在即将析构一个对象时,从何而知是否有另外的线程正在执行该对象的成员函数?
- 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
- 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会刚执行到一半?
解决这些 race condition 是 C++ 多线程编程面临的基本问题。本文试图以 shared_ptr 一劳永逸地解决这些问题,减轻 C++ 多线程编程的精神负担。
依据《Java 并发编程实践》/《Java Concurrency in Practice》一书,一个线程安全的 class 应当满足三个条件:
- 从多个线程访问时,其表现出正确的行为
- 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
- 调用端代码无需额外的同步或其他协调动作
依据这个定义,C++ 标准库里的大多数类都不是线程安全的,无论 std::string 还是 std::vector 或 std::map,因为这些类通常需要在外部加锁。
与 MutexLock
为了便于后文讨论,先约定两个工具类。我相信每个写C++ 多线程程序的人都实现过或使用过类似功能的类,代码从略。
Mutex 封装临界区(Critical secion),这是一个简单的资源类,用 RAII 手法 [CCS:13]封装互斥器的创建与销毁。临界区在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默认是不可重入的。Mutex 一般是别的 class 的数据成员。
MutexLock 封装临界区的进入和退出,即加锁和解锁。MutexLock 一般是个栈上对象,它的作用域刚好等于临界区域。它的构造函数原型如下。
1
|
explicit MutexLock::MutexLock(Mutex& m); |
这两个类都不允许拷贝构造和赋值。
示例
编写单个的线程安全的 class 不算太难,只需用同步原语保护其内部状态。例如下面这个简单的计数器类 Counter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
class Counter : boost::noncopyable { // copy-ctor and assignment should be private by default for a class. public : Counter(): value_(0) {} int64_t value() const ; int64_t increase(); int64_t decrease(); private : int64_t value_; mutable Mutex mutex_; } int64_t Counter::value() const { MutexLock lock(mutex_); return value_; } int64_t Counter::increase() { MutexLock lock(mutex_); int64_t ret = value_++; return ret; } // In a real world, atomic operations are perferred. // 当然在实际项目中,这个 class 用原子操作更合理,这里用锁仅仅为了举例。 |
这个 class 很直白,一看就明白,也容易验证它是线程安全的。注意到它的 mutex_ 成员是 mutable 的,意味着 const 成员函数如 Counter::value() 也能直接使用 non-const 的 mutex_。
尽管这个 Counter 本身毫无疑问是线程安全的,但如果 Counter 是动态创建的并透过指针来访问,前面提到的对象销毁的 race condition 仍然存在。
对象构造要做到线程安全,惟一的要求是在构造期间不要泄露 this 指针,即
- 不要在构造函数中注册任何回调
- 也不要在构造函数中把 this 传给跨线程的对象
- 即便在构造函数的最后一行也不行
之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果 this 被泄露 (escape) 给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// 不要这么做 Don't do this. class Foo : public Observer { public : Foo(Observable* s) { s-> register ( this ); // 错误 } virtual void update(); }; // 要这么做 Do this. class Foo : public Observer { // ... void observe(Observable* s) { // 另外定义一个函数,在构造之后执行 s-> register ( this ); } }; Foo* pFoo = new Foo; Observable* s = getIt(); pFoo->observe(s); // 二段式构造 |
这也说明,二段式构造——即构造函数+initialize()——有时会是好办法,这虽然不符合 C++ 教条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用端靠 initialize() 的返回值来判断对象是否构造成功,这能简化错误处理。
即使构造函数的最后一行也不要泄露 this,因为 Foo 有可能是个基类,基类先于派生类构造,执行完 Foo::Foo() 的最后一行代码会继续执行派生类的构造函数,这时 most-derived class 的对象还处于构造中,仍然不安全。
相对来说,对象的构造做到线程安全还是比较容易的,毕竟曝光少,回头率为 0。而析构的线程安全就不那么简单,这也是本文关注的焦点。
对象析构,这在单线程里不会成为问题,最多需要注意避免空悬指针(和野指针)。而在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行,也就是让每个函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把互斥器销毁掉。悲剧啊!
不是办法
Mutex 只能保证函数一个接一个地执行,考虑下面的代码,它试图用互斥锁来保护析构函数:
Foo::~Foo() { MutexLock lock(mutex_); // free internal state (1) } |
void Foo::update() { MutexLock lock(mutex_); // (2) // make use of internal state } |
extern Foo* x; // visible by all threads |
|
// thread A delete x; x = NULL; // helpless |
// thread B if (x) { x->update(); } |
有 A 和 B 两个线程,线程 A 即将销毁对象 x,而线程 B 正准备调用 x->update()。尽管线程 A 在销毁对象之后把指针置为了 NULL,尽管线程 B 在调用 x 的成员函数之前检查了指针 x 的值,还是无法避免一种 race condition:
1. 线程 A 执行到了析构函数的 (1) 处,已经持有了互斥锁,即将继续往下执行
2. 线程 B 通过了 if (x) 检测,阻塞在 (2) 处
接下来会发生什么,只有天晓得。因为析构函数会把 mutex_ 销毁,那么 (2) 处有可能永远阻塞下去,有可能进入“临界区”然后 core dump,或者发生其他更糟糕的情况。
这个例子至少说明 delete 对象之后把指针置为 NULL 根本没用,如果一个程序要靠这个来防止二次释放,说明代码逻辑出了问题。
前面的例子说明,作为 class 数据成员的 Mutex 只能用于同步本 class 的其他数据成员的读和写,它不能保护安全地析构。因为成员 mutex 的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)。另外,对于基类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的 mutex 不能保护整个析构过程。再说,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的,否则会有第 1 节谈到的竞态条件发生。
另外如果要同时读写本 class 的两个对象,有潜在的死锁可能,见 PPT 第 12 页的 swap() 和 operator=()。
有多难?
一个动态创建的对象是否还活着,光看指针(引用也一样)是看不出来的。指针就是指向了一块内存,这块内存上的对象如果已经销毁,那么就根本不能访问 [CCS:99](就像 free 之后的地址不能访问一样),既然不能访问又如何知道对象的状态呢?换句话说,判断一个指针是不是野指针没有高效的办法。(万一原址又创建了一个新的对象呢?再万一这个新的对象的类型异于老的对象呢?)
在面向对象程序设计中,对象的关系主要有三种:composition, aggregation 和 association。Composition(组合/复合)关系在多线程里不会遇到什么麻烦,因为对象 x 的生命期由其惟一的拥有者 owner 控制,owner 析构的时候会把 x 也析构掉。从形式上看,x 是 owner 的直接数据成员,或者 scoped_ptr 成员,或者 owner 持有的容器的元素。
后两种关系在 C++ 里比较难办,处理不好就会造成内存泄漏或重复释放。Association(关联/联系)是一种很宽泛的关系,它表示一个对象 a 用到了另一个对象 b,调用了后者的成员函数。从代码形式上看,a 持有 b 的指针(或引用),但是 b 的生命期不由 a 单独控制。Aggregation(聚合)关系从形式上看与 association 相同,除了 a 和 b 有逻辑上的整体与部分关系。如果 b 是动态创建的并在整个程序结束前有可能被释放,那么就会出现第 1 节谈到的竞态条件。
那么似乎一个简单的解决办法是:只创建不销毁。程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放掉,而是放回池子里。这个办法当然有其自身的很多缺点,但至少能避免访问失效对象的情况发生。
这种山寨办法的问题有:
- 对象池的线程安全,如何安全地完整地把对象放回池子里,不会出现“部分放回”的竞态?(线程 A 认为对象 x 已经放回了,线程 B 认为对象 x 还活着)
- thread contention,这个集中化的对象池会不会把多线程并发的操作串行化?
- 如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板?
- 会不会造成内存泄露与分片?因为对象池占用的内存只增不减,而且多个对象池不能共享内存(想想为何)。
回到正题上来,如果对象 x 注册了任何非静态成员函数回调,那么必然在某处持有了指向 x 的指针,这就暴露在了 race condition 之下。
一个典型的场景是 Observer 模式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class Observer { public : virtual ~Observer() { } virtual void update() = 0; }; class Observable { public : void register (Observer* x); void unregister(Observer* x); void notifyObservers() { foreach Observer* x { // 这行是伪代码 x->update(); // (3) } } // ... } |
当 Observable 通知每一个 Observer 时 (3),它从何得知 Observer 对象 x 还活着?
要不在 Observer 的析构函数里解注册 (unregister)?恐难奏效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
struct Observer { virtual ~Observer() { } virtual void update() = 0; void observe(Observable* s) { s-> register ( this ); subject_ = s; } virtual ~Observer() { // (4) subject_->unregister( this ); } Observable* subject_; }; |
我们试着让 Observer 的析构函数去 unregister(this),这里有两个 race conditions。其一:(4) 处如何得知 subject_ 还活着?其二:就算 subject_ 指向某个永久存在的对象,那么还是险象环生:
- 线程 A 执行到 (4) 处,还没有来得及 unregister 本对象
- 线程 B 执行到 (3) 处,x 正好指向是 (4) 处正在析构的对象
那么悲剧又发生了,既然 x 所指的 Observer 对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数(C++ 标准对在构造函数和析构函数中调用虚函数的行为有明确的规定,但是没有考虑并发调用的情况)。更糟糕的是,Observer 是个基类,执行到 (4) 处时,派生类对象已经析构掉了,这时候整个对象处于将死未死的状态,core dump 恐怕是最幸运的结果。
这些 race condition 似乎可以通过加锁来解决,但在哪儿加锁,谁持有这些互斥锁,又似乎不是那么显而易见的。要是有什么活着的对象能帮帮我们就好了,它提供一个 isAlive() 之类的程序函数,告诉我们那个对象还在不在。可惜指针和引用都不是对象,它们是内建类型。
指向对象的原始指针 (raw pointer) 是坏的,尤其当暴露给别的线程时。Observable 应当保存的不是原始的 Observer*,而是别的什么东西,能分别 Observer 对象是否存活。类似地,如果 Observer 要在析构函数里解注册(这虽然不能解决前面提到的 race condition,但是在析构函数里打扫战场还是应该的),那么 subject_ 的类型也不能是原始的 Observable*。
有经验的 C++ 程序员或许会想到用智能指针,没错,这是正道,但也没那么简单,有些关窍需要注意。这两处直接使用 shared_ptr 是不行的,会形成循环引用,直接造成资源泄漏。别着急,后文会一一讲到。
有两个指针 p1 和 p2,指向堆上的同一个对象 Object,p1 和 p2 位于不同的线程中(左图)。假设线程 A 透过 p1 指针将对象销毁了(尽管把 p1 置为了 NULL),那么 p2 就成了空悬指针(右图)。这是一种典型的 C/C++ 内存错误。
要想安全地销毁对象,最好让在别人(线程)都看不到的情况下,偷偷地做。
一个解决空悬指针的办法是,引入一层间接性,让 p1 和 p2 所指的对象永久有效。比如下图的 proxy 对象,这个对象,持有一个指向 Object 的指针。(从 C 语言的角度,p1 和 p2 都是二级指针。)
当销毁 Object 之后,proxy 对象继续存在,其值变为 0。而 p2 也没有变成空悬指针,它可以通过查看 proxy 的内容来判断 Object 是否还活着。要线程安全地释放 Object 也不是那么容易,race condition 依旧存在。比如 p2 看第一眼的时候 proxy 不是零,正准备去调用 Object 的成员函数,期间对象已经被 p1 给销毁了。
问题在于,何时释放 proxy 指针呢?
为了安全地释放 proxy,我们可以引入引用计数,再把 p1 和 p2 都从指针变成对象 sp1 和 sp2。proxy 现在有两个成员,指针和计数器。
1. 一开始,有两个引用,计数值为 2,
2. sp1 析构了,引用计数的值减为 1,
3. sp2 也析构了,引用计数的值为 0,可以安全地销毁 proxy 和 Object 了。
慢着!这不就是引用计数型智能指针吗?
引入另外一层间接性,another layer of indirection,用对象来管理共享资源(如果把 Object 看作资源的话),亦即 handle/body 手法 (idiom)。当然,编写线程安全、高效的引用计数 handle 的难度非凡,作为一名谦卑的程序员,用现成的库就行。
万幸,C++ 的 tr1 标准库里提供了一对神兵利器,可助我们完美解决这个头疼的问题。
shared_ptr 是引用计数型智能指针,在 boost 和 std::tr1 里都有提供,现代主流的 C++ 编译器都能很好地支持。shared_ptr<T> 是一个类模板 (class template),它只有一个类型参数,使用起来很方便。引用计数的是自动化资源管理的常用手法,当引用计数降为 0 时,对象(资源)即被销毁。weak_ptr 也是一个引用计数型智能指针,但是它不增加引用次数,即弱 (weak) 引用。
shared_ptr 的基本用法和语意请参考手册或教程,本文从略,这里谈几个关键点。
- shared_ptr 控制对象的生命期。shared_ptr 是强引用(想象成用铁丝绑住堆上的对象),只要有一个指向 x 对象的 shared_ptr 存在,该 x 对象就不会析构。当指向对象 x 的最后一个 shared_ptr 析构或 reset 的时候,x 保证会被销毁。
- weak_ptr 不控制对象的生命期,但是它知道对象是否还活着(想象成用棉线轻轻拴住堆上的对象)。如果对象还活着,那么它可以提升 (promote) 为有效的 shared_ptr;如果对象已经死了,提升会失败,返回一个空的 shared_ptr。“提升”行为是线程安全的。
- shared_ptr/weak_ptr 的“计数”在主流平台上是原子操作,没有用锁,性能不俗。
- shared_ptr/weak_ptr 的线程安全级别与 string 等 STL 容器一样,后面还会讲。
我同意孟岩说的“大部分用 C 写的上规模的软件都存在一些内存方面的错误,需要花费大量的精力和时间把产品稳定下来。”内存方面的问题在 C++ 里很容易解决,我第一次也是最后一次见到别人的代码里有内存泄漏是在 2004 年实习那会儿,自己写的C++ 程序从来没有出现过内存方面的问题。
C++ 里可能出现的内存问题大致有这么几个方面:
- 缓冲区溢出
- 空悬指针/野指针
- 重复释放
- 内存泄漏
- 不配对的 new[]/delete
- 内存碎片
正确使用智能指针能很轻易地解决前面 5 个问题,解决第 6 个问题需要别的思路,我会另文探讨。
- 缓冲区溢出 ⇒ 用 vector/string 或自己编写 Buffer 类来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。
- 空悬指针/野指针 ⇒ 用 shared_ptr/weak_ptr,这正是本文的主题
- 重复释放 ⇒ 用 scoped_ptr,只在对象析构的时候释放一次
- 内存泄漏 ⇒ 用 scoped_ptr,对象析构的时候自动释放内存
- 不配对的 new[]/delete ⇒ 把 new[] 统统替换为 vector/scoped_array
正确使用上面提到的这几种智能指针并不难,其难度大概比学习使用 vector/list 这些标准库组件还要小,与 string 差不多,只要花一周的时间去适应它,就能信手拈来。我认为,在现代的 C++ 程序中一般不会出现 delete 语句,资源(包括复杂对象本身)都是通过对象(智能指针或容器)来管理的,不需要程序员还为此操心。
需要注意一点:scoped_ptr/shared_ptr/weak_ptr 都是值语意,要么是栈上对象,或是其他对象的直接数据成员,或是标准库容器里的元素。几乎不会有下面这种用法:
shared_ptr<Foo>* pFoo = new shared_ptr<Foo>(new Foo); // WRONG semantic
还要注意,如果这几种智能指针是对象 x 的数据成员,而它的模板参数 T 是个 incomplete 类型,那么 x 的析构函数不能是默认的或内联的,必须在 .cpp 文件里边显式定义,否则会有编译错或运行错。(原因请见 boost::checked_delete)
上
既然透过 weak_ptr 能探查对象的生死,那么 Observer 模式的竞态条件就很容易解决,只要让 Observable 保存 weak_ptr<Observer> 即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
class Observable // not 100% thread safe! { public : void register (weak_ptr<Observer> x); void unregister(weak_ptr<Observer> x); // 可用 std::remove/vector::erase 实现 void notifyObservers() { MutexLock lock(mutex_); Iterator it = observers_.begin(); while (it != observers_.end()) { shared_ptr<Observer> obj(it->lock()); // 尝试提升,这一步是线程安全的 if (obj) { // 提升成功,现在引用计数值至少为 2 (想想为什么?) obj->update(); // 没有竞态条件,因为 obj 在栈上,对象不可能在本作用域内销毁 ++it; } else { // 对象已经销毁,从容器中拿掉 weak_ptr it = observers_.erase(it); } } } private : std::vector<weak_ptr<Observer> > observers_; // (5) mutable Mutex mutex_; }; |
就这么简单。前文代码 (3) 处的竞态条件已经弥补了。
把 Observer* 替换为 weak_ptr<Observer> 部分解决了 Observer 模式的线程安全,但还有几个疑点:
不灵活,强制要求 Observer 必须以 shared_ptr 来管理;
不是完全线程安全,Observer 的析构函数会调用 subject_->unregister(this),万一 subject_ 已经不复存在了呢?为了解决它,又要求 Observable 本身是用 shared_ptr 管理的,并且 subject_ 是个 weak_ptr<Observable>;
线程争用 (thread contention),即 Observable 的三个成员函数都用了互斥器来同步,这会造成 register 和 unregister 等待 notifyObservers,而后者的执行时间是无上限的,因为它同步回调了用户提供的 update() 函数。我们希望 register 和 unregister 的执行时间不会超过某个固定的上限,以免即便殃及无辜群众。
死锁,万一 update() 虚函数中调用了 (un)register 呢?如果 mutex_ 是不可重入的,那么会死锁;如果 mutex_ 是可重入的,程序会面临迭代器失效(core dump 是最好的结果),因为 vector observers_ 在遍历期间被无意识地修改了。这个问题乍看起来似乎没有解决办法,除非在文档里做要求。(一个办法是:用可重入的 mutex_,把容器换为 std::list,并把 ++it 往前挪一行。)
这些问题留到本文附录中去探讨,每个问题都是能解决的。
我个人倾向于使用不可重入的 Mutex,例如 pthreads 默认提供的那个,因为“要求 Mutex 可重入”本身往往意味着设计上出了问题。Java 的 intrinsic lock 是可重入的,因为要允许 synchronized 方法相互调用,我觉得这也是无奈之举。
思考:如果把 (5) 处改为 vector<shared_ptr<Observer> > observers_;,会有什么后果?
的线程安全
虽然我们借 shared_ptr 来实现线程安全的对象释放,但是 shared_ptr 本身不是 100% 线程安全的。它的引用计数本身是安全且无锁的,但对象的读写则不是,因为 shared_ptr 有两个数据成员,读写操作不能原子化。
根据文档,shared_ptr 的线程安全级别和内建类型、标准库容器、string 一样,即:
- 一个 shared_ptr 实体可被多个线程同时读取;
- 两个的 shared_ptr 实体可以被两个线程同时写入,“析构”算写操作;
- 如果要从多个线程读写同一个 shared_ptr 对象,那么需要加锁。
请注意,这是 shared_ptr 对象本身的线程安全级别,不是它管理的对象的线程安全级别。
要在多个线程中同时访问同一个 shared_ptr,正确的做法是:
1
2
3
|
shared_ptr<Foo> globalPtr; Mutex mutex; // No need for ReaderWriterLock void doit( const shared_ptr<Foo>& pFoo); |
globalPtr 能被多个线程看到,那么它的读写需要加锁。注意我们不必用读写锁,而只用最简单的互斥锁,这是为了性能考虑,因为临界区非常小,用互斥锁也不会阻塞并发读。
1
2
3
4
5
6
7
8
9
10
11
|
void read() { shared_ptr<Foo> ptr; { MutexLock lock(mutex); ptr = globalPtr; // read globalPtr } // use ptr since here doit(ptr); } |
写入的时候也要加锁:
1
2
3
4
5
6
7
8
9
10
11
|
void write() { shared_ptr<Foo> newptr( new Foo); { MutexLock lock(mutex); globalPtr = newptr; // write to globalPtr } // use newptr since here doit(newptr); } |
注意到 read() 和 write() 在临界区之外都没有再访问 globalPtr,而是用了一个指向同一 Foo 对象的栈上 shared_ptr local copy。下面会谈到,只要有这样的 local copy 存在,shared_ptr 作为函数参数传递时不必复制,用 reference to const 即可。
技术与陷阱
意外延长对象的生命期。shared_ptr 是强引用(铁丝绑的),只要有一个指向 x 对象的 shared_ptr 存在,该对象就不会析构。而 shared_ptr 又是允许拷贝构造和赋值的(否则引用计数就无意义了),如果不小心遗留了一个拷贝,那么对象就永世长存了。例如前面提到如果把 (5) 处 observers_ 的类型改为 vector<shared_ptr<Observer> >,那么除非手动调用 unregister,否则 Observer 对象永远不会析构。即便它的析构函数会调用 unregister,但是不去 unregister 就不会调用析构函数,这变成了鸡与蛋的问题。这也是 Java 内存泄露的常见原因。
另外一个出错的可能是 boost::bind,因为 boost::bind 会把参数拷贝一份,如果参数是个 shared_ptr,那么对象的生命期就不会短于 boost::function 对象:
1
2
3
4
5
6
7
8
|
class Foo { void doit(); }; boost::function< void ()> func; shared_ptr<Foo> pFoo( new Foo); func = bind(&Foo::doit, pFoo); // long life foo |
这里 func 对象持有了 shared_ptr<Foo> 的一份拷贝,有可能会不经意间延长倒数第二行创建的 Foo 对象的生命期。
函数参数。因为要修改引用计数(而且拷贝的时候通常要加锁),shared_ptr 的拷贝开销比拷贝原始指针要高,但是需要拷贝的时候并不多。多数情况下它可以以 reference to const 方式传递,一个线程只需要在最外层函数有一个实体对象,之后都可以用 reference to const 来使用这个 shared_ptr。例如有几个个函数都要用到 Foo 对象:
1
2
3
4
5
6
7
8
|
void save( const shared_ptr<Foo>& pFoo); void validateAccount( const Foo& foo); bool validate( const shared_ptr<Foo>& pFoo) { // ... validateAccount(*pFoo); // ... } |
那么在通常情况下,
1
2
3
4
5
6
7
|
void onMessage( const string& buf) { shared_ptr<Foo> pFoo( new Foo(buf)); // 只要在最外层持有一个实体,安全不成问题 if (validate(pFoo)) { save(pFoo); } } |
遵照这个规则,基本上不会遇到反复拷贝 shared_ptr 导致的性能问题。另外由于 pFoo 是栈上对象,不可能被别的线程看到,那么读取始终是线程安全的。
析构动作在创建时被捕获。这是一个非常有用的特性,这意味着:
- 虚析构不再是必须的。
- shared_ptr<void> 可以持有任何对象,而且能安全地释放
- shared_ptr 对象可以安全地跨越模块边界,比如从 DLL 里返回,而不会造成从模块 A 分配的内存在模块 B 里被释放这种错误。
- 二进制兼容性,即便 Foo 对象的大小变了,那么旧的客户代码任然可以使用新的动态库,而无需重新编译(这要求 Foo 的头文件中不出现访问对象的成员的 inline函数)。
- 析构动作可以定制。
这个特性的实现比较巧妙,因为 shared_ptr<T> 只有一个模板参数,而“析构行为”可以是函数指针,仿函数 (functor) 或者其他什么东西。这是泛型编程和面向对象编程的一次完美结合。有兴趣的同学可以参考 Scott Meyers 的文章。
这个技术在后面的对象池中还会用到。
析构所在的线程。对象的析构是同步的,当最后一个指向 x 的 shared_ptr 离开其作用域的时候,x 会同时在同一个线程析构。这个线程不一定是对象诞生的线程。这个特性是把双刃剑:如果对象的析构比较耗时,那么可能会拖慢关键线程的速度(如果最后一个 shared_ptr 引发的析构发生在关键线程);同时,我们可以用一个单独的线程来专门做析构,通过一个 BlockingQueue<shared_ptr<void> > 把对象的析构都转移到那个专用线程,从而解放关键线程。
现成的 RAII handle。我认为 RAII (资源获取即初始化)是 C++ 语言区别与其他所有编程语言的最重要的手法,一个不懂 RAII 的 C++ 程序员不是一个合格的 C++ 程序员。原来的 C++ 教条是“new 和 delete 要配对,new 了之后要记着 delete”,如果使用 RAII,要改成“每一个明确的资源配置动作(例如 new)都应该在单一语句中执行,并在该语句中立刻将配置获得的资源交给 handle 对象(如 shared_ptr),程序中一般不出现 delete”(出处见脚注 1)。shared_ptr 是管理共享资源的利器,需要注意避免循环引用,通常的做法是 owner 持有指向 A 的 shared_ptr,A 持有指向 owner 的 weak_ptr。
假设有 Stock 类,代表一只股票的价格。每一只股票有一个惟一的字符串标识,比如 Google 的 key 是 "NASDAQ:GOOG",IBM 是 "NYSE:IBM"。Stock 对象是个主动对象,它能不断获取新价格。为了节省系统资源,同一个程序里边每一只出现的股票只有一个 Stock 对象,如果多处用到同一只股票,那么 Stock 对象应该被共享。如果某一只股票没有再在任何地方用到,其对应的 Stock 对象应该析构,以释放资源,这隐含了“引用计数”。
为了达到上述要求,我们可以设计一个对象池 StockFactory。它的接口很简单,根据 key 返回 Stock 对象。我们已经知道,在多线程程序中,既然对象可能被销毁,那么返回 shared_ptr 是合理的。自然地,我们写出如下代码。(可惜是错的)
1
2
3
4
5
6
7
8
9
10
|
class StockFactory : boost::noncopyable { // questionable code public : shared_ptr<Stock> get( const string& key); private : std::map<string, shared_ptr<Stock> > stocks_; mutable Mutex mutex_; }; |
get() 的逻辑很简单,如果在 stocks_ 里找到了 key,就返回 stocks_[key];否则新建一个 Stock,并存入 stocks_[key]。
细心的读者或许已经发现这里有一个问题,Stock 对象永远不会被销毁,因为 map 里存的是 shared_ptr,始终有铁丝绑着。那么或许应该仿照前面 Observable 那样存一个 weak_ptr?比如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class StockFactory : boost::noncopyable { public : shared_ptr<Stock> get( const string& key) { shared_ptr<Stock> pStock; MutexLock lock(mutex_); weak_ptr<Stock>& wkStock = stocks_[key]; // 如果 key 不存在,会默认构造一个 pStock = wkStock.lock(); // 尝试把棉线提升为铁丝 if (!pStock) { pStock.reset( new Stock(key)); wkStock = pStock; // 这里更新了 stocks_[key],注意 wkStock 是个引用 } return pStock; } private : std::map<string, weak_ptr<Stock> > stocks_; mutable Mutex mutex_; }; |
这么做固然 Stock 对象是销毁了,但是程序里却出现了轻微的内存泄漏,为什么?
因为 stocks_ 的大小只增不减,stocks_.size() 是曾经存活过的 Stock 对象的总数,即便活的 Stock 对象数目降为 0。或许有人认为这不算泄漏,因为内存并不是彻底遗失不能访问了,而是被某个标准库容器占用了。我认为这也算内存泄漏,毕竟是战场没有打扫干净。
其实,考虑到世界上的股票数目是有限的,这个内存不会一直泄漏下去,大不了把每只股票的对象都创建一遍,估计泄漏的内存也只有几兆。如果这是一个其他类型的对象池,对象的 key 的集合不是封闭的,内存会一直泄漏下去。
解决的办法是,利用 shared_ptr 的定制析构功能。shared_ptr 的构造函数可以有一个额外的模板类型参数,传入一个函数指针或仿函数 d,在析构对象时执行 d(p)。shared_ptr 这么设计并不是多余的,因为反正要在创建对象时捕获释放动作,始终需要一个 bridge。
1
2
3
|
template < class Y, class D> shared_ptr::shared_ptr(Y* p, D d); template < class Y, class D> void shared_ptr::reset(Y* p, D d); |
那么我们可以利用这一点,在析构 Stock 对象的同时清理 stocks_。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
class StockFactory : boost::noncopyable { // in get(), change // pStock.reset(new Stock(key)); // to // pStock.reset(new Stock(key), // boost::bind(&StockFactory::deleteStock, this, _1)); (6) private : void deleteStock(Stock* stock) { if (stock) { MutexLock lock(mutex_); stocks_.erase(stock->key()); } delete stock; // sorry, I lied } // assuming FooCache lives longer than all Foo's ... // ... |
这里我们向 shared_ptr<Stock>::reset() 传递了第二个参数,一个 boost::function,让它在析构 Stock* p 时调用本 StockFactory 对象的 deleteStock 成员函数。
警惕的读者可能已经发现问题,那就是我们把一个原始的 StockFactory this 指针保存在了 boost::function 里 (6),这会有线程安全问题。如果这个 StockFactory 先于 Stock 对象析构,那么会 core dump。正如 Observer 在析构函数里去调用 Observable::unregister(),而那时 Observable 对象可能已经不存在了。
当然这也是能解决的,用到下一节的技术。
StockFactory::get() 把原始指针 this 保存到了 boost::function 中 (6),如果 StockFactory 的生命期比 Stock 短,那么 Stock 析构时去回调 StockFactory::deleteStock 就会 core dump。似乎我们应该祭出惯用的 shared_ptr 大法来解决对象生命期问题,但是 StockFactory::get() 本身是个成员函数,如何获得一个 shared_ptr<StockFactory> 对象呢?
有办法,用 enable_shared_from_this。这是一个模板基类,继承它,this 就能变身为 shared_ptr。
1
2
3
|
class StockFactory : public boost::enable_shared_from_this<StockFactory>, boost::noncopyable { /* ... */ }; |
为了使用 shared_from_this(),要求 StockFactory 对象必须保存在 shared_ptr 里。
1
|
shared_ptr<StockFactory> stockFactory( new StockFactory); |
万事俱备,可以让 this 摇身一变,化为 shared_ptr<StockFactory> 了。
1
2
3
4
5
6
7
8
9
10
11
|
shared_ptr<Stock> StockFactory::get( const string& key) { // change // pStock.reset(new Stock(key), // boost::bind(&StockFactory::deleteStock, this, _1)); // to pStock.reset( new Stock(key), boost::bind(&StockFactory::deleteStock, shared_from_this(), _1)); // ... |
这样一来,boost::function 里保存了一份 shared_ptr<StockFactory>,可以保证调用 StockFactory::deleteStock 的时候那个 StockFactory 对象还活着。
注意一点,shared_from_this() 不能在构造函数里调用,因为在构造 StockFactory 的时候,它还没有被交给 shared_ptr 接管。
最后一个问题,StockFactory 的生命期似乎被意外延长了。
把 shared_ptr 绑 (boost::bind) 到 boost:function 里,那么回调的时候 StockFactory 对象始终存在,是安全的。这同时也延长了对象的生命期,使之不短于绑得的 boost:function 对象。
有时候我们需要“如果对象还活着,就调用它的成员函数,否则忽略之”的语意,就像 Observable::notifyObservers() 那样,我称之为“弱回调”。这也是可以实现的,利用 weak_ptr,我们可以把 weak_ptr 绑到 boost::function 里,这样对象的生命期就不会被延长。然后在回调的时候先尝试提升为 shared_ptr,如果提升成功,说明接受回调的对象还健在,那么就执行回调;如果提升失败,就不必劳神了。
使用这一技术的完整 StockFactory 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
class StockFactory : public boost::enable_shared_from_this<StockFactory>, boost::noncopyable { public : shared_ptr<Stock> get( const string& key) { shared_ptr<Stock> pStock; MutexLock lock(mutex_); weak_ptr<Stock>& wkStock = stocks_[key]; pStock = wkStock.lock(); if (!pStock) { pStock.reset( new Stock(key), boost::bind(&StockFactory::weakDeleteCallback, boost::weak_ptr<StockFactory>(shared_from_this()), _1)); // 上面必须强制把 shared_from_this() 转型为 weak_ptr,才不会延长生命期 wkStock = pStock; } return pStock; } private : static void weakDeleteCallback(boost::weak_ptr<StockFactory> wkFactory, Stock* stock) { shared_ptr<StockFactory> factory(wkFactory.lock()); // 尝试提升 if (factory) { // 如果 factory 还在,那就清理 stocks_ factory->removeStock(stock); } delete stock; // sorry, I lied } void removeStock(Stock* stock) { if (stock) { MutexLock lock(mutex_); stocks_.erase(stock->key()); } } private : std::map<string, weak_ptr<Stock> > stocks_; mutable Mutex mutex_; }; |
两个简单的测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
void testLongLifeFactory() { shared_ptr<StockFactory> factory( new StockFactory); { shared_ptr<Stock> stock = factory->get( "NYSE:IBM" ); shared_ptr<Stock> stock2 = factory->get( "NYSE:IBM" ); assert (stock == stock2); // stock destructs here } // factory destructs here } void testShortLifeFactory() { shared_ptr<Stock> stock; { shared_ptr<StockFactory> factory( new StockFactory); stock = factory->get( "NYSE:IBM" ); shared_ptr<Stock> stock2 = factory->get( "NYSE:IBM" ); assert (stock == stock2); // factory destructs here } // stock destructs here } |
这下完美了,无论 Stock 和 StockFactory 谁先挂掉都不会影响程序的正确运行。
当然,通常 Factory 对象是个 singleton,在程序正常运行期间不会销毁,这里只是为了展示弱回调技术,这个技术在事件通知中非常有用。
除了使用 shared_ptr/weak_ptr,要想在 C++ 里做到线程安全的对象回调与析构,可能的办法有:
- 用一个全局的 facade 来代理 Foo 类型对象访问,所有的 Foo 对象回调和析构都通过这个 facade 来做,也就是把指针替换为 objId/handle。这样理论上能避免 race condition,但是代价很大。因为要想把这个 facade 做成线程安全,那么必然要用互斥锁。这样一来,从两个线程访问两个不同的 Foo 对象也会用到同一个锁,让本来能够并行执行的函数变成了串行执行,没能发挥多核的优势。当然,可以像 Java 的 ConcurrentHashMap 那样用多个 buckets,每个 bucket 分别加锁,以降低 contention。
- 第 4 节提到的“只创建不销毁”手法,实属无奈之举。
- 自己编写引用计数的智能指针。本质上是重新发明轮子,把 shared_ptr 实现一遍。正确实现线程安全的引用计数智能指针不是一件容易的事情,而高效的实现就更加困难。既然 shared_ptr 已经提供了完整的解决方案,那么似乎没有理由抗拒它。
- 将来在 C++ 0x 里有 unique_ptr,能避免引用计数的开销,或许能在某些场合替换shared_ptr。
有垃圾回收就好办。Google 的 Go 语言教程明确指出,没有垃圾回收的并发编程是困难的(Concurrency is hard without garbage collection)。但是由于指针算术的存在,在 C/C++里实现全自动垃圾回收更加困难。而那些天生具备垃圾回收的语言在并发编程方面具有明显的优势,Java 是目前支持并发编程最好的主流语言,它的 util.concurrent 库和内存模型是 C++ 0x 效仿的对象。
学习多线程程序设计远远不是看看教程了解 API 怎么用那么简单,这最多“主要是为了读懂别人的代码,如果自己要写这类代码,必须专门花时间严肃认真系统地学习,严禁半桶水上阵”(孟岩)。一般的多线程教程上都会提到要让加锁的区域足够小,这没错,问题是如何找出这样的区域并加锁,本文第 9 节举的安全读写 shared_ptr 可算是一个例子。
据我所知,目前 C++ 没有好的多线程领域专著,C 语言有,Java 语言也有。《Java Concurrency in Practice》是我读过的写得最好的书,内容足够新,可读性和可操作性俱佳。C++ 程序员反过来要向 Java 学习,多少有些讽刺。除了编程书,操作系统教材也是必读的,至少要完整地学习一本经典教材的相关章节,可从《操作系统设计与实现》、《现代操作系统》、《操作系统概念》任选一本,了解各种同步原语、临界区、竞态条件、死锁、典型的 IPC 问题等等,防止闭门造车。
分析可能出现的 race condition 不仅是多线程编程基本功,也是设计分布式系统的基本功,需要反复历练,形成一定的思考范式,并积累一些经验教训,才能少犯错误。这是一个快速发展的领域,要不断吸收新知识,才不会落伍。单 CPU 时代的多线程编程经验到了多 CPU 时代不一定有效,因为多 CPU 能做到真正的并发执行,每个 CPU 看到的事件发生顺序不一定完全相同。正如狭义相对论所说的每个观察者都有自己的时钟,在不违反因果律的前提下,可能发生十分违反直觉的事情。
尽管本文通篇在讲如何安全地使用(包括析构)跨线程的对象,但我建议尽量减少使用跨线程的对象,我赞同缙大师说的“用流水线,生产者-消费者,任务队列这些有规律的机制,最低限度地共享数据。这是我所知最好的多线程编程的建议了。”
不用跨线程的对象,自然不会遇到本文描述的各种险态。如果迫不得已要用,我希望本文能对您有帮助。
- 原始指针暴露给多个线程往往会造成 race condition 或额外的簿记负担;
- 统一用 shared_ptr/scoped_ptr 来管理对象的生命期,在多线程中尤其重要;
- shared_ptr 是值语意,当心意外延长对象的生命期。例如 boost::bind 和容器;
- weak_ptr 是 shared_ptr 的好搭档,可以用作弱回调、对象池等;
- 认真阅读一遍 boost::shared_ptr 的文档,能学到很多东西: http://www.boost.org/doc/libs/release/libs/smart_ptr/shared_ptr.htm
- 保持开放心态,留意更好的解决办法,比如 unique_ptr。忘掉已被废弃的 auto_ptr。
shared_ptr 是 tr1 的一部分,即 C++ 标准库的一部分,值得花一点时间去学习掌握,对编写现代的 C++ 程序有莫大的帮助。我个人的经验是,一周左右就能基本掌握各种用法与常见陷阱,比学 STL 还快。网络上有一些对 shared_ptr 的批评,那可以算作故意误用的例子,就好比故意访问失效的迭代器来证明 vector 不安全一样。
正确使用标准库(含 shared_ptr)作为自动化的内存/资源管理器,解放大脑,从此告别内存错误。
之谬
本文第 8 节把 shared_ptr/weak_ptr 应用到 Observer 模式中,部分解决了其线程安全问题。我用 Observer 举例,因为这是一个广为人知的设计模式,但是它有本质的问题。
Observer 模式的本质问题在于其面向对象的设计。换句话说,我认为正是面向对象 (OO) 本身造成了 Observer 的缺点。Observer 是基类,这带来了非常强的耦合,强度仅次于友元。这种耦合不仅限制了成员函数的名字、参数、返回值,还限制了成员函数所属的类型(必须是 Observer 的派生类)。
Observer 是基类,这意味着如果 Foo 想要观察两个类型的事件(比如时钟和温度),需要使用多继承。这还不是最糟糕的,如果要重复观察同一类型的事件(比如 1 秒钟一次的心跳和 30 秒钟一次的自检),就要用到一些伎俩来 work around,因为不能从一个 Base class 继承两次。
现在的语言一般可以绕过 Observer 模式的限制,比如 Java 可以用匿名内部类,Java 7 用 Closure,C# 用 delegate,C++ 用 boost::function/ boost::bind,我在另外一篇博客《以 boost::function 和 boost:bind 取代虚函数》里有更多的讲解。
在 C++ 里为了替换 Observer,可以用 Signal/Slots,我指的不是 QT 那种靠语言扩展的实现,而是完全靠标准库实现的 thread safe、race condition free、thread contention free 的 Signal/Slots,并且不强制要求 shared_ptr 来管理对象,也就是说完全解决了第 8 节列出的 Observer 遗留问题。不过这篇文章已经够长了,留作下次吧。有兴趣的同学可以先预习一下《借 shared_ptr 实现线程安全的 copy-on-write》。
《C++ 沉思录》/《Runminations on C++》中文版的附录是王曦和孟岩对作者夫妇二人的采访,在被问到“请给我们三个你们认为最重要的建议”时,Koenig 和 Moo 的第一个建议是“避免使用指针”。我 2003 年读到这段时,理解不深,觉得固然使用指针容易造成内存方面的问题,但是完全不用也是做不到的,毕竟 C++ 的多态要透过指针或引用来起效。6 年之后重新拾起来,发现大师的观点何其深刻,不免掩卷长叹。
这本书详细地介绍了 handle/body idiom,这是编写大型 C++ 程序的必备技术,也是实现物理隔离的法宝,值得细读。
目前来看,用 shared_ptr 来管理资源在国内 C++ 界似乎并不是一种主流做法,很多人排斥智能指针,视其为洪水猛兽(这或许受了 auto_ptr 的垃圾设计的影响)。据我所知,很多 C++ 项目还是手动管理内存和资源,因此我觉得有必要把我认为好的做法分享出来,让更多的人尝试并采纳。我觉得 shared_ptr 对于编写线程安全的 C++ 程序是至关重要的,不然就得土法炼钢,自己重新发明轮子。这让我想起了 2001 年前后 STL 刚刚传入国内,大家也是很犹豫,觉得它性能不高,使用不便,还不如自己造的容器类。近十年过去了,现在 STL 已经是主流,大家也适应了迭代器、容器、算法、适配器、仿函数这些“新”名词“新”技术,开始在项目中普遍使用(至少用 vector 代替数组嘛)。我希望,几年之后人们回头看这篇文章,觉得“怎么讲的都是常识”,那我这篇文章的目的也就达到了。
.全文完 2010/Jan/22初稿 Jan 27 修订.
《C++ 编程规范》/《C++ Coding Standards》, by Herb Sutter and Andrei Alexandrescu, 2005. 条款 13.
http://www.boost.org/doc/libs/1_41_0/libs/smart_ptr/shared_ptr.htm
http://www.artima.com/cppsource/top_cpp_aha_moments.html
要是我在文章的一开始就谈垃圾回收的好处,估计很多人直接就不看了。
孟岩《快速掌握一个语言最常用的 50%》博客,这篇博客的其他文字也很有趣味:“粗粗看看语法,就撸起袖子开干,边查 Google 边学习”这种路子也有问题,在对于这种语言的脾气秉性还没有了解的情况下大刀阔斧地拼凑代码,写出来的东西肯定不入流。说穿新鞋走老路,新瓶装旧酒,那都是小问题,真正严重的是这样的程序员可以在短时间内堆积大量充满缺陷的垃圾代码。由于通常开发阶段的测试完备程度有限,这些垃圾代码往往能通过这个阶段,从而潜伏下来,在后期成为整个项目的毒瘤,反反复复让后来的维护者陷入西西弗斯困境。……其实真正写程序不怕完全不会,最怕一知半解的去攒解决方案。因为你完全不会,就自然会去认真查书学习,如果学习能力好的话,写出来的代码质量不会差。而一知半解,自己动手土法炼钢,那搞出来的基本上都是废铜烂铁。
http://blog.csdn.net/Solstice/archive/2008/10/13/3066268.aspx
http://blog.csdn.net/Solstice/archive/2008/11/22/3351751.aspx