聊聊Go语言的异常处理机制

时间:2024-10-16 12:17:29

背景

最近因为遇到了一个panic问题,加上之前零零散散看了些关于程序异常处理相关的东西,对这块有点兴趣,于是整理了一下golang对于异常处理的机制。

名词介绍

Painc

golang的内置方法,能够改变程序的控制流。 当函数调用了panic,函数会停止运行,但是defer函数会运行,程序会在当前panic的goroutine全部退栈以后crash。

Recover

recover也是golang的内置方法,用于恢复发生panic的goroutine的控制,recover只在defer函数中生效。如果当前goroutine将要发生panic的话, recover会捕获这个panic,并恢复正常执行。

Defer

聊到panic和recover,需要聊聊defer这个关键字,后面会看到defer在异常处理机制中起到的作用。go的defer是用来延迟执行函数的,延迟的发生是在调用函数的returen之后。

相关问题

在聊Golang异常机制之前,我们可以先问几个问题,带着这些问题看看后续怎么解答。

  1. recover的代码为什么只能在defer里面执行。
  2. panic自己主动可以触发,系统遇到例如除0的请求也会触发panic,那这两种panic在处理上有什么区别。
  3. recover和defer方法中,如果再发生了panic,Golang是如何处理的
  4. Golang中关于运行的代码中是如何感知系统异常

使用场景

package main

import "fmt"

func main() {
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r)
        }
    }()
    fmt.Println("Calling g.")
    g(0)
    fmt.Println("Returned normally from g.")
}

func g(i int) {
    if i > 3 {
        fmt.Println("Panicking!")
        panic(fmt.Sprintf("%v", i))
    }
    defer fmt.Println("Defer in g", i)
    fmt.Println("Printing in g", i)
    g(i + 1)
}

比如这个例子,看下这里关于panic、recover和defer的一个基本用法。f函数中通过defer函数,包了一层recover的执行。

执行以后的输出:

Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
Recovered in f 4
Returned normally from f.

前面输出比较正常就不多说,到Panicking!输出以后,我们看到在g函数内部的defer函数先执行,先defer的语句会后输出,所以是 defer in g 3 2 1 0的顺序。 执行完以后回到f函数,f函数的recover函数拿到当前panic传入的参数4,并且输出。完成defer函数的输出以后,输出函数回到main继续执行。

如果我们把上面f函数的defer function去除,那么panic的话就不会被recovered到,并且输出如下:

Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
panic: 4

panic PC=0x2a9cd8
[stack trace omitted]

源码实现

上面的例子,我们看到了panic、recover、 defer的一个基本使用。 当然这些并不能解决我们之前的几个疑问,我们有必要深入源码角度看看Golang是如何实现这套异常处理流程的,并且回答我们之前提出的一些问题。

Go语言层实现

目前手上的代码库是Go SDK 1.13.4,GO sdk的代码目录也比较清晰,在runtime目录下可以找到panic.go这个文件。

_panic结构和defer结构

在panic函数具体了解之前,有必要先看一下后面会出现的两种数据结构,一个是_panic结构,一个是_defer结构

_panic结构

_panic结构主体包括下面这些参数:

argp: 指向defer调用时参数的指针

arg: 调用panic时传入的参数

link: 指向更早调用的_panic结构的指针

recovered: 当前panic是否被recover恢复

Aborted:当前panic是否被强行终止

_defer结构

Siz: 参数和结果的内存大小

Started: 是否执行过

Heap: 是否在堆上分配

Sp: 栈指针

Pc: 调用方的程序计数器

Fn: defer关键字中传入的函数

_panic:defer中的panic

Link: defer的链表,函数执行流程中的defer,会通过link 这个属性进行串联

GoPanic实现

异常判断

panic的入口实现是在panic.go文件里面的 gopanic方法。

612行 获取当前执行的g,Golang的GMP就不扩展了,有兴趣的同学可以查查相关资料。

613-618 这里会判断当前执行是否是在系统栈上,如果是的话无法恢复

620-625 这里会判断当前是否是在分配内存过程中遇到的panic,如果是的话无法恢复

626-633 这里会判断当前在禁止抢占状态时发生 panic,如果是的话无法恢复

635-640 这里会判断是否是在g 锁在m上时发生的panic,如果是的话无法恢复

对于以上这些异常情况进行了直接终止的操作,没有找到这么考虑的具体原因,不过看起来应该是对于这些异常情况的恢复会遇到更大的问题。对于我们日常开发中可能也需要面对这么一个选择场景,如果一些任务的异常处理极端复杂的情况,是不是可以考虑一种简单但是强力的方式来简化这个过程。

throw的实现

我们可以关注一下这个throw函数,golang对外的异常处理机制没有这个关键词,不过内部源码实现了一下throw函数。

767行 : systemstack来进行调用,作用是把这段逻辑的执行放在系统的调用栈中,运行在系统栈上的代码是隐式不可抢占的、且不会被垃圾回收检测。

774 :开始执行fatalthrow,并且看775行,正常来说应该不会执行才对,775行的写法有不少地方都有这种写法,虽然跑不到,但还是加了这类兜底逻辑,可能是代码风格,也可能是为了保底。

fatalthrow 的这类处理是不可恢复的,这里的注释也写明了当前的函数实现,冻结当前系统,打印调用栈信息,然后终止整个进程。

821 - 823 获取当前调用者的pc(指向指令地址)、sp(指向当前栈顶部)、g的值。

826 这里注释也解释了,为了避免堆栈的动态增长等导致更糟糕的情况,这里的执行放在了系统堆栈中。

827-836 这里进行了一些处理工作,最终执行了crash或者exit的方式。 这里具体的startpanic_m和dopanic_m的代码就不深究了。

不过看过dopanic_m里面代码,判断是否crash还是exit 是通过GOTRACEBACK这个变量来判断的。可以从官方文档找到这么一段话,对于不同系统有不同的处理方式

GOTRACEBACK=crash is like “system” but crashes in an operating system-specific manner instead of exiting. For example, on Unix systems, the crash raises SIGABRT to trigger a core dump

Panic处理流程

642 - 645行代码 会生成一个_panic的结构,并且进行一些参数的赋值。包括panic传进来的值放在arg里面,这里会把当前panic加入当前 runtime的g里面的链表中。

647 - 647通过原子的一些操作,增加了runningPanicDefers变量,这个变量从名字其实也是比较清楚的,表示正在运行中的panic的数量

650 - 653 取当前goroutine的defer函数,如果没有的话,直接跳出for循环,这里可以看到如果recover没有放在defer函数里面,panic的时候根本就没有机会去执行。

在这里我们看到了问题一的一个解答,如果我们的recover方法不放在defer函数里面,我们不会有机会在发生panic的时候调用到recover的方法。

657 - 666 会判断当前defer函数的started参数,如果为true,说明之前已经执行过,但是现在又重新执行了一遍,可能是之前的defer函数又出现了panic。 于是我们把当前defer函数从执行列表里面取走,不再执行。

这里我们看到了问题三的一个解答,如果我们在defer函数中又发生了panic,这里不会发生死循环,而是不再执行这个defer函数。

671 - 671 我们把defer的started标识置为true,表明当前defer正在执行。

676 - 676 我们记录当前执行的panic

679 - 679 通过reflectcall的方式会执行当前的defer函数。这个函数的设计相当的通用,我们看下参数的设计。

主要包含 argtype *_type、fn 、arg unsafe.Pointer 、argsize uint32、retoffset uint32几个参数。

fn比较直接,就是函数的指针。 reflectcall 会拷贝arg指针指向的n个字节的参数作为入参。fn调用返回后,reflectcall 在返回之前将 n-retoffset 的字节复制回 arg+retoffset的空间中。我们可以回顾一下之前的_defer的设计,size的大小是包含了参数和返回的结果。

recover恢复

696行 这里判断了一下 p.recovered,这里有一个存疑,什么时候会设置这个recovered的参数,我们在后面看recover函数的时候会比较清楚

697 - 697 这里认为defer函数执行完成,减少runningPanicDefers字段。

702 - 704 这里注释也说明了,之前有一些终止的panic,还在当前链表里面,移除这些_panic结构。

705 - 707 引出了一个新的概念signal,再扩展,篇幅太长,留一个点。

711 - 711 通过macall的方式,调用 recovery方法进行恢复。这里recovery方法具体做了什么,我们后面再说。

720 - 723 这里如果上面没有成功recover的话,这里会统一panic进行处理

recover实现

gorecover

这里我们应该记得之前在Panic处理流程中,我们遗留的一个问题,这个recovered是什么时候设置的。这里就比较明确了,这里在panic后,我们如果调用了recover的函数,会在这里把当前panic结构体的参数recovered标记是否为true。这样在panic执行的时候,会执行恢复的逻辑。

另外我们还有一个问题就是开头留下的问题1, recover的代码为什么只能在defer里面执行。因为我们需要在gopanic的流程中,来判断recovered这个字段,如果不是在defer函数里面执行,我们没有机会把这个字段设置为true。

recovery实现

记得我们在Panic处理流程中有提到过,我们最后会调用mcall(recovery)这个函数,我们具体看下这里recovery的一个实现。

我们看下recovery方法的实现,这里的重点是通过gogo的方法,来跳转到需要执行代码的地方。 可以在前面gopanic方法中得知, sigcode0是当前defer函数的sp, sigcode1是当前defer函数的pc,跳转回去以后,就不会走fatalpanic的那个方法,从而避免了最终的退出。

Golang的不同panic

我们知道除了我们主动触发panic的方式,我们如果写一些导致异常的情况下,也会出现panic的情况,针对这类情况,Golang是如何进行处理的呢。

编译确认的panic

我们写了一个比较简单的除0的代码,我们debug看到这里会到panicdivide这个函数中,但是我们发现这里是从main.go里面直接调用过来的,中间似乎缺失了具体的调用,记得之前在golang启动那篇文章里面我们也发现的这个问题,Golang有很多逻辑是编译期间就确定的。

我们通过gotool工具 go tool compile -N -l -S main.go 把代码变成汇编代码看下这里的调用。

我们发现这里会在编译完成以后直接就调用了panicdivide的方法。

我们从源码的注释中也发现了,这里是在编译期间就会去调用这里的panicdivide,再调用到我们之前的panic的方法。 这里就清楚了Golang中对于部分的代码异常,比如数组越界,除0的一些异常会在代码编译期间就会进行panic的调用。

这里这块有很多预定义好的panic处理,包括内存导致的panic(panicmem)、浮点数导致的(panicfloat)等等,具体可以看下panic.go这里面的定义。

系统回调panic

当然我们知道,并不是所有的异常处理都是可以在编译期间解决的,上面的注释其实也写了另外一种情况,有一些异常的处理需要也小通过signal的方式由操作系统进行回调处理。 这里牵扯到了系统层和用户层的一些异常处理交互,具体的流程我们在下面的操作系统层实现讲完后再说。

当然这里我们简单回答了一下之前的问题2,这两种panic在处理上有什么区别,系统的除0会触发调用panicdivide,最终调用的是也是panic方法。其他的一些非定义好的panic,会通过signal的方式来回调。

操作系统层实现

通过上面Golang的源码阅读,我们了解到了Golang对于panic,recovery的一些处理,我们再简单看下操作系统这层如何处理异常的,也加深我们的一些理解。

概念

我们以linux为例,一般操作系统有几个概念,中断、异常、陷阱。

中断:通常所说的外部中断,一般是异步的行为,无法预测此类中断会在什么时候发生。来自于处理器之外的中断信号,包括时钟中断、键盘中断、它机中断和外部设备中断。外中断又分可屏蔽中断和不可屏蔽中断,各个中断具有不同的优先级,表示事件的紧急程度,在处理高一级中断时往往会部分或全部屏蔽低级中断。因最开始的中断仅针对外中断,因此外中断也直接称作中断。

陷阱:由软件产生的中断,由一些专设的指令,如X86中的 int n,在程序中有意的产生,所以是主动的。只要cpu执行了一条INT指令,就知道在开始执行下一条指令之前一定要先进入中断服务程序。这种主动的中断成为陷阱。

异常:是指来自处理器内部的中断信号,通常是由于在程序执行过程中,发现与当前指令关联的、不正常的或错误的事件。内中断不能被屏蔽,一旦出现必须立即予以响应并进行处理,只是处理程序运行过程中可以选择是否屏蔽其它中断或屏蔽哪些中断。

  1. 访管中断,由系统程序执行访管指令引起,可以看做机器指令的一种扩充;
  2. 硬件故障中断,如:电源失效、奇偶校验错误,总线超时;
  3. 程序性中断,如:非法操作、地址越界、页面故障、调试指令、除数为0和浮点溢出等。

中断和异常响应过程

中断向量由硬件或操作系统预先分配和设置,系统调用所对应的向量则在访管指令中给出。用户程序通过访管指令找到对应的系统调用执行。异常向量则在 CPU 的硬件结构中预先预定。 不管是中断、陷阱还是异常,CPU的响应过程基本一致。就是当执行完当前指令以后,或者在执行当前指令的中途,就根据中断源所提供的中断向量,在内存中找到相应的服务程序入口并调用该服务程序。外部中断的向量是由软件或硬件设置好了的,陷阱的向量是在指令中发出的,而各种异常的向量则是CPU的硬件结构中预先规定好的。

一般来说,中断/异常的响应需要 顺序 做以下四件事,

  1. 发现中断源。在中断未被屏蔽的前提下,硬件发现中断/异常事件,并由CPU响应中断/异常请求。当发现多个中断源时,将根据预定的中断优先级先后响应中断请求。
  2. 保护现场。暂停当前程序运行,硬件将中断点的现场信息(PSW)保存至核心栈,使得中断/异常处理程序在运行时不会破坏被中断程序中的有用信息,以便在处理完成后返回原程序继续运行。
  3. 转向中断/异常事件处理程序执行。此时处理器状态已由用户态转为内核态,中断/异常处理程序开始工作。
  4. 恢复现场。当中断处理结束后,恢复原运行程序的PSW,重新返回中断点以便执行后续指令。当异常处理结束后,返回点会因异常类型而异,大部分应用程序指令执行出错时,异常处理会结束进程,不可能回到原程序;如果执行访管指令,则异常处理完成后返回这条访管的下一条指令;对于页面故障,异常处理结束后会返回发生异常的那条指令重新执行。

Golang信号处理

通过前面的一些介绍,我们了解在操作系统层面上,会产生中断、异常,并且会通过信号的方式来通知程序,最终在golang里面我们需要去注册一个系统的回调来处理这些异常。

By default, a synchronous signal is converted into a run-time panic. A SIGHUP, SIGINT, or SIGTERM signal causes the program to exit. A SIGQUIT, SIGILL, SIGTRAP, SIGABRT, SIGSTKFLT, SIGEMT, or SIGSYS signal causes the program to exit with a stack dump. A SIGTSTP, SIGTTIN, or SIGTTOU signal gets the system default behavior (these signals are used by the shell for job control). The SIGPROF signal is handled directly by the Go runtime to implement runtime.CPUProfile. Other signals will be caught but no action will be taken.
If the Go program is started with either SIGHUP or SIGINT ignored (signal handler set to SIG_IGN), they will remain ignored.

我们找到这么一段话,描述了一下默认的信号处理在go程序中的处理,可以当个参考。我们可以看一下对于Golang来说,这里是如何传递这么一个异常信息的。

我们可以看到这里有一个sighandler的函数,这里是golang注册的对于系统信号量的一个处理。信号量其实比较多,我们暂且不管具体的一些实现,这里有一个preparePanic,我们可以关注一下。

这里的实现也是比较复杂,我们只是为了梳理一下整个流程。

68行,有一个shouldPushSigpanic,这里最后一行,我们会赛一个函数的地址,我们继续往下看。

这里针对信号量的类型做了一些判断。

_SIGBUS类型:意味着指针所对应的地址是有效地址,但总线不能正常使用该指针。通常是未对齐的数据访问所致。 最终的处理是会去调用panicmem的函数,我们在Golang的panic例子那个地方看到,其实golang预定义了很多的异常类型处理,这里会调用内存的异常。

_SIGSEGV类型:意味着指针所对应的地址是无效地址,没有物理内存对应该地址,无效的内存引用,后面也会去调用内存的异常

_SIGFPE类型: 算术的运行错误,所以这里最终会去调用panic关于数字的几个预定义处理

当前面几个信号无法覆盖的情况后,最终兜底走了panic的方法。 至此我们回答了问题4(Golang如何感知系统异常)。

当然这里我这边遗留了一个问题,本来想构造一个异常的case,不在编辑期间确认的panic,这样可以触发调试一下异常的整个过程,不过想了几个case都不太行,这里有点存疑,暂时没有时间来调,有兴趣的小伙伴可以尝试一下。

总结

关于Golang panic的机制,我们从使用层面入手,一直到了Golang源码的处理逻辑,后续又稍微了解了一下从系统层到golang的一个配合。基本上回答了开始提的4个问题,对整个链路应该有了一个大体的认识。

当然从golang这个语言出发,了解Golang的异常处理逻辑,希望在后续对于其他语言层面的学习上,也可以有这么一条清晰的思路帮助去理解。 

相关文章