在上周参加的一场面试中, 面试官和我聊到了智能指针的问题, 问我常用哪些智能指针, 我说出了 shared_ptr、unique_ptr, 脑子一抽风把 scoped_ptr 也说出来了, 但是把 weak_ptr 和 intrusive_ptr 忘在了脑后…
他问我了一个问题, 对于 shared_ptr 的操作是否为线程安全的, 当时庆幸这个问题之前有做过了解, 就按照某个博客的说法来回答: 尽管使用计数提供了原子性修改操作, 但 shared_ptr 的赋值操作由复制对象指针和修改使用计数两个操作复合而成, 因此仍不是线程安全的, 具体理由如下.
设有两个线程与三个智能指针:
1 | thread thread1; |
在某时刻, spObject1 与 spObject2 的状态是这样的:
两个线程同时进行了 shared_ptr 的赋值操作:
1 | // thread1 |
观察 spObject1 与 spObject2 的操作, 首先 thread1 执行, spObject1 的对象指针指向了 spObject2 指向的位置:
接着 thread2 执行, spObject2 的指针指向了 spObject3 指向的的位置, 并且修改使用计数, 导致 object2 与相应的使用计数被销毁, spObject1 指向了一个被销毁的对象:
thread1 执行时, spObject1 修改使用计数, 错误的指向了原本 spObject3 的使用计数.
据此原博得出结论, 多线程无保护读写 shared_ptr 是不安全的, 必须加锁. 当我把这个过程呈现给面试官的时候, 他表示了疑惑: 既然 shared_ptr 的读写不是线程安全的, 那么使用计数操作的原子性意义何在? 我当时就囧了, 但仍然坚持原来的主张, 于是面试官便让我回去查查.
首先我阅读了 VC 2013 的 STL 库, 对于赋值部分看起来是线程安全的, 这里是赋值的源码:
1 | _Myt& operator=(const _Myt& _Right) _NOEXCEPT |
swap 交换是实现异常安全的惯用手法, 不难理解. _Swap 声明在基类 _Ptr_base<_Ty> 中, 具体如下:
1 | void _Swap(_Ptr_base& _Right) |
此处注意首先交换了使用计数, 然后是对象指针. 我们再看 shared_ptr 的复制构造函数, 因为赋值语句的第一行用到了它:
1 | shared_ptr(const _Myt& _Other) _NOEXCEPT |
可以注意到, 在泛型函数的 _Reset 中, 已经取得了一份 _Other 的拷贝, 也就是说争用情况不会发生在此之后, 只会在泛型版本 _Reset 调用 非泛型版本 _Reset 时发生.
下面做一个简单的实验证明上面的结论:
1 | shared_ptr<int> sp1(new int); |
此程序在 Debug 模式下运行不久就会出现错误, 因为尝试越界访问 (可能是写入一个已经销毁的堆上对象).
令人疑惑的是, cppreference.com 指出, 对不同的 shared_ptr 实例操作时是不需要额外同步的, 即使那些实例共享了同一个对象. 在上面的实验中, 两个线程只进行以下操作:
- 对不同的实例调用成员方法
operator=; - 对不同的实例调用成员方法
reset.
对于 VC 2013 的 STL 库, 以下的执行顺序是可能的:
- 线程
t1中, 泛型_Reset取得了 <sp2._Rep; - 线程
t2中, 完成了 sp2 = sp3, sp2._Rep 与 sp2._Ptr 被销毁; - 线程
t1中, 泛型_Reset取得了 sp2._Ptr (实际上已经是 sp3._Ptr), 调用非泛型_Reset, 对已销毁的 sp2._Rep 进行操作, 发生错误.
在 gcc 中测试, 也会发生类似的问题.
至此, 我仍然坚持 shared_ptr 并非线程安全这一观点, 起码对于现在的库来说是不安全的.





