有些朋友可能没有注意过,在 Go(甚至是大部分语言) 中,一条普通的赋值语句其实不是一个原子操作。例如,在 32 位机器上写 int64 类型的变量就会有中间状态,因为它会被拆成两次写操作 (汇编的 MOV 指令)——写低 32 位和写高 32 位,如下图所示:
32 机器上对 int64 进行赋值
如果一个线程刚写完低 32 位,还没来得及写高 32 位时,另一个线程读取了这个变量,那它得到的就是一个毫无逻辑的中间变量,这很有可能使我们的程序出现 Bug。
这还只是一个基础类型,如果我们对一个结构体进行赋值,那它出现并发问题的概率就更高了。很可能写线程刚写完一小半的字段,读线程就来读取这个变量,那么就只能读到仅修改了一部分的值。这显然破坏了变量的完整性,读出来的值也是完全错误的。
面对这种多线程下变量的读写问题,Go 给出的解决方案是 atomic.Value 登场了,它使得我们可以不依赖于不保证兼容性的 unsafe.Pointer 类型,同时又能将任意数据类型的读写操作封装成原子性操作。
之前我在文章 Golang 五种原子性操作的用法详解里,详细介绍过它的用法,下面我们先来快速回顾一下 atomic.Value 的使用方式
atomic.Value 的使用方式
atomic.Value 类型对外提供了两个读写方法:
- v.Store(c) - 写操作,将原始的变量 c 存放到一个 atomic.Value 类型的 v 里。
- c := v.Load() - 读操作,从线程安全的 v 中读取上一步存放的内容。
下面是一个简单的例子演示 atomic.Value 的用法。
type Rectangle struct {
length int
width int
}
var rect atomic.Value
func update(width, length int) {
rectLocal := new(Rectangle)
rectLocal.width = width
rectLocal.length = length
rect.Store(rectLocal)
}
func main() {
wg := sync.WaitGroup{}
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
update(i, i+5)
}()
}
wg.Wait()
\_r := rect.Load().(\*Rectangle)
fmt.Printf("rect.width=%d\\nrect.length=%d\\n", \_r.width, \_r.length)
}
你也可以试试,不用 atomic.Value,直接给 Rectange 类型的指针变量赋值,对比一下两者结果的区别。
你可能会好奇,为什么 atomic.Value 在不加锁的情况下就提供了读写变量的线程安全保证,接下来我们就一起看看其内部实现。
atomic.Value 的内部实现
atomic.Value 被设计用来存储任意类型的数据,所以它内部的字段是一个 interface{} 类型。
type Value struct {
v interface{}
}
除了 Value 外,atomic 包内部定义了一个 ifaceWords 类型,这其实是 interface{}的内部表示 (runtime.eface),它的作用是将 interface{}类型分解,得到其原始类型 (typ) 和真正的值(data)。
type ifaceWords struct {
typ unsafe.Pointer
data unsafe.Pointer
}
写入线程安全的保证
在介绍写入之前,我们先来看一下 Go 语言内部的 unsafe.Pointer 类型。
unsafe.Pointer
出于安全考虑,Go 语言并不支持直接操作内存,但它的标准库中又提供一种不安全 (不保证向后兼容性) 的指针类型 unsafe.Pointer,让程序可以灵活的操作内存。
unsafe.Pointer 的特别之处在于,它可以绕过 Go 语言类型系统的检查,与任意的指针类型互相转换。也就是说,如果两种类型具有相同的内存结构 (layout),我们可以将 unsafe.Pointer 当做桥梁,让这两种类型的指针相互转换,从而实现同一份内存拥有两种不同的解读方式。
比如说,[]byte 和 string 其实内部的存储结构都是一样的,他们在运行时类型分别表示为 reflect.SliceHeader 和 reflect.StringHeader
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
type StringHeader struct {
Data uintptr
Len int
}
但 Go 语言的类型系统禁止他俩互换。如果借助 unsafe.Pointer,我们就可以实现在零拷贝的情况下,将[]byte 数组直接转换成 string 类型。
bytes := \[\]byte{104, 101, 108, 108, 111}
p := unsafe.Pointer(&bytes)
str := \*(\*string)(p)
fmt.Println(str)
知道了 unsafe.Pointer 的作用,我们可以直接来看代码了:
func (v \*Value) Store(x interface{}) {
if x == nil {
panic("sync/atomic: store of nil value into Value")
}
vp := (\*ifaceWords)(unsafe.Pointer(v))
xp := (\*ifaceWords)(unsafe.Pointer(&x))
for {
typ := LoadPointer(&vp.typ)
if typ == nil {
runtime\_procPin()
if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {
runtime\_procUnpin()
continue
}
StorePointer(&vp.data, xp.data)
StorePointer(&vp.typ, xp.typ)
runtime\_procUnpin()
return
}
if uintptr(typ) == ^uintptr(0) {
continue
}
if typ != xp.typ {
panic("sync/atomic: store of inconsistently typed value into Value")
}
StorePointer(&vp.data, xp.data)
return
}
}
大概的逻辑:
- 通过 unsafe.Pointer 将现有的和要写入的值分别转成 ifaceWords 类型,这样我们下一步就可以得到这两个 interface{}的原始类型 (typ) 和真正的值(data)。
- 开始就是一个无限 for 循环。配合 CompareAndSwap 使用,可以达到乐观锁的效果。
- 通过 LoadPointer 这个原子操作拿到当前 Value 中存储的类型。下面根据这个类型的不同,分 3 种情况处理。
第一次写入 - 一个 atomic.Value 实例被初始化后,它的 typ 字段会被设置为指针的零值 nil,所以先判断如果 typ 是 nil 那就证明这个 Value 实例还未被写入过数据。那之后就是一段初始写入的操作:
- runtime_procPin()这是 runtime 中的一段函数,一方面它禁止了调度器对当前 goroutine 的抢占 (preemption),使得它在执行当前逻辑的时候不被打断,以便可以尽快地完成工作,因为别人一直在等待它。另一方面,在禁止抢占期间,GC 线程也无法被启用,这样可以防止 GC 线程看到一个莫名其妙的指向 ^uintptr(0) 的类型(这是赋值过程中的中间状态)。
- 使用 CAS 操作,先尝试将 typ 设置为 ^uintptr(0) 这个中间状态。如果失败,则证明已经有别的线程抢先完成了赋值操作,那它就解除抢占锁,然后重新回到 for 循环第一步。
- 如果设置成功,那证明当前线程抢到了这个 “ 乐观锁”,它可以安全的把 v 设为传入的新值了。注意,这里是先写 data 字段,然后再写 typ 字段。因为我们是以 typ 字段的值作为写入完成与否的判断依据的。
第一次写入还未完成 - 如果看到 typ 字段还是 ^uintptr(0) 这个中间类型,证明刚刚的第一次写入还没有完成,所以它会继续循环,一直等到第一次写入完成。
第一次写入已完成 - 首先检查上一次写入的类型与这一次要写入的类型是否一致,如果不一致则抛出异常。反之,则直接把这一次要写入的值写入到 data 字段。
这个逻辑的主要思想就是,为了完成多个字段的原子性写入,我们可以抓住其中的一个字段,以它的状态来标志整个原子写入的状态。
读取 (Load) 操作
先上代码:
func (v \*Value) Load() (x interface{}) {
vp := (\*ifaceWords)(unsafe.Pointer(v))
typ := LoadPointer(&vp.typ)
if typ == nil || uintptr(typ) == ^uintptr(0) {
return nil
}
data := LoadPointer(&vp.data)
xp := (\*ifaceWords)(unsafe.Pointer(&x))
xp.typ = typ
xp.data = data
return
}
读取相对就简单很多了,它有两个分支:
如果当前的 typ 是 nil 或者 ^uintptr(0),那就证明第一次写入还没有开始,或者还没完成,那就直接返回 nil (不对外暴露中间状态)。
否则,根据当前看到的 typ 和 data 构造出一个新的 interface{} 返回出去。
总结
本文由浅入深的介绍了 atomic.Value 的使用姿势,以及内部实现。让大家不仅知其然,还能知其所以然。
另外,原子操作由底层硬件支持,对于一个变量更新的保护,原子操作通常会更有效率,并且更能利用计算机多核的优势,如果要更新的是一个复合对象,则应当使用 atomic.Value 封装好的实现。
而我们做并发同步控制常用到的 Mutex 锁,则是由操作系统的调度器实现,锁应当用来保护一段逻辑。
https://www.tuicool.com/articles/yIjyQru