time.Ticker 适合固定间隔任务,但不可直接 for range ticker.C:一无法优雅停止(Stop() 需在 goroutine 外调用),二任务阻塞时 tick 会被丢弃(因 ticker.C 是无缓冲 channel);应改用 select + done channel 控制。

time.Ticker 适合固定间隔任务,但别直接在 for 循环里裸跑
很多人一上来就写 for range ticker.C,看似简洁,实际埋了两个坑:一是无法优雅停止(ticker.Stop() 必须在 goroutine 外调用,否则可能漏触发或 panic),二是任务执行阻塞时,下一次触发会被跳过——ticker.C 是无缓冲 channel,积压的 tick 会直接丢弃。
正确做法是用 select 配合退出信号 channel:
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case t := <-ticker.C:
task(t)
}
}
}()
// 停止时 close(done) 或发送任意值到 done
- 必须提前声明
done,且不能在 goroutine 内部定义,否则外部无法控制 - 如果
task()可能长时间阻塞,考虑加超时或丢弃机制,避免调度漂移 - 不要 defer ticker.Stop() 在 main 函数里——它只在 main 返回时才执行,而 goroutine 可能早已退出
大量一次性定时任务(如超时、重试)优先用 time.AfterFunc 而非手动管理 Timer
time.AfterFunc 是最轻量的一次性定时器封装,内部复用 runtime 的 timer heap,创建/销毁开销极低。对比自己 new + reset + stop 一套操作,出错概率高得多。
常见误用:
立即学习“go语言免费学习笔记(深入)”;
- 反复调用
time.NewTimer().Stop()—— 每次都新建堆节点,GC 压力大,且 Stop 不及时会导致泄漏 - 把
time.Timer存 map 里靠 ID 查找再 Stop —— 错误率高,且并发访问需额外锁 - 用
time.After()配合select做超时,却忽略它返回的是新 channel,每次都会分配
正确姿势:
// 启动一个 3 秒后执行的任务,返回可取消句柄
timer := time.AfterFunc(3*time.Second, func() {
doSomething()
})
// 取消(只要还没触发就有效)
timer.Stop()
注意:AfterFunc 返回的 *Timer 不可重复使用,也不建议长期持有——它本身不占资源,但逻辑上“已失效”后就该丢弃。
高密度、多周期任务(>1k 并发)必须加协程池,否则 goroutine 泛滥
每秒触发 100 次、每次起一个 goroutine,10 秒就是 1000 个活跃 goroutine;若每个任务还带 I/O 或 sleep,系统调度开销会陡增,甚至触发 GMP 中的 sysmon 抢占逻辑,导致延迟抖动。
简单有效的节流方式是 channel 信号量:
sem := make(chan struct{}, 10) // 最多 10 并发
go func() {
for t := range ticker.C {
sem <- struct{}{} // 等待空槽
go func(now time.Time) {
defer func() { <-sem }() // 归还槽位
task(now)
}(t)
}
}()
- channel 容量设为并发上限,比用
sync.WaitGroup+ 计数器更直观、无竞态 - 不要用
runtime.GOMAXPROCS控制并发——它管的是 OS 线程数,不是 goroutine 数量 - 如果任务有依赖顺序(比如必须按触发时间串行执行),那就不能并发,得改用带缓冲的 worker channel 模式
别碰红黑树定时器,Go 标准库的最小堆已经够用
看到“高性能”“500k 任务”就想去手撸红黑树定时器?停一下。Go 的 runtime.timer 底层是四叉最小堆,针对高频创建/销毁、毫秒级精度、key 高度重复的场景做了深度优化。你手动实现的红黑树,在插入/删除平均复杂度、内存局部性、GC 友好度上几乎不可能胜出。
真正卡性能的地方往往不在定时器结构本身,而在:
- 任务函数里做了同步阻塞 I/O(比如直连 MySQL 查询)
- 用了全局锁保护共享状态(比如所有任务共用一个
map记日志) - 频繁调用
time.Now()(它在某些内核版本下有 syscall 开销)
如果真遇到调度延迟 >10ms,先检查是否启用了 GODEBUG=asyncpreemptoff=1,或 sysmon 是否被长时间 GC 卡住——这些比换数据结构实在得多。



















