Ready to Go - go中的锁

1,281
原文链接: zhuanlan.zhihu.com
go不go哇?吼哇!已经过了两个星期了,陆陆续续看了go中内存模型的代码,发现go代码里面的锁,真是无处不在。所以,本期打算先和大家分享交流一下golang中的锁(同步机制)。

目录

----- 锁是什么?

----- 计算机中的锁

----- golang锁概括

----- runtime / sema (信号量)

----- sync / atomic (原子操作)

----- sync / mutex (互斥锁)

----- sync / rwmutex (读写锁)

----- 后记

00. 锁是什么?

在开始下面的讨论之前,童鞋们不妨先想想,锁是什么?

举个有味道的例子。

问:厕所隔间为什么要有门?

答:厕所坑只有一个啊,不关门难道两个人同时上么 = = !?

问:那没有锁也可以啊,A正在上,B看见了不进去就行了么?

答:那可不一定,万一那B素质比较差,憋得慌,是有可能把正在上洗手间的A拖出来,抢占坑位的!

问:...... ,想想觉得可怕,还是加上锁好了,总不能上到一半被叫出来,等一会再继续啊。

那,那如果A占着茅坑不拉怎么办?外面的人气不是气得团团转?(吐槽:有点像自旋)

答:......,这个话题我们稍后再说,我去买个橘子,你在此处等我。

综上,锁可以保护共享的资源(坑位)只能被一个任务(人)访问,保证各个任务能够正确地访问资源。

wiki百科对lock的解释是:

In computer science, a lock or mutex (from mutual exclusion) is a synchronization mechanism for enforcing limits on access to a resource in an environment where there are many threads of execution. A lock is designed to enforce a mutual exclusionconcurrency control policy.

大意是,锁是一种同步机制,用于在多任务环境中限制资源的访问,以满足互斥需求。

那么,锁的定义很明显了,它是一种机制,目的是做到多任务环境中的对资源访问的同步。

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包的信号量实现,包括信号量获取&信号量释放。

//go:linkname sync_runtime_Semacquire sync.runtime_Semacquire
func sync_runtime_Semacquire(addr *uint32) {
	semacquire1(addr, false, semaBlockProfile)
}
//go:linkname sync_runtime_Semrelease sync.runtime_Semrelease
func sync_runtime_Semrelease(addr *uint32, handoff bool) {
	semrelease1(addr, handoff)
}

Golang中的信号量,提供了goroutine的阻塞和唤醒机制,因为go中的任务就是goroutine,信号量的同步功能是作用于goroutine的。再看看sema.go中的一段注释:

// That is, don't think of these as semaphores.
// Think of them as a way to implement sleep and wakeup

它的主要数据结构如下:

type semaRoot struct {
    lock  mutex
    head  *sudog
    tail  *sudog
    nwait uint32 // Number of waiters. Read w/o the lock.
}
// 主要结构就是一个sudog的链表和一个nwait。
// 链表用于存储等待的goroutine,nwait表示在该信号量上等待的goroutine数目。
// lock一个互斥量,是在多线程环境中保护链表的。(但是这个mutex不是sync中的mutex,是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中,我们使用原子操作代码片段如下:

// 互斥量加锁操作
func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
......

这里调用了 atomic.CompareAndSwapInt32(addr *int32, old, new int32),这是一个CAS操作,为了保证原子性,golang是通过汇编指令来实现的。对应的汇编代码如下:

// Golang的汇编是基于Plan9汇编器,看着有点操蛋(非AT&T or Intel汇编)
// 基本介绍:
// TEXT: 定义函数的
// SB: SB伪寄存器可以想象成内存的地址,所以符号foo(SB)是一个由foo这个名字代表的内存地址。这种形式一般用来命名全局函数和数据。
// FP:Frame Pointer,帧指针。64位机器下,0(FP) 表示函数的第一个参数;8(FP)表示第二个参数等;
// AX:一般的,函数返回值保存在 EAX 寄存器中(Plan9 中叫 AX)
// NOSPLIT: 有NOSPLIT,不必提供参数大小

// 跳转到·CompareAndSwapUint32中执行

TEXT ·CompareAndSwapInt32(SB),NOSPLIT,$0-17
  JMP ·CompareAndSwapUint32(SB)

TEXT ·CompareAndSwapUint32(SB),NOSPLIT,$0-17
  MOVQ  addr+0(FP), BP      // 第一个参数命名为addr,放入BP(MOVQ,完成8个字节的复制)
  MOVL  old+8(FP), AX       // 第二个参数命名为old,放入AX
  MOVL  new+12(FP), CX      // 第三个参数命名为new,放入CX
  LOCK                      // 锁内存总线操作,防止其它CPU干扰(这里没有UNLOCK,我想是CPU执行完操作会自动UNLOCK吧,上面的硬件层面原子操作有提及)
  CMPXCHGL  CX, 0(BP)       // CMPXCHGL,该指令会把AX中的内容和第二个操作数中的内容比较,如果相等,那么把第一个操作数内容赋值给第二个操作数
                            // 即:if(*addr == old); then *addr = new 
  SETEQ swapped+16(FP)
  RET

这是golang中原子操作汇编实现的基本套路,其他指令就不重复了,可以分为以下几类操作:

  • CAS操作。比互斥锁乐观,Compare-And-Swap,可能有不成功的时候。
  • Swap操作。和上面不同,直接交换。
  • 增减操作。原子地对一个数值进行加减
  • 读写操作。防止读取一个正在写入的变量,我们的读写操作也需要做到原子。

05. sync / mutex (互斥锁)

互斥锁的作用就不介绍了。golang互斥锁的代码在 sync/mutex.go中。

Mutex的相关数据结构:

// A Mutex must not be copied after first use.
type Mutex struct {
    state int32    // 一个int32整数,用其中的位来表示
    sema  uint32
}

//iota,特殊常量,可以认为是一个可以被编译器修改的常量。
//在每一个const关键字出现时,被重置为0,然后再下一个const出现之前,每出现一次iota,其所代表的数字会自动增加1。
const (
    mutexLocked = 1 << iota //值为1,表示在state中由低向高第1位,意义:锁是否可用,0可用,1不可用
    mutexWoken // 值为2,表示在state中由低向高第2位,意义:mutex是否被唤醒
    mutexWaiterShift = iota //值为2,表示state中统计阻塞在此mutex上goroutine的数目需要位移的偏移量
)

看了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对应的数据结构如下:

type RWMutex struct {
	w           Mutex  // held if there are pending writers
	writerSem   uint32 // semaphore for writers to wait for completing readers
	readerSem   uint32 // semaphore for readers to wait for completing writers
	readerCount int32  // number of pending readers
	readerWait  int32  // number of departing readers
}

看起来读写锁,是基于互斥量和信号量来实现的。

读写锁的代码,除去race检测代码之后,比较简单,直接上(暂时先不加注释,考考童鞋们)

  • Rlock --读锁定
func (rw *RWMutex) RLock() {
// 为什么readerCount会小于0呢?往下看发现writer的Lock()会对readerCount做减法操作(原子操作),用来表示现在有writer。
// 所以这里调用信号量,对readerSem判断,如果 > 0, -1,无操作;= 0,阻塞当前加读锁的goroutine
  if atomic.AddInt32(&rw.readerCount, 1) < 0 {
    // A writer is pending, wait for it.
    runtime_Semacquire(&rw.readerSem)
  }
}
  • RUnlock --读释放
func (rw *RWMutex) RUnlock() {
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
		if r+1 == 0 || r+1 == -rwmutexMaxReaders {
			race.Enable()
			throw("sync: RUnlock of unlocked RWMutex")
		}
		// A writer is pending.
		if atomic.AddInt32(&rw.readerWait, -1) == 0 {
			// The last reader unblocks the writer.
			runtime_Semrelease(&rw.writerSem, false)
		}
	}
}
  • Lock --写锁定
func (rw *RWMutex) Lock() {
	// First, resolve competition with other writers.
	rw.w.Lock()
	// Announce to readers there is a pending writer.
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
	// Wait for active readers.
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
		runtime_Semacquire(&rw.writerSem)
	}
}
  • Unlock --写释放
func (rw *RWMutex) Unlock() {
	// Announce to readers there is no active writer.
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
	if r >= rwmutexMaxReaders {
		race.Enable()
		throw("sync: Unlock of unlocked RWMutex")
	}
	// Unblock blocked readers, if any.
	for i := 0; i < int(r); i++ {
		runtime_Semrelease(&rw.readerSem, false)
	}
	// Allow other writers to proceed.
	rw.w.Unlock()
}

07. 后记

通过自下而上学习了Golang中锁的实现能够对锁和多任务同步的理解更加深刻。如果有什么不足的地方,望大家斧正,后续我也会在本文加上新的理解~

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