对齐int的多线程读写

multi thread read write with aligned int

我有以下程序。

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. fine如果在update期间调用了onTimer,则用更新的m_d.d1和旧的[=15=计算总和].
  4. class 对象自然对齐
  5. 不用担心重新排序
  6. 速度很关键

那我需要做以下任何一项吗

  1. 使用 volatile 关键字,这样 m_d.d1m_d.d2 就不会存储在缓存中。
  2. 使用任何锁

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

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

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

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

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

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

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

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

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;
}

对于你的情况,我认为你不需要任何锁。如果您不使用内联函数,则可能也不需要 volatile 。

因为您提到如果 onTimer 观察到部分更新的 m_d 是可以的,您不需要保护整个对象的互斥锁。但是,C++ 不保证 int 的原子性。为了获得最大的可移植性和正确性,您应该使用 atomic int. Atomic operations allow you specify a memory order 来声明您需要什么样的保证。因为您说 onTimer 不使用缓存值很重要,所以我建议您使用 "Release-Acquire ordering." 这比 std::atomic 使用的默认顺序不那么严格,但这就是您在这里所需要的:

If an atomic store in thread A is tagged memory_order_release and an atomic load in thread B from the same variable is tagged memory_order_acquire, all memory writes (non-atomic and relaxed atomic) that happened-before the atomic store from the point of view of thread A, become visible side-effects in thread B, that is, once the atomic load is completed, thread B is guaranteed to see everything thread A wrote to memory.

使用上述指南,您的代码可能如下所示。请注意,您不能使用 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),但在这里进行尽职调查将有助于可移植性并消除不需要的编译器优化:

On strongly-ordered systems (x86, SPARC TSO, IBM mainframe), release-acquire ordering is automatic for the majority of operations. No additional CPU instructions are issued for this synchronization mode, only certain compiler optimizations are affected (e.g. the compiler is prohibited from moving non-atomic stores past the atomic store-release or perform non-atomic loads earlier than the atomic load-acquire). On weakly-ordered systems (ARM, Itanium, PowerPC), special CPU load or memory fence instructions have to be used.