不能直接用 time.ticker 做跨 goroutine 定时触发,因其 c 字段(chan time.time)仅支持单消费者,多 goroutine 同时读会因 channel 关闭或竞态 panic;正确做法是用 time.afterfunc 递归调度、context+time.timer 手动驱动,或基于 sync.map 实现发布-订阅模型。

为什么不能直接用 time.Ticker 做跨 goroutine 定时触发
因为 time.Ticker 本身是线程安全的,但它的 C 字段(chan time.Time)一旦被多个 goroutine 同时 range 或 ,会立刻引发 panic:<code>panic: send on closed channel 或更隐蔽的竞态——比如一个 goroutine 关闭了 ticker,另一个还在读。这不是 ticker 设计缺陷,而是它默认只服务「单消费者」场景。
常见错误模式:
- 在多个 goroutine 中各自启动
time.NewTicker,导致重复触发、资源泄漏 - 把同一个
ticker.C传给多个go func() { for range ticker.C { ... } }(),实际只有第一个能稳定读,其余因 channel 关闭或阻塞而失效 - 用
sync.Mutex包裹对ticker.C的读取——没用,channel 操作本身不可加锁同步
用 time.AfterFunc + sync.Once 实现单次安全重调度
如果定时任务只需「每 N 秒执行一次、不累积、允许轻微漂移」,最轻量且天然并发安全的方式是递归式重注册:time.AfterFunc 不依赖 channel,返回即脱离生命周期,且每次调用都是独立 timer 实例。
关键点:
-
time.AfterFunc内部不共享状态,goroutine 崩溃不影响后续调度 - 用
sync.Once控制「启动入口」,避免重复初始化 timer - 每次执行完立刻调用下一轮
time.AfterFunc,而不是用固定间隔的time.Sleep—— 防止执行耗时导致堆积
type SafeScheduler struct {
mu sync.RWMutex
stop chan struct{}
once sync.Once
f func()
dur time.Duration
}
func (s *SafeScheduler) Start() {
s.once.Do(func() {
s.stop = make(chan struct{})
go s.run()
})
}
func (s *SafeScheduler) run() {
for {
select {
case
<h3>需要精确周期 + 支持暂停/恢复?用 <code>context.Context</code> + <code>time.Timer</code> 手动驱动</h3>
<p><code>time.Ticker</code> 不支持动态暂停,<code>time.Timer</code> 可 Reset,配合 context 能精细控制生命周期。这是生产环境推荐做法,尤其当你的组件要被多个模块引用、需响应 cancel 或配置变更时。</p><div class="aritcle_card flexRow artxards">
<div class="artcardd flexRow">
<a class="aritcle_card_img" rel="nofollow" href="/xiazai/skill3345" title="Golang Samber Do"><img
src="https://img.php.cn/upload/skill/000/000/081/178954034424640.jpg" alt="Golang Samber Do" onerror="this.onerror='';this.src='/static/lhimages/moren/morentu.png'" ></a>
<div class="aritcle_card_info flexColumn">
<a rel="nofollow" href="/xiazai/skill3345" title="Golang Samber Do" class="overflowclass">Golang Samber Do</a>
<p class="overflowclass">使用 samber/do 在 Golang 中实现依赖注入 — 服务容器、生命周期管理、作用域、健康检查、优雅关闭和模块组织</p>
</div>
<a rel="nofollow" href="/xiazai/skill3345" title="Golang Samber Do" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span>
</a>
</div>
</div>
<p>注意点:</p>
- 每次
timer.Reset()前必须Stop(),否则可能漏触发或 panic(Go 1.20+ 对未 Stop 的 Reset 有 panic 保护,但旧版本不保证) - 不要在
select中同时监听timer.C和自定义 channel 并做case —— 若 timer 已被 Stop,<code>timer.C会变成 nil channel,导致该 case 永远不触发;应统一用timer.C作为唯一信号源 - 用
context.WithCancel封装外部控制,比裸 channel 更易组合(比如集成进 http.Server.Shutdown 流程)
如何让多个 goroutine 安全地「订阅」同一触发事件
核心不是共享 timer,而是共享「事件通知」。用 sync.Map 存管理回调函数,用 chan struct{} 或 sync.Cond 做广播,或者更简单:用 github.com/jpillora/backoff 这类库的思路,把触发逻辑封装成「发布-订阅」模型。
最小可行方案(无第三方依赖):
- 维护一个
sync.Map,key 是uintptr(用unsafe.Pointer转换),value 是func() - 每次定时触发时,遍历 map 并并发执行所有回调:
go fn(),不等待结果 - 取消订阅时,用
Map.Delete移除 key —— 注意:不能在遍历时 Delete,需先收集 keys 再删 - 若回调执行时间长且敏感,考虑加
context.WithTimeout包裹单个回调
真正难的不是定时,是清理。很多 bug 出现在 goroutine 持有已注销的回调引用,或忘记关闭底层 timer 导致内存泄漏。务必在 Stop() 方法里显式 timer.Stop()、close(stopCh)、sync.Map.Range() 清回调。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










