【问题标题】:Basic spin-lock mutex implementation ordering基本自旋锁互斥体实现排序
【发布时间】:2015-08-21 20:33:16
【问题描述】:

有一个流行的自旋锁互斥版本,它在 Internet 上传播,人们可能会在 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 用于test_and_set,这是一个RMW 操作。为什么不是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 个互斥锁同时进入临界区。为了防止这种情况发生,2 locks 之间应该有 happens before 关系。有人可以使用 C++ 标准概念向我展示代码是完全安全的吗?

更新 2:

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

[atomics.order] 第 11 条

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

关于这个主要问题,我可以很高兴地结束这个问题,但我仍然有疑问。 in the modification order 部分呢? 标准很清楚:

[intro.multithread] 第 8 条

对特定原子对象 M 的所有修改都发生在某些 特定的总阶,称为 M 的修改阶。如果 A 和 B 是原子对象 M 的修改,而 A 发生在之前(如定义 下面)B,则 A 应按 M 的修改顺序在 B 之前, 定义如下。

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

更新 3:

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

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

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

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

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

  1. 与之前的release形成sync with关系
  2. 参与release sequence。 但这只有在第一个 unlock 完成后才是正确的。

在此之前,如果我们有 2+ 个线程同时运行我们的 lock 代码,那么我们可以同时输入 pass lock,因为 2 个 acquire 操作不会形成任何类型的关系。它们与放松操作一样无序。由于它们是无序的,我们不能使用任何关于 RMW 操作的特殊子句,因为没有 happens before 关系,因此 locked 标志没有修改顺序。

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

【问题讨论】:

  • 为什么while循环中没有sleep(0)。正如所写的那样,我们在旋转时根本不屈服,所以我们将继续旋转,直到我们的时间片结束。这可能会导致性能不佳,尤其是在单核机器上。
  • @doron,因为这个实现是为了展示如何使用原子而不是如何编写好的自旋锁。当然,这个实现是幼稚的,但它绝不意味着更多。顺便说一句,C++ 中有一个yield 函数,所以你不需要使用sleep(0)

标签: c++ multithreading atomic


【解决方案1】:

我认为您缺少的是 test_and_set 是原子的,句号。没有使此操作不是原子的内存排序设置。如果我们只需要一个原子测试和设置,我们可以指定任何内存顺序。

但是,在这种情况下,我们需要的不仅仅是原子的“测试和设置”操作。我们需要确保在我们确认锁是我们要获取的锁之后执行的内存操作在我们观察到互斥锁被解锁之前不会重新排序。 (因为这些操作不会是原子操作。)

考虑:

  1. 某些数据读取不受互斥体保护。
  2. 对不受互斥体保护的数据进行一些写入。
  3. 我们尝试锁定互斥体。
  4. 我们看到互斥体已锁定,但未能锁定它。
  5. 我们看到互斥体已解锁并自动锁定它。
  6. 一些受互斥锁保护的数据读取。
  7. 对受互斥体保护的数据进行一些写入。

什么是不能发生的事情?就是在第 6 步和第 7 步中的读取和写入以某种方式重新排序到第 5 步之前,踩到另一个线程访问受互斥体保护的共享数据。

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

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

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

正是我们需要的。

【讨论】:

  • “所以第 4 步和第 5 步本质上是安全的”不,它们不是。原子意味着不可分割的写作,仅此而已。它们本质上并不安全。其他线程可能仍会看到旧值。为了添加更多“逻辑”,所有这些sync with 的东西都被发明了。请查看问题的更新。
  • 我猜隐含的假设(我认为成立)是原子的“test_and_set”操作对其他内核也有明显的影响。同一核心上的线程应该看到内存完全相同。当拥有核心本地缓存时,有时需要额外的同步。
  • @DavidSchwartz 我知道“测试和设置”是原子的,问题是它正在测试 什么。如果没有memory_order_acquire(如在 OP 的代码中),它可以测试核心缓存中的值而不是内存中的值。但是如果不将其设为释放操作,即使其他内核使用memory_order_acquire,操作的设置部分也可能对其他内核不可见。如果您似乎相信,test-and-set 总是绕过本地缓存,那么在示例代码中使用memory_order_acquire(以及后来的memory_order_release)的目的是什么?
  • @DavidSchwartz 看到我上面的评论 - “等等,我想我现在明白了。获取/释放顺序是针对在自旋锁被持有时发生在其他内存中的写入/读取?”从 29.3 开始:指定 memory_order_relaxed 的原子操作在内存排序方面放宽了。实现仍然必须保证对特定原子对象的任何给定原子访问相对于对该对象的所有其他原子访问都是不可分割的 - 这完全支持你的答案,我现在已经赞成。
  • 我的调查让我得出结论,你是对的,我是错的。感谢您的讨论,因为它对我帮助很大 - 它动摇了我的信念:) 我为这篇文章设置了 +1 并将@davmac 的帖子标记为答案,因为他的帖子有更多关于该主题的 C++ 信息,这对那些后来偶然发现这篇文章的人。谢谢你们,我消除了一个愚蠢的误解。
【解决方案2】:

谁能告诉我,使用 C++ 标准概念,代码是完全安全的?

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

完整的推理链:

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

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

1.10 p6:

对特定原子对象 M 的所有修改都以某个特定的总顺序发生,称为 M 的修改顺序 ...

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

第 6 段接着说:

...如果 A 和 B 是原子对象 M 的修改,并且 A 发生在(如下定义)B 之前,则 A 应按照 M 的修改顺序在 B 之前,定义如下。 [注:这表明修改命令必须尊重“发生在之前”的关系。 ——尾注]

在两个线程中发生的两个测试和设置操作之间没有“发生在之前”的关系,因此我们无法确定修改顺序中哪个先发生;然而,由于 p6 中的第一句话(它表明存在一个全序),一个肯定在另一个之前。现在,从 29.3 p12 开始:

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

这说明了test-and-setordered第二个必须看到test-and-setordered第一个写入的值。任何获取/释放选择都不会影响这一点。

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

对问题“更新 2”的回应:

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

正确的是没有“发生在之前”关系,但错误的是 RMW 操作需要这种关系才能保证最新的写入值。您列为“[atomics.order] 第 11 条”的语句不需要“发生在”关系,只是一个操作在原子标志的“修改顺序”中位于另一个操作之前。第 8 条规定会有这样的顺序,而且会是全顺序:

对特定原子对象 M 的所有修改都以某个特定的总顺序发生,称为 M 的修改顺序 ...

...然后它继续说总排序必须与任何“发生在之前”的关系一致:

... 如果 A 和 B 是原子对象 M 的修改,并且 A 发生在(定义如下)B 之前,则 A 应按照 M 的修改顺序在 B 之前,定义如下。

但是,在没有“发生在”关系的情况下,仍然存在总排序——只是这种排序具有一定程度的任意性。也就是说,如果A和B之间没有“发生在之前”的关系,那么没有指定A是在B之前还是在B之后排序。但必须是其中之一,因为存在一个特定的总顺序

那么为什么需要 memory_order_acquire?

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

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

进一步的证据:

首先,来自 29.3 的注释:

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

这实质上是说指定的内存顺序不会影响原子操作本身。访问必须“相对于所有其他原子访问是不可分割的”包括来自其他线程的访问。允许两个 test-and-set 操作读取相同的值实际上是将它们中的至少一个相除,因此它不再是原子的。

另外,从 1.10 第 5 段开始:

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

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

“宽松”原子操作不是同步操作,尽管与同步操作一样,它们不会导致数据竞争

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

1.10 p8:

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

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

尤其是 1.10 p19:

[ 注意:前面的四个一致性要求实际上不允许编译器将原子操作重新排序到单个对象,即使这两个操作都是宽松的加载。这有效地使缓存一致性 C++ 原子操作可用的大多数硬件提供的保证。 ——尾注]

请注意 缓存一致性 的提及,即使在存在松弛负载的情况下也是如此。这清楚地表明 test-and-set 一次只能在一个线程中成功,因为如果一个线程失败,要么缓存一致性被破坏,要么操作不是原子的。

【讨论】:

  • 我不同意。我不明白为什么你们都在谈论原子性,而它在此处完全无关紧要。 “相对于对该对象的所有其他原子访问而言,对该特定原子对象的访问是不可分割的” - 只是保证您无法在不同的线程中看到部分写入的对象。就这样。它没有说明如果设置了变量,那么它将很快在另一个线程中可见。只有发生在关系保证它之前。我们的问题中没有它。
  • 所以第一个线程生成t-a-s,将标志变量写入写缓冲区,第二个线程也一样。实际上他们都进入了临界区。什么会阻止他们这样做?没有内存屏障就无法实现正确的行为,但根据答案,我们甚至可以使用完全不产生屏障的relaxed 对象。那为什么要对整个执行模型大惊小怪呢?
  • ""对特定原子对象的访问相对于对该对象的所有其他原子访问是不可分割的" - 只是保证您无法在不同的线程中看到部分写入的对象。仅此而已。 - 但 test-and-set 操作本身是原子的。从 29.7 p5 开始:“效果:以原子方式将 object 或 this 指向的值设置为 true。根据 order 的值影响内存。这些操作是原子读取-修改-写入操作 (1.10)。”
  • @ixSci “所有这些原子的东西真的无关紧要” - 它不是,因为“原子”意味着缓存一致性,也就是说,原子标志的变化会立即在其他线程中看到。请参阅 1.10p19(我已修改答案以包含文本)
  • @ixSci 很清楚,就在文本中:对特定原子对象 M 的所有修改都以特定的总顺序发生,句号。您不能仅仅声称这种排序的存在需要发生之前。是的,我把这个子句分成两半,但这只是因为第一句话适用于你的例子,而其余的则不适用。该子句的其余部分用于强制 if 存在发生在关系之前,总排序尊重它。甚至还有一个说明。
【解决方案3】:

如您所说,test_and_set 是 RMW 操作。然而,为了测试,读取正确的值很重要。因此,memory_order_acquire 似乎就足够了。

另见http://en.cppreference.com/w/cpp/atomic/memory_order中的表Constants

【讨论】:

  • 我知道。我不明白为什么人们使用std::memory_order_acquire
  • 你是对的。我没有仔细阅读你的问题。请看看我编辑的答案。
  • 我看不出我们如何断言在我们有 2 个无序操作时读取了正确的值。据我了解,2 个 acquire 操作与 2 个 relaxed 操作无关。两个操作都进行写入,并且不与另一个同步。
  • 再试一次(对不起,我很难解释):对于锁操作,你需要test_and_set操作。 test_and_set 操作通常是原子的。因此,我们唯一需要确保的是,test_and_set 之前的内存写入不会重新排序。 “这确保了释放相同原子变量的其他线程中的所有写入在当前线程中都是可见的。”因此,如果一个线程获得了锁,那么其他线程中的“测试”操作“失败”并且不会导致集合,因为在那里写入肯定是可见的。
  • 你能用 C++ 标准术语写出你的意思吗?因为我仍然不明白为什么我们在这里有任何保证。设test_and_set(std::memory_order_acquire) 为 T1 中的操作 A 和 T2 中的操作 B。然后我看不到它们之间有任何发生之前关系,据我所知,它们只是没有形成任何关系。抛开标准:让这些操作在执行前有read_barrers。他们俩都可以将locked 放入相应CPU 的write buffers 中,如果我们只提供获取(又名读屏障)操作,我看不出是什么阻止了CPU 这样做。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-03-22
  • 1970-01-01
  • 2021-03-02
相关资源
最近更新 更多