深入解析Go语言中的defer机制

超级欧派课程 2024-08-26 15:00:40
一、引言

在 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 机制虽然看似简单,但在实际运用中蕴含着丰富的细节和优化策略。开发者只有充分理解其工作原理、执行顺序、错误处理方式以及不同的分配类型等方面,才能在编程实践中更加得心应手,编写出高效、可靠且易于维护的代码。

0 阅读:2
评论列表
  • 2024-08-26 18:17

    还没c语言好用

超级欧派课程

简介:感谢大家的关注