打开Go语言中的那把“锁” 打开Go语言中的那把“锁”


打开Go语言中的那把“锁”--互斥锁Mutex

操作系统中,关于进程间通信,是一个经常被谈起的问题。笔者也是在《现代操作系统》中第一次接触到这相关的内容。其中关于信号量、互斥锁等并发相关的内容,第一次接触也是从这里开始。

首先我们来看几个概念:
竞争条件:当两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为“竞争条件”。
注意理解,什么是精确时序?即进程执行的先后顺序会影响到最后的结果。
避免竞争条件,关键是阻止多个进程同时读取共享的数据。
这里我们把,对共享内存进行访问的程序片段称为“临界区”

如果同时访问临界区,就会造成访问或操作出错,为了避免这种情况发生,我们使用了“互斥锁”,限制临界区一次只能有一个进程/线程访问。
在《现代操作系统》一书中也有对互斥锁的描述,关于它的方法使用很简单,就像我们生活中的锁一样,只有开锁和解锁。本文主要从GO语言的角度来讨论互斥锁。
    
我们先来看一下Go语言中互斥锁Mutex的基本用法。

Mutex主要是实现了接口Locker

type Locker interface {
​    Lock()
​    Unlock()
}

这个接口很简单,简而言之就是进入临界区时加锁调用Lock(),离开临界区时解锁调用Unlock()。
关于为什么需要加锁,有并发编程经验的同学都有接触到,这里不做具体说明。
在Go中提供了一个检测并发资源是否有问题的工具race detector,在go run时添加-race就可以体验一下。

在实现一个线程安全的类时,可以采用嵌入字段的方式,将互斥锁sync.Mutex作为struct的一个对象。


我们再来看一下它的实现。总共经历过4个阶段的迭代优化。

1.第一版(使用一个flag字段标记是否持有锁)
2.给新goroutine机会(新的goroutine也能有机会竞争锁)
3.多给goroutine些机会(新来的和被唤醒的有机会获取更多锁)
4.解决饥饿(解决竞争问题,不会让goroutine长久等待)


1:第一版
mutex结构体包含2个字段:
key:用来标示这个排他锁是否被某个goroutine所持有。
sema:信号量变量,用来控制等待goroutine阻塞和休眠。(信号量用来管理等待的goroutine)

Lock()请求锁的时候,如果锁没有被goroutine持有,Lock()方法将key设置为1,这个goroutine就持有了该锁。
如果锁已经被其他goroutine所持有,当前goroutine会把key加1,而且调用semacquire()使用信号量将自己休眠,等锁释放的时候,信号量会将它唤醒。

Unlock()请求释放锁时,会将key减1,如果当前没有其他等待这个锁的goroutine这个方法就返回了。
但是如果还有其他goroutine在等待此锁,它会调用semrelease()利用信号量唤醒等待锁的其他goroutine中的一个。

使用时记住“谁申请谁释放”,Lock()和defer Unlock()搭配使用。
如果是临界区只是函数的一部分,则应该尽快释放。

2:给新goroutine机会
将初版的key字段换成了state字段。
state字段是一个复合型的字段,一个字段包含多个含义。
mutexWaiters(第三位):等待此锁的goroutine数
mutexWoken(第二位):唤醒标记(是否有唤醒的goroutine)
mutexLocked(最低位)这个锁是否被持有

加锁逻辑
1.如果没有goroutine持有锁,也没有等待持有锁的goroutine,那这个goroutine就很幸运,可以直接获得锁。
2.如果state不是零值,就通过一个循环进行检测,

请求锁的goroutine有两类,一类是新来请求锁的goroutine,另一类是被唤醒的等待请求锁的goroutine。

锁的状态也有两种,加锁和未加锁。
相对于初版,这次的改动主要就是:新来的goroutine也有机会先获得锁,打破了先来先得的逻辑。

3.多给goroutine些机会:
针对新来的goroutine,或者是被唤醒的goroutine,首次获取不到锁,会自旋获取,尝试一定自旋次数。

4.解决饥饿:
新的goroutine也会获得锁的机会,极端情况下,等待中的goroutine会一直获取不到锁,这就是饥饿问题。
让等待比较长的goroutine更有机会获得锁。

mutex的实现貌似非常复杂,其实主要还是针对饥饿模式和公平性问题,做了一些额外处理。


最后我们看一下比较常见的使用mutex出错的场景。
一.Lock/Unlock不成对
其中缺少Unlock的场景
1.代码中太多的if-else,可能在某个分支漏写了Unlock
2.重构时把Unlock给删除了
3.Unlock误写成了Lock
这种情况意味着其他goroutine永远获取不到锁

2.缺少Lock()的情况(触发panic)

二.复制已使用的mutex
package sync的同步原语,在使用后是不能被复制的。Go在运行时,有死锁的检查机制,使用go vet可以在编译的时候检查。

三.重入

java中的可重入锁,基本行为和互斥锁相同,加了一些扩展。
可重入锁:拥有该锁的线程,再次请求这把锁的话,不会阻塞,而是可以成功返回。只要拥有这把锁就可以一直用。
如何实现一个可重入锁?
1:记录获取锁的goroutine Id
2:提供一个token
可重入锁,解决了代码重入和递归调用带来的死锁问题。同时要求只有持有锁的goroutine可以解锁。
方式一:
0.获取goroutine Id(runtime.Stack方法获取)
1.先获取TLS对象
2.获取goroutine结构中的g指针
3.从g指针中取出goroutine id
方式二:通过用户传入的token,替换方式一的goroutine Id

三.死锁
两个或两个以上的进程(线程、goroutine)争夺共享资源而处于一种互相等待的状态,就被成为“死锁”。
死锁产生的4个条件:
1.互斥
2.持有和等待
3.不可剥夺(资源只能由持有它的goroutine来释放)
4.环路等待

本文针对go语言中的mutex只做了一个简单的介绍,主要参考了现代操作系统和Go并发编程实战课的内容,有需要的同学可以再依此来发散扩展。

上一篇:riscv - kernel - locks


下一篇:线程池关闭及等待关闭方法