首页>>后端>>Golang->Go 并发编程 — 结构体多字段更新的原子操作

Go 并发编程 — 结构体多字段更新的原子操作

时间:2023-11-29 本站 点击:0

多字段更新?

并发编程中,原子更新多个字段是常见的需求。

举个例子,有一个struct Person的结构体,里面有两个字段。我们先更新Person.name,再更新Person.age,这是两个步骤,但我们必须保证原子性。

有童鞋可能奇怪了,为什么要保证原子性?

我们以一个示例程序开端,公用内存简化成一个全局变量,开 10 个并发协程去更新。你猜最后的结果是啥?

packagemainimport("fmt""sync""time")typePersonstruct{namestringageint}//全局变量(简单处理)varpPersonfuncupdate(namestring,ageint){//更新第一个字段p.name=name//加点随机性time.Sleep(time.Millisecond*200)//更新第二个字段p.age=age}funcmain(){wg:=sync.WaitGroup{}wg.Add(10)//10个协程并发更新fori:=0;i<10;i++{name,age:=fmt.Sprintf("nobody:%v",i),igofunc(){deferwg.Done()update(name,age)}()}wg.Wait()//结果是啥?你能猜到吗?fmt.Printf("p.name=%s\np.age=%v\n",p.name,p.age)}

打印结果是啥?你能猜到吗?

可能是这样的:

p.name=nobody:2p.age=3

也可能是:

p.name=nobody:8p.age=7

按照排列组合来算,一共有 10*10 种结果。

那我们想要什么结果?我们想要 name 和 age 一定要是匹配的,不能牛头不对马嘴。换句话说,name 和 age的更新一定要原子操作,不能出现未定义的状态。

我们想要的是 ( nobody:i,i ),正确的结果只能在以下预定的 10 种结果出现:

(nobody:0,0)(nobody:1,1)(nobody:2,2)(nobody:3,3)...(nobody:9,9)

这仅仅是一个简单的示例,童鞋们思考下自己现实的需求,应该是非常常见的。

现在有两个问题:

第一个问题:这个 demo 观察下运行时间,用 time 来观察,时间大概是 200 ms 左右,为什么?

root@ubuntu:~/code/gopher/src/atomic_test#time./atomic_testp.name=nobody:8p.age=7real0m0.203suser0m0.000ssys0m0.000s

如上就是 203 毫秒。划重点:这个时间大家请先记住了,对我们分析下面的例子有帮助。

这个 200 毫秒是因为奇伢在update函数中故意加入了一点点时延,这样可以让程序估计跑慢一点。

每个协程跑update的时候至少需要 200 毫秒,10 个协程并发跑,没有任何互斥,时间重叠,所以整个程序的时间也是差不都 200 毫秒左右。

第二个问题:怎么解决这个正确性的问题。

大概两个办法:

锁互斥

原子操作

下面详细分析下异同和优劣。

锁实现

在并发的上下文,用锁来互斥,这是最常见的思路。锁能形成一个临界区,锁内的一系列操作任何时刻都只会有一个人更新,如此就能确保更新不会混乱,从而保证多步操作的原子性。

首先配合变量,对应一把互斥锁:

//全局变量(简单处理)varpPerson//互斥锁,保护变量更新varmusync.Mutex

更新的逻辑在锁内:

funcupdate(namestring,ageint){//更新:加锁,逻辑串行化mu.Lock()defermu.Unlock()//以下逻辑不变}

大家按照上面的把程序改了之后,逻辑是不是就正确了。一定是 ( nobody:i,i )配套更新的。

但你注意到另一个可怕的问题吗?

程序运行变的好慢!!!!

同样用time命令统计下程序运行时间,竟然耗费 2 秒!!!,10 倍的时延增长,每次都是这样。

root@ubuntu:~/code/gopher/src/atomic_test#time./atomic_testp.name=nobody:8p.age=8real0m2.017suser0m0.000ssys0m0.000s

不禁要问自己,为啥?

还记得上面我提到过,一个 update 固定要 200 毫秒。

加锁之后的update函数逻辑全部在锁内,10 个协程并发跑update函数,但由于锁的互斥性,抢锁不到就阻塞等待,保证update内部逻辑的串行化。

第 1 个协程加上锁了,后面 9 个都要等待,依次类推。最长的等待时间应该是 1.8 秒。

换句话说,程序串行执行了 10 次update函数,时间是累加的。程序 2 秒的运行时延就这样来的。

加锁不怕,抢锁等待才可怕。在大量并发的时候,由于锁的互斥特性,这里的性能可能堪忧。

还有就是抢锁失败的话,是要把调度权让出去的,直到下一次被唤醒。这里还增加了协程调度的开销,一来一回可能性能就更慢了下来。

思考:用锁之后正确性是保证了,某些场景性能可能堪忧。那咋吧?

在本次的例子,下一步的进化就是:原子化操作。

温馨提示:

怕童鞋误会,声明一下:锁不是不能用,是要区分场景,不分场景的性能优化措施是没有意义的哈。大部分的场景,用锁没啥问题。且锁是可以细化的,比如读锁和写锁,更新加写锁,只读操作加读锁。这样确实能带来较大的性能提升,特别是在写少读多的时候。

原子操作

其实我们再深究下,这里本质上是想要保证更新 name 和 age 的原子性,要保证他们配套。其实可以先再局部环境设置好 Person结构体,然后一把原子赋值给全局变量即可。Go 提供了atomic.Value这个类型。

怎么改造?

首先把并发更新的目标设置为atomic.Value类型:

//全局变量(简单处理)varpatomic.Value

然后update函数改造成先局部构造,再原子赋值的方式:

funcupdate(namestring,ageint){lp:=&Person{}//更新第一个字段lp.name=name//加点随机性time.Sleep(time.Millisecond*200)//更新第二个字段lp.age=age//原子设置到全局变量p.Store(lp)}

最后main函数读取全局变量打印的地方,需要使用原子Load方式:

p.name=nobody:2p.age=30

这样就解决并发更新的正确性问题啦。感兴趣的童鞋可以运行下,结果都是正确的 ( nobody:i,i )。

下面再看一下程序的运行时间:

p.name=nobody:2p.age=31

竟然是 200 毫秒作用,比锁的实现时延少 10 倍,并且保证了正确性。

为什么会这样?

因为这 10 个协程还是并发的,没有类似于锁阻塞等待的操作,只有最后p.Store(lp)调用内才有做状态的同步,而这个时间微乎其微,所以 10个协程的运行时间是重叠起来的,自然整个程序就只有 200 毫秒左右。

锁和原子变量都能保证正确的逻辑。在我们这个简要的场景里,我相信你已经感受到性能的差距了。

当然了,还是那句话,具体用那个实现要看具体场景,不能一概而论。而且,锁有自己无可替代的作用,它能保证多个步骤的原子性,而不仅仅是字段的赋值。

相信你已经非常好奇atomic.Value了,下面简要的分析下原理,是否真的很神秘呢?

原理可能要大跌眼镜。

趁现在我们还不懂内部原理,先思考个问题(不然待会一下子看懂了就没意思了)?

Value.StoreValue.Load是用来赋值和取值的。我的问题是,这两个函数里面有没有用户数据拷贝?StoreLoad是否是保证了多字段拷贝的原子性?

提前透露下:并非如此。

atomic.Value原理

atomic.Value结构体

atomic.Value定义于文件src/sync/atomic/value.go,结构本身非常简单,就是一个空接口:

p.name=nobody:2p.age=32

在之前文章中,奇伢有分享过 Go 的空接口类型(interface {})在 Go 内部实现是一个叫做eface的结构体(src/runtime/iface.go):

p.name=nobody:2p.age=33

interface {}是给程序猿用的,eface是 Go 内部自己用的,位于不同层面的同一个东西,这个请先记住了,因为atomic.Value就利用了这个特性,在value.go定义了一个ifaceWords的结构体。

划重点:interface {}efaceifaceWords这三个结构体内存布局完全一致,只是用的地方不同而已,本质无差别。这给类型的强制转化创造了前提。

Value.Store方法

看一下简要的代码,这是一个简单的 for 循环:

p.name=nobody:2p.age=34

有几个点稍微解释下:

atomic.Value使用^uintptr(0)作为第一次存取的标志位,这个标识位是设置在 type 字段里,这是一个中间状态;

通过CompareAndSwapPointer来确保^uintptr(0)只能被一个执行体抢到,其他没抢到的走 continue ,再循环一次;

atomic.Value第一次写入数据时,将当前协程设置为不可抢占,当存储完毕后,即可解除不可抢占;

真正的赋值,无论是第一次,还是后续的 data 赋值,再 Store 内,只涉及到指针的原子操作,不涉及到数据拷贝;

这里有没有大跌眼镜?

Store内部并不是保证多字段的原子拷贝!!!!Store里面处理的是个结构体指针。只通过了StorePointer保证了指针的原子赋值操作。

我的天?是这样的吗?那何来的原子操作。

核心在于:Value.Store()的参数必须是个局部变量(或者说是一块全新的内存)。

这里就回答了上面的问题:Store,Load 是否有数据拷贝?

划重点:没有!没动数据

原来你是这样子的atomic.Value

回忆一下我上面的update函数,真的是局部变量,全新的内存块:

p.name=nobody:2p.age=35

又有个问题,你可能会想了,如果p.Store( /* */ )传入的不是指针,而是一个结构体呢?

事情会是这样的:

编译器识别到这种情况,编译期间就会多生成一段代码,用runtime.convT2E函数把结构体赋值转化成eface(注意,这里会涉及到结构体数据的拷贝);

然后再调用Value.Store方法,所以就Store方法而言,行为还是不变;

再思考一个问题:既然是指针的操作,为什么还要有个 for 循环,还要有个CompareAndSwapPointer

这是因为ifaceWords是两个字段的结构体,初始赋值的时候,要赋值类型和数据指针两部分。

atomic.Value是服务所有类型,此类需求的,通用封装。

Value.Load方法

有写就有读嘛,看一下读的简要的实现:

p.name=nobody:2p.age=36

哇,太简单了。处理做了一下初始赋值的判断(返回 nil ),后续基本就只靠LoadPointer函数来个原子读指针值而已。

总结

interface {}efaceifaceWords本质是一个东西,同一种内存的三种类型解释,用在不同层面和场景。它们可以通过强制类型转化进行切换;

atomic.Value使用 cas 操作只在初始赋值的时候,一旦赋值过,后续赋值的原子操作更简单,依赖于StorePointer,指针值得原子赋值;

atomic.ValueStoreLoad方法都不涉及到数据拷贝,只涉及到指针操作;

atomic.Value的神奇的核心在于:每次 Store 的时候用的是全新的内存块 !!!且LoadStore都是以完整结构体的地址进行操作,所以才有原子操作的效果。

atomic.Value实现多字段原子赋值的原理千万不要以为是并发操作同一块多字段内存,还能保证原子性;

后记

说实话,原理让我大跌眼镜,当然也让我们避免踩坑。

作者:奇伢云存储


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/Golang/244.html