如果生产者是单线程,依靠“use_count()”来重用“shared_ptr”内存是否安全?

Is it safe to rely on "use_count()" to re-use "shared_ptr" memory in a in case the producer is a single thread?

提问人:Elad Maimoni 提问时间:10/13/2023 更新时间:10/14/2023 访问量:140

问:

这与这个悬而未决的问题有点相似,但稍微具体一些。

在我的应用程序中,我有一个生产者线程,它生成对象供其他线程使用。可以安全地假设此线程是唯一创建这些对象的线程。

我希望能够在所有其他消费者线程使用完此类对象后重用它。

最初我以为,如果生产者将保存所有创建的对象,那么如果它们的引用计数为 1,它可以重用它们。但似乎这还不够可靠,无法确定这一点,因为它使用的是弱内存访问(为什么?std::shared_ptr<T>::use_count

我有一个测试应用程序,我注意到有时即使其他消费者已经完成了该对象(或者这可能是我的软件错误),有时仍可能返回> 1。use_count

问题:

  • 在这种情况下,依靠等于 1 是否是一种安全的方法?use_count
  • 如果它不安全,为什么?
  • 对于这个用例,有哪些更好的替代方案?
C++ C++11 线程安全 共享 PTR

评论

0赞 Mike Vine 10/13/2023
是的,在单线程环境中是安全准确的。use_count
0赞 Ulrich Eckhardt 10/13/2023
前一个问题至少提供了代码,愿意分享一个从你的中提取的最小可重现的例子吗?此外,在原始问题中提出了一个非常具体的问题,即 的使用,但没有解决。通常,请使用 RAII 惯用语将实例返回到池中,但要显式执行此操作。您也许可以使用自定义删除器将其拉下,这将是安全的。std::weak_ptrshared_ptr

答:

0赞 LNorth 10/13/2023 #1

use_count在多线程场景中不可靠,它不是原子的。这真的是超级不可靠。如果在重用之前绝对有必要将 ref 计数为 1,请查看 std::atomic 共享指针,或某种专门用于检查 ref 计数的互斥锁功能。

评论

2赞 Jan Schultke 10/13/2023
use_count()是原子载荷。问题在于它是一个具有宽松内存排序的原子负载,因此不能依赖结果,因为此负载可以任意重新排序。
0赞 LNorth 10/13/2023
谢谢。我不能评论你的答案,但它是优越的,应该被接受。唯一指针是一个很好的建议,但是传递一个非拥有的原始指针不会在其他方面有问题吗?我正在对底层代码做出假设,但如果要求所有使用者都完成,我假设他们正在使用底层数据,并且非拥有指针可能与不等待shared_ptr引用计数为 1 一样有问题。如果原子计数器功能是使其工作的原因,那么shared_ptr在完全相同的结果下,难道不是更内存安全吗?
1赞 Jan Schultke 10/13/2023
问题在于,它的计数器使用宽松的内存排序,并且您也不能使用 RMW 操作访问它。因此,你会得到一个可能过时的、任意重新排序的计数器,这是没有用的。在这种情况下,最好至少使用获取/发布顺序创建自己的计数器,这样您就可以确保当计数器命中时,之后不会对指向对象的访问进行排序,并且您真正拥有唯一的所有权。std::shared_ptr1
1赞 Jan Schultke 10/13/2023 #2

不,这是不安全的。 是一个非常有问题的函数,只能用作粗略的近似值。[util.smartptr.shared.obs] shared_ptr::use_count中的标准注释:use_count

当多个线程可能影响 的返回值时,结果是近似值。 特别是,并不意味着通过先前销毁的访问在任何意义上都已完成。use_count()use_count() == 1shared_ptr

这些问题是由缺乏同步引起的。在实践中,问题是:

删除 和 in 的“仅调试”限制引入了一个错误:为了生成有用且可靠的值,它需要一个 synchronize 子句来确保通过另一个引用进行的先前访问对 的成功调用者可见。许多当前的实现使用宽松的负载,并且不提供此保证,因为它没有在标准中说明。对于调试/提示使用,这是可以的。没有它,规范是不清楚和误导的。use_countuniqueshared_ptruniqueunique

- P0521:针对 CA 14 的拟议决议(shared_ptr use_count/unique

注意:曾经有一个 unique() 函数返回 use_count() == 1,由于具有误导性而被删除。

您会受到这些问题的影响,即使只有一个线程生成新对象,缺乏同步也会阻止您确定地声明唯一所有权。std::shared_ptr

什么是更好的选择?

在您的例子中,只有一个线程实际生成对象并增加引用计数。 您还可以使该线程听起来像是执行一些后处理,因此永远不会在其他线程之前终止。std::shared_ptr

这听起来像是该线程实际上具有唯一的所有权,并且可以改用。所有其他线程都可以被赋予一个非拥有的原始指针。 为了跟踪有多少线程可以访问指针,您可以使用原子计数器,例如在线程启动/联接时递增/递减(使用 )。std::unique_ptrstd::atomic_intstd::memory_order::acq_rel

您还可以使用 std::latch 或其他一些计数机制来跟踪有多少线程放弃了对指向对象的引用。一旦所有线程都落入闩锁,只有一个线程会访问它。

1赞 Turtlefight 10/13/2023 #3

不应该依赖的主要原因是,一旦你开始检查它,它给你的信息就已经过时了。 (即使它报告这并不意味着它不能异步更改)use_count()1


这是两个主要的边缘情况,当涉及多个线程时,它们实际上毫无用处:use_count()

1.是一回事std::weak_pointer<T>

考虑两个线程,一个线程是 a 到你的对象,另一个是 a 到同一个对象:std::shared_ptr<T>std::weak_ptr<T>

// thread 1
std::shared_ptr<T> ptr = /* ... */;
// ...
long count = ptr.use_count();
if(count == 1)
    reuse(ptr);
// thread 2
std::weak_ptr<T> wptr = /* ... */;
// ...
std::shared_ptr<T> ptr = wptr.lock();
if(ptr)
    fiddle(ptr);

这些可以像这样交错:

线程 1 线程 2
long count = ptr.use_count();
(计数将为 1)
std::shared_ptr<T> ptr = wptr.lock();
(将成功)
if(count == 1) reuse(ptr);
(true,调用重用)
if(ptr) fiddle(ptr);
(true,调用小提琴)

...然后,线程 1 试图重用您的对象,认为它具有唯一的强引用,而线程 2 使用新创建的强引用来摆弄它。

Godbolt 示例

2. 最后一个实例可以共享std::shared_ptr<T>

如果最后一个实例在两个线程之间共享,您还可以获得争用条件:std::shared_ptr<T>

// global variable
static std::shared_ptr<T> gptr = /* ... */
// thread 1
long count = gptr.use_count();
if(count == 1)
  reuse(gptr);
// thread 2
std::shared_ptr<T> ptr = gptr;
fiddle(ptr);

这些可以像这样交错:

线程 1 线程 2
long count = ptr.use_count();
(计数将为 1)
std::shared_ptr<T> ptr = gptr;
(创建第二个强引用)
if(count == 1) reuse(gptr);
(true,调用重用)
fiddle(ptr);
(调用小提琴)

...这又会导致两者在不同的线程上被调用相同的对象。reusefiddle

Godbolt 示例


回答您的问题

  • 在这种情况下,依靠use_count等于 1 是否是一种安全的方法?
    如果它不安全,为什么?

    只有在以下情况下才安全:

    • 您检查use_count() == 1
    • 您确定不存在指向该值的弱指针
    • 您确信最后剩下的内容只能由执行检查的线程访问std::shared_ptr<T>use_count()
  • 对于这个用例,有哪些更好的替代方案?

    在 上使用自定义删除器,
    或者(如果可以使用 C++ 11 以上的 C++ 版本,特别是 C++20)为您的类型实现销毁删除解除操作函数。(运算符删除 - 参见 (27) - (30))
    std::shared_ptr

    请注意,对于这两个选项,您都不应该使用 ,因为这会将控制块与对象一起分配,如果您以后想重用它,则要避免这样做。std::make_shared

  • 因为它使用弱内存访问(为什么?

    即使它是强内存访问,结果也不可靠。
    返回后,任何其他线程都可能锁定 a,从而更改引用计数。
    use_count()std::weak_ptr

    因此,唯一可以有意义地使用 的值的情况是当它返回 1 并且最后一个实例不在线程之间共享并且没有 s 在起作用时。
    在这种情况下,是否重新排序加载并不重要,因为无论如何只能有 1 个线程。
    use_count()std::shared_ptrstd::weak_ptruse_count()

评论

0赞 Jan Schultke 10/13/2023
即使 ,这并不意味着将指针视为具有唯一所有权是安全的。引用计数使用宽松的原子操作进行修改,因此,在将引用计数设置为 1 的递减之后,可以对指向对象的大量访问进行排序,即使在代码中,看起来您首先访问指针,然后超出范围。只有当您添加额外的同步(如 或 等)时,它才会变得安全。use_count() == 1std::shared_ptrstd::mutexstd::latch
0赞 Turtlefight 10/14/2023
@JanSchultke这就是为什么我添加了两个额外的条件(没有弱指针,shared_ptr实例不共享)——没有它们,你是对的,这并不意味着唯一的所有权。请注意,递减引用计数必须防止指令在递减后重新排序,否则shared_ptr在多线程场景中将完全无用 - 通常使用 (libcxxuse_count() == 1std::memory_order::acq_rel)
0赞 Jan Schultke 10/14/2023
P0521 中所述,某些实现使用宽松排序。也许现在情况发生了变化,但我不确定。不,宽松的订购是完全合理的。它不会使多线程变得毫无用处。递增和递减是 RMW 操作,宽松排序通常足以满足 RMW 操作,因为您读取(并修改)了最新的值。在宽松的订购中,您列出的要求不足以确保安全。std::shared_ptr
0赞 Turtlefight 10/14/2023
@JanSchultke放松可以增加,但绝不能减少。您需要释放语义来防止访问指向对象的指令在递减之后被重新排序(对指向指针对象的所有修改必须对其他线程可见),并获取 refcount 降至 0 的情况的语义,以便对象析构函数可以观察其他线程对对象发生的所有更改。- 在递减之前和之后同步指令需要acq_rel(无论内存顺序如何,RMW 本身当然是原子的)
1赞 Jan Schultke 10/15/2023
嗯,我已经和几个人谈过了,我现在明白为什么减少必须至少是获得/释放。只有增量可以放宽。