sync.Mutex 源码分析(一)

今天我们看下mutex

golang中,互斥锁由sync.Mutex实现,其仅包含两个方法,使用如下:

mutext.Lock()
//临界区
mutext.UnLock()

虽然仅仅两个方法,但是如果不了解Mutex的特性,则很容易在日常开发中踩坑,这里我们一一列举:

不可复制

由于Mutex的状态是通过一个32位的int类型字段:state表示,因此如果mutex被复制,那么其状态也会被复制。

复制而来的锁的状态不可预测,很容易产生意外的情况,比如下面的代码:

    mu := sync.Mutex{}

    mu.Lock()
    defer mu.Unlock()

  //mu2复制了mu,此时mu处于lock状态
    mu2 := mu
  //基于lock状态的锁在同一个goroutine中再次调用lock,会导致死锁
    mu2.Lock()
    fmt.Println("mu2 lock")
    mu2.Unlock()

由于mu2复制了mu的状态,因此在mu2调用Lock时便会产生死锁。

因此,在golang中禁止复制Mutex

不可重入

以前写Java的同学应该已经习惯可重入锁的使用。但是对于golang来说,Mutex是不可重入的,其实现并没有记录goroutine_id,因此在获取锁之后,调用其他方法时一定需要注意调用的方法是否已经加锁,否则带来的后果便是死锁。

由于Mutex的不可重入性,在某些时候可能会需要有时候需要锁,有时候不需要锁的情况,此时可以拆分为两个方法:

func (m *Mutex) Foo() {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.FooLocked()
}

//Locked表示需要再加锁的条件下运行
func (m *Mutex) FooLocked() {
    fmt.Println("foo")
}

手动释放

习惯了Javasynchronized关键字的保姆式使用方式,Mutex却是需要手动释放的。建议将UnLock()defer配套使用:

mu.Lock()
defer mu.UnLock()

否则可能随着重构的分支越来越多,在某个分支忘记释放锁,导致出现意外的结果。

公平与非公平

由于goroutine从运行状态到休眠状态再到运行状态会导致性能下降,因此对于锁来说应当尽量goroutine的休眠,其中优化方案之一便是非公平锁。

公平与非公平的区别在于公平锁一定按照先执行Lock()goroutine一定会先获取锁,后执行Lock()goroutine一定后获取锁。

对于公平锁来说,如果临界区的执行时间较长,则会导致大多数goroutine都会经历唤醒->执行的过程。

而对于非公平锁来说,如果在释放锁的时候,正好存在一个goroutine正在尝试获取锁,此时可以直接将锁交给这个goroutine,使得这个goroutine避免了休眠->唤醒->执行的过程,能大幅度提高性能。

同时,对于非公平锁来说,goroutine从休眠->唤醒来争抢锁需要一定的时间,在这个时间间隙中有可能活跃的goroutine正好成功获取锁并执行完临界区的代码,这使得被唤醒的goroutine刚好能继续运行并获取锁,在这种情况下,公平锁顺序唤醒goroutine的间隙也被利用,性能进一步提高。

但是非公平锁会带来一个问题,如果正好源源不断的goroutine请求Lock(),则可能会导致休眠的Lock()一直无法被唤醒,这种情况被称为线程饥饿。

Mutex中,很好的解决了这个问题。默认来说,Mutex是非公平的,但是其在运行过程中也会不断检测自身运行情况,当发现存在某个goroutine等待锁的时间超过1msMutex会直接进入到饥饿模式,对于饥饿模式的Mutex的锁,会进入变为公平锁,先来的goroutine一定先拿到锁,后来的goroutine后拿到锁。

注意由非公平锁转变为公平锁的条件:等待时间超过1ms,因此如果临界区执行时间过长,那么锁一定会进入公平模式,进而导致性能下降,因此对于被加锁的代码不应该执行时间过长。

自旋

减少goroutine休眠的另外一种方案便是自旋。当goroutine发现锁已经被其他goroutine持有时,可以先简单循环检查几次,如果正好其他goroutine在循环的过程中释放了锁,那么也可以避免这个goroutine休眠。

golang实现中,对自旋的条件设置的非常苛刻,因此自旋会让出CPU时间片,而且此时goroutine此时处于_Grunning状态但却什么都没有做却还占用了Pgoroutine自旋会导致与这个goroutine绑定的P中的调度队列中的其他goroutine被延迟调度。因此,在Mutex源码中设置的可自旋条件为:

  • 自旋次数不大于4
  • CPU核数大于1
  • 有其他非idlep正在运行
  • GOMAXPROCS > 1
  • 与当前g绑定的p本地运行队列为空

Happens-Before

多线程一般都需要解决三个问题:

  • 可见性
  • 有序性
  • 互斥性

如果无法获取锁则阻塞只能解决互斥问题,而对于可见性和有序性,我们可以发现在Mutex并没有特殊的代码来实现这两个操作。

翻阅Mutex源码可以发现,在Lock()UnLock()执行之前,都有一次atomic操作m.state,由于Happens-Before具有传递性,因此可以说Mutex其实借助atomic操作实现了可见性和有序性的特点,这种技巧在channelsync.Once等源码中也能见到。

总结

以上便是Mutex的全部特性,虽然Mutex仅仅只有两个方法,但是如果不了解上面的各种特性,那么在日常开发中便很容易踩坑,深入了解其原理,有助于写出更好健壮的代码,在下一篇文章中,我们将会通过分析Mutex的源码,来了解golang是如何通过代码实现以上特性。