背景
在Go程序运行的过程中,很有可能造成程序panic,并且这个panic的信息是不会被记录在日志文件中的。这种情况该怎么办?
一种方法就是使用recover,通过recover来捕获panic信息,然后再写入日志文件。但是这种方法有两个缺陷:
- recover只能恢复同一个goroutine中的panic,不同goroutine的不能被捕获,这就要求每次启动goroutine的时候要写一遍recover代码,这太麻烦了。
- 遇到并发读写map这种fatal error的时候,panic是无法恢复的。
另外一种方法就比较简单了,因为panic信息实际上是被输出到标准错误了,默认的标准错误设备是终端。我们可以将标准错误重定向至文件,这样就可以捕获到程序的panic信息了。
而重定向的话也大概有两种方法:
- 启动的时候重定向
nohup ./program start 1>stdout.log 2>stderr.log &
命令行可用以上方式来重定向。如果使用supervisor等来管理程序的话,也可以通过配置stdout_logfile和stderr_logfile来重定向,原理都是一致的。
- 程序内通过Dup2系统调用来重定向
我们用的是第二种方法。
代码也很简单,如下:
// // coredump.go func init() { fd, err := os.OpenFile("coredump.log", os.O_WRONLY|os.O_APPEND|os.O_CREATE, os.ModePerm) if err != nil { fmt.Println("create file descriptor error:", err) return } SetStdHandle(fd, fd) } func SetStdHandle(stdout, stderr *os.File) error { if err := syscall.Dup2(int(stdout.Fd()), int(os.Stdout.Fd())); err != nil { return err } *os.Stdout = *stdout if err := syscall.Dup2(int(stderr.Fd()), int(os.Stderr.Fd())); err != nil { return err } *os.Stderr = *stderr return nil }
问题
上面的代码在一开始测试的时候是没问题的,但是后面发现有时候还是无法捕获到panic信息。甚至更离奇的是,不使用init方法,改为显式调用后正常,但是重构代码、调整了调用位置后又不行了!
嗯????
排查
为什么coredump.log文件是空的?
首先,程序启动后,coredump.log文件确实是创建了的。简单排查后发现,init函数也是确实执行了并且没有返回错误。但是程序panic后文件里也确实是空的。
首先使用strace工具,查看系统调用情况,到底有没有往coredump.log文件中写数据。
1strace -ff -o trace -p 30526
可以看到,确实是调用了系统调用write,并且写到了2(标准错误)中,但是返回结果却是Bad file descriptor。通常出现这个错误的原因是文件的权限设置有问题,比如以只读方式打开。但是以上代码是没问题的啊,也有写权限。
没办法,看一下进程的文件描述符信息吧。接着查看了/proc/$pid/fd目录。
1ll /proc/30526/fd
不看不知道,一看吓一跳。
为什么1和2变成了socket?正常来说应该是/dev/pts/0,重定向后应该是重定向的文件才对,为什么会变成socket?
为什么1和2变成了socket句柄?
使用lsof工具查看该socket信息
1lsof -i |grep 209374
发现该socket居然是我http服务开启监听的socket。这就很奇怪了。
完整代码如下(为了复现写的示例,非真实代码):
// // main.go func main() { ioutil.WriteFile("pidfile", []byte(strconv.Itoa(os.Getpid())), os.ModePerm) go func() { // 模拟程序panic time.Sleep(time.Second * 5) panic("adalkdjlfjlsfjalsfjls") }() // 下面的代码是为了使系统尽快进行垃圾回收操作 // //initSize := 100 //for i := 0; i < 20; i++ { // data = make([]byte, initSize) // //time.Sleep(time.Second) // initSize *= 2 //} go func() { if err := http.ListenAndServe(":10086", nil); err != nil { panic(err) } }() go func() { if err := http.ListenAndServe(":10010", nil); err != nil { panic(err) } }() ch := make(chan os.Signal) signal.Notify(ch, syscall.SIGINT) <-ch }
问题到这里就陷入了死胡同,以上代码应该不会有任何问题,但为什么会出现上述情形呢?
柳暗花明
在经过漫长的调整代码位置、加调试日志、一次次不同情形下复现后,终于发现了问题可能出在变量被垃圾回收这个问题上了。
再后头看下最开始的重定向的代码:
package main import ( "os" "syscall" ) func init() { fd, err := os.OpenFile("coredump.log", os.O_WRONLY|os.O_APPEND|os.O_CREATE, os.ModePerm) if err != nil { fmt.Println("create file descriptor error:", err) return } SetStdHandle(fd, fd) } func SetStdHandle(stdout, stderr *os.File) error { if err := syscall.Dup2(int(stdout.Fd()), int(os.Stdout.Fd())); err != nil { return err } *os.Stdout = *stdout if err := syscall.Dup2(int(stderr.Fd()), int(os.Stderr.Fd())); err != nil { return err } *os.Stderr = *stderr return nil }
其中第16行和第21行当初是为了其他地方在直接使用os.Stdout和os.Stderr的时候也会直接写入文件中,所以加的(事实证明是我画蛇添足了)。
但是问题就出在这里了。这两个变量指向了新的地址,而原来指向的内存由于没被引用了(不可达),所以会被垃圾回收掉。
关键到了,看一下这两个变量的定义:
var ( Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin") Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout") Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr") )
NewFile函数定义:
func NewFile(fd uintptr, name string) *File { kind := kindNewFile if nb, err := unix.IsNonblock(int(fd)); err == nil && nb { kind = kindNonBlock } return newFile(fd, name, kind) }
newFile:
func newFile(fd uintptr, name string, kind newFileKind) *File { f := &File{&file{ pfd: poll.FD{ Sysfd: fdi, IsStream: true, ZeroReadIsEOF: true, }, name: name, stdoutOrErr: fdi == 1 || fdi == 2, }} // 此处省略n行 runtime.SetFinalizer(f.file, (*file).close) return f }
也就是说,当这块内存被回收时,会调用close方法,当句柄没有被引用时,会被关闭。
为了验证这个想法,改写了标准库的代码,在close方法中增加了一行日志:
//file.go var ( Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin") Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout") Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr") debuger *File ) func init() { debuger, _ = OpenFile("debuger.log", O_WRONLY|O_CREAT|O_APPEND, os.ModPerm) } // file_unix.go func (file *file) close() error { debuger.WriteString("file descriptor closed:" + strconv.Itoa(Getpid()) + "\n") if file == nil { return syscall.EINVAL } if file.dirinfo != nil { file.dirinfo.close() } var err error if e := file.pfd.Close(); e != nil { if e == poll.ErrFileClosing { e = ErrClosed } err = &PathError{"close", file.name, e} } // no need for a finalizer anymore runtime.SetFinalizer(file, nil) return err }
重新运行程序,然后查看debuger.log文件:
果然,标准输出和标准错误都被关闭了!
后来,又查了linux下文件描述符的分配方法(fs/file.c: alloc_fd):每次优先分配fd值最小的空闲fd。
至此,问题已经明了了:
- 乱给变量赋值,造成垃圾回收,调用Finalizer,释放了文件描述符。
- 后续有申请文件描述符操作(打开文件、监听端口、接受连接等),重新分配了1和2。
- 后续调用write系统调用的时候,不可写,无法写入。
总结
- strace很好很强大
/proc/$pid/
下面可以查看进程的很多信息- 没事别瞎动标准库的东西