跳转到主内容
websoft网络软件专家 - 深耕网络技术,打造实用软件!

Golang并发模式:单例任务执行器防止并发重复执行

用 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
跑,尤其关注状态变更和唤醒逻辑 真正难的从来不是“怎么让代码只跑一次”,而是“怎么定义‘一次’——是指开始执行,还是指成功落地,或者是指对业务可见的结果只发生一次”。这个边界模糊的地方,最容易出线上事故。

相关文章