返回首页

Go 中的同步:原子操作和内存屏障

处理器级同步机制分析:指令和内存重排序、内存栅栏、原子操作。在 Go Assembler 上实现 atomic.And 的实际示例。

Go 中的低级同步:从 CPU 到原子操作
Advertisement 728x90

# Go 中的底层同步:原子操作、屏障与指令重排序

理解处理器级同步机制并非学术奢侈,而是构建多线程 Go 应用的开发者实际必需。本文拆解 CPU 如何优化代码执行,为什么这会破坏并发程序的预期逻辑,以及哪些硬件原语能帮助恢复秩序。

处理器如何捉弄程序员:重排序与乱序执行

当你写下 x = 1; ready = true 时,你期望严格的顺序执行。处理器可没那么听话。它会重排序独立的指令来填充流水线停顿。这就是指令重排序。目标:最大化执行单元利用率并隐藏内存延迟。

CPU 使用乱序执行:指令不是按程序顺序运行,而是操作数就绪时执行。例如:

Google AdInline article slot
MOVQ A(SB), AX ; load from memory
MOVQ B(SB), BX ; load from memory
ADDQ DX, CX    ; add registers

处理器可以先执行 ADDQ,因为操作数已在寄存器中,同时将加载操作放到后台——它们需要数百个时钟周期。当前线程的逻辑结果不变,但其他线程可能看到不同景象。

混乱出现的流水线阶段:

  • Fetch → Decode → Register Rename → Dispatch → Execute → Commit

关键阶段:Dispatch。调度器在这里挑选操作数就绪的指令,无视原始顺序。重排序缓冲区 (ROB) 确保指令提交到架构状态时按正确顺序——但仅限当前核心。

Google AdInline article slot

内存重排序:存储缓冲区何时背叛你

问题在内存层面更棘手。向 RAM 的写操作并非瞬时——它们先落入存储缓冲区,然后异步刷新到缓存。对于一个核心:

x = 1
ready = true

对于另一个核心,可能看起来像:

ready = true
x = 0

因为 ready=true 的写操作可能更早离开存储缓冲区。这就是内存重排序——对其他线程可见的内存操作重排序。经典数据竞争示例:

Google AdInline article slot
G1: 
x = 1
ready = true

G2:
if ready {
  print(x) // might print 0!
}

内存屏障:MFENCE 等恢复秩序的工具

解决方案:内存栅栏(内存屏障)。这些指令强制核心间操作可见性同步。在 x86 上,主要屏障是 MFENCE:

MOVQ $1, x(SB)
MFENCE           ; barrier
MOVQ $1, ready(SB)

MFENCE 保证它之前的全部存储操作在后续操作开始执行前对所有核心可见。它禁止四种重排序:

  • Store → Store
  • Load → Load
  • Load → Store
  • Store → Load

重要:x86 内存模型相对强大——许多重排序已被硬件禁止。但 Store→Load 仍可能,因此 MFENCE 对于严格同步必不可少。

不同架构的屏障类型:

  • x86: MFENCE (full), SFENCE (store→store), LFENCE (load→load)
  • ARM: DMB (Data Memory Barrier), ISH (inner shareable domain)
  • RISC-V: FENCE (flexible, with direction specifiers)

原子操作:基于 LOCK 的高级抽象

Go 将屏障隐藏在 sync/atomic 包的原子操作背后。底层使用 LOCK 前缀,使读-改-写操作在所有核心间不可分。例如 XADDQ:

TEXT ·Xadd64(SB), NOSPLIT, $0-24
	MOVQ	ptr+0(FP), BX
	MOVQ	delta+8(FP), AX
	MOVQ	AX, CX
	LOCK
	XADDQ	AX, 0(BX)   ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
	ADDQ	CX, AX
	MOVQ	AX, ret+16(FP)
	RET

LOCK 通过在 MESI 协议中锁定缓存行工作:

  • 核心获取缓存行的独占权
  • 执行操作
  • 释放缓存行——变更对其他核心可见

原子性 ≠ 排序!原子操作保证不可分,但不一定创建内存屏障。在 x86 上,简单加载/存储是原子的,但不提供排序。

编写自己的原子 AND:动手玩转 Go 汇编

Go 标准库没有 atomic.And。我们自己实现。创建文件 Xand8_x86.s

#include "textflag.h"

TEXT ·And8(SB), NOSPLIT, $0-9
	MOVQ	ptr+0(FP), BX      // BX = pointer
	MOVB	mask+8(FP), AX     // AX = mask (8 bits)
	LOCK
	ANDB	AX, 0(BX)          // *ptr &= mask
	RET

对应的 main.go

package main

import "C"

func And8(ptr *uint8, mask uint8)

func main() {
	var smth uint8 = 3
	And8(&smth, uint8(2))
	println("Result:", smth) // outputs 2
}

go run . 构建运行——ASM 文件自动拾取。注意:用 LOCK 前缀的 ANDB,尽管 x86 无 XAND 指令。这有效——LOCK 兼容基本逻辑操作。

关键要点

  • 指令重排序 — CPU 优化,通过改变独立指令执行顺序提升性能。
  • 内存重排序 — 因存储缓冲区导致核心间写操作可见性不同步。
  • 内存屏障(栅栏) — 强制线程间内存操作顺序同步的指令。
  • 原子操作 — 通过 LOCK 实现的不可分读-改-写动作,封装屏障。
  • LOCK + ANDB — 实现 Go 标准库缺失的位运算原子操作的有效方式。

— Editorial Team

Advertisement 728x90

继续阅读