Go 的秘密生活:原子操作
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存储any(interface{})。加载时务必验证类型断言;类型不匹配会导致运行时 panic。
同步层次结构
- 通道 – 用于通信和传递数据所有权。
- 互斥锁 – 用于保护关键区段和复杂的共享状态。
- 原子操作 – 用于简单计数器、标志位和高性能指标。
内存安全
- 不要将原子访问和非原子访问混合使用在同一个变量上。
- 如果使用
atomic.Store,则必须使用atomic.Load来读取。
下一章:项目结构与包 – Eleanor 将解释如何组织代码、内部目录以及如何设计简洁的 API。
Aaron Rose 是一名软件工程师兼技术作者,供职于 tech-reader.blog ,也是 Think Like a Genius 的作者。