C++开发:为什么多线程读写shared_ptr要加锁的详细介绍
我在《Linux多线程服务端编程:使用muduoC++网络库》第1.9节“再论shared_ptr的线程安全”中写道:
后文(p.18)则介绍如何高效地加锁解锁。本文则具体分析一下为什么“
shared_ptr是引用计数型(referencecounting)智能指针,几乎所有的实现都采用在堆(heap)上放个计数值(count)的办法(除此之外理论上还有用循环链表的办法,不过没有实例)。具体来说,shared_ptr<Foo>包含两个成员,一个是指向Foo的指针ptr,另一个是ref_count指针(其类型不一定是原始指针,有可能是class类型,但不影响这里的讨论),指向堆上的ref_count对象。ref_count对象有多个成员,具体的数据结构如图1所示,其中deleter和allocator是可选的。
图1:shared_ptr的数据结构。
为了简化并突出重点,后文只画出use_count的值:
以上是shared_ptr<Foo>x(newFoo);对应的内存数据结构。
如果再执行shared_ptr<Foo>y=x;那么对应的数据结构如下。
但是y=x涉及两个成员的复制,这两步拷贝不会同时(原子)发生。
中间步骤1,复制ptr指针:
中间步骤2,复制ref_count指针,导致引用计数加1:
步骤1和步骤2的先后顺序跟实现相关(因此步骤2里没有画出y.ptr的指向),我见过的都是先1后2。
既然y=x有两个步骤,如果没有mutex保护,那么在多线程里就有racecondition。
多线程无保护读写shared_ptr可能出现的racecondition考虑一个简单的场景,有3个shared_ptr<Foo>对象x、g、n:
shared_ptr<Foo>g(newFoo);//线程之间共享的shared_ptrshared_ptr<Foo>x;//线程A的局部变量shared_ptr<Foo>n(newFoo);//线程B的局部变量一开始,各安其事。
线程A执行x=g;(即readg),以下完成了步骤1,还没来及执行步骤2。这时切换到了B线程。
同时编程B执行g=n;(即writeg),两个步骤一起完成了。
先是步骤1:
再是步骤2:
这是Foo1对象已经销毁,x.ptr成了空悬指针!
最后回到线程A,完成步骤2:
多线程无保护地读写g,造成了“x是空悬指针”的后果。这正是多线程读写同一个shared_ptr必须加锁的原因。
当然,racecondition远不止这一种,其他线程交织(interweaving)有可能会造成其他错误。
思考,假如shared_ptr的operator=实现是先复制ref_count(步骤2)再复制ptr(步骤1),会有哪些racecondition?
杂项shared_ptr作为unordered_map的key如果把boost::shared_ptr放到unordered_set中,或者用于unordered_map的key,那么要小心hashtable退化为链表。http://stackoverflow.com/questions/6404765/c-shared-ptr-as-unordered-sets-key/12122314#12122314
直到Boost1.47.0发布之前,unordered_set<std::shared_ptr<T>>虽然可以编译通过,但是其hash_value是shared_ptr隐式转换为bool的结果。也就是说,如果不自定义hash函数,那么unordered_{set/map}会退化为链表。https://svn.boost.org/trac/boost/ticket/5216
Boost1.51在boost/functional/hash/extensions.hpp中增加了有关重载,现在只要包含这个头文件就能安全高效地使用unordered_set<std::shared_ptr>了。
这也是muduo的examples/idleconnection示例要自己定义hash_value(constboost::shared_ptr<T>&x)函数的原因(书第7.10.2节,p.255)。因为Debian6Squeeze、Ubuntu10.04LTS里的boost版本都有这个bug。
为什么图1中的ref_count也有指向Foo的指针?shared_ptr<Foo>sp(newFoo)在构造sp的时候捕获了Foo的析构行为。实际上shared_ptr.ptr和ref_count.ptr可以是不同的类型(只要它们之间存在隐式转换),这是shared_ptr的一大功能。分3点来说:
1.无需虚析构;假设Bar是Foo的基类,但是Bar和Foo都没有虚析构。
shared_ptr<Foo>sp1(newFoo);//ref_count.ptr的类型是Foo*
shared_ptr<Bar>sp2=sp1;//可以赋值,自动向上转型(up-cast)
sp1.reset();//这时Foo对象的引用计数降为1
此后sp2仍然能安全地管理Foo对象的生命期,并安全完整地释放Foo,因为其ref_count记住了Foo的实际类型。
2.shared_ptr<void>可以指向并安全地管理(析构或防止析构)任何对象;muduo::net::Channelclass的tie()函数就使用了这一特性,防止对象过早析构,见书7.15.3节。
shared_ptr<Foo>sp1(newFoo);//ref_count.ptr的类型是Foo*
shared_ptr<void>sp2=sp1;//可以赋值,Foo*向void*自动转型
sp1.reset();//这时Foo对象的引用计数降为1
此后sp2仍然能安全地管理Foo对象的生命期,并安全完整地释放Foo,不会出现deletevoid*的情况,因为delete的是ref_count.ptr,不是sp2.ptr。
3.多继承。假设Bar是Foo的多个基类之一,那么:
shared_ptr<Foo>sp1(newFoo);
shared_ptr<Bar>sp2=sp1;//这时sp1.ptr和sp2.ptr可能指向不同的地址,因为Barsubobject在Fooobject中的offset可能不为0。
sp1.reset();//此时Foo对象的引用计数降为1
但是sp2仍然能安全地管理Foo对象的生命期,并安全完整地释放Foo,因为delete的不是Bar*,而是原来的Foo*。换句话说,sp2.ptr和ref_count.ptr可能具有不同的值(当然它们的类型也不同)。
为什么要尽量使用make_shared()?为了节省一次内存分配,原来shared_ptr<Foo>x(newFoo);需要为Foo和ref_count各分配一次内存,现在用make_shared()的话,可以一次分配一块足够大的内存,供Foo和ref_count对象容身。数据结构是:
不过Foo的构造函数参数要传给make_shared(),后者再传给Foo::Foo(),这只有在C++11里通过perfectforwarding才能完美解决。