为什么多线程读写shared_ptr要加锁?[转]

原文:为什么多线程读写 shared_ptr 要加锁?

shared_ptr的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr有两个数据成员,读写操作不能原子化。shared_ptr的线程安全级别和内建类型、标准库容器、std::string一样,即:

  1. 一个shared_ptr对象实体可被多个线程同时读取
  2. 两个shared_ptr对象实体可以被两个线程同时写入
  3. 如果要从多个线程读写同一个shared_ptr对象,那么需要加锁

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

shared_ptr的数据结构

shared_ptr是引用计数型(reference counting)智能指针,几乎所有的实现都采用在堆(heap)上放个计数值(count)的办法(除此之外理论上还有用循环链表的办法,不过没有实例)。具体来说,shared_ptr包含两个成员,一个是指向Foo的指针ptr,另一个是ref_count指针(其类型不一定是原始指针,有可能是class类型,但不影响这里的讨论),指向堆上的ref_count对象。ref_count对象有多个成员,具体的数据结构如图所示,其中deleterallocator是可选的。

为什么多线程读写shared_ptr要加锁?[转]_第1张图片

为了简化并突出重点,后文只画出use_count的值:

这里写图片描述

以上是shared_ptr x(new Foo);对应的内存数据结构。

如果再执行shared_ptr y = x;那么对应的数据结构如下:

这里写图片描述

但是y=x涉及两个成员的复制,这两步拷贝不会同时(原子)发生。

  • 步骤1:复制ptr指针:

这里写图片描述

  • 步骤2:复制ref_count指针,导致引用计数加1:

步骤1和步骤2的先后顺序跟实现相关(因此步骤2里没有画出y.ptr的指向),我见过的都是先1后2。

既然y=x有两个步骤,如果没有mutex保护,那么在多线程里就有race condition。

多线程无保护读写shared_ptr可能出现的race condition

考虑一个简单的场景,有3个shared_ptr对象x,g,n

shared_ptr g(new Foo); // 线程之间共享的 shared_ptr
shared_ptr x;          // 线程 A 的局部变量
shared_ptr n(new Foo); // 线程 B 的局部变量

这里写图片描述

线程A执行x = g;(即read g),以下完成了步骤1,还没来及执行步骤2。这时切换到了B线程。

这里写图片描述

同时线程B执行g = n;(即write g),两个步骤一起完成了。

先是步骤1:

这里写图片描述

再是步骤2:

这里写图片描述

这是Foo1对象已经销毁,x.ptr成了空悬指针!

最后回到线程A,完成步骤2:

为什么多线程读写shared_ptr要加锁?[转]_第2张图片

多线程无保护地读写g,造成了x是空悬指针的后果。这正是多线程读写同一个shared_ptr必须加锁的原因。

当然,race condition远不止这一种,其他线程交织(interweaving)有可能会造成其他错误。

其他

1. 为什么ref_count也有指向Foo的指针?

shared_ptr sp(new Foo)在构造sp的时候捕获了Foo的析构行为。实际上shared_ptr.ptrref_count.ptr可以是不同的类型(只要它们之间存在隐式转换),这是shared_ptr的一大功能。分3点来说:

1)无需虚析构。假设BarFoo的基类,但是BarFoo都没有虚析构。

shared_ptr sp1(new Foo); // ref_count.ptr的类型是Foo*
shared_ptr sp2 = sp1; // 可以赋值,自动向上转型(up-cast)
sp1.reset(); // 这时Foo对象的引用计数降为1

此后sp2仍然能安全地管理Foo对象的生命期,并安全完整地释放Foo,因为其ref_count记住了Foo的实际类型。

2)shared_ptr可以指向并安全地管理(析构或防止析构)任何对象。

shared_ptr sp1(new Foo); // ref_count.ptr的类型是Foo*
shared_ptr<void> sp2 = sp1; // 可以赋值,Foo*向void*自动转型
sp1.reset(); // 这时Foo对象的引用计数降为1

此后sp2仍然能安全地管理Foo对象的生命期,并安全完整地释放Foo,不会出现delete void*的情况,因为delete的是ref_count.ptr,不是sp2.ptr

3)多继承。假设BarFoo的多个基类之一,那么:

shared_ptr sp1(new Foo);
shared_ptr sp2 = sp1; // 这时sp1.ptr和sp2.ptr可能指向不同的地址,因为Bar subobject在Foo object中的offset可能不为0。
sp1.reset(); // 此时Foo对象的引用计数降为1

但是sp2仍然能安全地管理Foo对象的生命期,并安全完整地释放Foo,因为delete的不是Bar*,而是原来的Foo*。换句话说,sp2.ptrref_count.ptr可能具有不同的值(当然它们的类型也不同)。

2. 为什么要尽量使用make_shared()

为了节省一次内存分配,原来shared_ptr x(new Foo);需要为Fooref_count各分配一次内存,现在用make_shared()的话,可以一次分配一块足够大的内存,供Fooref_count对象容身。数据结构是:

为什么多线程读写shared_ptr要加锁?[转]_第3张图片

不过Foo的构造函数参数要传给make_shared(),后者再传给Foo::Foo(),这只有在C++11里通过perfect forwarding才能完美解决。

你可能感兴趣的:(C-C++,c++)