zl程序教程

您现在的位置是:首页 >  后端

当前栏目

C++开发:为什么多线程读写shared_ptr要加锁的详细介绍

C++多线程开发 详细 介绍 为什么 读写 加锁
2023-06-13 09:14:52 时间

我在《Linux多线程服务端编程:使用muduoC++网络库》第1.9节“再论shared_ptr的线程安全”中写道:

(shared_ptr)的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr有两个数据成员,读写操作不能原子化。根据文档(http://www.boost.org/doc/libs/release/libs/smart_ptr/shared_ptr.htm#ThreadSafety),shared_ptr的线程安全级别和内建类型、标准库容器、std::string一样,即:

•一个shared_ptr对象实体可被多个线程同时读取(文档例1);

•两个shared_ptr对象实体可以被两个线程同时写入(例2),“析构”算写操作;

如果要从多个线程读写同一个shared_ptr对象,那么需要加锁(例3~5)。

请注意,以上是shared_ptr对象本身的线程安全级别,不是它管理的对象的线程安全级别。

后文(p.18)则介绍如何高效地加锁解锁。本文则具体分析一下为什么“因为shared_ptr有两个数据成员,读写操作不能原子化”使得多线程读写同一个shared_ptr对象需要加锁。这个在我看来显而易见的结论似乎也有人抱有疑问,那将导致灾难性的后果,值得我写这篇文章。本文以boost::shared_ptr为例,与std::shared_ptr可能略有区别。

shared_ptr的数据结构

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才能完美解决。