基本自旋锁互斥锁实现排序

时间:2015-06-07 07:26:32

标签: c++ multithreading atomic

有一种流行的自旋锁互斥锁版本,它在互联网上传播,可能会在Anthony Williams的书中遇到(C ++ Concurrency in Action)。这是:

class SpinLock
{
    std::atomic_flag locked;
public:
    SpinLock() :
        locked{ATOMIC_FLAG_INIT}
    {
    }
    void lock() 
    {
        while(locked.test_and_set(std::memory_order_acquire));
    }
    void unlock() 
    {
        locked.clear(std::memory_order_release);
    }
};

我不明白的是为什么每个人都使用std::memory_order_acquire作为RMW操作的test_and_set。为什么不是std::memory_acq_rel? 假设我们有2个线程同时尝试获取锁:

T1: test_and_set -> ret false
T2: test_and_set -> ret false

这种情况应该是可能的,因为我们有2次acquire次操作,彼此之间没有任何sync with关系。是的,在我们解锁互斥锁之后,我们进行release操作,后续release sequence并且生活变得丰富多彩,每个人都很开心。但是为什么在release sequence开始之前它是否安全?

由于很多人都提到了这个实现,我认为它应该可以正常工作。那我错过了什么?

更新1:

我完全理解该操作是原子操作,lockunlock之间的操作不能超出临界区。这不是问题。问题是我没有看到上面的代码如何防止2个互斥锁同时进入关键部分 。为了防止它发生,应该在<{em>}之间的关系之前发生。有人可以使用C ++标准概念向我展示代码是否完全安全?

更新2:

好的,我相信我们接近正确的答案。我在标准中找到了以下内容:

[atomics.order]第11条

  

原子读 - 修改 - 写操作应始终读取最后一个值   (在修改顺序)写入与写入关联之前   读 - 修改 - 写操作。

在这个重要的说明中,我可以愉快地结束这个问题,但我仍然怀疑。那么lock部分怎么样? 标准非常明确:

[intro.multithread]第8条

  

对特定原子对象M的所有修改都出现在某些原子对象中   特定的总订单,称为M的修改顺序。如果A和   B是原子对象M的修改,A 发生在之前(如定义的那样)   下面)B,那么A应该以M的修改顺序在B之前,   这定义如下。

因此,根据RMW操作具有最新写入值的该子句,最新的写操作应读取部分或RMW操作之前发生。在问题中不是这种情况。正确?

更新3:

我越来越认为自旋锁的代码被破坏了。这是我的推理。 C ++指定了3种类型的操作:

  • 获取,发布,获取 - 发布 - 这些是同步操作。
  • 放松 - 这些都不是同步操作
  • RMW - 这些是具有“特殊”特征的操作

让我们从RMW开始,找出他们的特别之处。首先,它们是形成in the modification order的宝贵资产,其次是上面引用的特殊条款([atomics.order]第11条)。我发现没什么特别的。

获取/发布是同步操作和release sequence,因此形成release sync with acquire关系。轻松的操作只是简单的原子,根本不参与修改顺序。

我们的代码中有什么?我们有一个使用获取内存语义的RMW操作,因此每当第一次解锁(释放)时,它就有两个角色:

  1. 与之前的happens before
  2. 形成sync with关系
  3. 它参与release。 但只有在第一个release sequence完成后才能成立。
  4. 在此之前,如果我们有2个以上的线程同时运行我们的unlock代码,那么我们可以同时输入lock,因为2 lock次操作不会形成任何类型关系。它们和放松的操作一样无序。由于它们是无序的,因此我们不能使用任何关于RMW操作的特殊条款,因为没有acquire关系,因此没有happens before标志的修改顺序。

    所以要么我的逻辑存在缺陷,要么代码被破坏。请知道真相的人 - 对此发表评论。

3 个答案:

答案 0 :(得分:14)

我认为你错过的是test_and_set是原子的,期间。没有内存排序设置使此操作不是原子操作。如果我们需要的只是一个原子测试和设置,我们可以指定任何内存排序。

然而,在这种情况下,我们需要的不仅仅是一个原子&#34;测试和设置&#34;操作。我们需要确保在我们确认锁是我们的之后执行的内存操作在我们观察互锁被解锁之前没有被重新订购。 (因为这些操作不会成为原子操作。)

考虑:

  1. 某些数据读取不受互斥锁保护。
  2. 有些人写入不受互斥锁保护的数据。
  3. 我们尝试锁定互斥锁。
  4. 我们看到互斥锁已锁定,无法锁定它。
  5. 我们看到互斥锁已解锁且原子锁定它。
  6. 对互斥锁保护的数据进行一些读取。
  7. 有些写入受互斥锁保护的数据。
  8. 什么是不可能发生的事情?它是步骤6和7中的读写以某种方式在步骤5之前被重新排序,踩在另一个线程上,在互斥锁的保护下访问共享数据。

    test_and_set操作已经是原子操作,因此步骤4和5本质上是安全的。并且步骤1和2不能修改受保护的数据(因为它们在我们甚至尝试锁定之前发生),因此在我们的锁定操作中重新排序它们没有坏处。

    但是第6步和第7步 - 在我们观察到锁被解锁之前,不得重新排序,以便我们可以原子地锁定它。那将是一场灾难。

    memory_order_acquire的定义:&#34; 具有此内存顺序的加载操作会对受影响的内存位置执行获取操作:在此加载之前,不能对当前线程中的内存访问进行重新排序。 &#34;

    正是我们需要的。

答案 1 :(得分:12)

  

有人可以使用C ++标准概念向我展示代码是否完全安全?

我最初和你有同样的担忧。我认为关键是要理解std::atomic_flag变量上的操作对于所有处理器/核心都是原子的。两个原子&#39;测试和设置&#39;无论指定的内存顺序如何,单独线程中的操作都不能同时成功,因为它们不能是原子的;该操作必须应用于实际变量,而不是缓存的本地副本(我认为,这甚至不是C ++中的概念)。

完整的推理链:

29.7 p5(谈论测试和设置操作):

  

效果:以原子方式将object指向的值或此值设置为true。内存受到订单价值的影响。这些操作是原子读 - 修改 - 写操作(1.10)。   返回:原子地,就是效果之前的对象的值。

1.10 p6:

  

对特定原子对象M的所有修改都以某种特定的总顺序出现,称为M ...的修改顺序

因此,如果在这种情况下两个线程同时尝试锁定自旋锁,则其中一个必须是第一个而另一个必须是第二个。我们现在只需要表明第二个必须返回标志已经设置,从而阻止该线程进入临界区。

第6段继续说:

  

......如果A和B是原子对象的修改M和A发生在(如下面定义的)B之前,那么A应该以M的修改顺序在B之前,其定义如下。 [注意:这表明修改订单必须遵守“之前发生”关系。 - 结束说明]

没有&#34;发生之前&#34;在两个线程中发生的两个测试和设置操作之间的关系,所以我们无法确定哪个在修改顺序中首先出现;然而,由于p6中的第一句话(其中表明存在总排序),一定必须先出现在另一个之前。现在,从29.3 p12开始:

  

原子读 - 修改 - 写操作应始终读取与读 - 修改 - 写操作相关的写操作之前写入的最后一个值(按修改顺序)。

这表明第二次测试和设置必须首先看到由测试和设置写入的值。任何获取/释放选择都不会影响这一点。

因此,如果执行两个测试和设置操作&#34;同时&#34;,它们将被赋予任意顺序,第二个将看到由第一个设置的标志值。 因此,为测试和设置操作指定的内存顺序约束无关紧要;它们用于控制在获取自旋锁的过程中对其他变量的写入顺序。

回复&#34;更新2&#34;问题:

  

因此,根据该条款,RMW操作具有最新的写入值,最新的写入操作应该在读取部分或RMW操作之前发生。在问题中不是这种情况。正确?

纠正在&#34;之前没有&#34;关系,但错误的是RMW操作需要这样的关系才能保证最新的书面价值。您列出的声明为&#34; [atomics.order]第11条和第34条;不需要&#34;发生之前&#34;在&#34;修改顺序&#34;中只有一个操作在另一个操作之前。为原子旗。第8条规定将有这样的命令,它将是一个完整的命令:

  

对特定原子对象M的所有修改都以某种特定的总顺序出现,称为M ...的修改顺序

...然后继续说总排序必须与任何&#34;在&#34;之前发生。关系:

  

......如果A和B是原子对象的修改M和A发生在之前(如下定义)B,则A应在M的修改顺序中位于B之前,其定义如下。

然而,在没有&#34;发生之前&#34;关系,仍然有一个总排序 - 它只是这个排序具有一定程度的随意性。也就是说,如果没有&#34;发生在&#34; A和B之间的关系,则不指定A是在B之前还是之后排序。但它必须是一个或另一个,因为特定的总订单

为什么需要memory_order_acquire呢?

螺旋锁等互斥锁通常用于保护其他非原子变量和数据结构。在锁定自旋锁时使用memory_order_acquire可确保从这些变量读取将看到正确的值(即由先前持有自旋锁的任何其他线程写入的值)。对于解锁,还需要memory_order_release以允许其他线程查看写入的值。

获取/释放都阻止编译器在锁的获取/释放之后重新排序读/写,并确保生成任何必要的指令以确保适当级别的高速缓存一致性。

进一步证据:

首先,本说明来自29.3:

  

注意:指定memory_order_relaxed的原子操作在内存排序方面是放宽的。实现必须仍然保证对特定原子对象的任何给定的原子访问对于该对象的所有其他原子访问都是不可分割的。 - 结束说明

这实质上是说指定的内存排序不会影响原子操作本身。对于所有其他原子访问,访问必须是不可分割的&#34; 包括来自其他线程的。允许两个测试和设置操作读取相同的值实际上将至少划分其中一个,这样它就不再是原子的。

另外,从1.10第5段开始:

  

此外,还有轻松的原子操作,它们不是同步操作,还有原子读 - 修改 - 写操作,它们具有特殊的特性。

(测试和设置属于后一类),尤其是:

  

“轻松”原子操作不是同步操作,即使像同步操作一样,它们也无法为数据竞争做出贡献

(强调我的)。两个线程同时执行原子测试和设置(并且都执行了&#39; set&#39;部分)的情况将是这样的数据竞争,因此该文本再次表明这不会发生。

1.10 p8:

  

注意:同步操作的规范定义何时读取另一个写入的值。对于原子对象,定义很清楚。

这意味着一个线程读取另一个线程写入的值。它说对于原子对象,定义是明确的,这意味着不需要其他同步 - 它足以对原子对象执行操作;其他线程会立即看到效果。

特别是,1.10 p19:

  

[注意:前面的四个一致性要求有效地禁止编译器将原子操作重新排序到单个对象,即使两个操作都是放松的加载。这有效地使缓存一致   大多数可用于C ++原子操作的硬件提供的保证。 - 结束说明]

即使存在宽松的负载,也请注意提及缓存一致性。这清楚地表明,测试和设置一次只能在一个线程中成功,因为如果一个线程失败,则高速缓存一致性被破坏或操作不是原子的。

答案 2 :(得分:1)

正如你所说,test_and_set是一个RMW操作。但是,对于测试,重要的是读取正确的值。因此,memory_order_acquire似乎已足够。

另请参阅http://en.cppreference.com/w/cpp/atomic/memory_order

中的表格Constants