为什么 std::scoped_lock 的不同互斥顺序会影响性能?

Why does the different order of mutexes for std::scoped_lock affects performance?

注意: 这不是一个实际问题(我从来没有用 scoped_lock 锁定超过 2 个互斥量),我很好奇为什么 scoped_lock 的实现在以不同顺序锁定互斥量时显然有相对较大的性能影响。

下面的示例代码,godbolt link

#include<mutex>
#include<thread>
#include<chrono>
#include<iostream>

std::mutex m1, m2, m3, m4, m5, m6;

int cnt =0;

void f(){
    for (int i=0; i< 500*1000; ++i){
        std::scoped_lock sl{m1, m2, m3, m4, m5, m6};
        cnt++;
    }
}

void f_unord(){
    for (int i=0; i< 500*1000; ++i){
        std::scoped_lock sl{m4, m5, m6, m1, m2, m3};
        cnt++;
    }
}


int main(){
for (int run = 0; run<4; ++run)
{
    {
        const auto start = std::chrono::steady_clock::now();
        std::thread t1(f), t2(f);
        t1.join();
        t2.join();
        const auto end = std::chrono::steady_clock::now();
        std::cout << "same lock order: " << std::chrono::duration_cast<std::chrono::milliseconds>(end-start).count() << std::endl; 
        std::cout << cnt << std::endl;
    }
    {
        const auto start = std::chrono::steady_clock::now();
        std::thread t1(f), t2(f_unord);
        t1.join();
        t2.join();
        const auto end = std::chrono::steady_clock::now();
        std::cout << "different lock order: " << std::chrono::duration_cast<std::chrono::milliseconds>(end-start).count() << std::endl; 
        std::cout << cnt << std::endl;
    }
}
}

请注意为什么这令人惊讶:我希望由于互斥量不可移动,因此实现只能按地址对互斥量进行排序并使用该锁定顺序。

关于 godbolt 基准测试的注意事项:我知道 godbolt 不可靠,我在我的虚拟机机器上得到了类似的结果:

g++ --version; g++ -O2 -std=c++17 scoped_lock.cpp -pthread; ./a.out

g++ (Ubuntu 9.2.1-9ubuntu2) 9.2.1 20191008 Copyright (C) 2019 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

different lock order: 1074

1000000

same lock order: 602

2000000

different lock order: 987

3000000

same lock order: 612

4000000

different lock order: 1012

5000000

same lock order: 585

6000000

different lock order: 1050

7000000

same lock order: 675

8000000

different lock order: 1107

9000000

same lock order: 609

10000000

当两个线程使用相同的互斥顺序时,不会发生死锁。如果线程 t1 尚未锁定 m1 和 vice-versa,则线程 t2 只能继续锁定 m1。不会出现死锁。

如果您对两个线程使用不同的顺序,则会发生死锁。即线程 t1 已锁定 m1m2m3,并尝试锁定 m4m5m6.但是,同时线程 t2 已锁定 m4m5m6 并尝试锁定 m1m2m3。两个线程无法进行,需要解决死锁

在这种情况下,任何一个作用域锁都必须释放它已经获得的互斥量以避免死锁。然后另一个线程可以继续,然后该线程必须再次获取所有互斥锁,并且在下一次迭代中再次发生同样的情况。

它与实施有关。我们可以想象 std::scoped_lock 在某些常规实现中使用 std::lock。

当您查看 std::lock doc 时:

The objects are locked by an unspecified series of calls to lock, try_lock, and unlock. If a call to lock or unlock results in an exception, unlock is called for any locked objects before rethrowing

std::lock 的 gcc 实现是:

void
    lock(_L1& __l1, _L2& __l2, _L3&... __l3)
    {
      while (true)
        {
          using __try_locker = __try_lock_impl<0, sizeof...(_L3) != 0>;
          unique_lock<_L1> __first(__l1);
          int __idx;
          auto __locks = std::tie(__l2, __l3...);
          __try_locker::__do_try_lock(__locks, __idx);
          if (__idx == -1)
            {
              __first.release();
              return;
            }
        }
    }

如你所见,如果你有相同的声明顺序,这很简单:

  • t1 std::lock 取得 m1
  • 的所有权
  • t2 std::lock 等待 m1 被释放

在第二种情况下,可能需要一些时间才能稳定(理论上永远不会发生)...

正如其他人所说,它 与实施相关。但是这个实现可以比 gcc 的 persistent 一遍又一遍地尝试同样的事情做得更好。

Peristent

Lock the first lock and then try_lock the rest. If any of the try_locks fail, unlock everything and try again.

如果两个线程以相同的顺序列出它们的互斥量,则此算法效果最佳。

为了获得更高性能和更稳健的算法,实施应该使用 this paper 所谓的智能和礼貌。

Smart & Polite

Lock the first lock and then try_lock the rest. If any of the try_locks fail, unlock everything, then yield, then retry except the first lock is done on the one that previously failed the try_lock.

The paper 表明该算法的性能绝不会比其他算法差,而且通常性能要好得多。此分析包括更传统的算法,该算法将可锁定对象排序为全局顺序,然后按该顺序锁定它们(其中标记为 Ordered)。

libc++ and Visual Studio both use Smart & Polite. gcc's libstdc++ 使用 Persistent.

在 non-Apple 平台上使用 clang 时,使用 -stdlib=libc++ 选择 libc++ 而不是 gcc 的 std::lib。

阅读 Dining Philosophers Rebooted 以深入分析 std::lock 的这些算法。