并发的影响
goroutine 并发对数据做读写操作,如果没有锁的保护,得到的结果也就是不确定的。我们通过 goroutine 做累加的例子来看一下,下面的情况,我们预期进行了10次循环,每次加1,但执行的结果却不一定的10。
func main() {
var wg sync.WaitGroup
var count = 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++
}()
}
fmt.Println(count)
}
为什么会出现这中情况呢,主要还是得看看 count++的汇编实现。命令行执行如下的代码,就可以查看代码汇编的结果。17行是代码中count++所在的行,go使用的版本是1.16
go tool compile -S main.go
其中,-S 表示输出汇编格式,一个 count++ 在汇编中分成了两条指令,首先将值移动到寄存器 AX 中,然后执行 INCQ。可以看出,count++并不是原子的,所以最终并行执行的结果不一定是 10
错误用法
mutex 实现了 Locker 接口,提供了2个方法,我们使用的时候一般也是成对使用,并不容易遇到死锁的情况。
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
Lock()
Unlock()
}
最常见的就是死锁,多发生在下面这种情况,封装的时候没有理顺调用关系,导致程序发生死锁。
下面的代码,就是经常手欠犯的错误,Get里面调用Put方法导致死锁。避免这类问题就特别需要反复提醒自己:Mutex 不支持重入。
type limit struct {
sync.Mutex
}
func (l *limit) Get() {
l.Lock()
// do something
l.Put() // 错误的用户,导致死锁
l.Unlock()
}
func (l *limit) Put() {
l.Lock()
// do something
l.Unlock()
}
另外, Mutex 锁不能被拷贝,主要的原因在于 Mutex 中保持了锁的状态信息,拷贝的副本是一个有状态的副本。
实现
下面是基于 go1.16的版本的Mutex结构体,主要就在这个 state 字段的设计上,int32 有 32 个bit位,Go通过一系列的位操作来处理这个 state。
比如,&^ 这个位运算,叫与非运算,127行完整的写法是 new = new &^ mutexWoken,&^ 的作用就是如果 mutexWoken 表示的bit位如果是1,就将对应new中的bit位设置为0。因为 mutexWoken 表示:当前存在活跃的协程,不需要唤醒阻塞等待的协程。所以,这里的操作表示设置当前没有活跃的协程。
如果是简易版的实现,其实没有必要设置这么复杂的状态位,不过,也正是因为这些状态位,让 mutex 更加合理。我们看看这 32 个bit位都表示什么?
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
第一个bit位表示上锁状态。如果是1表示上锁成功,如果是0表示可以上锁。这应该算的上是 mutex 的根本了,本质上有了这个状态位就可以了。
mutexWoken 上面已经谈到了,注释中也给出了这个bit位的解释。在请求锁的时候,假设如果锁已经被协程1获取了,另外一些协程便会阻塞到信号量上,等待被唤醒。但当协程1准备去释放锁的时候,可能还有一部分协程正在试图请求加锁。这种情况下,unlock操作其实不需要去唤醒阻塞的协程,因为此时有活跃的协程在请求加锁。