问题描述
注意: 这不是一个实际的问题(我从未使用过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
解决方法
正如其他人所说,它 与实现有关。但是该实现比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
已锁定m1
,m2
和m3
,并试图锁定m4
,m5
和{{1} }。但是,同时线程m6
已锁定t2
,m4
和m5
,并试图锁定m6
,m1
和{ {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释放
在第二种情况下,可能需要一些时间才能稳定下来(理论上不可能发生)...