【问题标题】:could there be a racing situation in the following code?以下代码中可能存在赛车情况吗?
【发布时间】:2019-10-30 15:28:06
【问题描述】:

我们知道下面的析构函数代码应该释放控制块,如果这是指向被管理资源的最后一个 smart_ptr。有没有可能我们在下面的“if”和“delete”之间存在赛车问题?如果我们尝试在“if”之后和“delete”之前在不同的线程中创建一个全新的 smart_ptr obj 会怎样?

// Thread D:
// smart_ptr destructor
~smart_ptr() {
  if (control_block_ptr->refs.fetch_sub(1, memory_order_acq_rel) == 0) {
    delete control_block_ptr;
  }
}

【问题讨论】:

  • "创建一个全新的 smart_ptr" 应该避免在“smart_ptr”附近使用“new”这个词。 ;)
  • 发布更多smart_ptr 的代码(并非所有像operator->() 这样无聊的东西,只是“移动部件”)可能会有所帮助。

标签: c++ multithreading shared-ptr reference-counting stdatomic


【解决方案1】:

首先,更正一下:

control_block_ptr->refs.fetch_sub(1, memory_order_acq_rel) == 0

你的意思是== 1fetch_sub 返回值之前减法,而不是之后。

已经清理完毕:

下面的“if”和“delete”之间是否存在竞速问题?如果我们尝试在“if”之后和“delete”之前在不同的线程中创建一个全新的 smart_ptr obj 会怎样?

所以,您正在销毁 smart_ptr 对象。如果control_block_ptr->refs 是1,那么这意味着当前smart_ptr唯一拥有控制块的对象,对吧?那你在担心什么?

毕竟,一些“全新的smart_ptr”会有自己的“全新”control_block_ptr 和自己的引用计数。这绝不会干扰被摧毁的人。

可能出现问题的唯一方法是,在 smart_ptr 的销毁和复制它的行为之间存在竞争。而“它”的字面意思是同一个对象;不只是任何smart_ptr,而是这个正在被销毁的控制块的唯一所有者。

看,如果你有两个 smart_ptr 对象共享相同的状态,复制一个同时销毁另一个是可以的,因为无论这些操作的顺序如何,一切都会正常进行。其中之一将减少引用计数;另一个会增加它。但是因为引用计数从 2 开始,所以引用计数永远不会达到 0。

但是,如果您要复制您要销毁的同一个对象……那么,这只是代码损坏。请注意,虽然std::shared_ptr 明确指出,在共享状态下对引用计数的更新是原子的并且不会导致数据争用,但从不同线程对同一shared_ptr 对象的多次访问是数据争用(因此是未定义的行为)。只要至少其中一个访问不是const 操作;复制是 const 操作,但 destruction 不是,所以它适用。

你的smart_ptr 也必须如此:如果你试图复制你正在销毁的同一个对象,那么糟糕的事情就会发生。

【讨论】:

  • 感谢 Nicol 对 refs.fetch_sub..==1 的更正。你的解释很有道理。
【解决方案2】:

通常(至少对于 boost 和标准库智能指针)智能指针对象本身并非设计为线程安全的。只有它们指向的对象的管理/生命周期是线程安全的。 smart_ptr 对象本身在多个线程中同时使用是不安全的,但是让多个不同的 smart_ptr 对象引用相同的基础数据是安全的,这些对象都同时使用。

在您的示例中,在不同线程中创建的“全新 smart_ptr”必须从某些现有 smart_ptr 复制。

如果现有的 smart_ptr 不是被破坏的那个,它将确保 if 分支永远不会在析构函数中被使用,因为它会使对象保持活动状态。

如果现有的 smart_ptr 是在您的示例中被破坏的那个,那么您就有问题了,但这是因为您正在尝试使用正在被破坏的 smart_ptr 对象。即使在这种情况下析构函数是无竞争的,其他线程仍然有可能在 smart_ptr 被销毁后继续使用它,这在 C++ 中总是非法的。

【讨论】:

  • 不仅“一般”,如果用户进行不安全的操作,在不安全语言中也无法保证安全。这包括使用对在使用时消失的变量的引用,或者通过以不正确的方式使用delete 管理的显式内存,滥用智能ptr,或者只是引用正在运行的函数中的局部变量在另一个被破坏的线程中。只有在像“安全”语言这样的 Java 中,您可能对任意代码有一些语义保证(在 MT 代码的情况下,保证比许多人认为的)。
【解决方案3】:

简单地说,没有。

一种竞争条件,即最终结果取决于特定的操作顺序,只有在引用计数上才有可能,一个操作可以使一个对计数有贡献的对象从一个没有,即当你有弱引用时

这里算作竞速端点的是资源是否因为 refcount (RC) 达到零而被释放; “哪个线程执行的哪个确切操作使 RC 为零”这个问题是一个有趣的端点:使用 RC 在多线程上下文中管理资源的隐含假设是任何线程(具有最后一个所有者)都可以释放资源。

根据定义,RC 是每个所有者对 RC 的严格积极贡献的总和(恰好为 1,因为 RC 是所有者的数量,但这不是很重要)。在抽象设置中,RC 也可以形式化为所有者集合,并且由于 RC 的特性,整数将是所需信息的有效表示:

  • 每个所有者都知道它在集合中
  • 业主互不认识
  • 每个所有者需要在添加到集合时将数字增加一定数量(根据定义为 1,但可以是任何严格的正数),并在从集合中移除时减少相同的数量李>

因此,您可以将数字本质上想象为所有者列表,每个所有者都由一条垂直线表示,就像孩子们学习数字时一样 (3 = |||),并且只有单个所有者知道自己的栏(您可以说所有条形都相同或颜色不同)。 (整数显然在物理上以二进制表示。)

在只有所有者存在的设置中(没有操作可以通过“弱引用”来引用 RC),对于所有者集只有两个基本操作:

  • 复制所有者:从所有者中创建新所有者
  • 移除所有者

复制只是添加了一个竖线。在图形显示中,您甚至可以通过擦除中间的条来拆分条,以两个半条结束。这是所有权被解散(就像您出售了交易公司的部分股份一样)。

删除操作会擦除属于所有者的竖线(当然在实践中没有标识的竖线,根本没有竖线,它是对二进制表示的整数的减量操作); 如果该操作删除了那个最后一根,那么删除线程负责释放资源

您可以很容易地看到,零 RC 对应于一组空的柱,这发生在所有所有者都放弃拥有之后。有一个竞争条件可以确定在任何给定时间的确切柱数,但这是一个细节:每个线程中的每个所有者都知道他是所有者,这很重要。它本质上与 其他 所有者的数量无关。 (如果您不是无动于衷,那么您可能首先希望拥有唯一的所有权,以便能够在不影响其他用户的情况下更改资源。)

在集合上存在竞争条件意味着必须使用内部原子(或类似的替代方案),但所有者一般不应该关心。如果在表示中意外删除了一个特定的“条”,这将是灾难性的,这意味着没有考虑所有者并且它将是僵尸所有者:它会相信它拥有资源,但实际上它不会拥有任何东西.原子 RMW(读修改写)操作保证不会发生:任何被 RMW 操作独占修改的原子对象不能丢失修改

所有权的概念暗示它不能从无到有:你只能成为某物的所有者:

  • 通过创造那个东西
  • 或从已经拥有一些所有权的人那里获得所有权(一些)

这只是常识。

由于所有权份额的销毁是不可逆转的,因此达到零所有者是一个终止事件;到那时,RC就保证不会再被改变,甚至不会被再次测量。 (因此 RC 表示的所有权可以叠加到资源本身的所有权上。)

这些属性使 RC 实现真正的所有权变得非常简单。当弱引用(即 RC-only watcher)进入图片时,情况就不同了:RC 测量工具保证它可以在未来的任何时候对 RC 进行测量,无论是无论用户资源是否仍然存在,受管用户资源是否仍然具有所有者。对于弱引用,原子操作可以将 RC 读取为零,即所有者集合的图形表示中没有竖线。这意味着 RC 的生命周期与用户资源的生命周期不同:RC 本身成为另一个托管资源(通过内部 RC)。

弱引用允许在不共享现有所有权的情况下创建所有者:弱引用是未来所有权的(不可靠的)“选项”。虽然弱引用只能从真实(即强)引用创建,但这与所有权的一般原则相冲突。

所以只有使用弱引用,RC 可以持久为零,即在最后一次减少操作和释放 RC 本身之间的小间隔之外为零。使用弱引用意味着用户代码必须设计为处理零 RC 的可能性,并且在 MT 代码中,一个线程放弃作为最后一个所有者的所有权与另一个试图从弱引用重新创建所有权的线程之间可能存在竞争。参考。

在现实生活中,传统文化知识是免费的,可以随意复制。但是对于极少数人知道的历史传统知识(例如家庭烹饪食谱),您最好在拥有该知识的最后一个人死亡之前复制:在传统知识死亡的人和人之间存在种族获取和传播这些知识。这与弱引用/强引用竞争问题本质上相同。

因此,弱引用对于模拟现实世界中的问题非常有用,其中观察者无法在外部因素破坏资源时强制保持资源存活,但可以在资源存在时观察资源的演变。将弱引用提升为强引用会在转换完成的那一刻对资源的活跃度进行快照,并且本质上是活泼的。

请注意,任何对许多原语的有意义的使用在某种程度上都是不正当的:您使用互斥锁是因为您不知道哪个线程首先需要独占访问资源;如果您知道确切的执行顺序,您将序列化线程并完全避免线程的复杂性。获取资源的竞争不是错误。只有当程序执行的正确性取决于特定的事件顺序时,才会出现错误。

我们知道下面的析构代码应该释放控制 如果这是最后一个 smart_ptr 指向的资源,则阻止 管理。

是的,这是正确的,当 RC 的有用生命周期达到零时结束时的理想行为,即实现真正的所有权并且不支持弱引用。这对于 Boost 或标准 shared_ptr 是正确的,它确实支持“弱”指针,即非拥有的观察者。

虽然你在语义上提到的竞争条件是不可能的,但这里有问题:

~smart_ptr() {
  if (control_block_ptr->refs.fetch_sub(1, memory_order_acq_rel) == 0) {
    delete control_block_ptr;
  }
}

如前所述,当不存在纯观察者(非拥有且可以看到零 RC)时,RC 的生命周期与托管用户资源相同。我没有看到用户资源 here 的释放(它可能在其他地方)。

用户资源本身在哪里管理?它在*control_block_ptr 对象的析构函数中吗?能不能多发点代码来个全图?

您还使用了 post-递减操作fetch_sub 代替了预递减操作:“post”操作返回前一个值然后执行操作。 在后期操作中,您想要操作的特别有趣的 RC 值,在最后一个所有者不再是所有者之前的值是 1,而不是 0

【讨论】:

    【解决方案4】:

    如果构造函数没有错误,那么它会看到引用计数已经为零,因此销毁过程已经开始并且是不可逆的。所以从其他线程的 POV 来看,当 refcount 达到 0 时,该对象已经被销毁,即使 delete 实际上还没有释放内存。

    另外,如果析构函数将引用计数归零,则意味着不再shared_pointer 对象可以从中复制构造。 (除非您有对象本身的释放后使用错误,而不是控制块。在这种情况下,您使用错误:通常您不会引用 shared_ptr 对象。)。

    所以如果我没记错 shared_ptr 的工作原理,那么这个问题就不存在了。 (因此,为什么可以将引用计数保留在 将被释放的对象中。在非垃圾收集环境中,回收问题很困难,因为您无法释放其他线程可能会释放的内存仍然有一个指向的指针。有关挑战的示例,请参阅用户空间与内核 RCU 实现。无锁链表队列可能总是将节点返回到该类型对象的专用空闲列表,不是 一个通用池,可以让它们作为其他东西重用或通过系统调用取消映射。)


    但是在引用计数对象的一般情况下,看到 refcount=0 意味着破坏已经过了不归路,并且您尝试获取对它的新引用失败了。 (即在销毁之后发生,按照原子计数器建立的全局顺序)。

    当然,这个“看见”会在 fetch_add(+1) 的返回值中。


    无论如何,这种尝试获取新引用的设计使得在您的减量使引用计数为零之后继续释放是安全的。

    【讨论】:

    • "所以如果我没记错 shared_ptr 是如何工作的,这个问题就不存在了" 实际上真正的shared_ptr 管理用户资源的生命周期,并且它的元信息的生命周期可能比前一个信息的寿命更长,因此它更加微妙。发布的代码适用于没有弱引用(又名观察者)的简单共享所有者智能 ptr。
    • @curiousguy:是的,这正是我要说的。 Nicol Bolas 比我更详细和清晰地解释了它:它是 shared_ptr 的复制构造函数在析构函数将引用计数变为 0 后运行的 UB。因为只有在没有更多 shared_ptr 时才会发生这种情况。引用控制块的对象。 weak_ref 可以使控制块保持更长时间仍然会产生构造函数可能无法在最后一个引用死亡之前获得引用的问题。 (相反,它使检查总引用计数实际上为零更加复杂)。
    • 我们同意这一点,我只是指出shared_ptr 比 Q 中描述的智能 ptr 更多。
    猜你喜欢
    • 1970-01-01
    • 2022-06-14
    • 1970-01-01
    • 1970-01-01
    • 2011-09-21
    • 1970-01-01
    • 1970-01-01
    • 2020-07-16
    • 2011-03-28
    相关资源
    最近更新 更多