用 sync.Once 可确保任务只执行一次,它基于原子状态机实现线程安全;但仅适用于确定性初始化,不支持失败重试,需自行实现带锁状态机来处理可能失败的并发任务。
Go 里怎么确保一个任务只执行一次,哪怕并发调用?
靠
最直接。它不是“锁”,也不是“计数器”,而是一个原子状态机:内部用
记录是否已执行,配合
保证线程安全。只要传给它的函数没 panic,就只会跑一次,后续所有 goroutine 都会直接返回。
常见错误是把耗时操作或可能失败的逻辑塞进
—— 一旦 panic,
就永久卡死,再也无法重试。它只适合「确定性初始化」,比如加载配置、初始化全局连接池、注册信号处理函数。
不要在
里做网络请求、文件读写、数据库查询等可能失败的操作
如果必须带错误处理,得在外层包装一层重试逻辑,
只负责“最多执行一次”的门控
接收的是
,不能直接传带参数或返回值的函数;需要闭包捕获变量,注意引用陷阱
想支持失败重试 + 并发控制,该用什么?
这时候
不够用了。得自己实现一个带状态的任务执行器,核心是用
+ 状态字段(比如
、
、
),再加个
或 channel 唤醒等待者。
典型场景是:服务启动时初始化 Redis 连接,但首次连接可能超时,希望最多重试 3 次,同时避免 100 个 goroutine 全部去连。
立即学习
“
go语言免费学习笔记(深入)
”;
状态字段必须用
保护,不能靠原子操作拼凑——因为要读写多个字段(如 running + err)
唤醒等待 goroutine 时,用
比循环检查更高效,但要注意虚假唤醒,需配合 for 循环判断条件
别在持有锁时调用用户回调函数,否则阻塞其他 goroutine;应先释放锁,再执行回调
为什么不用 channel + select 实现单例执行?
有人试图用带缓冲的
控制入口,比如只允许一个 goroutine 写入,其余阻塞在
上。这看似简单,但容易漏掉关键问题:
如果执行任务的 goroutine panic 了,channel 没被消费,后续所有等待者永远卡住
没有天然的“执行完成”通知机制,等待者不知道该读结果还是该重试
无法区分“正在执行中”和“已执行完毕且成功/失败”,状态表达不完整
性能上比
或带锁状态机差——channel 涉及 goroutine 调度和 runtime 唤醒开销
真实项目里最常踩的坑是什么?
不是不会写,而是混淆了「单次执行」和「单次成功」。很多同学以为用了
就万事大吉,结果初始化 DB 连接失败后整个服务再也起不来。
另一个高频问题是把执行器做成全局单例,却忘了它内部状态(比如最后一次错误)是共享的。当不同模块共用同一个执行器实例时,A 模块的失败会影响 B 模块的判断逻辑。
每个需要独立生命周期的任务,应该有自己专属的执行器实例,而不是复用一个全局变量
执行器的状态(如
)如果对外暴露,必须加锁读取,不能裸露 struct 字段
测试时容易忽略并发竞争,建议用
跑,尤其关注状态变更和唤醒逻辑
真正难的从来不是“怎么让代码只跑一次”,而是“怎么定义‘一次’——是指开始执行,还是指成功落地,或者是指对业务可见的结果只发生一次”。这个边界模糊的地方,最容易出线上事故。
sync.Onceuint32atomic.CompareAndSwapUint32sync.Once.DoOnceDosync.OnceDofunc()sync.Oncesync.Mutexrunningdoneerrsync.Condmutexcond.Broadcast()chan struct{}selectsync.Oncesync.OncelastErrgo test -race