通过前面的笔记,了解到了 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
包都会有一套函数给予支持。这些数据类型有:int32
、int64
、uint32
、uint64
、uintptr
,以及unsafe
包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。
下面这段示例代码,是介绍 sync.Mutex与sync 这篇笔记中的一段代码,当时虽然使用互斥锁解决了竞争条件,但是并没有保证绝对的并发安全,对于原子性问题,就应该使用原子操作来解决。
下面使用原子操作来解决:
1 | var ( |
运行一下,输出如下:
1 | Final Counter: 4 |
可以看到,也是符合预期的。
原子操作使用常见问题
下面通过一些常见的问题,来更深入了解原子操作。
1. 原子操作函数的第一个参数为什么必须是(整型)指针类型?
因为整型作为函数参数是值传递,被传入的参数值都会被复制一份,对传入的参数进行修改是不会影响到原值的,因此,想要修改原值,就必须传入被被操作值的指针,而不是这个值本身。
2. 用于原子加法操作的函数可以做原子减法吗?
可以的,atomic.AddInt32
函数的第二个参数代表差量,它的类型可以是 int32
,是有符号的。因此,如果想做原子减法,那么把这个差量设置为负整数就可以了。
总结
- 不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。
- 如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。
- 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免 panic 的发生。
- 如果可能的话,可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。