jimyag's Blog

Go 结构体赋值的并发边界与空结构体的用法

最后更新:

一份配置有两个必须一起变化的字段。热更新时写一句 current = next,读端就能看到完整的新配置吗?在 Go 里,这行代码表达了值赋值,没有给读写双方建立同步关系。另一个常见误会是把 struct{} 的零大小直接等同于“容器没有内存开销”。这两件事都要把语言给出的保证和实现细节分开看。

一次结构体赋值不是一次原子发布

下面两个字段应该相等。主 goroutine 反复读取 current,另一个 goroutine 反复给同一个变量赋值。读写在源码里各只有一行,但两边没有锁或其他同步。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import "fmt"

type State struct {
	Low  int
	High int
}

func main() {
	var current State
	done := make(chan struct{})
	go func() {
		defer close(done)
		for i := 0; i < 10000; i++ {
			current = State{Low: i, High: i}
		}
	}()

	mismatch := 0
	for i := 0; i < 10000; i++ {
		snapshot := current
		if snapshot.Low != snapshot.High {
			mismatch++
		}
	}
	<-done
	fmt.Println("mismatch:", mismatch)
}

保存为 main.go,执行 go run -race main.go。用 Go 1.27.3(macOS arm64)实测,mismatch 打印为 0,竞态检测器仍对 current 报出 WARNING: DATA RACE。最后的 <-done 只让主 goroutine 等到写端结束,循环里的读取已经发生了。若业务只需要写完后的最终状态,可以把读取移到 <-done 后面,并保证关闭 channel 后不再写。

  • go run:编译并运行指定的 Go 程序。
  • -race(data race,数据竞争):启用竞态检测器,检查本次运行实际执行到的共享内存访问;一次没报错不代表所有执行路径都没有竞态。
  • main.go:要运行的源文件;后面换成 snapshot.go 时,参数含义不变。

对于需要持续读写的状态,最直接的做法是把每次赋值和每次读取都放进同一把锁的临界区:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var mu sync.Mutex
var current State

func store(next State) {
	mu.Lock()
	current = next
	mu.Unlock()
}

func load() State {
	mu.Lock()
	snapshot := current
	mu.Unlock()
	return snapshot
}

用上面的包级 current 替换 main 内的局部变量,导入 sync,再把循环里的读写换成 load() 和 store(...)。两个字段就来自同一次发布,改过的示例用同样的 -race 命令实测没有报错。若状态包含 map、slice 或指针,复制结构体只复制这些字段的值,不会复制其引用的内容;释放锁之后仍要保证底层数据不会被无同步地修改。对于写少读多、可以构造独立不可变快照的状态,也可以用 atomic.Pointer[State] 发布快照;发布后不再修改快照及其引用的数据。

这个结论来自 Go 内存模型:多机器字的读写可以拆成多次操作,语言没有保证并发读取得到一致的组合。判断共享访问是否安全,要看两个访问之间有没有同步关系,不能靠字段大小、反汇编里看见的一条指令,或者竞态检测器这一次没报错。

把后端列表和路由一起切到新版本

假设网关热更新时要同时切换后端列表和路由表。即使当前只有一个 goroutine,也能先看到浅复制的问题:若 copy := draft,再执行 copy.backends[0] = "b" 或 copy.routes["/"] = "b",原来的 draft 也会读到 "b"。赋值复制了 slice 和 map 字段的值;两份结构体仍能访问同一份元素。

下面的版本在发布前复制这两份容器,再用一次 Store 切换快照。读端只 Load 一次,从同一份快照取两个字段。示例用 Go 1.21 或更新版本即可运行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
	"fmt"
	"maps"
	"slices"
	"sync/atomic"
)

type config struct {
	backends []string
	routes   map[string]string
}

var currentConfig atomic.Pointer[config]

func publish(draft config) {
	currentConfig.Store(&config{
		backends: slices.Clone(draft.backends),
		routes:   maps.Clone(draft.routes),
	})
}

func main() {
	draft := config{
		backends: []string{"a"},
		routes:   map[string]string{"/": "a"},
	}
	publish(draft)
	previous := currentConfig.Load()

	draft.backends[0] = "b"
	draft.routes["/"] = "b"
	fmt.Println(previous.backends[0], previous.routes["/"])

	publish(draft)
	snapshot := currentConfig.Load()
	fmt.Println(snapshot.backends[0], snapshot.routes["/"])
	fmt.Println(previous.backends[0], previous.routes["/"])
}

保存为 snapshot.go,运行 go run -race snapshot.go,输出依次是 a a、b b、a a。这三个结果分别验证了构建下一版不会改到旧版、新读者会拿到新版、已经拿到旧快照的读者还能用旧版。实际服务应在开放流量前发布初始配置,或者让读端处理 Load 返回 nil 的情况。构建者在复制期间也要独占草稿;如果另一个 goroutine 同时写同一个 map,调用 maps.Clone 并不能消除竞态。

这里的元素都是字符串,所以复制外层容器已经够用。如果换成 map[string][]string,maps.Clone 后两份 map 里的 slice 仍指向原数据;还需要逐个复制其内容,或保证这些内容之后不再修改。atomic.Pointer 只负责发布指针,不会把它指向的对象变成只读。读端也要保留第一次 Load 得到的指针:如果读后端列表时加载一次、读路由时再加载一次,两次之间恰好发布新版,就可能把 A 版的列表和 B 版的路由拼在一起。标准库的 atomic.Value 读多写少示例 用同一思路替换整张 map。

struct{} 表达只有成员资格或信号

前面的竞态示例用 done := make(chan struct{}) 等写端结束:channel 里传什么并不重要,重要的是“关闭”这件事。这正是空结构体的典型用途。空结构体只有一个值,没有字段。它适合让接口明确表达“有或没有”“发生或没发生”,读者不需要猜测某个占位的布尔值还代表什么。

要表达集合,键就是元素,查询用 map 的第二个返回值:

1
2
3
seen := make(map[string]struct{})
seen["node-a"] = struct{}{}
_, exists := seen["node-a"]

如果不需要附带数据,map 值不用存业务字段。map 仍然要存键、维护索引和分配存储;并发读写也仍需同步。Go 官方的 map 用法示例 也演示了用 map[T]bool 表示访问过的节点,直接利用缺失键返回 false。两种写法都可表达集合;如果业务要区分“键不存在”和“键存在但被禁用”,用 map[string]bool 的值和第二个返回值,或定义更具体的状态类型。

如果只需要通知“处理完了”,用关闭 channel 表达一次性完成:

1
2
3
4
5
6
done := make(chan struct{})
go func() {
	defer close(done)
	work()
}()
<-done

一个负责关闭的 goroutine 可以用 close(done) 唤醒多个等待者,之后开始等待的 goroutine 也能立即收到关闭。对这个 channel 发送一次 struct{}{} 则只让一次接收取到该信号。两者适用场景不同;对已经关闭的 channel 再关闭或再发送都会 panic。

用缓冲区容量限制同时进行的操作

同样的 chan struct{} 也可以表达两个并发名额。发送一个空值表示占用名额;接收一个空值表示归还。每个调用必须先占到名额才执行工作,并在返回时归还:

1
2
3
4
5
func withSlot(slots chan struct{}, work func()) {
	slots <- struct{}{}
	defer func() { <-slots }()
	work()
}

各调用者共用 make(chan struct{}, 2) 时,前两个正在执行的操作各占一个位置,第三个会卡在发送处,直到某个操作结束。这里限制的是同一时刻有多少操作在执行;如果每个操作很快,仍可能在一秒内完成很多次。等待名额的 goroutine 也没有数量上限。不要用 close(slots) 表示归还名额;那会让仍要占名额的调用 panic。若需要调用方可以取消等待,获取名额时再同时监听 ctx.Done()。

context.WithValue 也常使用包内私有的空结构体类型作键。键匹配看接口值的动态类型和值,所以即使都没有字段,requestIDKey{} 和 userIDKey{} 也不是同一个键:

1
2
3
4
5
type requestIDKey struct{}
type userIDKey struct{}

ctx := context.WithValue(context.Background(), requestIDKey{}, "req-42")
requestID, ok := ctx.Value(requestIDKey{}).(string)

同一类型的两个空结构体值相等,复用同一种键类型会查到同一项。实际包里可以用私有键类型,加上 WithRequestID、RequestID 访问函数,把键和类型断言封在包内。按 context.WithValue 的文档,它用于跨 API 边界传递请求范围的数据。

unsafe.Sizeof 衡量的是值本身的大小。struct{} 本身大小为零,带缓冲的 channel、map、装箱后的接口值以及含空字段的外层结构体仍受各自的表示和对齐规则影响。选择 struct{} 首先是为了表达没有载荷;若要判断某个具体容器节约了多少内存,需要在目标版本和用法下测量。

#Go