go不go哇?吼哇!已经过了两个星期了,陆陆续续看了go中内存模型的代码,发现go代码里面的锁,真是无处不在。所以,本期打算先和大家分享交流一下golang中的锁(同步机制)。
目录
----- 锁是什么?
----- 计算机中的锁
----- golang锁概括
----- runtime / sema (信号量)
----- sync / atomic (原子操作)
----- sync / mutex (互斥锁)
----- sync / rwmutex (读写锁)
----- 后记
00. 锁是什么?
在开始下面的讨论之前,童鞋们不妨先想想,锁是什么?
举个有味道的例子。
问:厕所隔间为什么要有门?
答:厕所坑只有一个啊,不关门难道两个人同时上么 = = !?
问:那没有锁也可以啊,A正在上,B看见了不进去就行了么?
答:那可不一定,万一那B素质比较差,憋得慌,是有可能把正在上洗手间的A拖出来,抢占坑位的!
问:...... ,想想觉得可怕,还是加上锁好了,总不能上到一半被叫出来,等一会再继续啊。
那,那如果A占着茅坑不拉怎么办?外面的人气不是气得团团转?(吐槽:有点像自旋)
答:......,这个话题我们稍后再说,我去买个橘子,你在此处等我。
综上,锁可以保护共享的资源(坑位)只能被一个任务(人)访问,保证各个任务能够正确地访问资源。
wiki百科对lock的解释是:
大意是,锁是一种同步机制,用于在多任务环境中限制资源的访问,以满足互斥需求。
那么,锁的定义很明显了,它是一种机制,目的是做到多任务环境中的对资源访问的同步。
01. 计算机中的锁
上面的概念放到计算机中,任务可以看做线程(thread) or 协程(routine),共享资源可以看做临界资源(critical resource)。代码片段中,访问临界资源的代码片段,叫做临界区(critical section)。
所以为了避免竞争条件,我们只要保证只有一个线程 or 协程在临界区就好了。
先不看系统怎么给我们实现,我们先想想,如果让我们自己来设计锁,怎么做?
.......
比如说在多线程程序中,为了保证共享资源只能被一个线程访问,我们可以在共享内存空间设置一个全局变量flag,当这个flag = 0时,表示资源可以访问,当flag = 1时,资源无法访问,需要占用资源的线程置flag = 0来释放占有权。
想想也没什么不对,但是,童鞋们有没有发现,flag是不是也是一个共享资源啊,大家都可以访问。如果在A正在占用资源,出现了恶意第三者,把flag设置为0,岂不是可以随便访问共享资源了?
这时候,我们需要一种机制,能够保证这个flag不被随意篡改。
这个机制就是,原子操作。顾名思义,原子操作是不能被中断的的一系列操作。我个人以为,原子操作是相对的,比如对于多线程中某个线程来说,对一系列操作加上mutex互斥锁就可以变成原子操作(与其它线程互斥),但是在内核中,它不一定是原子的,甚至一条汇编指令都不一定是原子的,需要通过一些机制才能保证(比如总线锁,后面会说)。
我们平时在编写代码时,接触的原子操作是软件层面的,它是在硬件原子操作的基础上实现的。所以我们先大概了解硬件原子操作~
- 硬件原子操作
硬件的原子操作分单核和多核(SMP)了,因为单核中的一条指令可以看做原子的,但如果涉及内存访问,在多核系统中,就不一定是原子的了(别的核会有线程也去同时访问改内存)。
单核:一条指令就是原子的,中断只会发生在指令之间。比如说有些cpu指令架构提供了test-and-set指令来原子地设属性值,使得临界资源互斥。
多核:多个核心同时运行,某个核心指令在执行单条指令访问内存地时候,也可能会受到其它核心的影响。所以,CPU有一种机制,在指令执行的过程中,对总线加锁,这种加锁是硬件级别的。这里引用一下别人的解释,如下:
CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀"LOCK",经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。
补充:但是,总线锁把CPU和内存之间的通信锁住了,这使得其它CPU不能操作其它内存地址的数据,相当于锁的粒度比较大,导致代价也比较大。所以,有一种方式是通过CPU缓存锁来代替总线锁,来进行优化。
- 软件原子操作
软件原子操作是基于硬件原子操作的。比如linux内核,提供了两种原子操作接口,一种是针对整数的(通常用作计数操作),一种是针对位的。这里我们不细讲。又比如golang中的sync/atomic,针对不同的系统架构,用汇编语言实现了一套原子操作接口。
总结一下,原子操作的意义是啥?
- 原子操作可以实现互斥锁。
- 当我们知道一个操作在它的执行环境原子的,那么多任务处理时,我们可以省去加锁带来的开销。
- 反过来,如果我们实现互斥锁,那么我们可以扩大原子操作的范围。(临界区可以有多个指令执行)
有了上面的基本介绍,我们就进入本次的主题,go中的锁,是如何设计的~
02. golang锁概括
golang的锁在源码的sync包中,该包中包含了各种多任务的同步方式。
从上面的目录结构中,可以看出go中用于同步的方式有很多种,常见的有:
- 原子操作
- 互斥锁
- 读写锁
- waitgroup
这是我们今天主要讲的几种方式,互斥锁,读写锁,waitgroup在golang中的实现都依赖于 原子操作 & 信号量,go中的信号量是在runtime包中实现的,下面我们一个一个来看。
03. runtime / sema (信号量)
runtime中的sema.go实现了sync包以及internal/poll包中的信号量处理。这里主要关注sync包的信号量实现,包括信号量获取&信号量释放。
Golang中的信号量,提供了goroutine的阻塞和唤醒机制,因为go中的任务就是goroutine,信号量的同步功能是作用于goroutine的。再看看sema.go中的一段注释:
它的主要数据结构如下:
另外,golang设置了可操作信号量个数的最大值为251,这些信号量的对应semaRoot结构被保存在semtable这个大小为251的数组里,数组下标是根据传入addr地址经过运算取模得到的。
- 信号量获取
semacquire1(addr *uint32, lifo bool, profile semaProfileFlags)
简单来说:semacquire1会先检查信号量addr对应地址的值(unsigned),若 > 0,让信号量-1,结束;若 = 0,就把当前goroutine加入此信号量对应的goroutine waitinglist,并调用gopark阻塞当期goroutine。
执行流程图:
- 信号量释放
semrelease1(addr *uint32, handoff bool)
简单来说:semrelease1会先让信号量+1,然后看信号量关联的goroutine waitinglist是否有goroutine,如果没有,结束;如果有,调用goready唤醒其中一个goroutine。
执行流程图:
至于这里的信号量具体怎么使用,后续的sync/mutex和sync/rwmutex中会涉及~(本质上还是用于goroutine之间的同步嘛)
04. sync / atomic (原子操作)
这里原子操作,是保证多个cpu(协程)对同一块内存区域的操作是原子的。
具体实现为在各个CPU架构上实现的汇编程序,大概如下:
我们挑x86的64位架构的asm_amd64.s来看吧,由于代码比较多,我们举例来看。
在sync/mutex.go中,我们使用原子操作代码片段如下:
这里调用了 atomic.CompareAndSwapInt32(addr *int32, old, new int32),这是一个CAS操作,为了保证原子性,golang是通过汇编指令来实现的。对应的汇编代码如下:
这是golang中原子操作汇编实现的基本套路,其他指令就不重复了,可以分为以下几类操作:
- CAS操作。比互斥锁乐观,Compare-And-Swap,可能有不成功的时候。
- Swap操作。和上面不同,直接交换。
- 增减操作。原子地对一个数值进行加减
- 读写操作。防止读取一个正在写入的变量,我们的读写操作也需要做到原子。
05. sync / mutex (互斥锁)
互斥锁的作用就不介绍了。golang互斥锁的代码在 sync/mutex.go中。
Mutex的相关数据结构:
看了state是不是表示有点疑惑,上图:
接下来是代码流程:
- func (m *Mutex) Lock() --加锁
简单来说,如果当前goroutine可以加锁,那么调用原子操作使得mutex中的flag设置成已占用达到互斥;如果当前goroutine发现锁已被占用,那么会有条件的循环尝试获取锁,这里是不用信号量去对goroutine进行sleep和wake操作的(尽可能避免开销),如果循环尝试失败,则最后调用原子操作争抢一次,获取不到则还是得调用runtime_Semacquire去判断阻塞goroutine还是继续争用锁。
- func (m *Mutex) Unlock() --释放锁
简单来说,就是设置state中的锁标志为0,放开锁,然后根据情况释放阻塞住的goroutine去争抢锁。
06. sync / RWMutex (读写锁)
RWMutex是一个读写锁,该锁可以加多个读锁或者一个写锁,其经常用于读次数远远多于写次数的场景(如果读写相当,用Mutex没啥区别)。但加读锁时,只能继续加读锁;当有写锁时,无法加载其他任何锁。也就是说,只有读-读之间是共享的,其它为互斥的。
RWMutex对应的数据结构如下:
看起来读写锁,是基于互斥量和信号量来实现的。
读写锁的代码,除去race检测代码之后,比较简单,直接上(暂时先不加注释,考考童鞋们)
- Rlock --读锁定
- RUnlock --读释放
- Lock --写锁定
- Unlock --写释放
07. 后记
通过自下而上学习了Golang中锁的实现,能够对锁和多任务同步的理解更加深刻。如果有什么不足的地方,望大家斧正,后续我也会在本文加上新的理解~
题外话,上一张图,这就是我艰苦的学习环境。。。