问答文章1 问答文章501 问答文章1001 问答文章1501 问答文章2001 问答文章2501 问答文章3001 问答文章3501 问答文章4001 问答文章4501 问答文章5001 问答文章5501 问答文章6001 问答文章6501 问答文章7001 问答文章7501 问答文章8001 问答文章8501 问答文章9001 问答文章9501

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

发布网友 发布时间:2024-09-29 22:20

我来回答

1个回答

热心网友 时间:2024-10-08 03:20

多字段更新?

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

举个例子,有一个structPerson的结构体,里面有两个字段。我们先更新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来观察,时间大概是200ms左右,为什么?

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.Store和Value.Load是用来赋值和取值的。我的问题是,这两个函数里面有没有用户数据拷贝?Store和Load是否是保证了多字段拷贝的原子性?

提前透露下:并非如此。

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{},eface,ifaceWords这三个结构体内存布局完全一致,只是用的地方不同而已,本质无差别。这给类型的强制转化创造了前提。

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{},eface,ifaceWords本质是一个东西,同一种内存的三种类型解释,用在不同层面和场景。它们可以通过强制类型转化进行切换;

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

atomic.Value的Store和Load方法都不涉及到数据拷贝,只涉及到指针操作;

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

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

后记

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

作者:奇伢云存储

热心网友 时间:2024-10-08 03:24

多字段更新?

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

举个例子,有一个structPerson的结构体,里面有两个字段。我们先更新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来观察,时间大概是200ms左右,为什么?

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.Store和Value.Load是用来赋值和取值的。我的问题是,这两个函数里面有没有用户数据拷贝?Store和Load是否是保证了多字段拷贝的原子性?

提前透露下:并非如此。

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{},eface,ifaceWords这三个结构体内存布局完全一致,只是用的地方不同而已,本质无差别。这给类型的强制转化创造了前提。

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{},eface,ifaceWords本质是一个东西,同一种内存的三种类型解释,用在不同层面和场景。它们可以通过强制类型转化进行切换;

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

atomic.Value的Store和Load方法都不涉及到数据拷贝,只涉及到指针操作;

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

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

后记

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

作者:奇伢云存储

声明声明:本网页内容为用户发布,旨在传播知识,不代表本网认同其观点,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。E-MAIL:11247931@qq.com
360浏览器怎么设置倍速播放 ...先讲女主的灵魂飘荡了一段时间,然后重生,请问是那本? 拯救者散热器怎么开 电脑如何一键还原系统电脑一键还原怎么操作 神舟笔记本电脑怎么重新设置神舟战神bios恢复出厂设置 神舟电脑恢复出厂设置神舟战神怎么恢复原厂系统 水泥楼梯如何铺木楼梯 家里面楼梯是水泥的不想铺地毯或者地砖还能铺什么 楼梯的水泥台阶上可以铺地板革吗 手机腾讯会议共享屏幕播放视频没声 梦见棕色鸟是什么意思啊 万字图解| 深入揭秘Golang锁结构:Mutex(下) 为什么我的手机储存卡拿读卡器读不出来 读卡器为什么无法识别手机的内存卡 为什么有时手机储存卡不能被读卡器读出来。。有I盘 却不能打开。 偶尔... 为什么我的卡用任何读卡器都读不出来? 读卡器读不出手机储存卡 手机内存卡放在读卡器上读不出来 为什么手机里的内存卡插到读卡器里就显示未插卡 苹果笔记本安装系统? 平面设计和面点师哪个更吃香 发展前景都如何 梦到好多桃子又大又红是什么意思 宿舍附近的大卖场和溜冰场一天到晚放音乐,吵死人了,根本都睡不着!噪音... 我隔壁学校每天把那个音乐开的打雷一样,每天早上一大早把我吵醒了,怎么... 我住的地方边上有个技校整天放音乐很吵怎么办 办手机卡需要什么条件 搜狗浏览器的工具箱里我的工具没有了,最想要的就是截图功能。至于里面... 扫地机器人可以扫地毯吗 董办做什么 ?哪五类人群别喝鸡汤补身 脖子上有个针眼大的小孔,时间长有白色透明液流出来,不疼不痒,说是篓... JAVA Script.根据输入的6科成绩,算出不及格的科目成绩以及6科的平均... 街上卖的炸豆腐怎么做 感觉玩lol已经成为一种负担了,每次玩都得在选人阶段,开局,打的时候费心... 如果游戏成了一种负担,你还玩吗? 玩了很长时间的游戏删号是一种什么样的体验? 跪求梦幻FC一生全攻略 疤痕红印4个月多久可以消 ...你太坚强太懂事就活该没人哄没人疼。这么多年都过来了,还有什么过... 国内期货门槛是多少? 期货门槛多少钱 期货门槛低吗 为什么爱奇艺打不开了呢? 期货有什么资金限制 怎么打不开爱奇艺了呢? WORD文档怎样才能插入空白页? 如何在word2010文档中设置奇偶页不同的页眉? 麻雀是一种常见的鸟,其身体最发达的肌肉应该是( ) A.胸部肌肉 B.后肢肌... 请人吃饭点几个菜有讲究吗 小针刀做一次多少钱