大家好,我是煎鱼。
今天继续给大家分享 Go1.23 的新特性,这一轮里还是有不小有意思的惊喜的。其中不得不评本文中的这个新变化。必须得来一篇独立话题来提一下这个事!
过去学习写 Go 时,初学者入门的教程教一定会提到在使用 panic 时,强烈建议要使用 recover。否则在 goroutine 的场景下很容易出问题,也会导致记不来日志。
新版本后,终于有兜底 Go 程序崩溃的日志记录方法了!过于感人!
快速入门
panic+recover 例子
较为标准的 panic+recover 代码如下:
func mayPanic() {
panic("脑子进煎鱼了!")
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered. Error:\n", r)
}
}()
mayPanic()
fmt.Println("煎鱼被烧着了")
}
输出结果:
Recovered. Error:
脑子进煎鱼了!
常见的错误场景
想法很美好,有两个常见的错误的场景。很折磨人心态。
1、会有经常会有出现起了 goroutine,业务程序出现了预料之外的场景,导致出现了 panic,也没有 recover。此时如果外部没有统一的 recover,就会导致业务受阻。
2、更夸张的是 Go 内部源码偶尔会有触发使用 throw 函数,导致抛出致命错误的场景,最经典的是 map 并发读写导致的致命错误。
如下代码例子:
func main() {
var wg sync.WaitGroup
m := make(map[int]int)
// 写操作
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
m[i] = i
}
}()
// 读操作
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
_ = m[i]
}
}()
wg.Wait()
fmt.Println("煎鱼收工了!")
}
在运行程序结果时,会看到输出如下结果:
煎鱼收工了!
只要你多运行几次,有概率触发以下问题:
fatal error: concurrent map read and map write
goroutine 35 [running]:
main.main.func2()
/Users/eddycjy/app/go/example/demo1/main.go:26 +0x6c
created by main.main in goroutine 1
/Users/eddycjy/app/go/example/demo1/main.go:23 +0xe8
goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x140000021c0?)
/opt/homebrew/Cellar/go/1.22.6/libexec/src/runtime/sema.go:62 +0x2c
...
这类致命错误是 recover 所无法捕获的。也因此在产线环境偶尔会出现这类纰漏导致的容器重启等问题。
问题背景
在 Go 编程中,现阶段很难很好地统一捕获 Go 程序的未知崩溃输出。崩溃会打印到 stderr,但是 Go 程序通常会将 stdout 和 stderr 用于其他目的。
虽然将其输出到 stderr 并没有错,但它会将两个输出混合在一起,使以后的分离更加困难。排查问题也需要查看所有大量的调试信息。
因此捕获未知的崩溃(无论是 panic 还是 thorew)对于事后调试和发送报告很有价值。
注:尤其是在 k8s 中很多是建议输出到 stdout、stderr 中的,这样在发生未知崩溃时,排查起来会更麻烦。
Go1.23 debug.SetCrashOutput
Go1.23 新版本中,本次在 runtime/debug 库中新增了 debug.SetCrashOutput 方法。
图片
函数签名如下:
func SetCrashOutput(f *os.File, opts CrashOptions) error
代码例子:
import (
"io"
"log"
"os"
"os/exec"
"runtime/debug"
)
func main() {
monitor()
println("煎鱼下午好!!!")
// 没有被 recover 的未知错误
panic("oops")
}
func monitor() {
const monitorVar = "RUNTIME_DEBUG_MONITOR"
if os.Getenv(monitorVar) != "" {
// 实际演示 debug.SetCrashOutput 设置后的逻辑
log.SetFlags(0)
log.SetPrefix("monitor: ")
crash, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("failed to read from input pipe: %v", err)
}
if len(crash) == 0 {
os.Exit(0)
}
f, err := os.CreateTemp("", "*.crash")
if err != nil {
log.Fatal(err)
}
if _, err := f.Write(crash); err != nil {
log.Fatal(err)
}
if err := f.Close(); err != nil {
log.Fatal(err)
}
log.Fatalf("saved crash report at %s", f.Name())
}
// 模拟应用程序进程,设置 debug.SetCrashOutput 值
exe, err := os.Executable()
if err != nil {
log.Fatal(err)
}
cmd := exec.Command(exe, "-test.run=ExampleSetCrashOutput_monitor")
cmd.Env = append(os.Environ(), monitorVar+"=1")
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stderr
pipe, err := cmd.StdinPipe()
if err != nil {
log.Fatalf("StdinPipe: %v", err)
}
debug.SetCrashOutput(pipe.(*os.File), debug.CrashOptions{})
if err := cmd.Start(); err != nil {
log.Fatalf("can't start monitor: %v", err)
}
}
输出结果:
$ go run main.go
煎鱼下午好!!!
panic: oops
goroutine 1 [running]:
main.main()
/Users/eddycjy/app/go/example/demo1/main.go:15 +0x48
exit status 2
monitor: saved crash report at /var/folders/y8/whksnvd17qn8bgs17yh_y59m0000gn/T/92172971.crash
崩溃后的文件记录:
$ cat /var/folders/y8/whksnvd17qn8bgs17yh_y59m0000gn/T/92172971.crash
panic: oops
goroutine 1 [running]:
main.main()
/Users/eddycjy/app/go/example/demo1/main.go:15 +0x48
非常顺利的记录到未 recover 的 panic 导致的 crash 了。
总结
本次 Go1.23 在 runtime/debug 中新增了 debug.SetCrashOutput 方法来允许设置未被捕获的错误、异常的日志写入。可用于为所有 Go 进程意外崩溃构建自动报告机制!
这个变动虽然不大,但是对于我们日常写 Go 业务工程的同学来讲,是个很不错的升级!终于打开了一个新的后门!