小艾的自留地

Stay foolish, Stay hungry

通过前面的笔记,了解到了 Go 的同步工具——互斥锁的相关知识。

通过对互斥锁的合理使用,可以使一个 goroutine 在执行临界区中的代码时,不被其他的 goroutine 打扰(保证临界区中代码的串行执行)。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。

原子操作

通过前面学习 goroutine 的调度原理可以知道,goroutine 调度器会从本地运行队列依次调用队列中的 goroutine 与物理处理器 M 运行,

因此,在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量只会与 M 的数量一致,而不会随着队列中的 goroutine 增多而增长。

当遇到阻塞的系统调用时,调度器会将等待系统调用的 goroutine 暂时换下,同时会从本地运行队列里换上另外一个 goroutine 来运行。

这里的换上的意思是,让一个 goroutine 由非运行状态转为运行状态,并促使其中的代码在某个 CPU 核心上执行。换下的意思正好相反,即:使一个 goroutine 中的代码中断执行,并让它由运行状态转为非运行状态。

这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。

即使这些语句在临界区之内也是如此。所以,互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)

在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。

这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,它的执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。

正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速完成

因为如果原子操作迟迟不能完成,而它又不会被中断,这将给计算机执行指令的效率带来很大的影响。

sync.atomic

Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包 sync/atomic 中。

sync/atomic 包中的函数可以做的原子操作有:

  • 加法(add)
  • 比较并交换(compare and swap,简称 CAS)
  • 加载(load)存储(store)和交换(swap)

这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic 包都会有一套函数给予支持。这些数据类型有:int32int64uint32uint64uintptr,以及unsafe 包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。

下面这段示例代码,是介绍 sync.Mutex与sync 这篇笔记中的一段代码,当时虽然使用互斥锁解决了竞争条件,但是并没有保证绝对的并发安全,对于原子性问题,就应该使用原子操作来解决。

下面使用原子操作来解决:

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
var (
counter int32
wg sync.WaitGroup
)

func main() {
wg.Add(2)

go incCounter()
go incCounter()

wg.Wait()
fmt.Println("Final Counter:", counter)
}

func incCounter() {
defer wg.Done()

for count := 0; count < 2; count++ {
// 当前 goroutine 从线程退出,并放回队列
runtime.Gosched()

// 绝对安全地对 counter 加 1
atomic.AddInt32(&counter, 1)
}
}

运行一下,输出如下:

1
Final Counter: 4

可以看到,也是符合预期的。

原子操作使用常见问题

下面通过一些常见的问题,来更深入了解原子操作。

1. 原子操作函数的第一个参数为什么必须是(整型)指针类型?

因为整型作为函数参数是值传递,被传入的参数值都会被复制一份,对传入的参数进行修改是不会影响到原值的,因此,想要修改原值,就必须传入被被操作值的指针,而不是这个值本身。

2. 用于原子加法操作的函数可以做原子减法吗?

可以的,atomic.AddInt32 函数的第二个参数代表差量,它的类型可以是 int32,是有符号的。因此,如果想做原子减法,那么把这个差量设置为负整数就可以了。

总结

  1. 不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。
  2. 如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。
  3. 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免 panic 的发生。
  4. 如果可能的话,可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。

参考链接

评论