互斥锁与自旋锁

互斥锁(Mutex)

互斥锁是一种实现线程同步的机制:当一个线程尝试获取互斥锁,如果互斥锁已经被占用则该线程会被挂起进入睡眠状态,直到被唤醒。线程被挂起时,CPU会将该线程当前的处理状态保存到内存中,等到唤醒时从内存中读取上次的处理状态,这个CPU切换线程处理状态的过程被称为“上下文切换”。上下文切换是一个非常耗时的操作,它需要相当多的CPU指令才能完成。但在早期单核处理器中,只能通过这个方式来完成,毕竟一口锅不能同时炒两盘菜。

自旋锁(Spinlock)

多核处理器开始普及之后,使用互斥锁经常会出现一种尴尬的情况:一个线程因为尝试获取互斥锁失败而进入睡眠状态,但上下文切换还没完成或者说刚切换上下文没多久,另一个线程就已经释放了那个互斥锁。

打个比方:现在酒店现在有两个厨师两口锅,厨师A正在炒红烧肉,厨师B正在炒辣椒,厨师A需要放盐了,但盐袋已经被厨师B拿去(厨师B正在放盐),这时厨师A把锅里的红烧肉暂时倒在盘里,等厨师B用完盐袋后,再继续炒自己的红烧肉,结果红烧肉刚倒入盘子里,厨师B已经把盐袋放回去了。

很显然厨师A是个傻X,但在多核处理器中使用互斥锁就会出现这种尴尬局面。

而如果厨师A并没有将红烧肉暂存在盘子中,而是一直在问厨师B:你还在用盐袋吗,你还在用盐袋吗……虽然这个时候厨师A仍然有这种一直追问的傻X行为,但这比起把红烧肉导入盘子里明显要明智的多。而这就是自旋锁干的事儿。

自旋锁和互斥锁一样也是实现线程同步的一种机制:当一个线程尝试获取自旋锁时,如果自旋锁已经被占用则该线程会一直循环等待并反复检查锁是否可用,直到锁可用时才会退出循环。如果持有锁的线程很快就释放了并且线程竞争不激烈,那自旋的效率就非常好,反之,自旋就会白白浪费CPU的处理时间,这反而会带来性能上的损失。

优缺点对比和适用场合

用一句话概括互斥锁和自旋锁:互斥锁是睡等,自旋锁是忙等

下面来说说它们的优缺点和适用场合。

在单核CPU上使用自旋锁明显是没有意义的,因为轮询占用了唯一的一个CPU内核,其他线程就没法儿运行了。并且由于其他线程不可能运行,这个自旋锁也就不能解开,所以在单核CPU上,自旋锁就只会浪费时间。而如果在单核CPU上使用互斥锁,线程会因为获取锁失败而进入睡眠,这样另一个线程运行后,才可能释放互斥锁。

在多核CPU上,大部分的锁只能维持很短的时间,如果使用互斥锁,这样将不断地“将线程挂起后又马上唤醒”,无疑会在上下文切换上浪费很多时间,这时合理的使用自旋锁就可以就可以减少这种情况的发生。特别是在服务器上,因为服务器上CPU是最早使用多核的,而且一个服务器多颗CPU也是正常的。当然并不是说在多核CPU上自旋锁就一定比互斥锁效率高,毕竟我们无法知道锁能维持多久,如果时间相对较长,自旋锁效率必定大打折扣。

互斥锁与自旋锁的结合

既然互斥锁与自旋锁各有优劣,我们可以把它们结合到一起:当一个线程获取锁失败,先让它自旋一段时间,一段时间过后还未能获取锁,再让它进入睡眠状态。这个过程的重点在于自旋时间的长短,过长可能退化成单纯的自旋锁,过短可能退化成互斥锁。

在C++的标准库中有一个std::timed_mutex可以实现延迟互斥锁,它有一个try_lock_for方法就能自定义自旋时间。

Java1.4.2引入自旋锁对synchronized关键字进行优化,我们可以使用-XX:+UseSpinning来开启自旋锁。Java6中已经变为默认开启,并且引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

参考文章

  • 维基百科:https://en.wikipedia.org/wiki/Lock_(computer_science))
  • 自旋锁与互斥锁:http://www.yebangyu.org/blog/2016/01/24/spinlock-and-mutex/