多线程读写与对齐int

时间:2016-03-04 00:55:08

标签: c++ multithreading race-condition lock-free

我有以下程序。

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 platform
  2. 每次调用onTimer时,结果都必须是最新的,以便使用m_d.d1m_d.d2的最新值而不是缓存值来计算总和
  3. 正常如果在onTimer期间调用update,则使用更新的m_d.d1和旧m_d.d2计算总和。
  4. 类对象自然对齐
  5. 无需担心重新排序
  6. 速度至关重要
  7. 然后我是否需要执行以下任何操作

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

5 个答案:

答案 0 :(得分:2)

编译器可以重新排列代码的顺序,并且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;
}

答案 1 :(得分:1)

因为您提到如果onTimer观察到部分更新的m_d,则表示没问题,您不需要使用互斥锁来保护整个对象。但是,C ++不保证int的原子性。为了获得最大的可移植性和正确性,您应该使用atomic int。原子操作允许您指定memory order,声明您需要什么样的保证。因为您说onTimer不使用缓存值至关重要,我建议您使用" Release-Acquire order。"这不像std::atomic使用的默认排序那么严格,但这只是你需要的:

  

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

使用上述指南,您的代码可能如下所示。请注意,您无法使用operator T()的{​​{1}}转换,因为它等同于atomic_int,默认为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加载或内存栅栏指令。

答案 2 :(得分:0)

这里唯一可行的答案是std::mutex

还有原子操作库。在给定条件3的情况下,您可能可以使用一对原子int来逃避。不过,我会推荐一个老式的互斥保护对象。更少的惊喜。

答案 3 :(得分:0)

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

答案 4 :(得分:-1)

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

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

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

相关问题