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

注意: 这不是一个实际的问题(我从未使用过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,m6};
        cnt++;
    }
}

void f_unord(){
    for (int i=0; i< 500*1000; ++i){
        std::scoped_lock sl{m4,m6,m1,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是不可靠的,我在VM中的机器上得到了类似的结果:

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

g ++(Ubuntu 9.2.1-9ubuntu2)9.2.1 20191008版权所有(C)2019免费 Software Foundation,Inc。这是免费软件。请参阅源 复制条件。没有保修;甚至没有 特定目的的适销性或适用性。

不同锁定顺序:1074

1000000

相同的锁定顺序:602

2000000

不同的锁定顺序:987

3000000

相同的锁定顺序:612

4000000

不同的锁定顺序:1012

5000000

相同的锁定顺序:585

6000000

不同的锁定顺序:1050

7000000

相同的锁定顺序:675

8000000

不同的锁定顺序:1107

9000000

相同的锁定顺序:609

10000000

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

正如其他人所说,它 与实现有关。但是该实现比gcc的 persistent 实现要好一遍又一遍。

永久

锁定第一个锁,然后尝试锁定其余的锁。如果任何try_locks失败,请解锁所有内容,然后重试。

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

对于性能更高且更健壮的算法,实现应使用this paper所谓的Smart&Polite。

聪明而礼貌

锁定第一个锁,然后尝试锁定其余的锁。如果任何一个try_locks失败,则将所有内容解锁,然后释放,然后重试,除了对先前失败的try_lock进行的第一个锁定之外。

The paper表明,该算法的性能从未比其他任何算法差,并且常常表现得更好。此分析包括更传统的算法,该算法将可锁定对象分类为全局顺序,然后按该顺序锁定它们(其中标记为 Ordered )。

libc++Visual Studio都使用 Smart&Polite gcc's libstdc++使用 Persistent

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

阅读Dining Philosophers Rebooted,以深入了解std::lock的这些算法的性能。

,

当两个线程使用相同的互斥锁顺序时,不会发生死锁。如果线程t2尚未锁定m1,则线程t1只能以锁定m1进行,反之亦然。不会发生死锁。

如果两个线程使用不同的顺序,则会发生死锁。也就是说,线程t1已锁定m1m2m3,并试图锁定m4m5和{{1} }。但是,同时线程m6已锁定t2m4m5,并试图锁定m6m1和{ {1}}。这两个线程无法继续进行,需要解决死锁。

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

,

它与实现联系在一起。我们可以想象std :: scoped_lock在某些常规实现中正在使用std :: lock。

当您查看std::lock doc时:

对象被一系列未指定的锁定调用锁定, try_lock,然后解锁。如果调用锁定或解锁导致 例外,在重新抛出之前,将对所有锁定的对象调用unlock

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释放

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

本文链接:https://www.f2er.com/1677695.html

大家都在问