今天我们看下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")
}
手动释放
习惯了Java
的synchronized
关键字的保姆式使用方式,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
等待锁的时间超过1ms
,Mutex
会直接进入到饥饿模式,对于饥饿模式的Mutex
的锁,会进入变为公平锁,先来的goroutine
一定先拿到锁,后来的goroutine
后拿到锁。
注意由非公平锁转变为公平锁的条件:等待时间超过
1ms
,因此如果临界区执行时间过长,那么锁一定会进入公平模式,进而导致性能下降,因此对于被加锁的代码不应该执行时间过长。
自旋
减少goroutine
休眠的另外一种方案便是自旋。当goroutine
发现锁已经被其他goroutine
持有时,可以先简单循环检查几次,如果正好其他goroutine
在循环的过程中释放了锁,那么也可以避免这个goroutine
休眠。
在golang
实现中,对自旋的条件设置的非常苛刻,因此自旋会让出CPU
时间片,而且此时goroutine
此时处于_Grunning
状态但却什么都没有做却还占用了P
, goroutine
自旋会导致与这个goroutine
绑定的P
中的调度队列中的其他goroutine
被延迟调度。因此,在Mutex
源码中设置的可自旋条件为:
- 自旋次数不大于4
CPU
核数大于1- 有其他非
idle
的p
正在运行 GOMAXPROCS
> 1- 与当前
g
绑定的p
本地运行队列为空
Happens-Before
多线程一般都需要解决三个问题:
- 可见性
- 有序性
- 互斥性
如果无法获取锁则阻塞只能解决互斥问题,而对于可见性和有序性,我们可以发现在Mutex
并没有特殊的代码来实现这两个操作。
翻阅Mutex
源码可以发现,在Lock()
和UnLock()
执行之前,都有一次atomic
操作m.state
,由于Happens-Before
具有传递性,因此可以说Mutex
其实借助atomic
操作实现了可见性和有序性的特点,这种技巧在channel
、sync.Once
等源码中也能见到。
总结
以上便是Mutex
的全部特性,虽然Mutex
仅仅只有两个方法,但是如果不了解上面的各种特性,那么在日常开发中便很容易踩坑,深入了解其原理,有助于写出更好健壮的代码,在下一篇文章中,我们将会通过分析Mutex
的源码,来了解golang
是如何通过代码实现以上特性。