用 sync.once 可确保任务只执行一次,它基于原子状态机实现线程安全;但仅适用于确定性初始化,不支持失败重试,需自行实现带锁状态机来处理可能失败的并发任务。

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










