我在 Go 的 for-select 循环里用 time.After 做超时、自以为简洁标准,结果服务跑久了内存一路缓慢上涨、profile 一看堆里全是没释放的定时器,最后才搞懂 time.After 每次求值都新建一个定时器而它要等到到期那一刻才会被回收的深度复盘

我有个后台 goroutine 在 for 循环里用 select 处理一个高频消息 channel,同时想加一个多久没消息就做点别的的超时,很自然写成 case <-time.After(5*time.Second),代码简洁读起来很标准。可服务跑了一段后内存缓慢但持续上涨,不是暴涨而是温水煮青蛙式爬高,pprof 抓 heap 发现堆里积压大量 time.Timer 对象和相关 channel 随运行时间不断增加。直到盯着 time.After 才明白:time.After(d) 每次被求值时都会在内部创建一个新的 Timer 并安排 d 后触发,而这个 Timer 一旦创建就要等到它到期(d 后)才会被释放回收、在到期之前即使没人再用它也不会被提前释放;问题出在 for-select 循环上:我的消息 channel 是高频的,每收到一条消息处理完循环就回到顶部重新执行一次 select、也就重新执行 time.After(5s) 又新建一个 5 秒 Timer,而上一个 Timer 因为消息先到那个 case 没被选中它并不会被取消而是继续躺着等满 5 秒才到期才回收;消息每秒来好几条,5 秒内就堆积起好几个十几个没到期的 Timer,持续不断就持续堆积来不及回收,内存就这样一点点涨上去。根因是 time.After 每次都新建一个有生命周期的 Timer 到期才回收、没被选中的旧 Timer 不会被取消、放在高频反复执行的循环里就会堆积。正解是 for-select 这类会反复执行的循环里需要超时不要每轮用 time.After 新建而要复用一个 Timer——循环外用 time.NewTimer(d) 创建一次拿句柄、循环里把它的 channel 当 case、每轮按需用 timer.Reset(d) 重置(Reset 前按规范 Stop 并排空已触发信号)这样始终只有一个 Timer 不堆积,或如果超时是整体固定截止就用 context.WithTimeout 创建一次监听 ctx.Done()。这篇复盘从故障现场讲到 time.After 每次新建 Timer 到期才回收、每轮新建与复用对照、怎么诊断,再到复用 Timer 与 context 的完整正解与骨架,以及循环里 defer 堆积/每轮新建连接/反复起 goroutine 不回收/反复订阅不取消等同类坑,和单次成本被循环放大、循环里的有寿命资源要复用或及时释放别依赖它自己消失的认知。

我在 Go 的 for-select 循环里用 time.After 做超时、自以为简洁标准,结果服务跑久了内存一路缓慢上涨、profile 一看堆里全是没释放的定时器,最后才搞懂 time.After 每次求值都新建一个定时器、而它要等到到期那一刻才会被回收

这是一次让我把 Go 里"for-select 里用 time.After"这件事,从"简洁标准的超时写法",重新理解成"每轮循环都新建一个定时器、到期前都不回收、会堆积泄漏"的事故。我在 for-select 循环里用 time.After 做超时,自以为简洁标准。结果服务跑久了内存一路缓慢上涨,profile 一看堆里全是没释放的定时器。最后才搞懂:time.After 每次求值都新建一个定时器,而它要等到到期那一刻才会被回收。这篇就把这次"for-select 里 time.After 堆积泄漏内存"的事故,从头到尾复盘一遍。

故障现场:内存缓慢上涨,堆里全是没释放的定时器

我有个后台 goroutine,在 for 循环里用 select 处理一个高频的消息 channel,同时想加一个"多久没消息就做点别的"的超时。我很自然地写成 case <-time.After(5 * time.Second):——每次进 select,要么收到消息处理掉,要么 5 秒超时触发。代码简洁、读起来很标准,我很满意。

可服务跑了一段时间后,我发现它的内存在缓慢但持续地上涨,不像有明显泄漏点的那种暴涨,而是温水煮青蛙式地一点点爬高。我用 pprof 抓 heap profile,发现堆里积压了大量 time.Timer 对象和相关的 channel,数量随运行时间不断增加。我一开始以为是别处有对象没释放,查了半天没找到。直到我盯着那行 time.After 反复琢磨,才彻底明白根因——time.After(d) 这个调用,每次被求值时,都会在内部创建一个新的 Timer(和一个 channel),并安排它在 d 时间后触发。而关键在于:这个 Timer 一旦创建,就要等到它到期(d 之后)才会被定时器系统释放、才能被 GC 回收;在它到期之前,即使没人再用它,它也会一直占着内存。问题就出在我的 for-select 循环上:我的消息 channel 是高频的,每收到一条消息、处理完,循环就回到顶部、重新执行一次 select——也就重新执行了一次 time.After(5s),又新建了一个 5 秒的 Timer!而上一个 Timer 因为消息先到、time.After 那个 case 没被选中,它并没有被取消,而是继续躺在那里,等着 5 秒后自己到期才被回收。如果消息每秒来好几条,那 5 秒内就会堆积起好几个、十几个还没到期的 Timer;消息持续不断,这些 Timer 就持续堆积、来不及被回收,内存就这样一点点涨上去。我以为 time.After 只是"设个超时"、用完即弃,可它其实每次都实实在在地新建了一个要等满 5 秒才肯走的定时器,而我在一个高频循环里,一遍遍地新建它们、又一个都没及时取消。

// 我的写法: for-select 里用 time.After 做超时
for {
    select {
    case msg := <-msgCh:        // 高频消息, 每条都让循环回到顶部
        handle(msg)
    case <-time.After(5 * time.Second):   // ★ 每轮循环都新建一个 Timer!
        doIdleWork()
    }
}

// 问题: 每次循环都重新执行 time.After(5s) → 新建一个 5 秒的 Timer;
//   消息先到时, 上一个 Timer 的 case 没被选中, 它【不会被取消】,
//   而是继续躺着等满 5 秒才到期、才被回收;
//   消息高频(每秒好几条)→ 5 秒内堆积起一堆没到期的 Timer → 内存缓慢上涨

// 验证: time.After 的实现 ≈ 每次都 NewTimer 一个, 到期前不回收
// t := time.NewTimer(d); return t.C   ← 每调一次就 new 一个

问题被钉死在这个认知错位上:我以为 time.After 是个"轻量的、用完即弃的超时信号",但它每次被求值都会实实在在地新建一个 Timer;而这个 Timer 不会因为"这一轮 select 没选中它"就被取消,它会一直存活到自己到期(d 之后)才被回收。把 time.After 放在一个高频循环的 select 里,意味着每轮循环都新建一个 Timer,而那些"因为消息先到、没被选中"的旧 Timer,要在各自的 d 时间后才陆续到期回收——在高频场景下,新建的速度远超到期回收的速度,Timer 就堆积起来、内存就涨。我把一个"每次调用都创建一个有生命周期的资源"的函数,放在了一个会反复执行它的热循环里,却没有在不需要它时主动释放,于是这些"来不及自然到期"的资源就堆积了。这和"循环里反复创建不及时释放的资源"是同一类问题,只是这里的资源是个"要等满时长才肯走的定时器"。我以为我只是每次循环顺手设了个闹钟,可那些没响就被我无视的闹钟,并不会消失,而是攒在角落里、一个个等到点了才肯走。

第一件事:想明白 time.After 每次新建 Timer、到期才回收

把这次事故彻底想清楚,关键是理解time.After(d) 的本质是"创建一个新的 Timer、并返回它的 channel";每调用一次就新建一个。这个 Timer 由 Go 的定时器系统管理,它会一直存活到 d 时间后到期(到期时往 channel 发一个值)才被释放回收;在到期之前,即使你已经不再关心它、它的 channel 也没人读,它也不会被提前释放(在较老的 Go 版本里尤其如此;time.After 不暴露 Timer 句柄,你也没法手动 Stop 它)。所以,在一个会反复执行的循环(尤其高频循环)里用 time.After,每轮都新建一个 Timer,而那些没被选中的会一直占着内存直到各自到期——高频下就会堆积、造成内存缓慢上涨。

这就引出了正确的做法:在 for-select 这类会反复执行的循环里需要超时,不要每轮都用 time.After 新建,而要复用一个 Timer——在循环外用 time.NewTimer(d) 创建一次、拿到它的句柄,在循环里把它的 channel 当 case;每轮根据需要用 timer.Reset(d) 重置它的到期时间(并在 Reset 前按规范 Stop/排空),这样始终只有一个 Timer,不会堆积。或者,如果这个超时是"整体的、固定的截止时间"(而非每轮重置),用 context.WithTimeout 创建一次、把 ctx.Done() 当 case 更清晰。关键认知是:任何"每次调用都会创建一个有生命周期、需要到期或显式释放才回收的资源"的操作(创建定时器、开连接、起 goroutine、分配缓冲),都不应该放在一个会反复执行的循环里反复无脑创建——否则即使每一个单独看都无害,在循环的反复执行下,它们会以快于回收的速度堆积,造成泄漏。循环里要么复用同一个资源(创建一次、循环内重置/重用),要么在不再需要时主动、及时地释放它,而不能依赖它"过一会儿自己会消失"。

// 正解1: 复用一个 Timer —— 循环外建一次, 循环内 Reset, 始终只有一个
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
    select {
    case msg := <-msgCh:
        handle(msg)
        // 处理完重置定时器(规范: 先 Stop 并按需排空, 再 Reset)
        if !timer.Stop() {
            select { case <-timer.C: default: }   // 排空已触发的
        }
        timer.Reset(5 * time.Second)
    case <-timer.C:
        doIdleWork()
        timer.Reset(5 * time.Second)
    }
}

// 正解2: 整体截止时间(非每轮重置)→ 用 context, 创建一次, 监听 Done
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for {
    select {
    case msg := <-msgCh:
        handle(msg)
    case <-ctx.Done():           // 到点统一退出, 只创建了一个定时资源
        return
    }
}

// 注: Go 1.23+ 改进了未被引用的 time.After 定时器的回收, 缓解但仍建议循环里复用

想通这一层,我才明白自己错在哪:我把 time.After 当成了"一次性的、用完即弃的超时信号",而没意识到它每次都新建一个要等满时长才回收的 Timer;我又把它放在一个高频反复执行的 for-select 里,每轮都新建一个,那些没被选中的就堆积着等到期、来不及回收,内存就涨。我在一个热循环里,反复创建一个有生命周期的资源,却没复用、也没及时释放。根治之道,是在循环里复用同一个 Timer(NewTimer 一次 + Reset)、或用 context 创建一次截止时间。不是在循环里一遍遍新建有寿命的资源还指望它们自己及时消失,而是创建一次反复重用、或在不需要时主动释放。

第二件事:正解——循环里复用一个 Timer,或用 context 创建一次截止

找到根因,正解就清晰了:for-select 里需要超时,别每轮用 time.After 新建,而要复用一个 Timer——循环外 time.NewTimer(d) 建一次拿句柄,循环内用它的 channel 当 case、每轮按需 Stop+排空后 Reset,始终只有一个 Timer;如果超时是"整体固定截止"(非每轮重置),就用 context.WithTimeout 创建一次、监听 ctx.Done()

// 错误: 高频 for-select 里每轮新建 time.After 的 Timer, 堆积泄漏
for {
    select {
    case msg := <-msgCh:
        handle(msg)
    case <-time.After(5 * time.Second):   // ✗ 每轮新建一个, 到期才回收
        doIdleWork()
    }
}

// 正解1: 复用一个 Timer(创建一次, 循环内 Reset, 始终只一个)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
resetTimer := func() {
    if !timer.Stop() {                       // 规范: 先 Stop
        select { case <-timer.C: default: }  // 已触发的排空, 防下次误读
    }
    timer.Reset(5 * time.Second)
}
for {
    select {
    case msg := <-msgCh:
        handle(msg); resetTimer()
    case <-timer.C:
        doIdleWork(); timer.Reset(5 * time.Second)
    }
}

// 正解2: 整体固定截止 -> context.WithTimeout, 创建一次
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for {
    select {
    case msg := <-msgCh:
        handle(msg)
    case <-ctx.Done():        // 到点统一退出, 只一个定时资源
        return
    }
}

这套做法的精髓,是让循环里那个"有生命周期的资源(Timer)"从"每轮新建一个"变成"全程只有一个、反复重用",从根上消除堆积。复用 Timer(NewTimer + Reset)适合"每收到一次消息就把空闲超时重新计时"的场景;context.WithTimeout 适合"整体一个固定截止、到点就退出"的场景。两者都只创建一个定时资源,不会随循环反复执行而堆积。Reset 时按规范先 Stop 再排空,避免读到已触发的旧信号。不是在热循环里反复新建有寿命的资源、赌它们自己及时消失,而是创建一次、循环内重用。

【for-select 里做超时, 我现在认死的几条】

1. time.After(d) 每次求值都新建一个 Timer, 到期(d 后)才回收

2. 没被 select 选中的旧 Timer 不会被取消, 会一直等到期占内存

3. 高频 for-select 里每轮 time.After → Timer 堆积、内存缓慢上涨

4. 复用一个 Timer: 循环外 NewTimer 一次, 循环内 Stop+排空后 Reset

5. 整体固定截止: 用 context.WithTimeout 创建一次, 监听 ctx.Done()

6. Reset 前按规范处理已触发的信号(Stop 返回 false 就排空 channel)

7. 通用: 循环里别反复新建有生命周期的资源, 要复用或及时释放

第三件事:其他"循环里反复新建不及时释放的资源"的同类坑

顺着"在会反复执行的循环里反复新建一个有生命周期、需到期或显式释放才回收的资源,累积成泄漏"这条线,我把同类的坑都排查了一遍:

第一个,循环里 defer 堆积。在 for 里写 defer,这些 defer 要到整个函数返回才执行,循环跑很多次就堆积一大堆没释放的资源(文件/锁)。要在循环体内显式释放或抽成子函数。

第二个,循环里每轮新建连接/client。每次循环 new 一个 http.Client/数据库连接而不复用,连接和 fd 堆积;要在循环外创建一次、循环内复用。

第三个,循环里反复起 goroutine 不回收。每轮 go 一个协程而它不退出(阻塞在 channel/无终止),协程越积越多,内存和调度泄漏。

第四个,循环里反复订阅/注册监听不取消。每轮注册一个事件监听/watcher 而不注销,监听器越积越多,事件被重复处理、内存泄漏。

第四件事:time.After 每轮新建 vs 复用 Timer——一张对照表

我把"for-select 里每轮 time.After"和"复用一个 Timer"摆在一起对比,核心看"会不会堆积、内存如何":

维度 每轮 time.After(d) 复用 NewTimer + Reset
每轮循环 新建一个 Timer 重用同一个 Timer
没被选中的旧 Timer 等到期(d 后)才回收 不存在(只一个)
高频循环下 Timer 堆积、内存缓慢涨 始终一个、内存平稳
能否提前取消 拿不到句柄, 不能 Stop 有句柄, 可 Stop/Reset
写法 简洁但有坑 多几行但安全
整体固定截止 不适合 用 context.WithTimeout 更佳

看清这张表,选择就有谱了:低频/一次性的超时,time.After 简洁够用;但放进高频 for-select 就会每轮新建 Timer 堆积,要改成复用一个 Timer(NewTimer + Reset)或用 context.WithTimeout。我这次踩坑,正是在高频 for-select 里每轮 time.After、旧 Timer 来不及回收就堆积。循环里复用一个定时资源,是内存平稳的关键。

第五件事:我曾经对 time.After 想当然的几个误区

这次事故也把我对 time.After 的一堆"想当然"照了个底朝天:

我以为 实际上
time.After 是轻量的、用完即弃的超时信号 每次都新建一个 Timer, 有真实的生命周期
没被选中的 time.After 会立刻消失 它会一直存活到自己到期(d 后)才回收
在 for-select 里用 time.After 很标准 高频循环里每轮新建会堆积、泄漏内存
内存缓慢涨肯定是别处有泄漏 可能就是 for-select 里 time.After 的 Timer 堆积
超时写法简洁就好, 不用管资源 循环里的定时器要复用或及时释放

这些误区的根子是同一个:我把一个"每次调用都创建一个有生命周期、要到期才回收的资源"的操作,当成了"无成本、用完即弃"的轻量动作,还把它放进了一个会反复执行的热循环里。单看一次 time.After 确实人畜无害,可在高频循环的反复执行下,这些"要等满时长才肯走"的 Timer 以快于回收的速度堆积起来,就成了内存的慢性泄漏。把"单次无害的资源创建"放进会反复执行的循环、却不复用也不及时释放,是这类缓慢泄漏的共同根源。

第六件事:写 for-select 超时、排查"内存缓慢上涨"时,我现在的自检习惯

现在每当我写 for-select 的超时、或排查"服务内存缓慢但持续上涨",我都会先按这张图问自己:

这张图的精髓,是"内存缓慢涨先 pprof 看堆里积压什么;是 Timer 就查 for-select 里每轮 time.After、改成复用一个 Timer 或用 context"设计就for-select 超时复用一个 Timer(NewTimer+Reset)或用 context.WithTimeout、别每轮 time.After、排查就pprof 看堆里是不是 Timer 堆积、对应 for-select 里高频用了 time.After这套习惯,让我从"for-select 里随手 time.After"变成了"先想这循环多频繁、定时器会不会堆积"——核心始终是:time.After(d) 的本质是创建一个新的 Timer 并返回它的 channel、每调用一次就新建一个,这个 Timer 由 Go 的定时器系统管理、它会一直存活到 d 时间后到期(到期时往 channel 发一个值)才被释放回收,在到期之前即使你已经不再关心它、它的 channel 也没人读它也不会被提前释放(在较老的 Go 版本里尤其如此,而且 time.After 不暴露 Timer 句柄你也没法手动 Stop 它);所以在一个会反复执行的循环尤其高频循环里用 time.After,每轮都新建一个 Timer,而那些没被 select 选中的会一直占着内存直到各自到期——高频下新建的速度远超到期回收的速度,Timer 就堆积、造成内存缓慢上涨;正解是在 for-select 这类会反复执行的循环里需要超时不要每轮都用 time.After 新建而要复用一个 Timer——在循环外用 time.NewTimer(d) 创建一次拿到句柄、在循环里把它的 channel 当 case、每轮根据需要用 timer.Reset(d) 重置它的到期时间(并在 Reset 前按规范 Stop 并排空已触发的信号)这样始终只有一个 Timer 不会堆积,或者如果这个超时是整体的固定的截止时间而非每轮重置就用 context.WithTimeout 创建一次把 ctx.Done() 当 case;关键认知是任何每次调用都会创建一个有生命周期、需要到期或显式释放才回收的资源的操作(创建定时器、开连接、起 goroutine、分配缓冲)都不应该放在一个会反复执行的循环里反复无脑创建,否则即使每一个单独看都无害在循环的反复执行下它们会以快于回收的速度堆积造成泄漏,循环里要么复用同一个资源(创建一次循环内重置重用)要么在不再需要时主动及时地释放它而不能依赖它过一会儿自己会消失。

我立下的几条规矩

这场"for-select 里 time.After 堆积泄漏"的事故,换来了我写 Go 超时时,刻进骨子里的几条铁律:

  1. time.After(d) 每次求值都新建一个 Timer,到期(d 后)才回收。
  2. 没被 select 选中的旧 Timer 不会被取消,会一直等到期占内存。
  3. 高频 for-select 里每轮 time.After → Timer 堆积、内存缓慢上涨。
  4. 复用一个 Timer:循环外 NewTimer 一次,循环内 Stop+排空后 Reset。
  5. 整体固定截止:用 context.WithTimeout 创建一次,监听 ctx.Done()。
  6. Reset 前按规范处理已触发的信号(Stop 返回 false 就排空 channel)。
  7. 通用:循环里别反复新建有生命周期的资源,要复用或及时释放。

附:我现在 for-select 做超时的"复用 Timer + 空闲检测"骨架

这是我现在写 for-select 超时固定套的骨架——把这次踩坑的教训(复用一个 Timer、规范 Reset、整体截止用 context、循环里别反复新建有寿命资源)固化成一套结构,让"time.After 堆积泄漏"那种坑再不会埋进代码:

// 写法一: 空闲超时(每收到一次消息就重新计时)→ 复用一个 Timer
func worker(ctx context.Context, msgCh <-chan Msg, idle time.Duration) {
    timer := time.NewTimer(idle)          // 循环外建一次, 全程只有这一个
    defer timer.Stop()
    reset := func() {
        if !timer.Stop() {                // 规范: 先 Stop
            select { case <-timer.C: default: }  // 排空已触发的, 防误读
        }
        timer.Reset(idle)
    }
    for {
        select {
        case msg := <-msgCh:
            handle(msg); reset()          // 有消息: 处理并重置空闲计时
        case <-timer.C:
            onIdle(); timer.Reset(idle)   // 空闲超时: 做点事并继续计时
        case <-ctx.Done():
            return                        // 优雅退出
        }
    }
}

// 写法二: 整体固定截止(到点统一退出, 非每轮重置)→ context.WithTimeout
func runUntil(parent context.Context, total time.Duration, msgCh <-chan Msg) {
    ctx, cancel := context.WithTimeout(parent, total)  // 创建一次
    defer cancel()
    for {
        select {
        case msg := <-msgCh:
            handle(msg)
        case <-ctx.Done():
            return
        }
    }
}

// 自检: 压测高频消息跑一段, 看 heap profile 里 time.Timer 数量是否平稳(不增长)
//   pprof: go tool pprof http://.../debug/pprof/heap → top / list 看 Timer

这套骨架把我这次的教训钉死在了结构里:空闲超时循环外 NewTimer 一次、循环内 reset 复用(始终只一个 Timer、reset 时先 Stop 再排空防误读);整体固定截止用 context.WithTimeout 创建一次、监听 ctx.Done();退出走 ctx.Done();并用压测 + heap profile 看 Timer 数量是否平稳自检。这样,无论消息多高频,定时资源始终只有一个、内存平稳,而不再是当初那个"每轮 time.After 新建 Timer、旧的等到期才回收、堆积成慢性泄漏"的局面。把"循环里的有寿命资源要复用或及时释放、别依赖它自己消失"这个道理,沉淀成 for-select 超时的固定骨架,这是我对这次"内存缓慢爬升"最实在的交代——毕竟,在一个转个不停的循环里,任何会留下"尾巴"的东西,都得我亲手收拾干净,而不能指望它自己慢慢散去。

写在最后

回头看,这场由"for-select 里 time.After"引发的"内存缓慢上涨"事故,真正教给我的,远不止"复用一个 Timer"这一个技巧。它让我对"一个单次看来'微不足道、用完就忘'的动作,一旦被放进一个'会高频反复执行'的循环里,它的'微小成本'就会被反复执行的次数无限放大;尤其当这个动作创建的是一个'有生命周期、不会立刻消失、要等一段时间或要你显式去清理才回收'的东西时,如果你既不复用它、又不及时清理它,它们就会以'新建快于回收'的速度悄悄堆积——不是轰然崩塌,而是温水煮青蛙式地、一点一点地把资源耗尽",有了一次刻骨的体会。我栽跟头,是因为我把一个"每次都创建一个有寿命的资源(定时器)"的动作,当成了"无成本、一次性、用完即弃"的轻量操作,还把它放进了一个高频反复执行的热循环——我看 time.After 就一行、读起来轻巧,以为它设完超时就烟消云散了;我没意识到,它每次都实实在在地造出一个"要躺满 5 秒才肯走"的定时器,而我那个循环每秒要转好几圈、每圈都造一个;那些"没等到响就被我无视"的定时器,并不会因为我不再需要就立刻消失,它们攒在那里、各自倒数着自己的 5 秒——而我造它们的速度,远快于它们逐个到期离场的速度,于是内存就这样被一群"等着到点才肯走"的定时器,一点点填满了这让我领悟到一个关于"单次成本与循环放大、有生命周期的资源"的深刻认知:评估一个操作的代价,不能只看它"单次执行"的轻重,而要看它被"执行的频率/次数"放大之后的总和;一个单次微不足道的动作,在一个高频循环里会被放大成可观的开销甚至泄漏;而最隐蔽的,是那些"创建了一个有生命周期、不会立即回收的资源"的动作——单次看它"反正过一会儿会自己消失",可在循环的反复执行下,"新建"和"自然回收"之间存在时间差,只要新建的频率高于回收的速度,这些资源就会在那段"还没回收"的窗口里不断累积、堆叠成泄漏;所以凡是放进会反复执行的循环里的、且会创建"有寿命资源"的操作,都不能依赖"它自己会消失",而要么在循环外创建一次、循环内反复重用(从源头不再反复新建),要么在每轮用完后主动、及时地把它清理掉(不留给"自然回收"那段危险的窗口)这给了我一种看待"一切'在循环里创建资源'之事"时的清醒:每当我在一个会反复执行的循环里写下一个"创建某资源"的动作时,要追问"这个资源有生命周期吗?它会立刻回收,还是要等一段时间或要我显式清理才回收?这个循环多高频?新建的速度会不会超过回收的速度、让它们堆积起来"——对会反复执行的循环里的有寿命资源,优先复用一份、或每轮主动及时释放,绝不依赖它自己慢慢消失;"看清单次成本被循环放大、循环里的有寿命资源要复用或及时释放",是用对 time.After、也是写出不泄漏的循环代码的关键认清 time.After 每次新建 Timer 到期才回收、高频循环里会堆积、要复用一个 Timer 或用 context——这,是我用一次"for-select 里 time.After 让内存慢慢涨"的事故,换来的、关于 Go、也关于单次成本如何被循环放大的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在 for-select 里随手写 case <-time.After(d): 时,先想一句"这循环高频吗?要不要在外面 NewTimer 一次、循环里 Reset?",那我对着那条"缓慢爬升、堆里全是 Timer"的内存曲线排查的大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我在 JavaScript 里把一个字符串数组用 map 直接交给 parseInt 想批量转成数字、写法简洁我很满意,结果转出来是 1、NaN、NaN 一片狼藉,我盯着这行干净利落的代码百思不得其解,最后才搞懂 map 会偷偷给回调塞三个参数而 parseInt 把其中那个下标当成了进制的深度复盘

2026-6-3 10:43:51

技术教程

我图省事把 Java 枚举的 ordinal 序号存进数据库当状态值、跑了一年风平浪静,直到产品要在枚举中间插一个新状态,我加完一上线数据库里成千上万条记录的状态全错位了、原本是已支付的变成了已取消,排查很久才反应过来 ordinal 是按声明顺序排的我一插队后面全跟着挪了位的深度复盘

2026-6-3 10:59:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索