【问题标题】:list::empty() multi-threaded behavior?list::empty() 多线程行为?
【发布时间】:2019-10-09 01:37:25
【问题描述】:

我有一个列表,我希望不同的线程从中获取元素。为了避免在列表为空时锁定保护列表的互斥锁,我在锁定之前检查了empty()

如果对 list::empty() 的调用在 100% 的情况下都不是正确的,那也没关系。我只想避免崩溃或中断并发的list::push()list::pop() 调用。

我是否可以安全地假设 VC++ 和 Gnu GCC 有时只会出错 empty() 并且不会更糟?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}

【问题讨论】:

  • 不,你不能这样假设。您可以使用像 VC 的 concurrent_queue 这样的并发容器
  • @Fureeish 这应该是一个答案。我要补充一点,std::list::size 保证了恒定的时间复杂度,这基本上意味着需要将大小(节点数)存储在单独的变量中;我们称之为size_std::list::empty 然后可能会返回 size_ == 0 的内容,并且 size_ 的并发读写会导致数据竞争,因此,UB。
  • @DanielLangr 如何测量“恒定时间”?是在单个函数调用上还是在整个程序上?
  • @curiousguy:DanielLangr 确实通过“独立于列表节点的数量”回答了您的问题,即 O(1) 的确切定义,这意味着每次调用都在不到某个恒定时间内执行,与元素数量无关。 en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions 另一个选项(直到 C++11)将是线性 = O(n) 意思是,该大小必须计算元素(链表),这对于并发性会更糟(更明显的数据竞争比计数器上的非原子读/写)。
  • @curiousguy:以你自己的 dV 为例,时间复杂度是相同的数学 - 限制。所有这些东西要么被递归定义,要么以“存在 C 使得每个 N 的 f(N) 平均,这意味着某些输入可能需要更长的时间来处理(例如需要重新散列/重新分配),但平均而言它仍然是恒定的(假设所有可能的输入)。跨度>

标签: c++ multithreading optimization race-condition stdlist


【解决方案1】:

如果对 list::empty() 的调用不是 100% 正确,那也没关系。

不,这不好。如果您在某些同步机制(锁定互斥锁)之外检查列表是否为空,那么您将遇到数据竞争。有数据竞争意味着你有未定义的行为。具有未定义的行为意味着我们无法再对程序进行推理,并且您获得的任何输出都是“正确的”。

如果您重视自己的理智,您会在检查前对性能造成影响并锁定互斥锁。也就是说,该列表甚至可能不是您的正确容器。如果您可以让我们确切地知道您在用它做什么,我们或许可以推荐一个更好的容器。

【讨论】:

  • 个人观点,调用list::empty()是读取动作,与race-condition无关
  • @NgọcKhánhNguyễn 如果他们将元素添加到列表中,那么当您同时写入和读取大小时,肯定会导致数据竞争。
  • @NgọcKhánhNguyễn 这是错误的。竞争条件是read-writewrite-write。如果您不相信我,请阅读the standard section on data races
  • @NgọcKhánhNguyễn:因为写入和读取都不能保证是原子的,因此可以同时执行,因此读取可能会完全错误(称为撕裂读取)。想象一下,在 little endian 8 位 MCU 中写入将 0x00FF 更改为 0x0100,首先将低 0xFF 重写为 0x00,现在读取正好为零,读取两个字节(写入线程减慢或暂停),通过将高字节更新为 0x01 继续写入,但是读取线程已经得到了错误的值(既不是 0x00FF,也不是 0x0100,而是意外的 0x0000)。
  • @NgọcKhánhNguyễn 它可能在某些架构上,但 C++ 虚拟机不提供这样的保证。即使您的硬件这样做了,编译器以您永远不会看到更改的方式优化代码也是合法的,因为除非存在线程同步,否则它可以假设它只运行一个线程并相应地进行优化。
【解决方案2】:

存在彼此不同步的读取和写入(很可能是std::listsize 成员,如果我们假设它是这样命名的)。想象一下,一个线程调用empty()(在你的外部if()),而另一个线程进入内部if()并执行pop_back()。然后,您正在读取一个可能正在修改的变量。这是未定义的行为。

【讨论】:

    【解决方案3】:

    作为一个可能出错的例子:

    一个足够聪明的编译器可以看到mutex.lock()不可能改变list.empty()的返回值,因此完全跳过内部if检查,最终导致列表上的pop_back在之后删除其最后一个元素第一个if

    为什么它可以做到这一点? list.empty() 中没有同步,因此如果它同时更改,将构成数据竞争。该标准规定程序不应存在数据竞争,因此编译器会认为这是理所当然的(否则它几乎不会执行任何优化)。因此,它可以对未同步的list.empty() 采取单线程视角并得出结论,它必须保持不变。

    这只是可能破坏您的代码的几种优化(或硬件行为)之一。

    【讨论】:

    • 当前的编译器似乎甚至不想优化a.load()+a.load() ...
    • @curiousguy 希望如何优化?你在那里要求完全的顺序一致性,所以你会得到......
    • @MaxLanghof 你不认为对a.load()*2 的优化很明显吗?无论如何,甚至a.load(rel)+b.load(rel)-a.load(rel) 都没有优化。没有什么是。为什么您希望锁(本质上大多具有 seq 一致性)得到更优化?
    • @curiousguy 因为非原子访问(这里是锁之前和之后)和原子访问的内存顺序完全不同?我不希望锁被“更多”优化,我希望非同步访问比顺序一致访问更优化。锁的存在与我的观点无关。不,不允许编译器将a.load() + a.load() 优化为2 * a.load()。如果您想了解更多信息,请随时提出相关问题。
    • @MaxLanghof 我不知道你想说什么。锁本质上是顺序一致的。为什么实现会尝试对某些线程原语(锁)而不是其他一些(原子)进行优化?您是否希望围绕原子的使用优化非原子访问?
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-02-15
    • 1970-01-01
    • 2015-11-05
    • 2012-02-29
    • 2022-12-01
    • 1970-01-01
    • 2011-05-25
    相关资源
    最近更新 更多