不知道你有没有听说过,隔壁Java的锁并不是“普通”的锁,它有一个锁升级策略,java里面有一系列锁的实现,从轻量级到重量级分为偏向锁,乐观锁,悲观锁。当情况变糟糕时(长时间不能获得锁),java会自动将锁替换成更重量级的锁。
当我第一次知道这件事的时候,我觉得这真是一个好办法,当情况允许,要尽可能用轻量级的锁。
(资料图片)
然而,我们常用的std::mutex,真的是一个“悲观”的锁吗?
当没有争用时,std::mutex的性能
perfbench是一个c++性能测试网站,我们提交了三个测试样例,我们看到箱线图上有四根线,它们从左边开始分别是
1. std::unique_lock lock(m);
2. std::lock_guard lock(m);
3. 自旋锁,ato是一个原子变量
bool expected = false;
while(!_exchange_weak(expected, true,
std::memory_order_acquire,
std::memory_order_relaxed))
expected = false;
(false , std::memory_order_release);
4. 这是一个对照组。g是一个volatile的全局变量。
for(int i=0; i<40; i++) g++;
volatile保证了这个循环不会被优化掉,而且每次都从内存中读写变量g。
volatile是一个非常不常用的关键字,绝大多数人没有理由去用它。如果你熟悉java,你应该知道java中也有volatile关键字,而且还挺常用的,但是我想提醒你注意的是,java中volatile关键字和c++中volatile关键字语义并不一样。java中的volatile几乎可以当作原子变量用了,它包含了内存屏障方面的语义。但c++的volatile没有这一层含义。所以在c++中,对volatile变量的读写应该被看做一次普通的内存读写。
我们可以在箱线图上看出,四个样例用时大致相当,也就是说,在可以获得锁时,获得锁的消耗大约相当于35次内存读写。在这个测试网站上,大约是20ns左右。(这个网站每个测试大约有15ns的固定开销,需要从结果中减去)。这个消耗和“标准”的自旋锁相当。
自旋锁还是mutex
当不能获取锁(其他线程获得了锁)时,可能会发生上下文切换,当前线程挂起,直到另一个线程退出加锁区域,当前线程重新回到就绪态然后排队等待运行。
系统上下文切换的开销一般认为是相对大的
我们g++的循环的开销是L1缓存读取这个级别的。按这张图片的说法,如果说在可以获得锁时,请求一个锁的开销是从房间的一端走到另一端,上下文切换的开销大约就相当于跑3公里。
自旋锁可以避免上下文切换的开销,我们用一个死循环等待另一个线程退出加锁区域,如果另一个线程能够很快退出加锁区域,那么我们的死循环就不用持续多久。
但是另一个线程能够很快退出加锁区域的必要条件是,它当前正在另一个cpu核心上运行。如果它正在排队等待调度的话,那么可以预见的,我们的死循环可能要跑不少时间。我们不太能预见我们的线程需要排多久队,这有可能引发非常糟糕的情况。
这里有一篇文章,/blog/2020/07/06/,l它举了各种各样的例子,其中粉色的是使用自旋锁的情况
现在看起来一切都好,然而当线程数超过核心数时,灾难发生了。
那么解决办法是什么呢?混合锁。请求一个锁时,如果不能获得锁,先自旋等待,自旋一段时间不能获得锁,就屈服,请求互斥锁。
然而幸运的是,现代系统中,crt的锁都是混合锁。也就是说,我们几乎不需要做任何额外的操作就能享受自旋锁和互斥锁两者的优点!或者说std::mutex虽然不要求使用类似java锁升级的策略,但是本质上,它的确有类似锁升级的机制!
总结
大部分情况下,std::mutex已经足够好了。几乎没有理由去写一个自旋锁。
std::mutex及不过分乐观,也不过分悲观,有一种能适应绝大多数情况的美。