【问题标题】:C++11 Implementation of Spinlock using <atomic>使用 <atomic> 实现自旋锁的 C++11
【发布时间】:2018-10-11 17:03:13
【问题描述】:

我实现了 SpinLock 类,如下

struct Node {
    int number;
    std::atomic_bool latch;

    void add() {
        lock();
        number++;
        unlock();
    }
    void lock() {
        bool unlatched = false;
        while(!latch.compare_exchange_weak(unlatched, true, std::memory_order_acquire));
    }
    void unlock() {
        latch.store(false , std::memory_order_release);
    }
};

我实现了上面的类并创建了两个线程,每个线程调用同一 Node 类实例的 add() 方法 1000 万次。

不幸的是,结果不是 2000 万。 我在这里想念什么?

【问题讨论】:

  • 请注意,这是您可能实现的最糟糕的自旋锁。除其他问题外: 1) 当您最终获得lock 时,您将所有错误预测分支的母亲带离while 循环,这是最糟糕的时间。 2) lock 函数可以饿死在超线程 CPU 上同一虚拟内核中运行的另一个线程。
  • @DavidSchwartz,感谢您的 cmets。关于你提到的问题,我可以再问一下吗? 2)。该锁定功能可以使另一个线程饿死,(没错!,它的意思是这样做,因为我确信锁定的生命时间很短)。我可以使用一些旋转计数器来解决这个问题,对吗? 1)。为什么我在这段代码中使用“所有错误预测的brabches之母”?我该如何改进它?你上面有cmets吗?再次感谢您
  • 暂停可防止推测执行,消除分支预测错误的惩罚。 (而且,顺便说一句,如果你对这类东西一无所知,你就没有写自旋锁的生意。你会犯每一个错误,甚至没有意识到你还有其他选择。)
  • @DavidSchwartz,我对写这类东西很感兴趣,但我对这类东西一无所知。你能推荐一个人如何让自己彻底了解这一点吗?
  • @DavidSchwartz,听起来只有尝试编写这种代码才能获得足够的知识以允许尝试编写这种代码。

标签: c++ multithreading c++11


【解决方案1】:

问题是compare_exchange_weak 一旦失败就会更新unlatched 变量。来自compare_exchange_weak的文档:

将原子对象的包含值的内容与 预期的: - 如果为真,则将包含的值替换为 val(如 store)。 - 如果为 false,则将预期替换为包含的值。

即,在第一次失败 compare_exchange_weak 之后,unlatched 将更新为 true,因此下一次循环迭代将尝试 compare_exchange_weak truetrue。这成功了,您刚刚获得了另一个线程持有的锁。

解决方案: 确保在每个compare_exchange_weak 之前将unlatched 设置回false,例如:

while(!latch.compare_exchange_weak(unlatched, true, std::memory_order_acquire)) {
    unlatched = false;
}

【讨论】:

  • +1,并注意 OP,Node 的默认构造函数将按呈现方式初始化 number。 IE。 Node node; 不会在 node.number 中留下确定性值来启动您的序列。你需要一个这样用法的构造函数,Node() : number() {} 就足够了。 see it live
  • 感谢您的 cmets!我发现了我的泳池错误!谢谢
  • 请注意,循环还需要__mm_pause() 才能启用超线程。
【解决方案2】:

正如@gexicide 所提到的,问题在于compare_exchange 函数使用原子变量的当前值更新expected 变量。这也是为什么你必须首先使用局部变量unlatched 的原因。要解决此问题,您可以在每次循环迭代中将 unlatched 设置回 false。

但是,与其将compare_exchange 用于其界面不太适合的东西,不如使用std::atomic_flag 来代替:

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

来源:cppreference

手动指定内存顺序只是一个小的潜在性能调整,我从源代码中复制了它。如果简单性比最后一点性能更重要,您可以坚持使用默认值并调用locked.test_and_set() / locked.clear()

顺便说一句:std::atomic_flag 是唯一可以保证无锁的类型,尽管我不知道任何平台,std::atomic_bool 上的操作不是无锁的。

更新:正如@David Schwartz、@Anton 和@Technik Empire 在 cmets 中所解释的,空循环具有一些不良影响,例如分支预测错误、HT 处理器上的线程不足和过高的功耗- 简而言之,这是一种非常低效的等待方式。影响和解决方案是特定于架构、平台和应用程序的。我不是专家,但通常的解决方案似乎是将 linux 上的 cpu_relax() 或 windows 上的 YieldProcessor() 添加到循环体中。

EDIT2: 明确一点:这里介绍的便携式版本(没有特殊的 cpu_relax 等说明)对于许多应用程序来说已经足够好了。如果您的SpinLock 旋转很多,因为其他人长时间持有锁(这可能已经表明一般的设计问题),那么最好还是使用普通的互斥锁。

【讨论】:

  • 只需在 while 循环中添加一个 std::this_thread::yield 调用:en.cppreference.com/w/cpp/thread/yield
  • @Martin:我虽然谈到了这一点,但std::this_thread::yield() 是一个相当繁重的系统调用,所以我不确定是否将它放在自旋锁的循环体中。我的(未经测试的)假设是,在大多数情况下,如果可以,你会想要使用 std::mutex (或类似的)开始。
  • @MartinGerhardy 在自旋锁中使用 yield 毫无意义。自旋锁的目标是避免在小的关键部分代价高昂的上下文切换。
  • @rox 这不是我的意思。自旋互斥锁的技术定义是一个锁,如果它已经被其他人获取,它不会释放 CPU。在单核系统中,您通常不使用自旋锁,而是使用常规互斥锁。
  • @JorgeBellón 据我所知,glibc 实现使用类似于 yield:github.com/lattera/glibc/blob/master/mach/spin-solid.c 的东西。我不确定你在哪里找到了定义。
猜你喜欢
  • 1970-01-01
  • 2017-05-17
  • 1970-01-01
  • 2020-03-27
  • 2021-10-17
  • 1970-01-01
  • 1970-01-01
  • 2013-04-26
  • 1970-01-01
相关资源
最近更新 更多