defer是Go中最常用、也最容易被“误用”的关键字之一 会用 ≠ 真正理解,很多看似“诡异”的行为,其实背后都有非常确定、严谨的执行方式
本文基于Go1.25.5源码与行为表现,从使用结果出发,自顶向下、由浅入深地拆解defer
- defer是什么
- defer的执行时机与顺序
- defer与闭包、return、panic的关系
- defer常见但容易踩坑的行为
说明:
- 本文是defer系列的第一篇,聚焦使用与行为结果
- 原理与runtime源码分析将在下一篇单独展开
1.defer是什么
官方语义一句话
defer用来将一个函数调用延迟到当前函数返回之前执行, 并且无论函数是正常 return,还是发生 panic,都会执行
几个关键词非常重要:
- 函数返回之前(不是函数结束之后)
- 一定会执行(panic 场景也一样)
- 延迟的是一次函数调用,而不是一段代码
2.defer的基本使用
在深入runtime之前,我们先通过几个非常经典的例子,建立defer的基本认知
2.1例一(defer的参数求值时机)
func main() {
var whatever [3]struct{}
for i := range whatever {
defer func() {
fmt.Println(i)
}()
}
}
这个案例的结果在golang不同的版本会有不同的结构,在golang1.22之前打印的结果是:
2
2
2
在golang1.22及之后做了优化,但是在这里并不是讨论这个问题,在这里想告诉大家的是defer关键字后面跟的函数在执行的时候,函数调用的参数会被保存起来,也就是复制了一份。真正执行的时候,实际上用到的是这个复制的变量,因此如果此变量是一个“值”,那么就和定义的时候是一致的。如果此变量是一个“引用”,那就可能和定义的时候不一致(很显然这里存在闭包的问题) 关键结论:
- defer后面的函数调用在注册时就完成了参数绑定
- 真正执行defer时,使用的是当时保存下来的那份“参数/环境”
- 如果捕获的是值,结果固定
- 如果捕获的是引用,则可能随着后续变化而变化(闭包问题)
在这里只是展示了defer其中的一个特性,接下来会用其他的例子来展示defer其他的特性
2.2例二(defer的执行顺序:后进先出)
type number int
func (n number) print() {
fmt.Println(n)
}
func (n *number) pprint() {
fmt.Println(*n)
}
func main() {
var n number
defer n.print()
defer n.pprint()
defer func() {
n.print()
}()
defer func() {
n.pprint()
}()
n = 3
}
先说结论打印结果是:
3
3
3
0
根据这个执行结果,可以知道defer的第二个特性:defer关键字执行的顺序是先进后出(LIFO),具体打印值的原因:
- 第一个3打印:第四个defer先执行,因为闭包的原因所以打印3
- 第二个3打印:第三个defer执行,原因同上
- 第三个3打印:第二个defer执行,因为这里的n是一个指针,简单来说拷贝了一个引用进去指向了同一块内存地址,所以打印3
- 0打印:第一个defer执行,这里在直接拷贝了n的值,因为在最开始的时候n的值是0,所以打印的也是0
接下来再来看看defer的第三个特性
2.3例三(return之后的defer不会执行)
func main() {
defer func() {
fmt.Println("1")
}()
if true {
fmt.Println("2")
return
}
defer func() {
fmt.Println("3")
}()
}
上面代码的执行结果:
2
1
结论:
- defer必须在return之前注册
- return后的defer语句根本没有机会被注册
2.4例四(return的三步模型)
func main() {
fmt.Println(f())
}
func f() int {
defer fmt.Println("defer")
return 1
}
这里的打印结果:
defer
1
这也说明了return的一个特性就是在一个函数的返回过程当中其实分了三步:
- 计算return表达式
- 执行所有defer(LIFO)
- 函数真正返回(RET)
基于这个特性,那么defer在实际使用中就会有一些骚操作,defer可以修改返回值,下面有一个命名返回值的示例
func main() {
fmt.Println(f())
}
func f() (x int) {
defer func() {
x++
}()
return 1
}
上面代码块中的打印结果:
2
让我们来拆解一下返回的过程 4. 进入函数:x=0 5. 执行return 1,这里只是赋值:x=1 6. 执行defer:x++ 7. RET:x->2
return 对命名返回值来说,只是一次赋值 defer 有机会在真正返回前“最后修改”
相反我们来看另外一个无名返回值例子
func main() {
fmt.Println(f())
}
func f() int {
x := 1
defer func() {
x++
}()
return x
}
上面代码块执行结果:
1
这次在defer中却没有修改x的值,来给大家分析一下原因:
- 进入函数x:=1
- 执行return temp = x
- 执行defer:x++
- RET:返回temp
说明:
- defer 并没有修改返回值
- 修改的只是一个已经不再参与返回的局部变量
这一点和defer本身关系不大,而是Go返回值模型的特性
3. defer的进阶使用示例
在真实工程中,defer 很少只是用来 Println,更多时候它承担的是资源治理、异常兜底、状态修复等职责,下面补充几个非常经典、也非常“工程化”的使用示例
3.1例一(defer + panic / recover)
func safeExecute() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recover panic:", r)
}
}()
panic("something wrong")
}
执行结果:
recover panic: something wrong
关键结论:
- recover 只能在defer函数中生效
- recover只能捕获当前 goroutine、当前调用链上的panic
- panic 并不是异常,而是一种强制的栈展开机制
在工程中,recover 往往用于:
- HTTP / RPC 框架的统一兜底
- goroutine 入口的防御性保护
- 防止单个请求击穿整个进程
为什么recover必须和defer搭配使用
func badRecover() {
if r := recover(); r != nil {
fmt.Println(r)
}
}
上面代码永远无法生效,原因是:
- recover的生效窗口只存在于defer调用过程中
- runtime在panic栈展开时,只会检查defer链
这一点在 runtime 的_panic状态机中有非常明确的实现, 会在下篇中结合源码详细展开
3.3例二(defer + 资源释放)
func readFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return nil
}
- 资源获取与释放语义上强绑定
- 避免多return分支遗漏释放逻辑
- 即使后续panic,资源也能被正确回收 这也是 Go 官方在设计defer时,最核心的使用场景之一
3.4例三(defer + 锁)
func example(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock()
// do something
}
注意事项
- defer解锁非常安全
- 但在高频、极短函数中可能有性能成本
- runtime在Go1.14+对defer做了大量优化,但并非零成本
4. 小结
在进入runtime之前,我们先总结defer的几个非常重要的行为特征:
- defer延迟的是函数调用,不是表达式
- defer的参数在注册时就已经确定
- defer按LIFO顺序执行
- defer在panic场景下一定会执行
- return分三步执行,defer位于中间
- 命名返回值可以被defer修改
到这里为止,我们只是站在语言行为的角度理解 defer。 下一篇文章中,我们将从runtime源码的角度,深入defer的创建、保存、执行与优化过程,包括:
- _defer结构体的真实作用
- deferreturn / panic / open-coded defer
- 为什么recover必须在同一调用栈生效
至此,defer(上)完 👉 下一篇文章:深入理解 Go defer(下):编译器与runtime视角的实现原理