Go并发编程一年回顾(2021)

去年的时候我写了一篇Go并发编程一年回顾,如今2021年也快结束了,Go 1.18的特性已经冻结,美国页很快进入了假期模式,趁这个节点,我们回顾一下近一年Go并发编程的进展。

TryLock终于要发布

很久以来(可以追溯到2013年#6123),就有人提议给Mutex增加TryLock的方法,被大佬们无情的拒绝了,断断续续,断断续续的一直有人提议需要这个方法,如今到了2021年,Go team大佬们终于松口了,增加了相应的方法(#45435)。

一句话来说,Mutex增加了TryLock, 尝试获取锁, RWMutex 增加了 TryLock和TryRLock方法,尝试获取写锁和读锁。它们都返回bool类型。如果返回true,代表已经获取到了相应的锁,如果返回false,则表示没有获取到相应的锁。

本质上,要实现这些方法并不麻烦,接下来我们看看相应的实现(去除了race代码)。

首先是Mutex.TryLock:

1
2
3
4
5
6
func (m *Mutex) TryLock() bool {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return true
}
return false
}

也就是利用aromic.CAS操作state字段,如果当前没有被锁或者没有等待锁的情况,就可以成功获取到锁。不会尝试spin和与等待者竞争。

不要吐槽上面的代码风格,可能你觉得不应该写成下面的方式吗?原因在于我删除了race代码,那些代码块中包含race代码,所以不能像下面一样简写:

1
2
3
func (m *Mutex) TryLock() bool {
return atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)
}

读写锁有些麻烦,因为它有读锁和写锁两种情况。

首先看RWMutex.TryLock(去除了race代码):

1
2
3
4
5
6
7
8
9
10
func (rw *RWMutex) TryLock() bool {
if !rw.w.TryLock() {
return false
}
if !atomic.CompareAndSwapInt32(&rw.readerCount, 0, -rwmutexMaxReaders) {
rw.w.Unlock()
return false
}
return true
}

首先底层的Mutex.TryLock,尝试获取w字段的锁,如果成功,需要检查当前的Reader, 如果没有reader,则成功, 如果此时不幸还有reader没有释放读锁,那么尝试Lock也是不成功的,返回false。注意返回之前一定要把rw.w的锁释放掉。

接下来看RWMutex.TryRLock(去除了race代码):

1
2
3
4
5
6
7
8
9
10
11
func (rw *RWMutex) TryRLock() bool {
for {
c := atomic.LoadInt32(&rw.readerCount)
if c < 0 {
return false
}
if atomic.CompareAndSwapInt32(&rw.readerCount, c, c+1) {
return true
}
}
}

这段代码首先检查readerCount,如果为负值,说明有writer,此时直接返回false。

如果没有writer, 则使用atomic.CAS把reader加1, 如果成功,返回。如果不成功,那么此时可能有其它reader加入,或者也可能有writer加入,因为不能判断是reader还是writer加入,那么就用一个for循环再重试。

如果是writer加入,那么下一次循环c可能就是负数,直接返回false,如果刚才是有reader加入,那么它再尝试加1就好了。

以上就是新增的代码,不是特别复杂。Go team不情愿的把这几个方法加上了, 同时有很贴心的提示(恐吓):

Note that while correct uses of TryLock do exist, they are rare,
and use of TryLock is often a sign of a deeper problem
in a particular use of mutexes.

WaitGroup的字段变化

先前,WaitGroup类型使用[3]uint32作为state1字段的类型,在64位和32位编译器情况下,这个字段的byte的意义是不同的,主要是为了对齐。虽然使用一个字段很"睿智",但是阅读起来却很费劲,现在,Go team把它改成了两个字段,根据对齐规则,64位编译器会对齐相应字段,讲真的,我们不差那4个字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type WaitGroup struct {
noCopy noCopy
// 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
// 64-bit atomic operations require 64-bit alignment, but 32-bit
// compilers only guarantee that 64-bit fields are 32-bit aligned.
// For this reason on 32 bit architectures we need to check in state()
// if state1 is aligned or not, and dynamically "swap" the field order if
// needed.
state1 uint64
state2 uint32
}
// state returns pointers to the state and sema fields stored within wg.state*.
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
if unsafe.Alignof(wg.state1) == 8 || uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
// state1 is 64-bit aligned: nothing to do.
return &wg.state1, &wg.state2
} else {
// state1 is 32-bit aligned but not 64-bit aligned: this means that
// (&state1)+4 is 64-bit aligned.
state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
return (*uint64)(unsafe.Pointer(&state[1])), &state[0]
}
}

64位对齐情况下state1和state2意义很明确,如果不是64位对齐,还得巧妙的转换一下。

Pool中使用fastrandn替换fastrand

Go运行时中提供了fastrandn方法,要比fastrand() % n快很多,相关的文章可以看下面中的注释中的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//go:nosplit
func fastrand() uint32 {
mp := getg().m
// Implement wyrand: https://github.com/wangyi-fudan/wyhash
if goarch.IsAmd64|goarch.IsArm64|goarch.IsPpc64|
goarch.IsPpc64le|goarch.IsMips64|goarch.IsMips64le|
goarch.IsS390x|goarch.IsRiscv64 == 1 {
mp.fastrand += 0xa0761d6478bd642f
hi, lo := math.Mul64(mp.fastrand, mp.fastrand^0xe7037ed1a0b428db)
return uint32(hi ^ lo)
}
// Implement xorshift64+
t := (*[2]uint32)(unsafe.Pointer(&mp.fastrand))
s1, s0 := t[0], t[1]
s1 ^= s1 << 17
s1 = s1 ^ s0 ^ s1>>7 ^ s0>>16
t[0], t[1] = s0, s1
return s0 + s1
}
//go:nosplit
func fastrandn(n uint32) uint32 {
// This is similar to fastrand() % n, but faster.
// See https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/
return uint32(uint64(fastrand()) * uint64(n) >> 32)
}

所以sync.Pool中使用fastrandn做了一点点修改,用来提高性能。好卷啊,这一点点性能都来压榨,关键,这还是开启race才会执行的代码。

sync.Value增加了Swap和CompareAndSwap两个便利方法

如果使用sync.Value,这两个方法的逻辑经常会用到,现在这两个方法已经添加到标准库中了。

1
2
func (v *Value) Swap(new interface{}) (old interface{})
func (v *Value) CompareAndSwap(old, new interface{}) (swapped bool)

Go 1.18中虽然实现了泛型,但是一些库的修改有可能在将来的版本中实现了。在泛型推出来之后,atomic对类型的支持会有大大的加强,所以将来Value这个类型有可能退出历史舞台,很少被使用了。(参考Russ Cox的文章Updating the Go Memory Model)

整体来说,Go的并发相关的库比较稳定,并没有大的变化。