本篇内容是根据2021年2月份When Go programs end音频录制内容的整理与翻译,两位主持人邀请Go团队的Michael Knyszek,讨论了当Go程序结束时会发生什么
过程中为符合中文惯用表达有适当删改, 版权归原作者所有.
Mat Ryer: 大家好,欢迎收听 Go Time。我是 Mat Ryer。今天我们要讨论 Go 程序结束时发生的事。当 func main
返回时会发生什么?那些可能仍在运行的 goroutines 怎么办?还记得那些 defer
语句吗?它们会怎样被执行呢?打开的文件会自动关闭吗?还是需要我们自己处理?还有 HTTP 响应体,我们一直被提醒要关闭它们,那当程序退出时,这些响应体会发生什么?有很多问题,今天我们会在这期深入分析中找到所有这些问题的答案。所以虽然这个话题可能听起来有点平淡,但我觉得不会的。
今天加入我们讨论的是常驻嘉宾 Jon Calhoun。你好,Jon。
Jon Calhoun: 嗨,Mat。
Mat Ryer: 你之前告诉我,你从未让一个 Go 程序结束过,所以这对你来说是个全新的领域
Jon Calhoun: 我没那么说... [笑] 我说的是大部分我的程序都不是设计来结束的。所以当它们结束时,我通常是忙着确保我的服务器能重新启动。
Mat Ryer: 对,明白。好吧,我相信我们会深入讨论这个问题。今天还有一位加入我们的是 Go 团队的一员,他在过去的 2-3 年里一直在研究运行时。欢迎 Michael Knyszek。你好!
Michael Knyszek: 你好。
Mat Ryer: 欢迎!
Michael Knyszek: 谢谢,真的很高兴能来这里。
Mat Ryer: 好的。你是真的很兴奋,还是只是出于礼貌?
Michael Knyszek: 我有点紧张,但也很兴奋。
Mat Ryer: 嗯,紧张和兴奋其实很相似。好的,我们从头开始吧,假设有人刚接触 Go,程序最终会停止运行... 那么当程序结束时会发生什么?具体是怎么回事?
Michael Knyszek: 基本上,Go 程序会直接调用操作系统,告诉它 "我们结束了"。然后操作系统会清理所有东西。如果 Go 程序有父进程,比如在 Linux 系统上所有进程都有父进程,操作系统会向父进程返回一个退出码。在 Linux 上,这个值介于 0 到 255 之间。出于兼容性考虑,Go 的 os.Exit
默认返回 0,表示一切正常。返回非零值则意味着程序出了问题。一些程序会用不同的数字代表不同的错误类型,但总体模式是:0 表示正常,非零表示出错。
Mat Ryer: 明白。那么这些退出码是不是像 HTTP 状态码一样有标准呢?还是仅仅是 0 表示成功,其他的则由程序定义?
Michael Knyszek: 我认为唯一可以依赖的就是 0 或非 0。如果你在处理特定程序,比如编写一个脚本并想记录错误消息或其他内容,那么定义具体的退出码可能会有用。但通常来说,唯一可以依赖的就是 0 和非 0。
Mat Ryer: 明白。那么在 Go 中,main
函数返回时没有返回值,所以它只是默认返回 0,对吗?
Michael Knyszek: 是的,没错。
Mat Ryer: 那如果我们想返回一个非 0 的状态码,就需要用 os.Exit
对吧?
Michael Knyszek: 是的,完全正确。
Mat Ryer: 我们稍后会深入讨论这个... 你提到操作系统会清理所有东西,Go 会留下一些"烂摊子"... 那具体会清理什么呢?
Michael Knyszek: 基本上,Go 会向操作系统请求一堆内存。最明显的是所有分配的内存都会被回收。除此之外,如果有任何打开的文件句柄,它们也会被关闭。操作系统会跟踪所有这些资源,程序退出后,它会逐一关闭这些资源。
Mat Ryer: 那挺好的,内存会被回收。如果我们有一个包含大量数据的映射(map),在返回之前,我们不需要手动释放这些内存,对吧?一切都会自动完成,是吗?
文件这个问题也很有趣。如果你在 Go 中打开了一个文件,通常我们会 defer
关闭文件,或者使用其他机制。如果你没有关闭文件,程序退出时会泄露文件句柄吗?还是操作系统会帮我们清理?
Michael Knyszek: 不,操作系统会清理这些文件句柄。在大多数系统中,文件的表现形式相当美观------文件的概念在操作系统中深入人心。操作系统会跟踪这些文件,并在程序退出时关闭它们。
Mat Ryer: 明白。但是如果代码是在一个循环中运行,那么最好还是及时关闭文件,不能完全依赖程序结束时的清理。Jon,那你通常是如何处理程序退出和取消操作的呢?如果你在运行一个命令行工具,你会怎么做?
Jon Calhoun: 最常见的方式是使用 context
,通过它来处理取消操作... 但我必须承认,我并不是每次都这样做,特别是当我只是在为自己写一个快速的工具,且它不会运行太久时。如果程序只需要快速地处理几个文件,我觉得即使我想取消它,它可能在我取消之前就已经结束了,所以这并不重要。
如果是一个长时间运行的任务,那可能就另当别论了。这取决于你在做什么,以及在中途停止是否会造成严重后果。对我来说,这通常是决定是否需要优雅关闭的关键。
Mat Ryer: 对,这很有意思------是否重要,程序是否能在中途直接停止。我最近写了一个处理文件的程序,它会为每个文件创建一个新文件。如果程序在中途结束,可能会导致磁盘上的状态不一致。这个问题引发了对优雅关闭的讨论。我们如何知道程序即将结束?又该怎样在结束前处理一些工作呢?
Michael Knyszek: 程序可以通过两种方式结束:外部信号或程序自身。外部信号比如 Ctrl+C,当你按下 Ctrl+C,Linux 会发送一个信号。Go 在这方面做得很好,通过 os/signal
包,你可以捕获这些信号并处理它们,实现优雅关闭。
Mat Ryer: 明白。那么在运行时处理这些信号时,会不会很复杂?毕竟需要处理很多操作系统的边缘情况,对吧?
Michael Knyszek: 信号处理非常复杂。信号处理器可以在任何线程上运行,随时可能产生冲突,因此在 Go 的运行时中处理这些情况确实很棘手。
Mat Ryer: 是的,信号处理是很古老的技术,深深嵌入在系统中。
Michael Knyszek: 没错。不过 Go 的 signal
包确实提供了一个非常简洁的封装,使用起来非常安全。
Jon Calhoun: 如果我想捕获信号,我需要了解很多不同的信号吗?比如有人在 Linux 终端使用 kill
命令终止进程,或者按 Ctrl+C 等等... 我该从哪里开始?
Michael Knyszek: os/signal
包的文档对不同的信号有详细说明。你提到的 kill
信号实际上是无法捕获的,它会强制终止进程。其他常见的信号包括 SIGINT
(Ctrl+C)和 SIGABRT
,后者会导致 Go 运行时输出所有 goroutine 的堆栈跟踪。
Michael Knyszek: 我觉得 os/signal
包的文档确实很好地描述了不同的信号。你提到的 kill
信号很有意思,因为如果我没记错的话,kill
是无法捕获的信号之一。这就是 kill
的危险之处------如果你给一个进程发送 kill
信号,进程就没有机会进行清理。这就像"强制退出"的极端情况,没有任何机会进行处理。
还有两个我比较熟悉的信号,一个是 SIGINT
,就是中断信号,通常是按 Ctrl+C
产生的。另一个是 SIGABRT
,它会导致 Go 运行时输出一堆 goroutine 的堆栈跟踪。这也是一个有时值得显式处理的信号。不过 Ctrl+C
是最常见的信号。
我认为 os/signal
包的文档写得非常好,因为它不仅涵盖了不同的信号,还考虑到了 Go 支持多个平台的情况。当然,在不同的平台上,如 Windows,处理方式会略有不同。所以我建议查看 os/signal
的文档来了解具体的语义。
Mat Ryer: 没错。自从 Go 1.16 后,signal
包中还增加了一个 NotifyContext 的辅助函数,它可以在接收到信号时取消一个 context
。这非常方便。如果你在整个程序中使用 context
来处理取消操作,那么这个模式就比较简单了。对于不熟悉的人来说,这意味着你将 context
作为第一个参数传递给程序中的链式调用,然后在循环或数据迭代中,你可以定期检查该 context
是否已经完成,比如在每次循环的开始处。你可以检查是否有一个关闭的通道,或者检查是否返回了错误,这样你就可以中止该操作。这是一种优雅关闭的方式,或者至少是一种"我会完成当前的工作,然后停止"的方式。使用 context
可以很好地实现这种优雅的关闭。而在之前,你需要自己编写处理信号的代码;有了 NotifyContext
后,你不再需要这样做了。你可以将它连接到一个 context
上,当程序被中断时,它会自动取消。
我觉得这是一个不错的实践------这是我一直在做的事情... 如果你接收到第二个中断信号,那么进行一次更加严肃的退出是值得的。有时操作系统会将第二个信号作为 kill
信号发送。但如果你只是按了 Ctrl+C
,而你的逻辑出错了,你可能会卡住,因为你捕获了那个信号。因此,检查第二个信号并立即调用 os.Exit
是一个好习惯,这样你就不会遇到必须强制退出程序的情况。所以,我觉得优雅关闭是非常重要的。
另一种实现优雅关闭或至少进行清理的方法是使用 defer
语句。在 func main
函数中,你可以 defer
一些操作,它们会在函数退出之前被调用,也就是在程序退出之前被调用。但这并不适用于 os.Exit
,对吧,Michael?
Michael Knyszek: 没错。os.Exit
是一种强制退出,它只会进行最少的必要清理。对于 Go 运行时来说,这意味着如果你启用了竞争检测器,它会做一些清理工作,比如确保如果你的程序存在数据竞争,它将返回非零的退出码。但除此之外,它基本上就是强制退出。它不会运行任何 defer
语句;如果你知道 finalizers
,它也不会运行它们。这是一个比较幽微隐蔽的角落,但值得提一下。
Mat Ryer: 明白。所以 os.Exit
是一种非常立即的停止操作,你将不会得到 Go 提供的那些优雅的清理功能;你需要记住这一点。
另一个有趣的问题是,标准输入输出流(STDIN、STDOUT、STDERR)会发生什么?比如,STDOUT 会接收到一个 io.EOF
吗?它会关闭管道吗?具体发生了什么?这是否也依赖于操作系统?
Michael Knyszek: 这可能在某种程度上依赖于系统。我更倾向于从 Linux/Unix 的哲学角度来看,管道就是文件。对于操作系统来说,它使用相同的资源------文件句柄。这些 STDOUT
、STDERR
和 STDIN
都会像其他文件一样被关闭。
我想指出的是,当你调用 exit
时,是否有代码运行完全是不确定的。在进程被终止的那几毫秒内,某些 Go 代码可能会运行,或者线程可能会停止... 但你无法依赖它。因此,不会有 EOF
被传播,因为根本没有代码来处理那个 io.EOF
,如果你理解我的意思的话。代码可能根本不会运行。
Jon Calhoun: 所以当我们调用 os.Exit
时,你可以假设从那一刻起就像有人离开了一样,接下来发生的事情就是让它自生自灭,一切都会随时间崩溃...
Michael Knyszek: 是的,它是一种非常非常强制的退出。
Mat Ryer: 而且这是唯一可以返回非零退出码的方式,对吧?
Michael Knyszek: 没错。
Mat Ryer: 这很有趣... 你可能希望程序以特定的状态码退出。但如果你在程序的深处这样做,可能会导致其他事情没有得到处理... 所以你可能只会在 main
函数的顶部,或者非常接近顶部的位置使用 os.Exit
,基于某些其他函数的返回值来决定。
Michael Knyszek: 是的,这通常是一个好的模式。我见过的做法是,你在 main
函数中返回,如果你从 main
函数正常返回,那么这就是 os.Exit(0)
。有趣的是,如果你查看底层实现,当你从 main
返回时,它只进行非常少量的清理工作,比如竞争检测器的处理,然后它会调用相同的 exit
系统调用。它的行为与 os.Exit
完全相同。
所以这是一个适合放置 exit
的位置,因为就像是在说:"如果我从 main
返回,它会调用 os.Exit(0)
,那么现在是运行 os.Exit(1)
的好时机。" 当然,这取决于程序的情况。我可以想象某些程序在某个时候你会发现:"我根本无法继续了。即使其他事情还在运行,我也绝对无法继续了。" 在这种情况下,也许直接放弃一切是合理的。
Mat Ryer: 是的。在 Go 中我们有 panic
来处理这种情况。关于 panic
也很有意思,因为它们可以在程序中的任何地方发生... 如果没有被捕获,它们会导致程序终止。但在 panic
的情况下,defer
语句还是会被执行的,对吧?因为我们知道可以通过 defer
函数中的代码来恢复 panic
。
Michael Knyszek: 没错。panic
会运行 defer
,而且这并不是唯一会运行 defer
的情况。如果你调用 runtime.Goexit
,比如一个 goroutine 调用了 runtime.Goexit
,它也会运行它的 defer
。这非常安全,因为我们知道此时 goroutine 自身的执行已经停止,我们可以回溯并运行所有的 defer
。
Jon Calhoun: 那么,如果你为某个 goroutine 调用了 runtime.Goexit
,我猜它不会像 os.Exit
那样拥有相同的清理保证... 比如你提到的那些文件和操作系统相关的资源。我猜 goroutine 的文件不会单独被跟踪,对吧?
Michael Knyszek: 不,不会。这些在更低的层次上处理。如果一个 goroutine 退出---除非它是最后一个 goroutine---否则这并不会影响程序可能正在使用的其他资源。
Mat Ryer: 这很有趣,当你考虑 HTTP 响应体等问题时,你必须确保关闭它们。当你使用 HTTP 客户端发出请求时,你会收到一个响应,响应可能有一个 body。我们有责任关闭这些 body 以清理内存。假设程序结束了,操作系统会帮我们处理这些事情,对吗?因为它们依赖于底层操作系统来管理资源,对吧?
Michael Knyszek: 没错。按照 Unix 的哲学,"一切都是文件",包括互联网连接、TCP/IP 连接,它们是 HTTP 的基础。大多数操作系统将这些资源作为操作系统的一部分构建,并通过类似于 socket 的接口暴露出来。Go 中的接口表现为 net.Conn
,它代表底层的连接。所以,如果你调用 os.Exit
,它会像处理其他文件一样关闭这个 socket。如果客户端一直在监听这个连接,它会视连接为突然中断。所以这是相同的失败模式。
Jon Calhoun: 有趣的是,你可以通过编写一个小程序来测试这类情况。比如写一个简单的 Web 服务器,服务器只睡眠十秒钟,你可以用 curl
连接它,看看当你在服务器完成响应前关闭它时会发生什么。
Mat Ryer: 你是说作为服务器的客户端,观察服务器崩溃的情况。
Jon Calhoun: 对。如果你用 curl
作为客户端连接到你的服务器,而服务器正在本地运行,你可以在服务器响应前按 Ctrl+C
或杀掉它,看看 body 是否关闭,或者是否有响应。
Mat Ryer: 这听起来像是一个很酷的 API... 一种"休眠"的 API,不是 RESTful,而是静态的休眠。尤其是在今天这个快节奏的时代,这似乎是个好主意。
Jon Calhoun: 完美。人们可以调用它来测试 Web 请求是否超时。
Mat Ryer: 是的,太好了。Michael,你是怎么开始接触计算机的?
Michael Knyszek: 那是很久以前的事情了... 我最早接触的是 Flash,现在已经没落了...
Mat Ryer: 哦,真的?Flash?
Michael Knyszek: 对,那是我很早的入门途径。我当时以为自己想成为一名动画师,但后来发现我画画非常差... 然后我就深入到了编程方面...
Mat Ryer: 是 ActionScript,对吧?
Michael Knyszek: 是的,是 ActionScript。然后我在高中和大学里学得更多,现在我就在这里了。好几年过去了,我就在这里了(笑)。
Mat Ryer: 太棒了。我以前也做过 Flash。ActionScript 后来作为一门语言变得非常强大,我当时都不敢相信它能做这么多事情...
Michael Knyszek: 是的...
Mat Ryer: 我也喜欢它非常直观... 因为当时在网页上用 CSS 并不能做太多事情,所以 Flash 是让网页看起来更有趣的唯一方法。Flash 并没有什么错。
Michael Knyszek: 是的。我对使用 Flash 软件本身有很美好的回忆;不是 Flash 播放器,而是可以拖放东西的那个软件。你可以点击一个对象,然后直接在上面写代码... 感觉就像"哇!我可以直接在这个按钮上写代码,让它有反应"。
Mat Ryer: 是的,这很合理,对吧?
Michael Knyszek: 是的。
Mat Ryer: 我喜欢 Flash 和 ActionScript 中的对象,这些对象就像基类... 你可以有其他对象是它的变体。这是一个非常奇怪的思维方式... 但如果你习惯了面向对象编程,它可能会很适应。我记得这非常酷,你可以对基对象进行修改,然后它会级联到整个树中。是的,我不知道这些内容是否会出现在 Go 播客里,但...
Michael Knyszek: [笑]
Jon Calhoun: 有一个问题(我记得是在 Twitter 上提的)是:"为什么在调用 os.Exit
时 defer
语句不会被执行?"
Michael Knyszek: 其实这里有一个很不错的解释。如果你调用 go exit
,那么你有一个 goroutine 表示"我已经完成了,我要退出了"。因此,它运行自己的 defer
是完全安全的。但现在想象一下,如果你有一个 goroutine 决定"哦,我要退出",然后考虑这样一个世界:如果你调用 os.Exit
,它会运行你程序中所有的 defer
。最终的结果是,那个 goroutine 调用了 os.Exit
,它停止了其他的所有东西,并要求所有这些 goroutine 无论它们在哪里,开始运行它们的 defer
。棘手的部分是,运行这些 defer
并不总是安全的。你不知道那些 goroutine 实际上停在哪里。至少在 go exit
的情况下,作为程序员的你知道"好吧,我在一个我知道 defer
会正常运行的地方调用了这个函数。"
假设你有一个 defer
依赖于某个它捕获的变量。你有一个 defer func()
,并且在其中你对一个在外部声明的变量进行了某些操作,这个变量最初是 nil
。但到函数结束时,它实际上已经非 nil
,并且依赖于它不会在 defer
中引发 panic
。那么,如果其他的 goroutine 在函数执行过程中调用了 go exit
会发生什么呢?现在,exit
会导致其他某个 goroutine 发生 panic
,而这并不是你所期望的,对吧?这还会引入一种全局的思维方式,你现在不得不考虑"哦不,也许这个变量实际上可能是 nil
,因为其他的 goroutine 可以调用 os.Exit()
。"
关于 os.Exit
是否应该执行调用它的 goroutine 的 defer
,也就是调用 os.Exit
的那个 goroutine 的 defer
,这是一个合理的问题。只不过这样做似乎有点不一致。这有点奇怪,只执行那个 defer
。但我实际上没有一个很好的答案;这个问题对我来说可以有不同的观点。
Jon Calhoun: 我想这个问题有点奇怪------如果 defer
中出现一个无限循环,我知道这听起来有点怪,但如果真的有这种情况,你可能会希望有其他方式来最终终止程序,也就是说,从编程的角度看,你需要其他的 API 来做 os.Exit
所做的事情。但如果不是 os.Exit
,那就会显得很奇怪。
我觉得有一点需要澄清,不知道我们有没有提到过------当你调用 os.Exit
时,它会终止所有的 goroutine,对吧?
Michael Knyszek: 是的。
Jon Calhoun: 我不确定我们有没有提到这一点,但这是你刚才谈到的重要部分之一---
如果另一个 goroutine 被随机关闭了,它就不会再控制它了。
Michael Knyszek: 是的。当我想到 exit
时,我把 Go 程序想象成一个大黑盒子,调用 exit
就好像你把整个盒子扔进了垃圾堆里。这包括所有 goroutine 和其中包含的所有资源。
Mat Ryer: 这很有趣---
你觉得这是一个合理的策略吗?比如说,像 Jon 这样的程序,你永远不希望它们结束,因为它们做得太好了,人们非常依赖它们,它们永远不会结束。或者如果你有一种情况,可能有很多 goroutine 正在运行,但当你想让某些东西停止时,你是否乐意它们都被中止,而且不会在意?这是一个可行的策略吗?如果一个初级开发者这样做,是否会被高级开发者批评呢?
Michael Knyszek: 我不这么认为。总体来说,很多情况下并不需要真正优雅的关闭。特别是在那些情况下,事情会变得非常复杂。有些资源你确实需要清理。如果你有一个子进程,你希望等待它结束再退出,或者比如你在运行一个像 Docker 这样的程序时创建了一个新的网络接口,当你退出时,你可能希望将其清理掉。而清理这个东西,尤其是在一个大型应用程序中,无论发生什么情况,都会非常复杂。
所以,一种方式是进行优雅的关闭,尝试在退出前清理所有东西。另一种方式是让你的程序对遗留的东西有弹性。这样当它重新启动,发现有同名的东西时,它可以以合理的方式处理。这总是很难。这永远是一个难题。清理、拆卸、关闭、终止,不管你怎么叫它,这总是一个难题。
Mat Ryer: 是的,但我认为这是很好的建议,Michael。即使你写的程序不需要做太多优雅的关闭,在一个小的命令行工具中,构建这样的功能也是一个不错的实践。比如你按了 Ctrl+C
,即使只是打印一条信息,说我们正在清理或完成任务,这也是一个很好的习惯。
Dave Cheney 经常谈到"当你启动一个 goroutine 时,要知道它将如何结束。" 如果你考虑那些长时间运行的系统,这些系统有着较长的生命周期,那么当你不依赖它们总是被重启时,了解什么时候将它们拆除就变得很重要。
所以我认为这是一个不错的思维方式。而且,这也可以帮助你进行设计。它可能会让你设计得更加优雅。如果你发现很难停止某些任务,也许你可以用更简单的方式来组织它。
Michael Knyszek: 是的,我完全同意。
Mat Ryer: 是的,进程确实很有趣。我学到的是,当你运行一个子进程时,默认情况下,它不会随着你的程序终止而终止。至少在 Mac 上是这样的。我得设置进程组,或者设置一些组 ID。我记得这是一个奇怪的情况。Michael,你知道这背后发生了什么吗?
Michael Knyszek: 是的,事情是这样的:如果一个进程有子进程并且它退出了,基本上所有操作系统------Windows 也有同样的行为,Linux 也是。如果父进程退出,那么子进程就会被孤立,而不会立即退出。就像你说的,有一些解决办法。你可以创建一个进程组并向其中的所有进程发送信号。但如果你不这样做,子进程就会被孤立,并需要有一个新的父进程来加入这个进程的层级结构。在 Linux 上,这意味着它会被根进程继承,而这个根进程只是坐在那里等待它的子进程完成。因此,如果你退出了,而子进程还在运行,它会继续运行直到它自己关闭。 (译者注: 常见八股,僵尸进程和孤儿进程的区别)
Mat Ryer: 是的。这很有趣,需要注意这一点,因为我觉得这不是你期望的行为。如果你启动了子进程,你可能会期望当程序接收到信号时,子进程也会终止。但确实有解决办法。一个办法是使用 CommandContext
,再次利用上下文(context)。这样,当你取消上下文时,它会有一个级联效应,基本上终止子进程。这是另一种很酷的方式。
Jon Calhoun: 如果你这样做了,但之后调用 os.Exit
,它还会传播到所有子进程吗?
Mat Ryer: 不。我认为 os.Exit
只是杀掉了所有东西
Michael Knyszek: 是的。如果你有 Go 代码负责清理子进程,而你调用了 os.Exit
,没有任何保证说那个代码会运行...
Mat Ryer: 是的,你必须通过某种管理机制退出。通常是返回一个错误,或者其他方式。这只是设计的一部分,我想。
Jon Calhoun: 只是为了确认一下---
当你说子进程时,你是指使用 Go 中的 command
函数吗?
Mat Ryer: 是的,os.exec.Command
...
Jon Calhoun: 对,os.exec.Command
,然后你可以获取它的输出,如果你需要的话。
Mat Ryer: 还有 os.exec.CommandContext
,它接受上下文,并在上下文被取消时终止命令。这非常酷。
Jon Calhoun: 好的。我想这就是你指的吧... 很有趣的是,这并不会退出,因为我不确定如果不看文档或者听你讲,我会期望什么... 因为我以前确实用过它,但我从来没有太多思考过它,因为大多数时候我运行的是非常快的任务... 但我可以想象,如果你启动了一个服务器,做一些外部的事情,这可能会导致一些奇怪的行为。
Mat Ryer: 是的。它会继续运行进程,你得去弄清楚为什么。
Jon Calhoun: 我应该说这会是一个很奇怪的 bug,比如下次你运行程序时,它说"这个端口被占用了",你会想"什么?!为什么被占用了?"
Mat Ryer: 事实上,几乎就是这样表现出来的。每次都是这样。
Michael Knyszek: 是的,os.Exit
实际上---
从上下文来看,它确实是非常低级别的。当你调用它时,它真的就是把所有东西都扔到地上。
实际上,我也遇到过这种情况---
我试图清理一个子进程,现在我有了所有这些复杂的 defer
语句,并使用 signal
包来捕获 Ctrl+C
,以便我可以尝试优雅地清理子进程之类的东西... 因为是的,它是一个占用端口的服务器。
Jon Calhoun: 所以你说 os.Exit
是非常低级的... 如果我没记错的话,在 C++ 中你是从 main
函数返回状态码,对吧?
Michael Knyszek: 没错。
Jon Calhoun: 我想问的是,你对这个有什么看法... Go 显然不是这样做的,所以如果你那样做了,我猜 defer
语句...
Mat Ryer: 它可以那样做...
Jon Calhoun: 它可以,我猜。
Mat Ryer: func main
可以返回一个 int
。我觉得这没什么问题。
Jon Calhoun: 是的,但它没有。我有点好奇... 我的猜测是,它不这么做的原因仅仅是大多数人只想返回零,而且这对某些人来说可能会有些困惑。我知道当你第一次学习 C++ 时,你会想"为什么我要在这里返回一个数字?谁在使用它?"
Mat Ryer: 对。
Jon Calhoun: 但当你真的想返回一个错误状态码时,唯一的方法---
至少我知道的方法------就是调用 os.Exit
。而如果你调用了它,那么事情可能不会按照你的预期运行。
Michael Knyszek: 是的。
Mat Ryer: 说到这个,我以前也做过类似的事情,我会有哨兵错误类型,这是 Dave Cheney 提出的另一个术语 ... 你有一个变量,它只是一个错误类型;或者你有其他方式来判断错误的类型。然后在 main
函数的最顶端,我通常会调用一个 run
函数,然后根据这个 run
函数返回的错误,我会检查这个错误,并根据特定的值来决定返回的状态码。否则,我会返回一个通用的 1。这样,你可以将所有的 os.Exit
都放在 main
函数里,你可以清楚地看到整个流程。
Jon Calhoun: 我们之前讨论过这种模式,我觉得在你遇到一些错误之前,很难理解这种方法可以避免多少小 bug... 如果你这样做了,但如果你没意识到这个问题,你可能会很快看到自己把所有东西都放进 main
函数,调用 os.Exit
,然后困惑为什么某些 defer
没有执行。
Mat Ryer: 是的,我觉得这是个好问题。不让 func main
返回 int
的其中一个好处是,它让代码变得更简单。这是一种预期的行为,看起来像其他 Go 代码一样。如果 func main
返回 int
,也许会让事情变得更复杂。但我喜欢那些小的 run
函数抽象。我也会在里面传递参数... 所以即使我要解析标志,我也会在 run
函数里做这件事,传递 os.Args
。因为这样我可以在测试代码中测试整个程序,而不需要做任何花哨的事情,只需要通过不同的参数调用 run
函数,并检查返回值。所以这确实是一个很棒的模式------通常我还会向 run
传递一个上下文(context)。这样我甚至可以测试取消和超时功能。我可以设置一个一秒的定时器并启动程序,之后检查时间差,确保它没有花费更长时间。这样我就知道我的程序尊重上下文中的取消信号。
Jon Calhoun: Mat,你谈了很多关于优雅关闭的内容... 能不能给一些更具体的例子,说明什么时候人们应该考虑这个问题?
Mat Ryer: 是的。其实我之前做过的几次---
最初是在 HTTP 的上下文中......我们希望在退出之前完成当前的请求。这现在已经内置在 HTTP 包中了。我想你可以通过某种特定方式使用 http.ListenAndServe
来实现这一点;我会检查一下并把它放在节目笔记里......我得把这写下来,因为有时候我承诺的节目笔记没有兑现,结果被严厉批评了。对此我很抱歉。
还有一次,是当我在处理文件时,我会执行类似 io.Copy
这样的操作,在这种情况下,我宁愿不打断它,避免留下一个奇怪的半成品文件,因为我不确定会发生什么。 它可能会变成有意识的,呃,可能不会,但是你不想冒这个险。很多事情都是这样发生的。
所以,是的,类似这样的情况。我不知道让文件损坏是不是很大问题,因为也许我在运行这个程序时会删除所有文件。但我喜欢让程序优雅关闭的实践。这样当我需要时,这就是我工具箱中的一个工具,可以随时使用。
Jon Calhoun: 我完全同意。这种实践很重要,这样当它真的重要时,你就能正确地处理......因为正如我之前说的,我有时也会犯不优雅关闭的错误,比如你提到的清理文件......如果我正在处理文件时按了 Ctrl+C
,我会假设所有文件都可能无效,然后删除它们并重新开始,如果它是在生成文件的话。
Mat Ryer: 这也是一种策略,不是吗?
Jon Calhoun: 是的。我是说,对于一些快速的事情,你会权衡"删除文件并重新运行程序是否比写优雅的关闭代码花费更多时间?"所以你必须权衡哪种做法更合理......但如果这是一个一次性的程序,那么随便怎样都行。但是如果是公司里经常会用到的东西,也许这样做就不太合理了。
Mat Ryer: 是的。我觉得这也取决于情况。另一个我用过的场景是我们要在 Docker 中运行代码;在某种云环境中运行时,中断信号基本上是平台告诉你这个实例将会消失。而你可能正好在处理一些请求,在这种情况下,优雅关闭是我们不得不考虑的。
所以,是的,这是我们另一个不得不处理信号的场景。不要只是调用 os.Exit
直接退出,而是持有那个信号,通常是放在一个缓冲通道里,至少留出一个缓冲空间,这样你不会因为等待信号而阻塞......然后等待工作完成后再退出---
不要接收新的工作。你可以切换到关闭模式,停止接收任何新的请求。很多这些问题可能都已经解决了......但是如果你看一下 12-factor 应用程序设计,使用这些基础并保持一致是有好处的,因为运行你代码或与其交互的其他系统也会期望这种行为。所以我觉得成为操作系统中的"好公民"也是有意义的。
Jon Calhoun: 12-factor 的内容确实有很大作用,因为对于不熟悉的人来说,其中一个重要的点是你的服务器可以随时关闭,而你会失去硬盘上的任何东西,或者其他类似的东西。所以你不能指望本地文件系统总是存在。通常有一些变通办法,比如你可以直接将文件上传到你想要的位置......但我确实看过一些服务,用户上传文件后,服务器会处理这个文件,并将其上传到其他地方,比如 S3 或 Google 的 Blobstore。如果你在做类似的事情,我能看到优雅关闭的重要性,这样你就不会让用户上传的图片没有被推送到正确的位置......因为如果用户觉得"图片上传了,为什么还没到呢?"那会很让人挫败。
Mat Ryer: 是的。
Jon Calhoun: 好吧,我觉得我们可以进入"不受欢迎的意见"环节了。
Mat Ryer: 好的,又到了那个特别的时刻,大家聚在一起,拿着啤酒......我有侄子侄女,但我不知道自己在干什么。是时候进入"不受欢迎的意见"了!
Mat Ryer: 好的,谁想先来?Michael,你有没有什么不受欢迎的意见?
Michael Knyszek: 我肯定有很多......我想先声明,我对未来的其他选择完全持开放态度;这不是一个固定不变的观点。但我现在的想法是---
这可能有点深奥---
我不认为 Go 的垃圾回收器需要变成复制式或代际垃圾回收器。
如果你不熟悉这些术语,也不用担心......但我觉得在未来还有很多发展空间,我们可以让它变得非常非常---
它已经是第一流的并且非常好。已经有很多优秀的工作投入进去了。但我认为这里还有很大的提升空间,通常的想法是"当然,代际垃圾回收会让你的程序运行得更快。"我觉得这种想法在 Go 中并不适用,而且我认为有更好的发展路径。
所以这是我的不受欢迎意见......当然,也许我一两年后会改变主意,但这是我现在的想法。这也是我过去一年一直坚持的观点。
Mat Ryer: 这是个很棒的意见。我们会在 Twitter 上测试这些意见。我们会做个投票,看看它们到底是受欢迎还是不受欢迎的。这会是个有趣的结果。Jon,你怎么看?我猜你不用垃圾回收器,因为你的数据都不是垃圾,对吧?
Jon Calhoun: 不,我一直用垃圾回收器,Mat。
Mat Ryer: 哦,好吧。那你怎么看这个意见?
Jon Calhoun: 我大概是同意的。我不像其他人那样对垃圾回收器有太多感受。我不需要它改变,或者---
是的,它被改进了,这很好,但大多数时候垃圾回收并不是我的限制。所以我压根没怎么考虑过这个问题。
Mat Ryer: Michael,是不是在某些情况下,不同的策略会更适合,取决于具体情况?
Michael Knyszek: 绝对是的。垃圾回收的设计空间非常大。对于通用应用程序来说,很多语言和运行时似乎都在某个特定的地方达成了共识......有很多小众的回收器,但我觉得也许值得探索一下针对通用程序的设计空间。我认为 Go 有一些特殊的属性,使得它特别值得研究。
Mat Ryer: 是的。我认识有人关闭了垃圾回收器,因为他们的程序运行时间很短,不会需要太多内存......然后程序运行得飞快,因为完全没有垃圾回收。
Jon Calhoun: 你怎么关闭垃圾回收器?这不是一个标志吗,Michael?
Michael Knyszek: 是的,Go 的垃圾回收器著名的只有一个调节参数,叫做 GOGC。你可以通过环境变量设置它。它让你在 CPU 和内存之间做权衡。但你也可以直接关闭它。你可以通过运行时 API 传递一个负数,或者直接通过环境变量设置"GOGC=off"。这样它就不会回收任何东西。它只会不断分配内存,即使这些内存是垃圾,它也不会尝试回收。
Jon Calhoun: 所以这是确保我的程序最终关闭的完美方式。
Michael Knyszek: 哈哈,确实很搞笑。当你查看内存配置文件时,有时---
因为你可以查看内存配置文件,看到你的应用程序总共分配了多少内存......对于一个长期运行的服务器,你会看到几个 TB 或者 PB 的信息,取决于它运行了多久......然后你会觉得,"哇,如果没有垃圾回收器,我早就崩溃了。"哈哈。
Mat Ryer: 这还挺有趣的......这些数字其实从来---
你永远没有任何参考标准来理解这些数字,真的。就像新闻中会说,"在英国,人们喝了 500 亿杯茶。"哈哈。你会想,"这是很多吗?听起来像很多,但也许不是。"
Jon Calhoun: 就像,"我不知道那儿有多少人......我得先查一下。"
Mat Ryer: 就是这样。所以知道你一生中用了多少 RAM 其实并没有什么帮助......不过我倒想看看。
Michael Knyszek: 这对查找内存泄漏和其他问题确实有用。
Jon Calhoun: 如果你要做一个信息图表,这也很棒。
Mat Ryer: 但关于"不要担心,关闭垃圾回收"的想法呢?听起来像个很 hack 的东西,但......有人在云环境中为此辩护,因为你只需要这些短暂运行的小功能,它们启动,完成工作,然后消失......我不觉得我能用这个例子。作为一种策略,这是不是有点疯狂?
**Michael Knyszek:**我不认为这完全疯狂。我不确定 Go 社区是否广泛知道(可能知道),但 Plan 9 的 C 编译器有点出名的地方在于它只分配内存,从不释放内存 。它是用 C 编写的,它只调用 malloc
,但从不调用 free
,因为假设你编译完成后---
"无所谓,操作系统会清理掉的,没问题。"
所以对于短期程序来说,这确实有些道理......我也知道其他系统有类似的做法,因为这样可以获得性能提升。如果你知道自己不会运行很长时间,那么当然是可行的。
当然,我要说的是,大多数情况下这可能没有意义。这肯定是一种过早的优化,尤其是如果你有一个命令行工具,它的功能越来越多;总有一天它会崩溃,而你不知道为什么。但在某些情况下,它确实是有效的。
Mat Ryer: 非常酷。好了,现在是时候取消上下文了,我正在给你们发送中断信号......我不会杀掉你们,但我要对这一期节目调用 os.Exit
了。非常感谢 Michael 的参与。你以后还得回来再聊聊其他话题,如果可以的话。
Michael Knyszek: 好的。
Mat Ryer: 我们会在 Twitter 上测试你的不受欢迎意见。如果它不是真的不受欢迎,你得回来。这是有法律约束力的。
Michael Knyszek: 明白了。
Mat Ryer: Jon Calhoun,跟你在一起总是很愉快。
Jon Calhoun: 谢谢你,Mat。
Mat Ryer: 你要说点好听的话吗?
Jon Calhoun: 嗯,其实现在我才是唯一有 os.Exit
权限结束 Zoom 会议的人。你们只能为自己结束会议,不能为所有人结束。
Mat Ryer: 哦,你确实有这个权限。是的,你有退出的权力。我们只能结束调用运行时的 goroutine。可怜的家伙。
Jon Calhoun: Zoom 不太一样。它不允许每个人都有这个权限。
Mat Ryer: 好吧,公正点说。好的,非常感谢大家的参与。下次见!
Jon Calhoun: 我真的不知道终结者会怎么运作,因为现在有那么多物联网设备---
当 AWS 停止服务时,所有设备都会离线......如果你穿越回没有 AWS 的时间......这行不通啊。
Mat Ryer: 是的。终结者会在 AWS 上运行吗?
Jon Calhoun: 我是说,他肯定会用某种云服务。
Mat Ryer: 你会觉得他应该用抽象云服务,但......
Jon Calhoun: 如果和我们目前的时间线差不多,那所有东西都得放在云上。
Mat Ryer: 是的。也许终结者的肚子里装了很多运行 Kubernetes 集群的树莓派。很可能是。Robocop (译者注: 机械战警) 的腿里有把枪,他需要的时候随时能拿出来。
Jon Calhoun: 哦,我好久没看那些电影了。我记得以前我看错了,我父母会说"你不能看这个。这是 R 级电影。"但它还是在家里。不知道为什么,他们把这部电影拿出来测试环绕声系统。
Mat Ryer: 真的吗?
Jon Calhoun: 我想是的......因为所有的枪声------我不知道,只是......我记得当时我还很小,他们拿出这部电影来测试新买的环绕声系统,而我不允许看。
Mat Ryer: 好吧......我们是不是该开始做那个......记得我们在做的播客吗?
Jon Calhoun: 也许吧......
Michael Knyszek: 什么?!抱歉,什么?
Mat Ryer: 是的,我们应该开始。