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,针对不同的系统架构,用汇编语言实现了一套原子操作接口。


总结一下,原子操作的意义是啥?

  1. 原子操作可以实现互斥锁。
  2. 当我们知道一个操作在它的执行环境原子的,那么多任务处理时,我们可以省去加锁带来的开销。
  3. 反过来,如果我们实现互斥锁,那么我们可以扩大原子操作的范围。(临界区可以有多个指令执行)

有了上面的基本介绍,我们就进入本次的主题,go中的锁,是如何设计的~


02. golang锁概括

golang的锁在源码的sync包中,该包中包含了各种多任务的同步方式。

go源码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架构上实现的汇编程序,大概如下:

sync/atomic

我们挑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是不是表示有点疑惑,上图:

state各个bit的作用

接下来是代码流程:

  • 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中锁的实现能够对锁和多任务同步的理解更加深刻。如果有什么不足的地方,望大家斧正,后续我也会在本文加上新的理解~

题外话,上一张图,这就是我艰苦的学习环境。。。