具有对齐 int 的多线程读写

multi thread read write with aligned int

本文关键字:多线程 读写 int 对齐      更新时间:2023-10-16

我有以下程序。

class A {
  struct {
    int d1;
    int d2;
  } m_d;
  int onTimer() {
    return  m_d.d1 + m_d.d2;
  }
  void update(int d1, int d2) {
    m_d.d1 = d1;
    m_d.d2 = d2;
  }
};

A::updateA::onTimer 由两个不同的线程调用。假设

  1. x64 平台
  2. 每次调用 onTimer 时,结果必须是最新的,以便使用 m_d.d1m_d.d2 的最新值而不是缓存值计算总和
  3. 如果在update期间调用onTimer,则使用更新的m_d.d1和旧m_d.d2计算总和是可以的。
  4. 类对象自然对齐
  5. 无需担心重新排序
  6. 速度至关重要

那么我是否需要执行以下任何一项操作

  1. 使用 volatile 关键字,以便m_d.d1m_d.d2不存储在缓存中。
  2. 使用任何锁

编译器可以重新排列代码的顺序,CPU 也可以对读取和存储重新排序。 如果你不在乎有时 m_d.d1 和 m_d.d2 将是来自不同调用 update() 的值,那么你不需要锁定。 了解这意味着您可能会获得旧的 m_d.d1 和新的 m_d.d2,反之亦然。 设置值的线程中代码的顺序不控制另一个线程看到值更改的顺序。 你说"5)不用担心重新排序",所以我说不需要锁定。

在 x86 int 上,mov 是"原子的",因为另一个读取相同 int 的线程将看到以前的值或新值,但看不到一些随机的位。 这意味着 m_d.d1 将始终是传递给 update() 的 d1,m_d.d2 也是如此。

易失性告诉编译器不使用值的缓存副本(在寄存器中)。 如果您有一个循环,该循环在另一个线程修改这些值时不断尝试添加这些值,则可能会发现易失性是必要的。

void func {
    // smart optimizing compiler might move d1 into AX and d2 into BX here,
    // OUTSIDE the loop, because the compiler doesn't see anything in 
    // the loop changing d1 or d2.  
    // The compiler does this because it saves 2 moves per iteration.
    // This is referred to as "caching values in registers"
    // by laymen like me.
    while (1) {
       printf("%d", m_d.d1 + m_d.d2);  // might be using same initially
                                       // "cached" AX, BX every iteration
    }
}

在您的示例中并非如此,因为您有一个添加它们的函数调用(除非函数是内联的)。 调用函数时,它不会在寄存器中缓存任何值,因此它必须从内存中获取副本。 我想如果你想真正确定没有任何东西被缓存过,你可以做这样的事情:

int onTimer() {
    auto p = (volatile A*)this;
    return  p->m_d.d1 + p->m_d.d2;
}

因为你提到如果onTimer观察部分更新的m_d是可以的,所以你不需要互斥锁来保护整个对象。 但是,C++不保证int的原子性。 为了获得最大的可移植性和正确性,您应该使用原子int。 原子操作允许您指定一个内存顺序,该顺序声明您需要哪种保证。 因为您说onTimer不使用缓存值至关重要,所以我建议您使用"发布-获取排序"。 这不如 std::atomic 使用的默认排序严格,但这就是您在这里所需要的:

如果线程 A 中的原子存储被标记为

memory_order_release并且线程 B 中来自同一变量的原子加载被标记为 memory_order_acquire ,则从线程 A 的角度来看,在原子存储之前发生的所有内存写入(非原子和松散原子)都会成为线程 B 中的可见副作用,也就是说,一旦原子加载完成, 线程 B 保证看到线程 A 写入内存的所有内容。

使用上述指南,您的代码可能如下所示。 请注意,您不能使用atomic_intoperator T()转换,因为它等效于 load() ,默认为std::memory_order_seq_cst排序,这对于您的需求来说过于严格。

class A {
  struct {
    std::atomic_int d1;
    std::atomic_int d2;
  } m_d;
  int onTimer() {
    return m_d.d1.load(std::memory_order_acquire) +
           m_d.d2.load(std::memory_order_acquire);
  }
  void update(int d1, int d2) {
    m_d.d1.store(d1, std::memory_order_release);
    m_d.d2.store(d2, std::memory_order_release);
  }
};

请注意,在您的情况下(x86_64),此排序应该是免费的,但在此处进行尽职调查将有助于可移植性并消除不需要的编译器优化:

在强序系统(x86、SPARC TSO、IBM 大型机)上,大多数操作的发布-获取排序是自动的。不会为此同步模式发出额外的 CPU 指令,只会影响某些编译器优化(例如,禁止编译器将非原子存储移动到原子存储释放之外或在原子加载获取之前执行非原子加载)。在弱序系统(ARM、Itanium、PowerPC)上,必须使用特殊的CPU负载或内存围栏指令。

这里唯一实际的答案是std::mutex

还有原子操作库。给定条件 3,您可能可以使用一对原子整数。尽管如此,我还是会推荐一个老式的互斥保护对象。更少的惊喜。

对于您的情况,我认为您不需要任何锁。如果您不使用内联函数,也许也不需要易失性。

我所知.您只有 1 个线程来修改数据。而且您不需要修改 m_d.d1 和 m_d.d2 即可成为原子操作。所以没有必要使用任何锁.

如果您有 2 个或更多线程来更新数据,并且新值与以前的值有关系,则可以使用 std::atomic<> 来保护它。

如果您需要更新2个或更多数据成为原子操作,则使用std::mutex来保护它们。