【问题标题】:Is std::optional's assignment operator 'effectively atomic' for address types?对于地址类型,std::optional 的赋值运算符是否“有效原子”?
【发布时间】:2020-07-16 20:35:31
【问题描述】:

我所说的足够原子性是指其行为方式与 Linux 地址类型相同。在 Linux 上,如果您有一个代表地址的变量,无论是引用还是指针赋值,都可以保证只能以原子方式观察。

因此您可以执行以下操作。

在某处定义

int *my_ptr = nullptr;

然后在两个单独的线程中你可以得到代码

my_ptr = new int();

if (my_ptr) {
    *my_ptr = 1;
}

并且一切都应该正常工作,这意味着 my_ptr 不会指向一个垃圾地址,该地址是nullptr 的一半和new int() 返回的地址的一半混合。它要么是nullptr 并且什么都不做,要么是它被初始化并将my_ptr 指向的值设置为1

我的问题是,如果my_ptr 的类型更改为std::optional<int*> 甚至std::optional<std::wrapped_reference<int>>,同样的属性是否适用?

【问题讨论】:

  • if (my_ptr) { *my_ptr = 1;} -- 这不是原子的。如果在初始测试之后和分配之前,其他一些线程将my_ptr 更改为nullptr
  • “在 Linux 上,如果你有一个代表地址的变量,无论是引用还是指针赋值,都只能以原子方式观察。” - 你从哪里学来的?因为那不是真的(或者至少不是你想的那样)。您的整个问题都是基于一个不正确的假设。
  • @PaulMcKenzie 这是一个糟糕的措辞选择。我不知道该怎么称呼这个概念。我只打算设置一次变量,这样就可以了。
  • @SebastianRedl 我找到了提到它的文章,链接在这里。 lwn.net/Articles/262464。这家伙似乎很有名,我认为他不太可能弄错了。然而,这篇文章来自 2007 年。如果您向下滚动到“快速测验 2”,您会看到它被提及。为方便起见,我将在下一条评论中发布摘录。
  • "答案:在所有运行 Linux 的系统上,指针的加载和存储都是原子的,也就是说,如果对指针的存储与从同一指针的加载同时发生,则加载将返回初始值或存储的值,而不是两者的按位混搭。此外,list_for_each_entry_rcu() 总是在列表中前进,从不回头。因此,list_for_each_entry_rcu() 将看到正在添加的元素通过 list_add_rcu(),或者它不会,但无论哪种方式,它都会看到一个有效的格式良好的列表。”

标签: c++ optional


【解决方案1】:

欢迎其他答案,因为我对此并不完全确定。

我做了一些研究,据我所知,std::optional 并没有有效 像我描述的 gcc standard library 那样是原子的。

  template<typename... _Args>
    void
    _M_construct(_Args&&... __args)
    noexcept(is_nothrow_constructible_v<_Stored_type, _Args...>)
    {
      ::new ((void *) std::__addressof(this->_M_payload))
        _Stored_type(std::forward<_Args>(__args)...);
      this->_M_engaged = true;
    }

这似乎是创建std::optional 时使用的函数。在这种情况下this-&gt;_M_engaged 表示std::optional 是否被初始化,上面的行只是存储数据类型。由于在运行时没有什么可以阻止指令重新排序,因此可以在存储实际数据类型的前一行的结果之前看到 this-&gt;_M_engaged = true; 的结果。所以在代码段中的问题案例中(修改为支持std::optional

if (my_ptr.has_value()) {
    **my_ptr = 1;
}

如果my_ptrstd::optional&lt;int*&gt;,那么my_ptr.has_value() 可以返回true,即使my_ptr.value() 返回nullptr

我也检查了读取可选的功能,似乎也没有任何东西阻止指令重新排序。即使在包含中,似乎也没有类似的东西。

【讨论】:

  • 我认为这是正确的答案。 Linux 可能会说“我们只在对正确对齐、指针大小的内存位置的冲突访问保证是原子的平台上运行”,但这并不意味着编译器必须实际提供@987654335 的任何排序保证@变量。
【解决方案2】:

Linux 与它无关;这是编译器的事。该编译器受 ABI 约束,但 ABI 没有说明这一点。所以你回退到 C++ 标准,这使它成为未定义的行为,对于 int*std::optional&lt;anything&gt;

这不仅意味着“一半的任务”,还意味着“格式化你的硬盘”。 Linux 特定部分可能会添加一些实际限制,例如“仅当您以 root 身份运行时”,但删除 $HOME 是公平的游戏。

现在,至于为什么会发生这种情况,基本原因是因为标准是这样说的。这意味着优化器在处理您的代码时不受限制。如果觉得暂时将该指针的存储空间用于其他目的(例如换出寄存器)很有用,那完全没问题 - 正是因为其他线程无法看到非原子更新。当然,编译器可以选择另一个内存位置,但是如果编译器只是将您的指针加载到寄存器 A 中,然后需要某个地方来存储寄存器 B - 嘿,猜猜 CPU 缓存中已经存在什么?只有一个线程需要指针值,并且该线程将使用寄存器 A 而不是内存。

【讨论】:

  • 感谢您的回答。我对您说编译器可以将 my_ptr 的内存用于其他用途感到有些困惑。因为即使使用互斥锁促进了对它的访问,该值也可能在多线程环境中包含垃圾。这是否意味着您必须用volatile 标记所有可以在多线程环境中访问的变量?如果是这样,我就犯了一个大错误。
  • @J.Rehbein:更糟糕的是,volatile 也不起作用。你需要互斥锁。那时,C++ 标准开始生效,它不再是未定义的行为。 C++ 标准现在告诉您变量的使用是sequenced-after 互斥锁。反过来,优化器必须确保在锁之外的内存是最新的。通常,优化器通过将互斥锁和解锁点标记为实际系统状态必须与逻辑状态匹配的点来做到这一点,即内存至少是最新的缓存级别。 std::atomic 更细粒度。
  • 我有点惊讶编译器能够弄清楚这一点。我认为会有一些退化的停止问题类型案例,它只是不知道。知道这一点非常有用。我想我应该在某个时候读一本关于这些东西的书。我一直都知道多线程很难,但这看起来很疯狂。
  • @J.Rehbein:会有边缘情况,是的。但是一般来说你无法证明优化器不能证明的东西,所以你不能滥用它。停止定理是非建设性的。
【解决方案3】:

并且一切都应该正常工作,这意味着 my_ptr 没有指向 到垃圾地址

请注意,C++11 标准明确指出数据竞争会导致未定义的行为。未定义的行为意味着您的程序可能偶尔会“正常”运行,但也可能会格式化您的 HDD。 :-)

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-10-24
    相关资源
    最近更新 更多