服务跑了几天,goroutine 数量涨到了几十万,内存也跟着爆了:我写的 goroutine 悄悄地泄漏了的那次排查复盘

Go 服务跑几天就内存越涨越高、最后崩溃,重启又能撑几天。pprof 一查,活跃 goroutine 竟有几十万个,绝大多数都卡在同一行 channel 发送上。根因是 goroutine 泄漏:我为每个查询起一个 goroutine 往无缓冲 channel 发结果,但主函数只接收一次拿最快的就 return 了,其余 goroutine 因为没人接收、永远阻塞在发送上、退不出去。这篇从 goroutine 为何泄漏讲到带缓冲/context 保证退出的正解、各种泄漏姿势、pprof+goleak 监控排查,以及"创造容易、负责到底难"。

服务跑了几天,goroutine 数量涨到了几十万,内存也跟着爆了:我写的 goroutine,悄悄地泄漏了

这是一个"慢性病"式的故障,潜伏了好几天才发作。我有一个 Go 服务,刚上线时一切正常,内存稳定、响应飞快。可跑了几天之后,它开始变得越来越"虚胖"——内存占用一天天往上涨,响应也慢慢变慢,最后内存被撑爆、服务崩溃。我重启它,它又能正常跑几天,然后又重复这个"慢慢变胖、最后崩溃"的循环。这种"随时间缓慢恶化"的症状,是典型的资源泄漏

我用 Go 的 pprof 工具一查,看到一个让我倒吸凉气的数字:这个服务里,活跃的 goroutine 数量,竟然涨到了几十万个!而正常情况下,它应该只有几十、几百个。这几十万个 goroutine,每一个都占着一点内存、占着一点调度资源,累积起来,就把内存和性能,一点点地拖垮了。我顺着 pprof 的 goroutine 堆栈一看,发现这几十万个 goroutine,绝大多数都卡在了同一个地方——一个 channel 的发送操作上,永远地阻塞着、一动不动。真相终于浮出水面:我犯了 Go 并发编程里一个极其经典、又极其隐蔽的错误——goroutine 泄漏(goroutine leak)。我启动了大量 goroutine,让它们把结果发送到一个 channel 里;可接收方,在某些情况下(比如超时、提前返回),提前不再接收了——而那些还想往 channel 里发送结果的 goroutine,因为没有人接收,就永远地阻塞在了那个发送操作上,既不能完成、也不会退出,变成了一个个"僵尸",永远地泄漏在了那里,越积越多。

故障现场:永远卡在 channel 发送上的 goroutine

我把出问题的代码,简化一下。你能看出那个 goroutine 是怎么"卡死"的吗?

// 一个"并发查询、取最快返回"的函数(有 goroutine 泄漏的版本)
func queryFastest(queries []string) string {
    ch := make(chan string)   // ← 无缓冲 channel!

    // 为每个查询启动一个 goroutine, 把结果发到 ch
    for _, q := range queries {
        go func(query string) {
            result := doQuery(query)   // 耗时查询
            ch <- result               // ← 把结果发到 channel (问题就在这!)
        }(q)
    }

    // 只取"第一个返回"的结果, 就直接返回了
    return <-ch   // ← 只接收一次! 拿到最快的那个就 return 了!
}

// 问题分析:
//   假设有 5 个查询, 启动了 5 个 goroutine。
//   主函数 <-ch 只接收【一次】, 拿到最快的那个结果, 就 return 了。
//   → 但是! 另外 4 个 goroutine, 还想往 ch 发送它们的结果!
//   → 而主函数已经 return 了, 【再也没有人接收 ch】了!
//   → 这 4 个 goroutine, 就【永远阻塞】在 ch <- result 这行, 退不出去!
//   → 每调用一次 queryFastest, 就泄漏 4 个 goroutine!
//   → 调用几十万次, 就泄漏几十万个 goroutine → 内存爆炸!

看清这个流程,我才明白那几十万个 goroutine 是怎么来的。问题的核心,在于"启动的 goroutine 数量"和"实际接收的次数"不匹配,加上我用的是一个无缓冲的 channel我为 5 个查询,启动了 5 个 goroutine,每个 goroutine 算完都要往 channel 里发送它的结果;可我的主函数,只用 <-ch 接收了一次——它拿到最快返回的那个结果,就心满意足地 return 了。问题就在于:主函数 return 之后,就再也没有人会去接收那个 channel 了;而剩下的 4 个 goroutine,它们算完后,还想往 channel 里发送结果——可对于一个无缓冲的 channel,"发送"操作必须有一个"接收"方同时准备好接收,才能完成;现在没有任何接收方了,这 4 个 goroutine 的 ch <- result 发送操作,就永远地阻塞在那里,完成不了、也退不出去——它们,就变成了 4 个永远卡死的"僵尸 goroutine",泄漏掉了。queryFastest 这个函数,被高频地调用着——每调用一次,就泄漏 4 个;调用了几十万次,就泄漏了几十万个。这些泄漏的 goroutine,一个都不会被回收,越积越多,最终把内存撑爆。我那个"慢性病"式的故障,根源就是这个不起眼的、却每次调用都在悄悄发生的 goroutine 泄漏。

第一件事:搞懂 goroutine 为什么会"泄漏"

定位到根源,我必须搞懂 goroutine 泄漏到底是怎么回事:goroutine 泄漏,指的是一个 goroutine,因为某种原因(通常是阻塞在一个永远不会满足的操作上),永远无法结束、无法退出,从而一直占用着内存和资源。Go 的 goroutine 虽然"轻量",但它不是免费的——每个都占着一点栈内存;而一个泄漏的 goroutine,因为永远不退出,它占的这点资源,就永远无法被回收。泄漏的 goroutine 一多,资源就被一点点耗尽。

// goroutine 泄漏的本质: goroutine 永远阻塞、无法退出, 资源无法回收。

// 最常见的泄漏原因: 阻塞在一个"永远不会满足"的操作上:

// 原因1: 往无缓冲 channel 发送, 但没有接收方 (本文的坑)
ch := make(chan int)
go func() { ch <- 1 }()   // 没人接收 → 永远阻塞在这里

// 原因2: 从 channel 接收, 但没有发送方
ch2 := make(chan int)
go func() { <-ch2 }()      // 没人发送 → 永远阻塞

// 原因3: select 没有任何 case 能就绪, 又没有 default/超时
go func() {
    select {
    case <-ch3:   // 如果 ch3 永远没数据, 又没有别的 case → 永远阻塞
    }
}()

// 原因4: 死锁 / 等一个永远不会来的锁/信号

// 关键认知:
//   Go 不会自动回收"阻塞的 goroutine"(它没死, 只是卡住了, GC 也动不了它)。
//   一个 goroutine 要被回收, 必须它自己"运行结束"(函数返回)。
//   而一个永远阻塞的 goroutine, 永远不会"运行结束" → 永远泄漏。

//   所以: 每启动一个 goroutine, 都要想清楚 ——
//        "它在所有情况下, 都能保证'退出'吗?"
//        如果有一种情况它会"永远卡住", 那就是一个泄漏。

原理终于清晰了。goroutine 泄漏,本质是一个 goroutine"永远阻塞、无法退出",从而它占用的资源(主要是栈内存)永远无法被回收。它最常见的原因,就是阻塞在一个永远不会满足的操作上——比如往一个没有接收方的 channel 发送(我的坑)、从一个没有发送方的 channel 接收、卡在一个永远不就绪的 select 上、或等一个永远不会来的信号。这里有一个极其关键、却容易被忽略的认知:Go 的垃圾回收器(GC),不会、也无法回收一个"还阻塞着"的 goroutine——因为这个 goroutine 并没有"死",它只是"卡住"了,从 GC 的角度看,它还"活着"(还可能被唤醒),所以 GC 动不了它。一个 goroutine 要想被回收,唯一的途径,是它自己"运行结束"(它的函数正常返回);而一个永远阻塞的 goroutine,永远不会"运行结束",所以,它就永远地泄漏在那里。这就引出了一条写 goroutine 的铁律:每当你启动一个 goroutine,你都必须想清楚一个问题——"这个 goroutine,在所有可能的情况下,都能保证最终'退出'吗?"如果存在任何一种情况,会让它"永远卡住、无法退出",那它就是一个潜在的泄漏。我那个坑,正是因为我启动 goroutine 时,只想到了"它会把结果发出去"这个正常情况,却完全没想到"如果没人接收,它会怎样"这个会导致它永远卡死的异常情况。

第二件事:正解——用带缓冲 channel,或 context 保证 goroutine 能退出

搞懂了根因——"goroutine 阻塞在没人接收的 channel 发送上、永远退不出去"——正解就清晰了:核心目标是"保证每个 goroutine,在任何情况下都能退出"。针对我这个'只取最快'的场景,有两个常用解法:一是用带缓冲的 channel,让发送不阻塞;二是用 contextdone 信号,让 goroutine 在"没人要结果了"时,能主动放弃发送、退出。

// 正解1: 用"带缓冲"的 channel —— 让发送不阻塞
func queryFastest(queries []string) string {
    ch := make(chan string, len(queries))   // ← 缓冲区大小 = goroutine 数量!
    for _, q := range queries {
        go func(query string) {
            ch <- doQuery(query)   // ← 缓冲够大, 即使没人接收, 也能发进去, 不阻塞!
        }(q)
    }
    return <-ch   // 取最快的一个; 其余 goroutine 把结果发进缓冲区就退出了, 不泄漏!
}
// 关键: 缓冲区能装下所有 goroutine 的结果, 它们发完就能退出, 不会卡死。
// 代价: 那些没被取的结果, 占着一点缓冲内存(但 goroutine 退出了, 不泄漏)。

// 正解2: 用 context, 让 goroutine 在"不需要结果时"主动退出
func queryFastestCtx(ctx context.Context, queries []string) string {
    ch := make(chan string)
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()   // ← 函数返回时, cancel, 通知所有 goroutine "可以退出了"
    for _, q := range queries {
        go func(query string) {
            result := doQuery(query)
            select {
            case ch <- result:       // 要么成功发送
            case <-ctx.Done():       // 要么收到"取消"信号, 主动放弃发送、退出!
            }
        }(q)
    }
    return <-ch   // 拿到第一个就 return, defer cancel() 通知其余 goroutine 退出
}
// 关键: select 让 goroutine "能发就发, 发不出去(没人要了)就走人", 绝不卡死。

这两个正解,都从根本上保证了"goroutine 一定能退出",只是思路不同。正解1(带缓冲 channel)是最简单的:把 channel 的缓冲区大小,设成 goroutine 的数量。这样,即使没有接收方,每个 goroutine 也能把它的结果发进缓冲区(因为缓冲区有空位),发完就正常退出了——不会再阻塞、不会再泄漏。代价是,那些没被取走的结果,会占着一点缓冲内存,但这无伤大雅(goroutine 已经退出了,没泄漏)。正解2(context 取消信号)则更优雅、更通用:用 context,在函数返回时(defer cancel()),向所有 goroutine 发出一个"取消(可以退出了)"的信号;而每个 goroutine,在发送结果时,用 select 同时监听"发送成功"和"收到取消信号"两件事——这样,它要么成功把结果发出去,要么在"没人要结果了(收到取消信号)"时,主动放弃发送、立刻退出这两个正解的共同精髓,是"给 goroutine 一条保证能走通的退路"——带缓冲让它"总能发出去",context 让它"发不出去时能主动走人"。绝不能让一个 goroutine,陷入"既发不出去、又没有别的路可走"的死局——那就是泄漏。其中,context 是 Go 里管理 goroutine 生命周期、防止泄漏的'标准武器',尤其值得掌握。

下面这张图,对比了"会泄漏"和"不泄漏"两种写法:

这张图的对比很清楚:左边红色那条,无缓冲 + 没人接收,goroutine 永远阻塞在发送上、退不出去,每次调用泄漏几个、越积越多,最终内存爆;右边绿色那条,要么用带缓冲 channel 让发送总能成功、发完就退出,要么用 context/select 让 goroutine 在没人要结果时主动退出——两种方式都保证了 goroutine 能退出、不泄漏。两条路的根本分野,在于你有没有给 goroutine"一条保证能走通的退路"。

第三件事:goroutine 泄漏的其它"常见姿势"

填平了这个坑,我系统排查了 goroutine 泄漏的其它"常见姿势",发现它的"作案手法"还真不少:

// goroutine 泄漏的其它常见姿势:

// 姿势1: 启动了 goroutine, 却忘了它需要的"退出条件"
go func() {
    for {                      // 无限循环, 没有退出条件!
        doSomething()          // 永远跑下去, 永不退出 → 泄漏(除非进程结束)
    }
}()
// 正解: for 循环里, 用 select + ctx.Done() 提供退出条件:
go func() {
    for {
        select {
        case <-ctx.Done(): return   // 收到取消, 退出循环
        default: doSomething()
        }
    }
}()

// 姿势2: time.After / timer 用法不当, 或忘了 Stop
//   for 循环里频繁 time.After, 会创建大量未触发的 timer(也是一种资源累积)

// 姿势3: 等一个永远不会关闭的 channel, 或永远不会来的 WaitGroup.Done()
var wg sync.WaitGroup
wg.Add(2)         // 加了 2
go func() { wg.Done() }()   // 但只有 1 个 Done!
wg.Wait()         // → 永远等不到第 2 个 Done, 永远阻塞!

// 姿势4: HTTP 请求没设超时, goroutine 卡在一个永不返回的网络调用上
//   每个远程调用都要设超时(用 context.WithTimeout), 别让它无限期挂着。

// 姿势5: 把 goroutine "fire and forget" 启动后, 完全不管它的生死
//   go someFunc()  ← 启动就不管了, 它会不会泄漏? 你心里要有数。

这一排查,让我对 goroutine 泄漏的"防范"有了全面的认识。它的"作案姿势"虽多,但根源都是同一个——"goroutine 没有一个能保证走到的'退出路径'"。姿势1(无退出条件的无限循环):在 for{} 里跑活,却没有"什么时候该停"的判断,正解是用 select + ctx.Done() 提供退出条件。姿势2(timer 累积):time.After 等用法不当会累积未触发的 timer。姿势3(WaitGroup 计数不匹配):Add 的数量和 Done 的次数对不上,Wait 就会永远等下去。姿势4(网络调用无超时):goroutine 卡在一个永不返回的网络调用上,正解是给每个远程调用设超时。姿势5(fire and forget):启动一个 goroutine 后就完全不管它的死活,是最危险的——你必须心里有数它会不会泄漏。这些姿势共同指向一条核心的防范原则:每启动一个 goroutine,都要为它设计好'在所有情况下都能退出的路径'——尤其是要用 context 来传递'取消信号'、用超时来给阻塞操作设上限、用 select 来给 goroutine 多一条'走人'的选择。让每个 goroutine 都'有始有终、能进能退',是写出不泄漏的 Go 并发代码的根本。

第四件事:怎么"发现"goroutine 泄漏?——监控与排查

这次泄漏潜伏了好几天才被发现,让我意识到:除了"预防",还得有"发现"它的手段。我把"如何监控和排查 goroutine 泄漏"也系统地学了一遍:

// 监控和排查 goroutine 泄漏的手段:

// 手段1: runtime.NumGoroutine() —— 实时看 goroutine 数量
import "runtime"
log.Printf("当前 goroutine 数: %d", runtime.NumGoroutine())
// 把它接入监控(Prometheus等), 画成曲线:
//   - 正常: 数量稳定在一个范围内波动
//   - 泄漏: 数量【只涨不跌】, 一路爬升 → 警报!

// 手段2: pprof —— 排查泄漏的"利器", 能看到 goroutine 都卡在哪
import _ "net/http/pprof"
// 启动 pprof: go func() { http.ListenAndServe("localhost:6060", nil) }()
// 然后: go tool pprof http://localhost:6060/debug/pprof/goroutine
//   或浏览器看: http://localhost:6060/debug/pprof/goroutine?debug=2
//   → 它会列出所有 goroutine 的堆栈, 一眼看出"几十万个都卡在哪一行"!
//   (我就是这么找到那行 ch <- result 的)

// 手段3: 测试时检测泄漏
//   用 go.uber.org/goleak 这个库, 在测试结束时检查有没有泄漏的 goroutine
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)   // 测试结束自动检测 goroutine 泄漏
}

// 手段4: 对比"压测前后"的 goroutine 数
//   压测一波, 等流量停了, 看 goroutine 数有没有回落到压测前 ——
//   没回落、留下了一堆, 就是泄漏。

// 核心: 泄漏是"慢性病", 早发现靠监控(数量曲线), 定位靠 pprof(看卡在哪)。

这一学习,让我对 goroutine 泄漏的"发现"有了趁手的工具。泄漏是一种"慢性病"——它不会立刻发作,而是悄悄累积、缓慢恶化,所以"尽早发现"至关重要,而这需要专门的监控和排查手段。手段1(runtime.NumGoroutine())是最基础的监控:把当前 goroutine 数量,接入监控系统、画成曲线——正常时它应该稳定波动,而如果它"只涨不跌、一路爬升",那就是泄漏的强烈信号。手段2(pprof)是排查的"利器":它能列出所有 goroutine 的堆栈,让你一眼看出"这几十万个 goroutine,都卡在哪一行代码上"——我这次,正是靠 pprof,精准地定位到了那行 ch <- result手段3(goleak 库)则把检测前移到了测试阶段:在单元测试结束时,自动检查有没有 goroutine 泄漏,把问题挡在上线之前。手段4(压测前后对比):压测一波、等流量停了,看 goroutine 数有没有回落——没回落,就是泄漏。这套'监控数量曲线(早发现)+ pprof 看堆栈(快定位)+ 测试检测(早拦截)'的组合,是发现和排查 goroutine 泄漏的标准方法。把它用起来,你就能让 goroutine 泄漏这种'慢性病',在它酿成大祸之前,就被揪出来。把这些监控排查手段整理成一张表:

手段 作用 用在
NumGoroutine 监控 看数量曲线, 早发现 生产监控
pprof goroutine 看卡在哪, 快定位 排查现场
goleak 库 测试时检测泄漏 单元测试
压测前后对比 看数量回不回落 上线前压测

第五件事:并发的"强大"与"危险",是一体两面

这次踩坑,让我对 Go 引以为傲的"并发"能力,有了更辩证、更敬畏的认识。我把对"并发"的几点深层思考,沉淀了下来:

关于"并发"的深层思考: 它的强大与危险, 是一体两面。

Go 的并发(goroutine + channel)非常强大:
  - goroutine 极轻量, 能轻松开成千上万个
  - channel 让 goroutine 间通信优雅("通过通信共享内存")
  - 写并发代码, 比很多语言简单得多

但"强大"的另一面, 是"危险":
  - 正因为 goroutine 太容易开(go 一下就行), 就太容易"开了不管"→ 泄漏
  - 正因为并发是"看不见"的(后台跑), 它的问题(泄漏/竞态/死锁)也"看不见"→ 隐蔽
  - 并发的 bug, 往往不是"立刻崩", 而是"慢慢恶化"(泄漏)或"偶尔出错"(竞态)→ 难查

所以, 写并发代码, 要有一种"敬畏":
  1. 每开一个 goroutine, 都为它负责到底——它怎么退出? 谁来管它的生死?
  2. 并发资源(goroutine/channel/锁), 像内存一样, "有借有还"——开了要能关。
  3. 用 context 贯穿始终, 给并发的"生命周期"一个统一的、可取消的管理。
  4. 监控 + 测试, 主动去发现并发的"隐性问题"(泄漏/竞态)。

核心: 并发是把"双刃剑"——它的强大, 让你能写出高性能的程序;
  它的危险, 也让你能写出"慢慢拖垮自己"的程序。
  驾驭它的关键, 是对每一个并发单元的"生命周期", 都心中有数、负责到底。

这层思考,是这次踩坑给我最深的认知升华。Go 的并发,确实非常强大——goroutine 极轻量、channel 优雅、写起来比很多语言简单;但'强大'的另一面,恰恰是'危险',而且这两者是一体两面、无法分割的。正因为 goroutine 太容易开(一个 go 就行),所以就太容易"开了之后不管它的死活",从而泄漏;正因为并发是在"后台、看不见"地运行,它的问题(泄漏、竞态、死锁)也往往是"看不见、很隐蔽"的;正因为并发的 bug 常常表现为"慢慢恶化(泄漏)"或"偶尔出错(竞态)"、而非"立刻崩溃",所以它格外难查。所以,写并发代码,需要一种特别的"敬畏":每开一个 goroutine,都要为它的'生与死'负责到底(它怎么退出?谁管它?);把并发资源(goroutine、channel、锁)像内存一样'有借有还'(开了要能关);用 context 贯穿始终,给并发的'生命周期'一个统一的、可取消的管理;并用监控和测试,主动去发现那些'看不见'的隐性问题。这次踩坑,让我从"觉得 goroutine 很爽、随手就开"的轻率,转变为"对每个 goroutine 的生命周期负责到底"的审慎。并发是一把双刃剑——它的强大,能让你写出高性能的程序;它的危险,也能让你写出'慢慢拖垮自己'的程序。而驾驭这把双刃剑的关键,就在于:对你启动的每一个并发单元,它的'生命周期',你都要心中有数、负责到底。把并发的"强大"与对应的"危险"对照成一张表:

并发的强大 对应的危险 该有的敬畏
goroutine 极易创建 易"开了不管"→泄漏 为每个 goroutine 的退出负责
并发后台运行 问题看不见、隐蔽 监控 + pprof 主动发现
能开成千上万 泄漏起来量也巨大 资源有借有还, 用 context 管
性能高 bug 慢慢恶化难查 测试检测 + 压测验证

一张"启动 goroutine 前该想什么"的决策图

把这次踩坑沉淀成一张图。每当你要启动一个 goroutine 时,照着它走一遍:

这张图的核心问题只有一个:"这个 goroutine,在所有可能的情况下,都能保证退出吗?"——如果它可能阻塞在 channel、锁、网络调用上,就必须给它一条退路(带缓冲、context 取消、超时、select done);如果它是无限循环,必须有退出条件。把"启动 goroutine 前,先想清楚它怎么退出"变成铁律,goroutine 泄漏就再也碰不到你。

我立下的几条 goroutine 使用规矩

这次"goroutine 泄漏拖垮服务"的事故后,我给自己立了几条规矩:

  1. 启动前先想退出:每启动一个 goroutine,先想清楚"它在所有情况下都怎么退出",有任何一种情况会永远卡住,就必须改。
  2. 给阻塞操作留退路:goroutine 里的 channel 发送/接收、网络调用,要用带缓冲、context、超时、select 给它"走人"的退路。
  3. 用 context 管生命周期:用 context 贯穿并发,函数返回时 defer cancel() 通知 goroutine 退出。
  4. 无限循环要有退出条件:for{} 跑活的 goroutine,必须用 select + ctx.Done() 提供退出条件。
  5. 网络调用必设超时:goroutine 里的远程调用都设超时,别让它卡在永不返回的调用上。
  6. 监控 goroutine 数量:把 runtime.NumGoroutine() 接入监控,数量只涨不跌就警报。
  7. 测试检测泄漏:用 goleak 在测试里检测泄漏,压测后对比 goroutine 数,把问题挡在上线前。

这几条里,第一条"启动前先想退出"是最该刻进肌肉记忆的铁律。而贯穿所有规矩的那条主线,是对"善始善终、有借有还"的责任意识。我这次栽跟头,根子上是我对启动的 goroutine,只管了"开始"(启动它去干活),却没管"结束"(它怎么、能不能退出)——我"创造"了它,却没有"为它的整个生命周期负责"。goroutine,就像我创造出来的一个个'小生命';而创造一个东西容易,为它'负责到底、善始善终'却需要责任心。我只享受了'随手开个 goroutine'的便利,却逃避了'确保它能善终'的责任,于是它们就变成了一个个无法退出的'孤魂野鬼',泄漏在系统里。这背后,其实是一个朴素的工程伦理:凡是你'创造/获取'的资源(goroutine、连接、文件、锁、内存),你都有责任'管理它的整个生命周期',确保它在用完后,能被妥善地'释放/回收'——有借有还、善始善终。这份'对自己创造之物负责到底'的责任意识,是写出不泄漏、不留烂摊子的、负责任的代码的根本。

写在最后:创造容易,负责到底难

这次被 goroutine 泄漏教育的经历,给我一个超越 Go 并发本身的、颇有分量的启示:'创造'一个东西,往往是容易的、令人愉悦的;而'为这个东西负责到底、确保它善始善终',却是更难的、更需要责任心的。而我们犯的很多错误、留下的很多隐患,恰恰源于'只享受了创造的便利,却逃避了负责到底的责任'。开一个 goroutine,一个 go 关键字,轻松愉快;可要确保这个 goroutine 在所有情况下都能善终、不泄漏,却需要你审慎地考虑它的整个生命周期。我这次踩坑,正是贪图了'创造'的便利(随手就开),而疏忽了'负责'的义务(没管它怎么退出)——于是,那些被我'轻率地创造、又被我遗忘的'goroutine,就变成了拖垮系统的隐患。

想通这一点,我对'负责到底'这件事的分量,有了更深的体会。在编程里,有一个反复出现的、深刻的主题:'资源的生命周期管理'——任何你'获取/创造'的资源(内存、连接、文件句柄、锁、goroutine……),你都必须负责'释放/回收'它;'有借有还',是写出不泄漏、不留烂摊子的代码的根本纪律。而这条纪律的本质,是一种'对自己创造之物负责到底'的责任意识——你不能只管'创造/获取'的那一刻的便利,而不管它'之后该如何被妥善地了结'。那些优秀的、可靠的代码,背后往往都有这样一份'善始善终'的责任心:它们获取了资源,就一定会用 defer、用 try-finally、用 context,确保资源最终一定会被释放;它们创造了 goroutine,就一定会为它设计好退出的路径。'创造的便利'与'负责的义务',是一枚硬币的两面;只享受前者、逃避后者,就会留下一个个泄漏的、失控的烂摊子。

所以,如果你也想写出负责任的、不留烂摊子的代码,我想把这次踩坑最想说的话送给你:对你'创造/获取'的每一个东西,都怀有一份'负责到底、确保它善始善终'的责任心。开一个 goroutine,就想清楚它怎么退出;借一个连接,就确保它会被归还;打开一个文件,就 defer 上它的关闭;申请一块资源,就规划好它的释放。因为'创造'的便利,常常会诱使我们忽略'负责'的义务;可一个真正成熟、可靠的工程师,与一个只图一时便利、留下满地隐患的人,差距恰恰就在于:前者,对自己创造和获取的每一样东西,都有一份'管理它整个生命周期、确保它善始善终'的责任心;而后者,则只享受了创造那一刻的轻松,把'如何收场'的烂摊子,丢给了未来的某个时刻、某次崩溃。那几十万个泄漏的、永远无法退出的 goroutine,最终教给我的,正是这份'创造容易、负责到底难'的箴言——它让我懂得,写代码,不只是'创造'出能跑的东西那么简单,更是要对自己创造的每一样东西,负责到它生命的最后一刻;唯有这份'善始善终'的责任心,才能让你的代码,不留下那些慢慢拖垮一切的、看不见的烂摊子。

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

"全部保存完成"先打印了出来,可数据库里一条都还没存:我在 forEach 里用 async/await 踩的那个坑的踩坑复盘

2026-6-1 20:24:11

技术教程

一边遍历列表一边删元素,程序抛了 ConcurrentModificationException:我才搞懂这个"并发修改异常"其实和并发没半点关系

2026-6-1 20:34:31

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