当前位置: 首页 > 科技观察

为什么Go的Atomic.Value不用加锁就能保证数据线程安全?

时间:2023-03-12 21:27:19 科技观察

有些朋友可能没有注意到,在Go(甚至大部分语言)中,普通的赋值语句并不是原子操作。比如在32位机器上写一个int64类型的变量,会有一个中间状态,因为它会被拆分成两个写操作(汇编中的MOV指令)——写低32位和写高32位,如如下图所示:在32位机器上赋值给int64。如果一个线程刚刚写完低32位,还没来得及写高32位,另一个线程读取这个变量,得到的是一个不合逻辑的中间变量。我们的程序很可能会出现错误。这只是一个基本类型。如果我们给一个结构体赋值,出现并发问题的概率会更高。很有可能写线程刚写完一小半字段,读线程就来读这个变量,所以只能读到刚刚修改过的值。这显然破坏了变量的完整性,读到的值是完全错误的。面对这个多线程下读写变量的问题,Go给出的解决方案是atomic.Value的登场,让我们不依赖不保证兼容性的unsafe.Pointer类型,同时,任何数据类型的读写操作都被封装成原子操作。我之前在Golang的五种原子操作的详细用法中已经详细介绍过它的用法。让我们快速回顾一下atomic.Value的用法。atomic.Value的使用。atomic.Value类型提供了两种Read和write方法:v.Store(c)-写操作,将原始变量c存储到一个atomic.Value类型中v.c:=v.Load()-读操作,读取内容存储在上一步中的线程安全v。下面是一个演示atomic.Value用法的简单示例。typeRectanglesstruct{lengthintwidthint}varrectatomic.Valuefuncupdate(width,lengthint){rectLocal:=new(Rectangle)rectLocal.width=widththrectLocal.length=lenghrect.Store(rectLocal)}funcmain(){wg:=sync.WaitGroup{}wg.Add(10)//10个协程并发更新fori:=0;i<10;i++{gofunc(){deferwg.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{}类型。typeValuestruct{vinterface{}}除了Value,atomic包内部还定义了一个ifaceWords类型,其实就是interface{}(runtime.eface)的内部表示,其作用是分解interface{}类型,得到其原始类型(typ)和实际值(data)。//ifaceWordsisinterface{}internalrepresentation.typeifaceWordsstruct{typunsafe.Pointerdataunsafe.Pointer}写线程安全的保证在介绍写之前,我们先看一下Go语言内部的unsafe.Pointer类型。unsafe.Pointer出于安全考虑,Go语言不支持直接操作内存,但其标准库提供了一个不安全(不保证向后兼容)指针类型unsafe.Pointer,允许程序灵活操作内存。unsafe.Pointer的特殊之处在于它可以绕过Go语言类型系统的检查,与任何指针类型相互转换。也就是说,如果两种类型有相同的内存结构(布局),我们可以使用unsafe.Pointer作为桥梁,将两种类型的指针相互转换,从而使同一块内存有两种不同的解释方式。比如[]byte和string其实内部存储结构相同,它们的运行时类型分别表示为reflect.SliceHeader和reflect.StringHeadertypeSliceHeaderstruct{DatauintptrLenintCapint}typeStringHeaderstruct{DatauintptrLenint},但是Go语言的类型系统禁止它们交互.改变。借助unsafe.Pointer,我们可以在零拷贝的情况下直接将[]byte数组转为字符串类型。bytes:=[]byte{104,101,108,108,111}p:=unsafe.Pointer(&bytes)//将*[]byte指针转换为unsafe.Pointerstr:=*(*string)(p)//将unsafe.Pointer重新转换为unsafe.Pointer的指针string类型,然后把这个指针的值作为字符串类型outfmt.Println(str)//输出"hello"知道了unsafe.Pointer的作用,我们可以直接看代码:func(v*Value)Store(xinterface{}){ifx==nil{panic("sync/atomic:storeofnilvalueintoValue")}vp:=(*ifaceWords)(unsafe.Pointer(v))//Oldvaluexp:=(*ifaceWords)(不安全。Pointer(&x))//Newvaluefor{typ:=LoadPointer(&vp.typ)iftyp==nil{//Attempttostartfirststore.//Disablepreemptionssothatothergoroutinescanuse//activespinwaittowaitforcompletion;andsothat//GCdoesnotseetthefaketypeaccidentally.runtime_proc.ComparePin()if!,nil,unsafe.Pointer(^uintptr(0))){runtime_procUnpin()continue}//完成firststore.StorePointer(&vp.data,xp.data)StorePointer(&vp.typ,xp.typ)runtime_procUnpin()return}ifuintptr(typ)==^uintptr(0){//Firststoreinprogress.Wait.//Sincewedisablepreemptionaroundthefirststore,//wecanwaitwithactivespinning.continue}//Firststorecompleted.Checktypeandoverwritedata.iftyp!=xp.typ{panic("sync/atomic:storeofinconsistentlytypedvalueintoValue")}StorePointer(&vp.data,xp.data)return}}大概逻辑:通过unsafe。指针将已有的和待写入的值转换成ifaceWords类型,这样我们下一步就可以得到两个interface{}的原始类型(typ)和真实值(data)。它以无限循环开始。与CompareAndSwap一起使用,可以达到乐观锁的效果。通过LoadPointer的原子操作获取当前Value中存储的类型。根据不同的类型,处理以下三种情况。先写-一个atomic.Value实例初始化后,它的typ字段会被设置为指针的零值nil,所以先判断typ是否为nil,证明这个Value实例没有写入数据。之后就是初始的写操作:runtime_procPin()这个是runtime里面的一个函数。一方面,它禁止调度器抢占当前goroutine(抢占),使其在执行当前逻辑时不会被打断。以便尽快完成工作,因为其他人一直在等待它。另一方面,在抢占期间不能启用GC线程,这样可以防止GC线程看到一个莫名其妙的类型指向^uintptr(0)(这是赋值过程中的一个中间状态)。要使用CAS操作,首先尝试将typ设置为^uintptr(0)的中间状态。如果失败,则证明其他线程先完成了赋值,然后释放抢占锁,然后返回到for循环的第一步。如果设置成功,则证明当前线程已经抢到了这个“乐观锁”,可以安全地将v设置为传入的新值。注意这里是先写data字段,再写typ字段.因为我们以typ字段的值作为判断写入是否完成的依据。第一次写入还没有完成——如果看到typ字段还是中间类型^uintptr(0),证明第一次写入还没有完成,所以会一直循环,直到第一次写入完成.第一次写入已经完成——先检查上一次写入的类型和本次要写入的类型是否一致,不一致则抛出异常。否则,直接将本次要写入的值写入数据域。这个逻辑的主要思想是,为了完成多个字段的原子写入,我们可以抓取其中一个字段,用它的状态来标记整个原子写入的状态。先读取(Load)操作代码:func(v*Value)Load()(xinterface{}){vp:=(*ifaceWords)(unsafe.Pointer(v))typ:=LoadPointer(&vp.typ)iftyp==nil||uintptr(typ)==^uintptr(0){//Firststorenotyetcompleted.returnnil}data:=LoadPointer(&vp.data)xp:=(*ifaceWords)(unsafe.Pointer(&x))xp.typ=typxp.data=datareturn}读取就简单多了,它有两个分支:如果当前typ是nil或者^uintptr(0),证明第一次写入还没有开始或者完成,那么直接返回nil(不暴露中间状态)).否则,根据当前看到的typ和数据构造一个新的interface{}并返回它。小结本文由浅入深介绍了atomic.Value的使用姿势和内部实现。让大家不仅知其然,更知其所以然。此外,底层硬件支持原子操作。为了保护变量更新,原子操作通常更高效并且可以利用多核计算机。如果要更新复合对象,则应将其封装为atomic.Value实现。我们常用于并发同步控制的互斥锁是由操作系统的调度器实现的,锁应该用来保护一段逻辑。