← 中文资源

简体中文版本

Go — 循环内 defer 把清理推迟到函数退出

修复 Go 循环内 defer 累积到函数返回才执行的资源泄漏,把循环体提取成函数,并用句柄计数验证。

阅读英文原文

症状

  资源曲线呈阶梯状:fd 计数每次迭代加一、中途从不回落,直到撞上限崩溃——清理被“堆叠”而不是“执行”。代码形态一目了然:for 循环里 os.Open 后紧跟 defer f.Close(),这条 defer 绑定的是整个外层函数,一千个路径就是一千个同时打开的文件。mutex 版本失败方式不同但根源相同:循环内 defer mu.Unlock() 意味着迭代之间从不解锁,互斥形同虚设还常直接死锁。生产里泄漏、跑得快的测试毫无察觉,是因为短测试在撞限前就结束了。

诊断思路

  先实时观察 /proc//fd 计数形态(每迭代 +1、批次结束才断崖回落即匹配 defer 语义);再用 grep 在 defer 前八行内找 for 头定位模式;最后用低 fd 上限(ulimit 64)跑有界测试,让泄漏在批次中途确定性失败而不是只在生产暴露。

根因

  defer 把清理调度到函数退出而非作用域退出。循环内每轮迭代都向函数的延迟栈追加一次调用,所有文件、连接或锁同时保持打开,直到外层函数最终返回。

修复与验证

  把循环体提取成独立函数或方法,defer 绑定到单次迭代;提取也顺带改良错误路径——每轮拥有自己的清理,错误不会搁浅已累积的延迟调用;重型场景用 worker 池,每个 worker 有自己的函数边界,同样的作用域外加有界并发。验证时在低 fd 限制下跑完整批次断言完成,并断言迭代期间句柄计数保持平坦。不建议改成手工 close——那会重新引入 defer 本来要解决的提前 return 泄漏问题,提取函数才是正解。

代码示例

fd count: 3 → 4 → 5 → 6 → ... → 1024 → crash: too many open files
func processAll(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        defer f.Close() // runs when processAll returns — after the whole loop
        handle(f)
    }
    return nil
}
func processAll(paths []string) error {
    for _, p := range paths {
        if err := processOne(p); err != nil {
            return err
        }
    }
    return nil
}

func processOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // bound to this iteration
    return handle(f)
}