关于weak_ptr的线程安全性

23
std::shared_ptr<int> g_s = std::make_shared<int>(1);
void f1()
{
    std::shared_ptr<int>l_s1 = g_s; // read g_s
}

void f2()
{
    std::shared_ptr<int> l_s2 = std::make_shared<int>(3);
    std::thread th(f1);
    th.detach();
    g_s = l_s2; // write g_s
}

关于上面的代码,我知道不同的线程读写相同的shared_ptr会导致竞态条件。但是对于weak_ptr呢?下面的代码中是否存在任何竞态条件?(我的平台是Microsoft VS2013。)

std::weak_ptr<int> g_w;

void f3()
{
    std::shared_ptr<int>l_s3 = g_w.lock(); //2. here will read g_w
    if (l_s3)
    {
        ;/.....
    }
}

void f4()
{
    std::shared_ptr<int> p_s = std::make_shared<int>(1);
    g_w = p_s;

    std::thread th(f3);
    th.detach();
    // 1. p_s destory will motify g_w (write g_w)
}

1
是的,l_s3是否为空指针完全是任意的。处理器核心数量越多,f3()开始运行的可能性就越大,因此它为空指针的可能性就越小。这是标准的线程竞争错误,与weak_ptr<>实现完全无关。 - Hans Passant
1
实际上情况比这更糟:带有数据竞争的程序具有未定义的行为。在典型的实现中,f3 中对 g_w 的读取将看到值 nullptrp_s - 但是该行为不被标准保证。特别地,如果普通单字写入是非原子性的,则实现可能返回一个“撕裂”的值,即部分旧值和部分新值。 - Casey
2
绝不是竞态条件就是一个错误。弱指针可能为空,如果你足够快地抓住它,那么很好,如果没有,那么太糟糕了,但这不会导致软件出现错误。当使用弱引用时,必须确保两个路径(锁定成功和失败)都是有效的路径。如果在任一情况下存在错误,则与竞态条件无关,这只是一个缺陷。如果需要随时进行锁定,请将其设置为共享。 - v.oddou
f3()f4()的可见代码是正确的。然而,注释掉的代码是不正确的:// 1. p_s destroy will modify g_w (write g_w)。在f4()中更新g_w并同时通过g_w.lock()读取g_wf3()中是未定义的行为! - Kai Petzke
@KaiPetzke 我认为 f4 中的注释是错误的。p_s 超出作用域不会影响 g_w,它们是不同的对象。只有控制块会被调整。 - LWimsey
5个回答

48

我知道我来晚了,但是当搜索"weak_ptr thread"时会出现这个问题,而且Casey的回答并不完全准确。 shared_ptrweak_ptr 都可以在没有进一步同步的情况下从线程中使用。

对于shared_ptr,有很多文档(例如在 cppreference.com 或者 stackoverflow 上)。您可以安全地从不同的线程访问指向相同对象的shared_ptr。只是不能从两个线程同时操作同一个指针。换句话说:

// Using p and p_copy from two threads is fine.
// Using p from two threads or p and p_ref from two threads is illegal.
std::shared_ptr<A> p = std::make_shared<A>();
std::shared_ptr<A> &p_ref = p;
std::shared_ptr<A> p_copy = p;
为解决您代码中的问题,请将 g_s 作为参数(按值传递)传递给 f1()
对于弱指针(weak pointers),安全保证在 weak_ptr::lock 的文档中隐含着:

有效地返回 expired() ? shared_ptr<T>() : shared_ptr<T>(*this),并以原子方式执行。

您可以使用 weak_ptr::lock() 从其他线程获取一个 shared_ptr,无需进一步同步。这也得到了 Boost 中的确认,这里和 Chris Jester-Young 在这个 SO 回答中也有提到。
同样,您必须确保不要在一个线程中修改相同的 weak_ptr,而在另一个线程中访问它,因此也需要按值将 g_w 传递给 f3()

9
您有没有注意到原帖中的示例涉及到一个shared_ptrweak_ptr对象,在一个线程中读取,在另一个线程中写入,且缺乏同步?由于数据竞争,这两个示例均存在未定义行为。 - Casey
1
@Casey:我的确错过了那个,就是cppreference.com链接所说的你不能这样做的事情。我已经更新了答案。谢谢! - Christian Aichinger
我似乎发现了矛盾的文档。您提供的Boost和SO答案是关于boost shared/weak ptr的,似乎确认了您对boost的结论,但不是std(这是问题中提到的)。std文档针对expired()声明:如果托管对象在线程之间共享,则此函数本质上具有竞争性。特别地,false结果可能变得陈旧,无法使用。true结果是可靠的。 因此,如果std::weak_ptr<>::lock()实现使用了expired()并且没有其他同步机制,则存在竞争问题。 - Erroneous
1
@Erroneous: lock()在回答中描述的方式下是线程安全的。它不会在内部使用expired()expired()是一个额外的便利函数,用于测试弱指针是否仍然有效。由于它不获取shared_ptr,因此它无法防止对象在调用返回时被删除。最好避免使用它。这种类型的竞争条件不会影响lock(),因为该函数确实获取了shared_ptr,从而确保对象保持活动状态。 - Christian Aichinger
@ChristianAichinger 哦,看起来我错过了关键短语“原子执行”,这不允许实现使用给定的代码来实现其有效返回。 - Erroneous
显示剩余2条评论

8
为了简洁起见,在下文中,不同的weak_ptrshared_ptr都是由同一个原始的shared_ptrunique_ptr生成的,它们将被称为“实例”。不共享相同对象的weak_ptrshared_ptr不需要在此分析中考虑。评估线程安全性的一般规则如下:
  1. 对于同一实例上的同时const成员函数调用是线程安全的。所有观察者函数都是const
  2. 对于不同实例的同时调用是线程安全的,即使其中一个调用是修改器。
  3. 当至少有一个调用是修改器时,在同一实例上的同时调用不是线程安全的。
下表显示了两个线程在同时操作同一实例时的线程安全性。
+---------------+----------+-------------------------+------------------------+
|   operation   |   type   | other thread modifying  | other thread observing |
+---------------+----------+-------------------------+------------------------+
| (constructor) |          | not applicable          | not applicable         |
| (destructor)  |          | unsafe                  | unsafe                 |
| operator=     | modifier | unsafe                  | unsafe                 |
| reset         | modifier | unsafe                  | unsafe                 |
| swap          | modifier | unsafe                  | unsafe                 |
| use_count     | observer | unsafe                  | safe                   |
| expired       | observer | unsafe                  | safe                   |
| lock          | observer | unsafe                  | safe                   |
| owner_before  | observer | unsafe                  | safe                   |
+---------------+----------+-------------------------+------------------------+
cppreference关于std::atomic(std::weak_ptr)的讨论最清楚地阐述了同时访问不同实例的安全性:
请注意,std::weak_ptr和std::shared_ptr使用的控制块是线程安全的:即使这些实例是副本或共享相同的控制块,在多个线程中仍然可以使用可变操作(例如operator=或reset)同时访问不同的非原子std::weak_ptr对象。
C++20引入了std::atomic的特化版本,它提供了对同一实例的线程安全修改。请注意,在构造函数方面,从另一个实例初始化不是原子操作。例如,atomic<weak_ptr<T>> myptr(anotherWeakPtr);不是原子操作。

6

shared_ptrweak_ptr与所有其他标准库类型一样,属于相同的线程安全要求:如果这些成员函数是非修改性的(const),那么对成员函数的同时调用必须是线程安全的(详见C++11 §17.6.5.9 Data Race Avoidance [res.data.races])。需要注意的是,赋值运算符明显不是const


std::shared_ptrstd::weak_ptr 的线程安全性保证比所有 STL 容器的总体陈述更强,详情请参考我的回答。 - Christian Aichinger
1
@ChristianAichinger shared_ptrweak_ptr的唯一“额外线程安全保证”是确保来自多个线程的对隐藏引用计数的更改不会引入数据竞争。单独的shared_ptr/weak_ptr对象没有特殊的数据竞争保护。 - Casey
确实如此,但实际上这是一个非常有用的保证,在“关于weak_ptr线程安全性”的问题中甚至没有提到,这忽略了一些重要内容。加上这个内容我会取消踩(现在由于锁定状态无法取消)。说实话,我来这里通过谷歌搜索标题问题,却发现这个答案并不是很有帮助,让我有点失望。 - Christian Aichinger
1
如果这是一个关于shared_ptr和相关内容线程安全性的一般性问题,我会同意。但尽管标题听起来很笼统,问题却非常具体:“我知道我不能使用shared_ptr做<foo>,那么我能用weak_ptr做<foo>吗?”我的回答正好回答了这个问题,并指出<foo>之所以不能与weak_ptr一起使用,原因与不能与shared_ptr一起使用相同。讨论关于shared_ptr线程安全性的每个方面只会混淆问题。 - Casey
@ChristianAichinger "有更强的线程安全保证" 实际上并没有。明确描述了使用计数的并发修改不是竞争条件的事实。一开始就不是这样。 - curiousguy

0

在不同线程间使用 weak_ptr 和 shared_ptr 是安全的;但是这些 weak_ptr/shared_ptr 对象本身并不是线程安全的。 你不能在不同线程中读写同一个智能指针。

像你之前那样访问 g_s 是不安全的,无论它是 shared_ptr 还是 weak_ptr。


0

为了让我清楚明白,如果在调用std::weak_ptr的lock()函数时,同时调用reset()函数来重置std::shared_ptr,我仍然不确定会发生什么。

简单来说:

std::shared_ptr<Object> sharedObject;
std::weak_ptr<Object> weakObject = sharedObject;

void thread1()
{
    std::shared_ptr<Object> leaseObject = weakObject.lock(); //weakObject is bound to sharedObject
}

void thread2()
{
    sharedObject.reset();
}

我们假设sharedObject没有与任何其他对象共享其指针。

如果reset()和lock()两个命令恰好同时发生,我期望以下情况之一:

  • sharedObject成功重置且weakObject.lock()返回nullptr,并且sharedObject从内存中删除。

  • sharedObject成功重置且weakObject.lock()返回指向sharedObject的指针。当leaseObject失去作用域时,sharedObject将从内存中删除。

如果上述代码未定义,则必须将Object类中的std:mutex替换为类外部,但这可能是另一个线程中的另一个问题。 ;)


你的例子看起来很好,而且行为正如你所描述的那样。当然,在调用 thread1() 或 thread2() 之前,你必须确保代码的前两行已经完全执行。这里的“完全”特别指的是在初始化和调用这些函数之间有一个内存屏障指令。标准的同步函数,如 std::mutex::lock() 或 std::thread(),会为你发出这些屏障。 - Kai Petzke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接