登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go 怎样用 atomic.Bool 实现一次开关

来源:17golang原创

时间:2026-10-17 17:32:21 448浏览 收藏

如果一个初始化动作、迁移动作或首次注册动作只能成功一次,可以把状态放进 atomic.Bool,让所有调用共享同一个开关,再用 CompareAndSwap(false, true) 抢占唯一的成功机会。返回 true 的调用继续执行后续动作,其他调用直接结束;这个判断和状态修改是一个原子操作,不会因为并发读写而产生两个“首次成功者”。

这里的“一次”指从 false 到 true 的一次条件转换。如果业务允许重新打开,就需要明确调用 Store(false) 的时机;如果状态还要和订单、锁定时间等字段保持整体一致,单独一个布尔原子值并不能替代事务或更大范围的同步。

先把一次开关的状态说清楚

atomic.Bool 是 sync/atomic 提供的类型化原子布尔值,零值为 false,常用方法包括 Load、Store、Swap 和 CompareAndSwap。它适合表示一个可以独立读取、独立改变的状态,例如“是否已经完成初始化”“是否已经发送首个通知”。

我在这种场景里通常先把对象身份固定下来:并发调用拿到的是同一个 *OnceSwitch,而不是每次函数调用新建一个局部变量。只有共享同一个原子字段,竞争才会发生在同一个状态上。

一次开关对象与 atomic.Bool 访问修改方法的静态关系
图1:一次开关对象、atomic.Bool 以及 Load、Store、Swap、CompareAndSwap 的静态 API 关系说明图,不是运行截图。

用 CompareAndSwap 让并发调用只成功一次

一次性动作最适合使用 CompareAndSwap:只有当当前值仍是 false 时,才把它替换成 true,并返回是否替换成功。多个 goroutine 同时尝试时,只有一个调用能满足这个条件;其他调用看到旧值已经不是 false,就会返回 false。

package main

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

// OnceSwitch 把一次性状态集中到一个不可复制的共享对象中。
type OnceSwitch struct {
	started atomic.Bool
}

// TryStart 只让第一个调用者取得后续动作的执行权。
func (s *OnceSwitch) TryStart() bool {
	// 比较和写入在同一个原子操作内完成,避免两个调用都成功。
	return s.started.CompareAndSwap(false, true)
}

func main() {
	switcher := new(OnceSwitch) // 使用同一个指针,所有调用竞争同一状态。
	var wg sync.WaitGroup
	const callers = 4

	wg.Add(callers)
	for i := 0; i 

这个示例的关键不是 goroutine 数量,而是 switcher 只创建一次,并把指针交给所有调用者。预期只有一行 winner,但具体是哪一个编号并不固定。这里的输出是根据代码逻辑说明的预期,不是本文制作时执行得到的运行截图。

如果一次性动作本身可能失败,要先决定状态表示的是“已经抢到执行权”还是“动作已经成功完成”。上面的写法表示前者:一旦某个调用把状态改成 true,后续调用就不会再进入动作。若业务要求失败后允许重试,就不能在失败时无条件保持这个状态,应重新设计回滚或状态机边界。

Load、Store 和 Swap 分别解决什么问题

Load 只读取当前布尔值,适合做展示、采样或在不需要抢占时判断状态。它不会改变开关,也不能把“先读取再执行”自动变成一次性竞争。

// IsStarted 返回一次开关在某个读取时刻的快照。
func (s *OnceSwitch) IsStarted() bool {
	return s.started.Load() // 原子读取,不承担后续动作的排他性。
}

Store 是无条件写入。初始化时可以用它明确设置状态,测试或明确的生命周期切换也可以用它重置;但 Store(true) 不会告诉你之前是谁改的,Store(false) 也会重新打开一次性入口。

// Reset 只有在业务确认允许重新开始时才调用。
func (s *OnceSwitch) Reset() {
	s.started.Store(false) // 重置后下一次 CompareAndSwap 可能再次成功。
}

Swap 会原子地写入新值并返回旧值。如果你需要知道“切换前是否已经打开”,它比单独 Load 再 Store 更合适,因为读取旧值和写入新值不会被另一个调用插入。

// OpenAndReport 打开开关,并报告这次写入前的旧状态。
func (s *OnceSwitch) OpenAndReport() (wasOpen bool) {
	return s.started.Swap(true) // 返回旧值,false 表示此前处于关闭状态。
}

不要把 Load 加 Store 当成一次开关

下面这种写法看起来直观,但在并发下不成立:

// wrongTryStart 把判断和写入拆开,不能保证只有一个成功者。
func (s *OnceSwitch) wrongTryStart() bool {
	if s.started.Load() { // 两个调用都可能同时读到 false。
		return false
	}
	s.started.Store(true) // 另一个调用可能已经在这里之前完成了同样的写入。
	return true
}

每一次 Load 和 Store 本身都可以是原子的,但两次调用之间的业务判断不是原子的。两个调用都读到 false 后,就可能都返回 true。要竞争“旧值是 false 的那个机会”,必须直接使用 CompareAndSwap(false, true)。

共享 atomic.Bool、唯一成功者与业务一致性边界的静态结构
图2:共享原子布尔值、唯一成功者、重复调用与业务后续动作的静态边界说明图,不是运行结果或执行流程。

对象不能复制,业务动作也不能只靠一个字段

原子类型使用后不要复制。不要把包含 atomic.Bool 的结构体按值传递、按值返回或用赋值制造第二份对象;需要共享时传递指针,需要对外提供结果时读取普通的 bool 快照。复制对象会让调用者误以为自己仍在竞争同一个开关。

还要把“状态更新”和“业务动作完成”分开。CompareAndSwap 只能保证布尔字段的这次条件替换;它不会自动把数据库写入、文件创建、日志记录等多个动作包成事务。如果动作需要和其他字段保持一致,就用事务、互斥锁或明确的状态机覆盖整个一致性范围。

最后,原子性也不等于执行顺序已经满足业务要求。成功取得一次开关后,调用者仍要负责错误处理、资源清理和幂等设计;如果动作失败后必须重试,就不要把“抢到执行权”直接当成“业务已完成”。

怎么选择这四个方法

方法适合的任务要注意的边界
Load读取当前快照不能和后续动作组成一次性竞争
Store无条件设置或明确重置会覆盖当前状态,重置会重新开放入口
Swap写入新值并拿到旧值表示替换,不表达复杂业务条件
CompareAndSwap只有旧值匹配时才更新最适合争抢唯一的首次执行权

实际使用时可以按一个问题判断:你需要的是“看一眼状态”、“直接覆盖状态”、“知道替换前是什么”,还是“只有状态仍为某值才允许我改写”?一次开关通常属于最后一种,因此从 CompareAndSwap(false, true) 开始设计最清楚。

官方文档入口

官方地址:https://pkg.go.dev/sync/atomic

版本说明:https://go.dev/doc/go1.19

内存模型:https://go.dev/ref/mem

相关问题

  • atomic.Bool 和互斥锁怎么选?只保护一个独立布尔状态时原子类型更直接;需要同时保护多个字段或一段临界区时,互斥锁通常更容易表达整体不变量。

  • 为什么不能先 Load 再 Store?因为判断和写入之间存在竞争窗口,多个调用都可能基于旧的 false 返回成功。

  • 一次性动作失败后能不能重试?可以,但要把“已抢到执行权”和“动作已完成”拆开,不能无条件把失败当作成功状态。

  • Store(false) 会发生什么?它会重新打开开关,之后另一个调用可能再次通过 CompareAndSwap(false, true)。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>