最轻量的并发定时器组合是 time.Ticker + goroutine;需在 select 的 case <-ticker.C 中启动新 goroutine 执行任务,避免同步阻塞导致 tick 积压。

time.Ticker + goroutine 是最轻量的并发定时器组合
直接用 time.Ticker 配合 go 启动协程,就能实现“每次 tick 都并发执行”,不阻塞、不跳过、不累积。这是 Go 原生支持的最小可行方案,无需框架。
常见错误是把任务逻辑写在 for range ticker.C 循环里同步执行——一旦任务耗时超过 tick 间隔,后续 tick 就会排队等待,失去并发意义。
- 正确做法:在
case 分支中立即启动新 goroutine,例如 <code>go task() - 必须调用
ticker.Stop(),否则 goroutine 和 channel 会泄漏(尤其在服务热重启或配置变更时) - 若任务函数依赖循环变量(如
for i := range jobs),务必显式传参:go func(id int) { ... }(i),否则所有 goroutine 可能读到同一个终值
用带缓冲 channel 控制并发数,避免 goroutine 泛滥
无限制启 goroutine 是生产环境最常踩的坑:500ms 一次 tick、持续运行 1 小时,就可能累积上万 goroutine,内存暴涨、调度延迟升高。
简单有效的方式是用信号 channel 限流,比如 sem := make(chan struct{}, 5) 表示最多 5 个任务同时执行。
立即学习“go语言免费学习笔记(深入)”;
- 每次执行前先写入:
sem (若满则阻塞) - 执行完立刻释放:
- 注意:这个 channel 必须在所有 goroutine 共享,不能在每个 goroutine 里重新声明
- 不推荐用
sync.WaitGroup单纯计数——它无法阻止新 goroutine 启动,只管等待,治标不治本
多个定时任务共存时,别共享一个 ticker
不同周期的任务(比如“每 3 秒发心跳”和“每 30 秒刷缓存”)如果强行塞进同一个 ticker.C,就得在内部做时间判断,逻辑耦合、难以测试、也无法独立启停。
Go 的优势在于 cheap goroutine,每个任务该有自己的 time.Ticker 和专属 goroutine。
- 各自管理生命周期:
defer ticker.Stop()放在对应 goroutine 内部 - 关闭控制统一走
context.Context,比如用ctx, cancel := context.WithCancel(context.Background()),任务内 select 监听ctx.Done() - 避免用全局变量传递 ticker 或 channel,容易引发竞态;用闭包捕获或结构体字段封装更安全
需要重试、持久化、分布式时,time.Ticker 就不够用了
time.Ticker 是内存级、单机、无状态的——进程挂了任务就丢,节点扩容后任务重复执行,失败了没法自动重试。
这时候得换方案,但别急着上重型框架。可按需叠加:
- 本地增强:用
github.com/robfig/cron/v3替代手写 ticker,支持 cron 表达式、任务名、单独启停 - 失败恢复:任务执行前先写 DB 记录为 “pending”,成功后更新为 “done”,定时扫描超时 pending 重试
- 分布式协调:加一层 etcd 或 Redis 分布式锁,确保同名任务在集群中仅一个实例执行
真正难的不是并发,而是任务语义的可靠性——tick 能发多少次不重要,关键是谁在什么条件下执行了什么、有没有重复、失败后怎么兜底。这些细节往往藏在业务边界里,而不是 goroutine 数量上。


















