Go sync包中锁的知识总结

一、sync包与channel对比

Go设计的理念是"不要通过共享内存来通信,而应该通过通信来共享内存"。但是在以下一些场景下,我们依然需要sync包提供的低级同步原语。

1.1、需要高性能的临界区同步机制场景

在Go中,channel属于高级同步原语,其实现是构建在低级原语之上的。因此空,channel自身的性能与低级同步原语相比要略显逊色。
下面是sync.Mutex和channel各自实现的临界区同步机制的性能对比。

var cs = 0
var mu sync.Mutex
var ch = make(chan struct{}, 1)

func cirticalSectionSYncByMutex() {
	mu.Lock()
	cs++
	mu.Unlock()
}

func cirticalSectionSYncByChan() {
	ch <- struct{}{}
	cs++
	<-ch
}

func BenchmarkCirticalSectionSYncByMutex(b *testing.B) {
	for n := 0; n < b.N; n++ {
		cirticalSectionSYncByMutex()
	}
}

func BenchmarkCirticalSectionSYncByChan(b *testing.B) {
	for n := 0; n < b.N; n++ {
		cirticalSectionSYncByChan()
	}
}
$ go test -bench . sync_package_test.go
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkCirticalSectionSYncByMutex-4           66355532                15.22 ns/op
BenchmarkCirticalSectionSYncByChan-4            21486084                50.34 ns/op
PASS
1.2、不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问

基于channel的并发设计是在goroutine间通过channel转移数据对象的所有权,只有拥有数据对象所有权的goroutine才可以对该数据对象进行状态变更。
如果你的设计中没有转移结构体对象所有权,但又要保证结构体内部状态数据能在多个goroutine之间同步访问,那么你可以使用sync包提供的低级同步原语来实现。

二、sync.Mutex的内部结构
2.1、内部结构
type Mutex struct {
	state int32
	sema uint32
}
  • state:记录该Mutex的状态信息,其低3bit对应所得状态,高29bit记录等待锁的协程数量
  • sema:记录该锁所使用的信号量,这种信号量可以用来唤醒等待中的协程
2.2、方法
  • Lock:加锁方法,可以将当前unlocked状态的Mutex锁住,将Mutex变为locked状态。当一个Mutex被加锁后,在未解锁之前,想再次调用Lock()进行加锁,则会被阻塞以等待。
  • Unlock:解锁方法,可以将当前locked状态的Mutex解锁,变为unlocked状态。可以被重新解锁。
2.3、加解锁操作顺序

Mutex是互斥锁,即同一时间只能有一个Lock()解锁成功,其他Lock()操作不会立即返回,而是被阻塞等待锁。知道该Mutex被调用Unlock()方法解锁后,将唤醒一个等待该锁的协程。这个被唤醒的协程上Lock()操作将执行成功,其他Lock()操作的协程将继续阻塞等待锁。

2.4、常见问题
三、sync.RWMutex的内部结构
3.1、内部结构
type RWMutex struct {
	w 				Mutex
	writerSem	uint32
	readerSem	uint32
	readerCount int32
	readerWait	int32
}
  • w:普通读写锁Mutex
  • 其他字段用来保存读写锁的状态等信息

RWMutex同样是不可重入锁,对于Mutex存在的拷贝问题、零值对象问题,RWMutex同样存在。

3.2、对外方法
  • Lock()和UnLock():加写锁和解写锁
  • RLock()和RUnlock():加读锁和解读锁
3.3、加锁顺序

为了相对公平的获得锁,Go为RWMutex设定了如下读锁和写锁顺序:

  • 如果一个RWMutex已经有某个读取协程加了读锁,新来的读取协程在没有写锁阻塞时,可以直接加读锁成功。直到有一个写入协程尝试加写锁,由于读锁未释放,这个写入协程被阻塞,在这之后如果有读取协程尝试加读锁,则也被阻塞,直到加写锁成功并释放写锁
  • 如果一个RWMutex已经由某个写入协程加了写锁,那么新来的读取协程尝试加读锁时,由于写锁未释放,读取协程将被阻塞。在写锁未被释放的情况下,后续的写加锁也会被阻塞。
四、sync.Mutex vs sync.RWMutex

sync包提供了两种用于临界区同步原语:Mutex和RWMutex。互斥锁是临界区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。那么RWMutex的使用场景有哪些呢?

var cs1 = 0
var mu1 sync.Mutex
var cs2 = 0
var mu2 sync.RWMutex

func BenchmarkReadSyncByMutex(b *testing.B) {
	b.RunParallel(func(p *testing.PB) {
		for p.Next() {
			mu1.Lock()
			_ = cs1
			mu1.Unlock()
		}
	})
}

func BenchmarkReadSyncByRWMutex(b *testing.B) {
	b.RunParallel(func(p *testing.PB) {
		for p.Next() {
			mu2.RLock()
			_ = cs2
			mu2.RUnlock()
		}
	})
}

func BenchmarkWriteSyncByRWMutex(b *testing.B) {
	b.RunParallel(func(p *testing.PB) {
		for p.Next() {
			mu2.Lock()
			cs2++
			mu2.Unlock()
		}
	})
}
$ go test -bench . rw_sync_package_test.go -cpu 2
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-2              63713601                18.80 ns/op
BenchmarkReadSyncByRWMutex-2            34377452                36.79 ns/op
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-2              63713601                18.80 ns/op
BenchmarkReadSyncByRWMutex-2            34377452                36.79 ns/op
BenchmarkWriteSyncByRWMutex-2           37906867                31.65 ns/op
PASS

$ go test -bench . rw_sync_package_test.go -cpu 4
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-4              47289754                24.38 ns/op
BenchmarkReadSyncByRWMutex-4            26156896                46.62 ns/op
BenchmarkWriteSyncByRWMutex-4           22702334                50.65 ns/op
PASS

$ go test -bench . rw_sync_package_test.go -cpu 8
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-8              18814114                60.22 ns/op
BenchmarkReadSyncByRWMutex-8            33422086                37.00 ns/op
BenchmarkWriteSyncByRWMutex-8           19112235                57.71 ns/op
PASS

$ go test -bench . rw_sync_package_test.go -cpu 16
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-16             13460233                93.03 ns/op
BenchmarkReadSyncByRWMutex-16           25600545                47.75 ns/op
BenchmarkWriteSyncByRWMutex-16          19603036                61.31 ns/op
PASS

$ go test -bench . rw_sync_package_test.go -cpu 32
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-32              9795333               136.6 ns/op
BenchmarkReadSyncByRWMutex-32           28648437                38.93 ns/op
BenchmarkWriteSyncByRWMutex-32          14580368                80.71 ns/op
PASS

$ go test -bench . rw_sync_package_test.go -cpu 64
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-64              9887911               134.0 ns/op
BenchmarkReadSyncByRWMutex-64           26738265                46.75 ns/op
BenchmarkWriteSyncByRWMutex-64           9184809               139.1 ns/op
PASS

$ go test -bench . rw_sync_package_test.go -cpu 128
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
BenchmarkReadSyncByMutex-128             9005194               147.4 ns/op
BenchmarkReadSyncByRWMutex-128          32027244                39.45 ns/op
BenchmarkWriteSyncByRWMutex-128          8782561               143.2 ns/op
PASS

通过对比上述压测结果,可以得到以下结论:
在并发量较小的情况下,互斥锁性能更好;随着并发量增大,互斥锁的竞争激烈,导致加锁和解锁性能下降。
读写锁的读锁性能并未随着并发量的增大而发生变化。
在并发量较大的情况下,读写锁的写锁性能比互斥锁、读写锁的读锁都差,并且随着并发量增大,其写锁性能会继续下降。
由此可以看出,读写锁适合应用在具有一定并发量且读多写少的场合。这是因为在有大量并发读的情况下,多个goroutine可以同时持有读锁,从而较少锁竞争中等待的时间;而互斥锁即便是读请求,同一时刻也只能有一个goroutine持有锁,其他goroutine只能阻塞在加锁操作上等待被调度。

六、sync.Cond

条件变量是同步原语的一种,如果没有条件变量,开发人员就需要在goroutine中通过连续轮询的方式检查是否满足条件。这样很浪费资源。

sync.Cond实例的初始化需要一个满足实现了sync.Locker接口的类型实例,通常我们使用sync.Mutex。条件变量需要这个互斥锁来同步临界区,保护用作条件的数据。各个等待条件成立的goroutine在加锁后判断条件是否成立,如果不成立,则调用sync.Cond的Wait方法进入等待状态。Wait方法在goroutine挂起前会进行Unlock操作。

调用sync.Cond的Broadcast方法后,各个阻塞的goroutine将被唤醒并从Wait方法中返回。在Wait方法返回前,Wait方法会再次加锁让goroutine进入临界区。接下来goroutine会再次对条件数据进行判定,如果条件成立,则解锁并进入下一个工作阶段;如果条件依旧不成立,那么再次调用Wait方法挂起等待。

七、sync.Once

到目前为止,我们知道的在程序运行期间只被执行一次且goroutine安全的函数只有每个包的init函数。sync包提供了另一种更为灵活的机制,可以保证任意一个函数在程序运行期间只被执行一次,这就是sync.Once。

在Go标准库中,sync.Once的“仅执行一次”语义被一些包用于初始化和资源清理的过程中,以避免重复执行初始化或资源关闭操作。

sync.Once的语义十分适合实现单例(singleton)模式。

八、sync.Pool

sync.Pool是一个数据对象缓存池,它具有如下特点:

  • 它是goroutine并发安全的,可以被多个goroutine同时使用;
  • 放入该缓存池中的数据对象的生命是暂时的,随时都可能被垃圾回收掉;
  • 缓存池中的数据对象是可以重复利用的,这样可以在一定程度上降低数据对象重新分配的频度,减轻GC的压力;
  • sync.Pool为每个P(goroutine调度模型中的P)单独建立一个local缓存池,进一步降低高并发下对锁的争抢。
9、使用场景及注意事项
9.1、sync.Mutex问题及使用场景
9.1.1、sync.Mutex解锁顺序
  • 对未加锁的Mutex解锁会导致程序崩溃
  • 在未解锁时,同一个协程中,再次对Mutex解锁操作会阻塞该协程
9.1.4 sync.Mutex值拷贝问题

锁一旦被使用过(即使已经解锁),也不建议被复制,因为锁对象中保存了其状态、信号量等信息,复制sync.Mutex锁后,对锁的操作结果都是未知的。常见的sync.Mutex拷贝问题发生场景:值类型赋值、值类型传参、调用值类型作为接受者的方法

  • 对sync.Mutex锁进行拷贝可能导致解锁操作非预期结果
  • sync.Mutex以值类型嵌入的同步数据结构若被拷贝也可能发生非预期的结果
  • 对闭包捕获的锁对象调用Lock()和Unlock()不会产生拷贝问题,因为内部传递的是指针
9.1.5、sync.Mutex零值对象

零值sync.Mutex是出于Unlock状态,可以对零值对象进行Lock操作。对于零值的sync.Mutex对象拷贝是安全的,因为相关锁状态都是零值,拷贝相当于创建一个新的零值sync.Mutex对象。但是不建议拷贝零值sync.Mutex。

9.2、sync.RWMutex问题及使用场景
  • 在写锁未释放时,在同一协程中,再次对同一个RWMutex加读锁或写锁,都会阻塞。在读锁未释放时,在同一协程内,对同一个RWMutex加写锁,也会阻塞。
  • 读写锁方法是成对使用的,不能在没有加写锁时,释放写锁;也不能在没有加读锁时,释放读锁,否则会导致崩溃
  • RWMutex可以同时加锁哥读锁,但不要用这个规则将读锁当递归锁使用,因为如果一个协程内两次读锁之间有其他协程尝试加写锁,会导致阻塞
  • 19
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值