【问题标题】:Can I replace the atomic with a volatile in an one-reader/one-writer queue?我可以在单读/单写队列中用 volatile 替换原子吗?
【发布时间】:2020-02-19 04:05:44
【问题描述】:

让我们考虑以下使用链表实现的单读/单写队列。

struct queue {
  queue() {
    tail = head = &reserved;
    n = 0;
  }
  void push(item *it) {
     tail->next = it;
     tail = it;
     n++;
  }
  item* pop() {
     while (n == used);
     ++used;
     head = head->next;
     return head;
  }

  item reserved;
  item *tail, *head;

  int used = 0;
  std::atomic <int> n;
}

现在我发现使用volatile int n 可以使我的编写器运行得更快,但我不确定它是否能保证head = head-&gt;next 始终可以读取正确的值。

更新:如果在tail-&gt;next、n++ 之间添加原子操作会怎样,即,

void push(item *it) {
   tail->next = it;
   tail = it;
   a.store(0, std::memory_order_release);
   a.load(std::memory_order_acquire);
   n++;
}

其中a 永远不会被读者访问?这会保证tail-&gt;next = it 和head = head-&gt;next 的顺序吗? (不过,它比使用 atomic n 运行得更快)

【问题讨论】:

  • 在线程协调问题之外,在我看来,这段代码有几个问题。首先,当你完成节点时,你不能释放它们,因为最后一个消耗的节点必须保持活动状态才能保持队列有效。
  • 其他问题:1)head = head->next;返回头;看起来像一个明显的错误,将来程序员将“修复”而没有意识到您的实现依赖于节点的异常延长寿命(这将很难或不可能使用原始指针实现)。 2) 当您遇到实现定义的 used 和 n 溢出行为时会发生什么? 3)如果你的作者让你的读者陷入困境(也许是不可能的)会发生什么?
  • volatile 与线程无关,请改用原子类型或互斥锁。
  • @Jarod42 是什么让你说 volatile 不能用于线程?
  • "a.load(std::memory_order_acquire);" 这到底是什么意思?你没有使用结果!

标签: c++ multithreading atomic volatile happens-before


【解决方案1】:

C++ 中的volatile 关键字不是为变量读/写保证在多线程环境中在代码中看起来如此有序的构造。因此,在您的代码中,仅使用 volatile 关键字将原子模板包装的计数器变为裸露,消费者线程观察到的计数器的增加并不能保证 item::next 也已更新。

为了在保证的情况下实现最大性能,我认为至少您必须在更新head-&gt;next 和计数器的增量之间插入一个写屏障,例如由n.fetch_add(1, std::memory_order_release) 和在获取tail-&gt;next 之前的读取屏障,如n.load(std::memory_order_acquire)。不过,我不知道 CPU 架构的具体细节。

【讨论】:

  • 我明白了。正如您所描述的那样,我只是在 tail->next = it 和 n++ 之间添加 k.store(0, std::memory_order_release) 的图像,其中 k 是读者从未访问过的原子变量。这是否会保证 tail->next 在 n++ 之前发生(因此在 head = head->next 之前发生),而无需读取器进行原子加载操作?
  • 我认为这会导致所谓的未定义行为。原子加载/存储功能不会被神奇的机器代码取代,以使世界在所有平台上都以某种方式顺序。而是通过它以 C++ 标准定义的方式向编译器显示要求以输出适当的机器代码,无论目标是哪个 CPU 架构。
【解决方案2】:

正如在其他几个 cmets 中已经指出的那样,volatile 与多线程无关,因此这里应该不使用它。然而,volatile 比 atmoic 表现更好的原因仅仅是因为 volatile ++n 转换为简单的加载、inc、存储指令,而 atomic 转换为更昂贵的 lock xadd(假设您为 x86 编译)。

但是由于这只是一个单读单写队列,所以不需要昂贵的读-修改-写操作:

struct queue {
  queue() {
    tail = head = &reserved;
    n = 0;
  }
  void push(item *it) {
     tail->next = it;
     tail = it;
     auto new_n = n.load(std::memory_order_relaxed) + 1;
     n.store(new_n, std::memory_order_release);
  }
  item* pop() {
     while (n.load(std::memory_order_acquire) == used);
     ++used;
     head = head->next;
     return head;
  }

  item reserved;
  item *tail, *head;

  int used = 0;
  std::atomic <int> n;
}

这应该与 volatile 版本一样好。如果pop 中的acquire-load “看到”了push 中store-release 写入的值,则这两个操作相互同步,从而建立所需的happens-before 关系。

【讨论】:

    猜你喜欢
    • 2019-08-11
    • 2011-10-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-11-29
    • 1970-01-01
    • 2018-05-27
    • 1970-01-01
    相关资源
    最近更新 更多