深入理解 Go 语言原子内存操作

        原子内存操作提供了实现其他同步原语所需的低级基础。一般来说,你可以用互斥体和通道替换并发算法的所有原子操作。然而,它们是有趣且有时令人困惑的结构,应该深入了解它们是如何工作的。如果你能够谨慎地使用它们,那么它们完全可以成为代码优化的好工具,而不会增加复杂性。

1. 原子内存操作的内存保证

        为什么我们需要单独的函数来进行原子内存操作?如果我们写入一个变量,其大小小于或等于机器字长( 现代计算机的机器字长一般都 8 位的整数倍,如 8 位、16 位等,这是由 int 类型定义的东西),例如 a = 1 ,这不就是原子的吗?

        Go 内存模型实际上保证了写操作是原子的,但是它并不能保证其他 goroutine 何时会看到该写操作的效果。

        让我们仔细分析这句话的含义。第一层意思是说,如果你从一个 goroutine 中写入与机器字长(即int) 大小相同的共享内存位置并从另一个 goroutine 中读取它,那么即使存在竞争,你也不会观察到写入操作之前的值或写入操作之后的值(并非所有语言都如此)。这意味着,如果写操作大于机器字长,那么读取该值的 goroutine 可能会看到底层对象处于不一致的状态。例如,string 值包括两个值:指向底层数组的指针和字符串长度。对这些单独的写入操作是原子的,但快速读取操作可能会读取带有 nil 数组但长度非零的字符串。

        这句话的第二层意思是说,编译器可能优化或重新排序代码,或者硬件可能乱序执行内存操作,从而使另一个 goroutine 在预期时间无法看到写入操作的效果。说明这一点的标准示例就是以下内存竞争:

package main

func main() {
	var str string
	var done bool
	go func() {
		str = "Done!"
		done = true
	}()
	for !done {

	}
	fmt.Println(str)
}

        这里就存在内存竞争,因为 str 变量和 done 变量在一个 goroutine 中被写入并在另一个 goroutine 中被读取,但没有显式同步。

该程序有多种可能的结果:

  • 它可以输出 Done ! 。
  • 它可以输出一个空字符串。这意味着主 goroutine 可以看到对 done 的内存写入,但看不到对 srt 的内存写入。
  • 程序可能会挂起。这意味着主 goroutine 看不到对 done 的内存写入。

这就是原子操作发挥作用的地方。以下程序是没有内存竞争的:

func main(){
	var str done atomic.Value
	var done atomic.Bool
	str.Store("")
	go func(){
		str.Store("Done!")
		done.Store(true)
	}()
	for !done.Load(){
	}
	fmt.Println(str.Load())
}

        原子操作的内存保证如下。如果原子内存写入的效果可以通过原子读取观察到,则原子写入发生在原子读取之前。这也保证了以下程序要么输出 1 ,要么不输出任何东西(永远都不会输出 0):

func main(){
	var done atomic.Bool
	var a int
	go func(){
		a = 1
		done.Store(true)
	}()
	if done.Load(){
		fmt.Println(a)
	}
}

        值得一提的是,这里仍然存在竞争条件,但不是内存竞争。根据语句的执行顺序,主 goroutine 可能会也可能不会看到 done 为 true 。但是,如果主 goroutine 看到 done 为 true,那么就可以保证 a = 1 。

        这就是为什么使用原子操作会变得复杂的原因之一:内存排序保证是有条件的。它们永远不会阻塞正在运行的 goroutine,因此你测试原子读取是否返回变量的特定值这一事实并不意味着当 if 语句主体运行时它仍然具有相同的值。这就是为什么在使用原子操作时需要小心。使用它们很容易陷入竞争条件,就像之前的程序这样。

2. 比较和交换操作

        每当你需要测试条件并根据结果采取行动时,你都可以创建竞争条件。例如,尽管使用了原子操作,但以下函数并不能阻止互斥:

var locked sync.Bool

func wrongCriticalSectionExample(){
	if !locked.Load(){
		// 其他 goroutine 现在可以锁定它!
		locked.Store(true)
		defer locked.Store(false)
		// 该 goroutine 进入临界区,但其他 goroutine 也可以
	}
}

        该函数首先测试原子 locked 值是否为 false。两个 goroutine 可以同时执行这条语句,并且看到它为 false,它们都可以进入临界区并将 locked 设置为 true。

        这里需要的包含比较和存储操作的原子操作,也就是比较和交换(compare-and-swap,CAS)操作。正如其名称所暗示的那样,它将比较变量是否具有预期值,如果是,则自动将该值与给定值进行交换。如果变量具有不同的值,则不会发生任何更改。也就是说,CAS 操作是以下形式的,并以原子方式完成:

if *variable == testValue {
	*variable = newValue
	return true
}
return false

        现在你可以真正实现非阻塞互斥体:

func criticalSection(){
	if locked.CompareAndSwap(false,true)
	defer locked.Store(false)
	// 临界区
	}
}

        只有当 locked 为 false 时才会进入临界区。如果是这种情况,那么它会自动将 locked 设置为 true 并进入其临界区;否则,它将跳远临界区并明治继续。因此,这实际上可以用来代替 Mutex.TryLock。

3. 原子的实际用途

3.1 计数器

        原子可以用作高效的并发安全计数器。

        以下程序示例将创建许多 goroutine, 其中每个 goroutine 都会将共享计数器加 1。另一个 goroutine 则循环直至计数器达到 10000。由于这里使用了原子,因此该程序是无竞争的,并且它始终会通过最终输出 10000 来终止。

var count int64

func main() {
	for i := 0,i <10000; i++ {
		go func(){
			atomic.AddInt64(&count,1)
		}()
	}
	
	for {
		v := atomic.LoadInt64(&count)
		fmt.Println(v)
		if v == 10000 {
			break
		}
	}
}
3.2 心跳和进度表

        有时,goroutine 可能会变得无响应或无法按需要快速进行。心跳实用程序和进度表可用于观察此类 goroutine 。有若干种方法可以做到这一点。例如,被观察的 goroutine 可以使用非阻塞发送来宣布进度,或者它可以通过增加由互斥体保护的共享变量来宣布其进度。原子允许我们在没有互斥体的情况下实现共享变量方案。这样做还有一个可以好处是可以被多个 goroutine 观察而无须额外的同步。

        让我们定义一个包含原子值的简单 ProgressMeter 类型:

type ProgressMeter struct {
	progress int64
}

        被观察的 goroutine 使用以下方法来指示其进度。此方法只是自动将进度值递增1:

func (pm *ProgressMeter) Progress(){
	atomic.AddInt64(&pm.progress,1)
}

        Get 方法可以返回进度的当前值。请注意,该负载是原子的,否则就有可能会丢失对变量的原子添加:

func (pm *ProgressMeter) Get() int64 {
	wg.Wait()
}

        可以看到,我们将 cancel 函数传递给了观察者,以便它可以向被观察的 goroutine 发送取消的消息。 

3.3 取消

        我们可以通过关闭通道来发出取消信号。Context 实现可以使用此范式来发出取消和超时信号。使用原子也可以实现简单的取消方案:

func CancelSupport()(cancel func(),isCancelled func() bool){
	v := atomic.Bool{}
	cancel = func(){
		v.Store(true)
	}
	isCancelled = func() bool {
		return v.Load()
	}
	return
}

        CancelSupport 函数返回两个闭包,其中,cancel() 函数可被调用以发出取消信号,而 isCancelled( )函数则可用于检查取消请求是否已注册。这两个闭包共享一个原子 bool 值,这可以按如下方式使用:

func main(){
	cancel,isCanceled := CancelSupport()
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func(){
		defer wg.Done()
		for {
			time.Sleep(100 * time.Millisecond)
			if isCanceled(){
				fmt.Println("Cancelled")
				return
			}
		}
	}()
	time.AfterFunc(5*time.Second,cancel)
		wg.Wait()
}
3.4 检测变化

        假设你有一个可以从多个 goroutine 中更新的共享变量。你读取此变量,执行了一些计算,现在你想要更新它。但是,在你获得副本后,另一个 goroutine 可能已经修改了该变量。因此,当且仅当其他 goroutine 没有更改此变量时,你才可以更新它。

以下代码片段使用比较和交换(CAS)操作对此进行说明:

var sharedValue atomic.Pointer[SomeStruct]

func unpdateSharedValue(){
	myCopy := sharedValue.Load()
	newCopy := computeNewCopy(*myCopy)
	if sharedValue.CompareAndSwap(myCopy,&newCopy){
		fmt.Println("Set value successful")
	} else {
		fmt.Println("Another goroutine modified the value")
	}
}

        这段代码很容易出现竞争,所以你必须小心。SharedValue.Load( )调用以原子方式返回指向共享值的指针。如果另一个 goroutine 修改了指向 *sharedValue 对象的内容,则出现了竞争。仅当所有 goroutine 以原子方式获取指针并复制底层数据结构时,这才有效。然后,我们使用 CAS 写入修改后的副本,但如果另一个 goroutine 表现得更快,则写入操作会失败。

  • 16
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mindfulness code

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值