Go 的秘密生活:原子操作

发布: (2025年12月19日 GMT+8 12:40)
11 min read
原文: Dev.to

Source: Dev.to

不可分割的瞬间

星期二早晨,哈德逊河吹来刺骨的寒风,摇晃着图书馆档案室的老式窗扇。伊桑走进来,抖掉外套上的寒气,手里端着一个装有两杯咖啡和一个小蜡纸袋的纸托盘。

埃莉诺已经坐在桌前,阅读眼镜挂在鼻尖,正在审阅一堆打印稿。她闻了闻空气。

埃莉诺: “蓝莓味的?”
伊桑: “差不多。西村一家店的越橘司康。还有一杯抹茶风味的手冲咖啡,黑的。”

埃莉诺罕见地露出真诚的微笑,接过咖啡。

埃莉诺: “太好了。你正在学会,精确度在咖啡里和代码里同样重要。”

伊桑拉起一把椅子。

伊桑: “说到精确,我在想上周的事。我们用互斥锁修复了计数器的数据竞争。它起作用了,但……”
埃莉诺: “但?” (她掰下一块司康)
伊桑: “感觉很沉重。加锁、解锁、延迟……仅仅为了把数字加一?有没有更快的办法?更……小的办法?”

埃莉诺把碎屑从笔记本上擦掉。

埃莉诺: “有。不过这需要你少一点程序员的思维,多一点 CPU 的思考方式。”

她打开笔记本电脑。

埃莉诺: “记得我跟你说过 counter++ 不是一步操作吗?它其实有三步:读取值、加一、写回。互斥锁通过冻结它们周围的世界来保护这三步。但现代处理器有指令可以一次性完成这三步——不可分割地。”
伊桑: “不可分割?”
埃莉诺: “原子操作。来自希腊语 atomos——不可切割的。一次性完成且不能被中断的操作。”

埃莉诺在笔记的边缘快速画了个示意图。

NORMAL EXECUTION                ATOMIC EXECUTION
[CPU Core 1]                    [CPU Core 1]
    |                               |
    | Read 0                        | AtomicAdd(1)
    | Add 1                         | (Bus Locked 🔒)
    | Write 1                       | (Value becomes 1)
    |                               | (Bus Unlocked 🔓)
[CPU Core 2]                    [CPU Core 2]
    |                               |
    | (Interferes!)                 | (Must Wait)

埃莉诺: “看到了吗?在原子版本里,核心 2 实际上根本无法干扰。硬件会阻止它。”

伊桑点点头。

伊桑: “好,我明白理论了。那在 Go 语言里怎么写?”

Go 示例 – 原子加法

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	var counter int64
	var wg sync.WaitGroup

	start := time.Now()
	// ... rest of the example ...
}

Eleanor: “没有 Mutex。atomic.AddInt64 使用我们刚才画的那条硬件指令。它是最原始的同步形式。”

Ethan 看了看代码。

Ethan: “看起来很简单。为什么不把它用于所有场景?”
Eleanor: “因为它只适用于简单的东西——整数、指针、布尔值。你不能对切片进行原子追加,也不能原子地更新映射。但对于计数器、仪表或标志,它的速度要快几个数量级。Mutex 可能花费约 30 ns;而原子加法?一两纳秒。”
Eleanor: “而且它永远不会睡眠。Mutex 需要 Go 运行时调度器——可能会让 goroutine 睡眠。原子操作完全在硬件中完成,要么成功,要么自旋。”

她抿了一口 Geisha。

Eleanor: “不过有个陷阱。你也必须原子地读取数据。直接打印 counter 而其他协程正在写入时,可能会出现‘撕裂读取’——一半位来自旧值,一半位来自新值。”

Go 示例 – 原子加载

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func main() {
	var value int64

	// Writer
	go func() {
		for {
			atomic.AddInt64(&value, 1)
			time.Sleep(time.Millisecond)
		}
	}()

	// Reader
	for i := 0; i < 10; i++ {
		fmt.Println(atomic.LoadInt64(&value))
		time.Sleep(500 * time.Millisecond)
	}
}

Eleanor: “注意 atomic.LoadInt64。正如你必须原子化写入一样,也必须原子化读取。这保证了内存的一致快照。”

伊桑慢慢点了点头。

Ethan: “所以,用 Add 写入,用 Load 读取。还有别的么?”
Eleanor:Store,以及最强大的:比较并交换(CAS)。”

Source:

Compare‑and‑Swap (CAS)

package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var status int32 = 0 // 0 = idle, 1 = busy

	// Try to set status to busy
	swapped := atomic.CompareAndSwapInt32(&status, 0, 1)

	if swapped {
		fmt.Println("Success: Status changed from 0 to 1")
	} else {
		fmt.Println("Failure: Status was not 0")
	}

	// Try again
	swapped = atomic.CompareAndSwapInt32(&status, 0, 1)

	if swapped {
		fmt.Println("Success: Status changed from 0 to 1")
	} else {
		fmt.Println("Failure: Status was already 1")
	}
}

Output

Success: Status changed from 0 to 1
Failure: Status was already 1

Eleanor:CompareAndSwap——简称 CAS——的含义是:‘我认为值是 0。如果是,就把它改成 1;如果不是,告诉我。’ 这让你能够构建无锁的数据结构和算法。”

对话继续深入,探讨了内存顺序、atomic.Value 类型,以及何时应该使用互斥锁而不是原子操作。但亲爱的读者,这个故事留到下一章再说。

Ethan,这里有一条规则:
• 使用 channel 来编排。
• 使用 Mutex 来保护复杂状态。
• 只有在需要对简单计数器或状态标志进行原始速度的操作时,才使用 atomic

“所以,不要当英雄?”
“没错。巧妙的代码难以调试,原子代码几乎不可能调试。”

她在笔记本上敲入了最后一个示例。

有一种高级原子工具既安全又极其有用:atomic.Value
它可以处理任何类型,而不仅仅是数字。非常适合在服务器运行时更新配置。

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

type Config struct {
	APIKey  string
	Timeout time.Duration
}

func main() {
	var config atomic.Value

	// Initial config
	config.Store(Config{APIKey: "INITIAL", Timeout: time.Second})

	// Background updater
	go func() {
		for {
			time.Sleep(100 * time.Millisecond)
			// Atomically replace the entire struct
			config.Store(Config{
				APIKey:  "UPDATED",
				Timeout: 2 * time.Second,
			})
		}
	}()

	// Reader loop
	for i := 0; i < 10; i++ {
		cfg := config.Load().(Config)
		fmt.Printf("APIKey=%s Timeout=%s\n", cfg.APIKey, cfg.Timeout)
		time.Sleep(200 * time.Millisecond)
	}
}

“看到了吗?config.Load() 返回最新的完整配置。config.Store() 完全替换它。没有锁,也没有阻塞读取器。读取器始终看到一个完整、有效的 Config 对象——要么是旧的,要么是新的,永远不会出现混合。只要注意类型断言——atomic.Value 存储的是 any,所以 Go 只会在运行时检查类型。”

Ethan 吃完了司康。“这真的很简洁。配置用 atomic.Value,指标用 atomic.Add。”

“正是如此,” Eleanor 合上笔记本。“你正在构建一个工具箱,Ethan。Channel 是你的电钻,Mutex 是你的扳手,Atomic…它们是手术刀。你不会用手术刀去建房子,但当需要手术时,别无选择。”

她端起杯子。咖啡已经凉了,但她仍然长舒一口,满意地喝下。

“下次我们把视角从硬件上移开。我们需要讨论如何组织这些代码——包、可见性,以及如何构建一个不会因自身重量而崩溃的程序。”

Ethan 收拾垃圾。“项目结构?”

“项目结构。Go 应用的架构。是时候停止写脚本,开始构建系统了。”

第10章关键概念

  • 原子操作 – 不可分割地执行;不会被其他 goroutine 或 CPU 指令中断。位于 sync/atomic 包中。
  • atomic.AddInt64 – 原子地递增变量。对于简单计数器,它比互斥锁快得多,因为它使用 CPU 硬件指令而非操作系统级锁。
  • atomic.Load / atomic.Store – 在没有锁的情况下安全读取和写入值。防止出现“撕裂读取”,即可能看到部分写入的数据。
  • 比较并交换(CAS)atomic.CompareAndSwapInt32(&addr, old, new)。仅当当前值与“旧”值相匹配时,才原子地更新为新值。是无锁算法的基础。
  • atomic.Value – 可以原子地加载和存储任意类型的值(结构体、映射等)。非常适合“读多写少”的场景,例如更新全局配置。
  • 类型安全atomic.Value 存储 anyinterface{})。加载时务必验证类型断言;类型不匹配会导致运行时 panic。

同步层次结构

  1. 通道 – 用于通信和传递数据所有权。
  2. 互斥锁 – 用于保护关键区段和复杂的共享状态。
  3. 原子操作 – 用于简单计数器、标志位和高性能指标。

内存安全

  • 不要将原子访问和非原子访问混合使用在同一个变量上。
  • 如果使用 atomic.Store,则必须使用 atomic.Load 来读取。

下一章:项目结构与包 – Eleanor 将解释如何组织代码、内部目录以及如何设计简洁的 API。


Aaron Rose 是一名软件工程师兼技术作者,供职于 tech-reader.blog ,也是 Think Like a Genius 的作者。

Back to Blog

相关文章

阅读更多 »