在 Go 语言的广袤世界中,defer 机制宛如一颗璀璨的明珠,吸引着众多开发者的目光。它既是初学者初探 Go 语言时的好奇所在,也是经验丰富的开发者优化代码、提升程序可靠性的有力工具。然而,defer 并非表面上那般简单直观,其背后隐藏着众多微妙而关键的细节。从基本的执行规则到复杂的错误处理,从不同的分配类型到性能优化的考量,每一个方面都值得我们深入挖掘和探讨。接下来,让我们一同揭开 defer 机制的神秘面纱,探索其丰富的内涵和应用。
二、defer 的基本概念(一)什么是defer
在深入探究之前,先对 defer 进行简要介绍。在 Go 语言中,defer 是用于延迟函数执行直至所在函数结束的关键字。
在上述代码片段中,defer 语句安排 fmt.Println("hello") 于 main 函数的最后执行。因此,fmt.Println("world") 会率先被调用并打印出 “world”,随后因使用了 defer ,“hello” 会在 main 函数结束前最后被打印。
defer 类似于在函数即将退出前设置后续要运行的任务,对于诸如关闭数据库连接、释放互斥量或关闭文件等清理操作十分有用。
上述代码虽能展示 defer 的工作原理,但在使用方式上存在不足之处,此问题将在后续讨论。
有观点提出“为何不将 f.Close() 置于函数末尾?”,这样做存在以下良好理由:
其一,将关闭操作紧邻打开操作放置,更便于遵循逻辑,避免遗忘关闭文件,减少在函数中向下滚动检查文件是否关闭的操作,从而避免分散对主要逻辑的注意力;
其二,即便发生 panic (运行时错误),当函数返回时,defer 函数仍会被调用。
当 panic 发生时,栈会被展开,defer 函数会按照特定顺序执行,相关内容将在后续介绍。
(二)defer 的栈顺序执行
在一个函数中使用多个 defer 语句时,它们会依照“栈”的顺序执行,即最后添加的 defer 函数会首先执行。
每次调用 defer 语句时,会将该函数添加至当前 goroutine 的链表顶部,如下所示:
当函数返回时,会遍历此链表并按照上述顺序执行每个 defer 函数。但需注意,它不会执行 goroutine 链表中的所有 defer 函数,而只会执行当前返回函数中的 defer 函数,因为 defer 链表可能包含来自众多不同函数的 defer 。
因此,只有当前函数(或当前栈帧)中的 defer 函数会被执行。
然而,当发生 panic 时,当前 goroutine 中的所有 defer 函数都会被执行。
(三)defer 、panic 与 recover
除了编译时错误,还存在一系列运行时错误,如整数除以零、越界、解引用空指针等,这些错误可能导致应用程序 panic 。
panic 会停止当前 goroutine 的执行、展开栈并执行其中的 defer 函数,进而导致应用程序崩溃。
为处理意外错误并防止应用程序崩溃,可在 defer 函数中使用 recover 函数来重新获取对发生 panic 的 goroutine 的控制。
通常,在 panic 中置入错误,并使用 recover(..) 来捕获,但捕获对象可以是任意类型,如字符串、整数等。
在上述示例中,仅在 defer 函数内部使用 recover 才有效。在此,可列举出几种错误情况。在实际代码中,至少能见到如下三个片段。
首先,直接将 recover 作为 defer 函数:
上述代码仍会引发 panic ,此乃 Go 运行时的设计。
recover 函数旨在捕获 panic ,但必须在 defer 函数内部被调用才能正常工作。
在底层,对 recover 的调用实际为 runtime.gorecover ,它会检查 recover 调用是否处于正确的上下文,特别是在 panic 发生时处于活跃状态的正确的 defer 函数中。
这是否意味着不能在 defer 函数内部的函数中使用 recover ?
答案是肯定的,上述代码无法如预期工作,原因是 recover 并非直接从 defer 函数中调用,而是从嵌套函数中调用。
此外,另一种错误是试图从不同的 goroutine 中捕获 panic :
此情况合乎逻辑,已知 defer 链属于特定的 goroutine ,若一个 goroutine 能干预另一个处理 panic ,会较为困难,因为每个 goroutine 都有自身的栈。
不幸的是,若不在相应的 goroutine 中处理 panic ,在此情形下,唯一的结果便是令应用程序崩溃。
(四)defer 的参数求值
曾遇到过这样的问题,旧数据被推送至分析系统,难以查明原因。
输出为 10,而非 20。这是由于使用 defer 语句时,会立即获取值,此称为“按值捕获”。所以,当 defer 被安排时,发送给 pushAnalytic 的 a 的值已被设定为 10,即便后续 a 发生了变化。
有两种解决此问题的方法。
其一,使用闭包。即将延迟的函数调用包装在另一函数中,如此可通过引用捕获变量,而非如之前那样按值捕获。
其二,传递变量的内存地址而非其值。
这两种方法均能解决问题,但在 Go 中,特别是处理简单变量捕获时,使用闭包可能更符合习惯。
存在疑问“是否通过使用闭包或指针来解决此问题?”,使用闭包有效,而仅使用指针则不足够。即便将 Data{} 更改为 &Data{} ,仍无法解决,因仍将解引用后的值传递给了延迟函数:
需通过将值接收者更改为指针接收者来改变将接收者传递给延迟函数的方式。
如此,便能如预期般正常工作。
(五)defer 与错误处理
此前我们曾提及打开文件并关闭的示例:
此例虽能展示 defer 的工作方式,但并非使用 defer 的理想示例。当使用 defer f.Close() 时,我们错失了妥善处理错误的良机,因为 Close 方法可能返回错误,而我们却未捕获。
所谓“妥善”,意即能够将错误返回给调用者或记录下来以便进一步探查,以免错失深入理解代码的契机。在我们的示例中,若 close 方法返回错误,通常意味着文件描述符未能正确关闭,这可能由多种原因所致,如系统调用中断或底层 I/O 错误等。对于追求高可用性和可靠性的软件而言,此乃重大问题。
当被问及“如何将错误返回给调用者”时,答案是不能仅像往常那样简单地返回错误,而是要借助 defer 和命名返回值来实现。
即便延迟了 Close 方法,我们仍能通过命名返回值将其产生的错误与原始错误有效结合并返回。在 errors.Join 中,任何 nil 值都会被舍弃,因此如此操作是安全的。需注意,此例旨在展示 defer 可能掩盖错误,而非单纯聚焦于文件的打开与关闭问题。
(六)defer 的类型
当调用 defer 时,会创建一个名为 _defer 的对象结构,用于保存延迟调用的所有必要信息。该对象会被推送至 goroutine 的 defer 链中,正如之前所讨论的那样。
每次函数退出,无论是正常退出还是因错误导致,编译器都会确保调用 runtime.deferreturn 。此函数负责展开延迟调用链,从 defer 对象中检索存储的信息,并按照正确的顺序执行延迟函数。
堆分配和栈分配的 defer 区别在于对象的分配位置。在 Go 1.13 及以下版本,仅有堆分配的 defer 。
在 Go 1.22 中,若在循环中使用 defer ,则会进行堆分配。
堆分配在此处是必要的,因为 defer 对象的数量可能在运行时发生变化。所以,堆能够确保程序可以处理任意数量的 defer ,无论它们在函数中的数量和位置如何,同时避免栈的膨胀。
虽然堆分配在性能方面被认为存在劣势,但 Go 语言试图通过使用 defer 对象池来进行优化。我们设有两个池:一是逻辑处理器 P 的本地缓存池以规避锁竞争,二是所有 goroutine 共享并从中获取的全局缓存池,而后将 defer 对象放入处理器 P 的本地池。
有人提出疑问:“在 Go 1.22 中,if 语句中的 defer 情况如何?它是否也是不可预测的?”
答案是在 if 语句中使用 defer 可能具有不确定性。
自 Go 1.13 起,defer 可以是栈分配的,即在栈上创建 _defer 对象,然后将其推至 goroutine 的 defer 链中。
若 if 块中的 defer 语句仅被调用一次,且不在循环或其他动态上下文中,它将受益于 Go 1.13 引入的优化,即 defer 对象将是栈分配的。
上述代码片段在 Go 1.23 中依然有效。通过这种优化,依据相关提案 ,在 cmd/go 二进制文件中,此优化适用于 370 个静态 defer 站点中的 363 个。与先前堆分配 defer 对象的方法相比,这些站点的性能提升了 30%。
既然如此,为何还需要开放编码的 defer ?若仅将 defer 置于函数末尾,直接调用的性能要优于其他两种方式。截至 Go 1.13 ,大多数 defer 操作约需 35ns (相较 Go 1.12 的约 50ns 有所下降),而直接调用约需 6ns 。
Go 语言会在函数的末尾和汇编代码中的每个返回语句之前直接内联 defer 调用,但此类应用存在一定限制。
若一个函数中至少存在一个堆分配的 defer ,则函数内的任何 defer 都不会被内联或开放编码。这意味着,若要优化上述函数,应当将堆分配的 defer 移除或移至其他位置。
另需铭记,函数中的 defer 数量与返回语句数量的乘积需小于或等于 15 。这是由于我们在每个返回语句之前都放置了 defer ,若此类情况过多,二进制代码将会变得异常臃肿。
三、结论Go 语言中的 defer 机制虽然看似简单,但在实际运用中蕴含着丰富的细节和优化策略。开发者只有充分理解其工作原理、执行顺序、错误处理方式以及不同的分配类型等方面,才能在编程实践中更加得心应手,编写出高效、可靠且易于维护的代码。
还没c语言好用