Go 学习笔记(19)— 函数(05)[如何触发 panic、触发 panic 延迟执行、panic 和 recover 的关系]

1. 异常设计思想

Go 语言的错误处理思想及设计包含以下特征:

  • 一个可能造成错误的函数,需要返回值中返回一个错误接口( error ),如果调用是成功的,错误接口将返回 nil ,否则返回错误。

  • 在函数调用后需要检查错误,如果发生错误,则进行必要的错误处理。

Go 里没有用经典的 try/except 捕获异常。Go 提供两种错误处理方式

  • 函数返回 error 类型对象判断错误
  • panic 异常

一般而言,当宕机发生时,程序会中断运行,并立即执行在该 goroutine (可以先理解成线程)中被延迟的函数( defer 机制),随后,程序崩溃并输出日志信息,日志信息包括 panic value 和函数调用的堆栈跟踪信息, panic value 通常是某种错误信息。

虽然 Go 语言的 panic 机制类似于其他语言的异常,但 panic 的适用场景有一些不同,由于 panic 会引起程序的崩溃,因此 panic 一般用于严重错误,如程序内部的逻辑不一致。

任何崩溃都表明了我们的代码中可能存在漏洞,所以对于大部分漏洞,我们应该使用 Go 语言提供的错误机制,而不是 panic

一般情况下在 Go 里只使用 error 类型判断错误, Go 官方希望开发者能够很清楚的掌控所有的异常,在每一个可能出现异常的地方都返回或判断 error 是否存在。

panic可以手工调用,但是 Go 官方建议尽量不要使用panic,每一个异常都应该用 error 对象捕获。

如果异常出现了,但没有被捕获并恢复,Go 程序的执行就会被终止,即便出现异常的位置不在主 Goroutine 中也会这样。

2. 如何触发 panic


使用 panic 抛出异常后,函数执行将从调用 panic 的地方停止,如果函数内有 defer 调用,则执行 defer 后边的函数调用,如果 defer 调用的函数中没有捕获异常信息,这个异常会沿着函数调用栈往上传递,直到 main 函数仍然没有捕获异常,将会导致程序异常退出。示例代码:

package main
func demo() {
    panic("抛出异常")
}
func main() {
    demo()
}
package main

import (
	"fmt"
)

func main() {
	panic("crash")
	fmt.Println("end")

}

输出结果:

panic: crash

goroutine 1 [running]:
main.main()
	/home/wohu/gocode/src/hello.go:8 +0x39
exit status 2

以上代码中只用了一个内建的函数 panic() 就可以造成崩溃, panic() 的声明如下:

func panic(v interface{})    //panic() 的参数可以是任意类型的。

请谨慎使用panic 函数抛出异常,如果没有捕获异常,将会导致程序异常退出

3. 触发 panic 延迟执行

Go 中,panic 主要有两类来源,一类是来自 Go 运行时,另一类则是 Go 开发人员通过 panic 函数主动触发的。

panic() 触发的宕机发生时, panic() 后面的代码将不会被运行,但是在 panic() 函数前面已经运行过的 defer 语句依然会在宕机发生时发生作用,参考下面代码:

package main

import (
	"fmt"
)

func main() {
	defer fmt.Println("defef run")

	panic("crash")
	fmt.Println("end")

}

输出结果:

defef run
panic: crash

goroutine 1 [running]:
main.main()
	/home/wohu/gocode/src/hello.go:10 +0x95
exit status 2

从结果中可以看到,触发 panic 前, defer 语句会被优先执行。

panic() 是一个内建函数,可以中断原有的控制流程,进入一个令人 panic 的流程中。当函数 main 调用 panic,函数的执行被中断,但是 main 中的延迟函数(必须是在 panic 之前的已加载的 defer )会正常执行,然后 main 返回到调用它的地方。在调用的地方,main 的行为就像调用了 panic。这一过程继续向上,直到发生 panicgoroutine 中所有调用的函数返回,此时程序退出。

异常可以直接调用 panic 产生。也可以由运行时错误产生,例如访问越界的数组。

4. recover 使用

recover 是一个 Go 语言的内建函数,可以让进入宕机流程中的 goroutine 恢复过来。

recover 仅在延迟函数 defer 中有效

  • 在正常的执行过程中,调用 recover 会返回 nil 并且没有其他任何效果;

  • 如果当前的 goroutine 陷入 panic ,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行;


注意:

在其他语言里, panic 往往以异常的形式存在,底层抛出异常,上层逻辑通过 try/catch 机制捕获异常,没有被捕获的严重异常会导致宕机,捕获的异常可以被忽略,让代码继续运行。

Go 语言没有异常系统,其使用 panic 触发宕机类似于其他语言的抛出异常, recover 的宕机恢复机制就对应其它语言中的 try/catch 机制。

package main

func test() {
	defer func() {
		if err := recover(); err != nil { // recover 捕获错误。
			println(err.(string)) // 将 interface{} 转型为具体类型。
		}
	}()
	panic("panic error!")	// panic 抛出错误
}
func main() {
	test()
}

由于 panicrecover 参数类型为 interface{} ,因此可抛出任何类型对象。

func panic(v interface{})
func recover() interface{}

延迟调用中引发的错误,可被后续延迟调用捕获,但仅最后一个错误可被捕获。

package main

import "fmt"

func test() {
	defer func() {
		fmt.Println(recover())
	}()
	defer func() {
		panic("defer panic")
	}()
	panic("test panic")
}
func main() {
	test()
}

输出:

defer panic

捕获函数 recover 只有在延迟调用内直接调用才会终止错误,否则总是返回 nil 。任何未捕获的错误都会沿调用堆栈向外传递。

当没有异常信息抛出时, recover 函数返回值是 nilrecover 只有在 defer 调用的函数内部时,才能阻止 panic 抛出的异常信息继续向上传递,如果不是在 defer 调用的函数内部,将会失效。

package main

import "fmt"

func test() {
	defer recover()              // 无效!
	defer fmt.Println(recover()) // 无效!
	defer func() {
		func() {
			println("defer inner")
			recover() // 无效!
		}()
	}()
	panic("test panic")
}
func main() {
	test()
}

输出

defer inner
<nil>
panic: test panic

使用延迟匿名函数或下面这样都是有效的。

package main

import "fmt"

func except() {
	fmt.Println(recover())
}
func test() {
	defer except()
	panic("test panic")
}
func main() {
	test()
}

如果需要保护代码片段,可将代码块重构成匿名函数,如此可确保后续代码被执行。

package main

import "fmt"

func test(x, y int) {
	var z int
	func() {
		defer func() {
			err := recover()
			fmt.Println(err)
			if err != nil {
				z = 0
			}
		}()
		z = x / y
		return
	}()
	println("x / y =", z)
}
func main() {
	test(10, 0)
}

输出结果:

runtime error: integer divide by zero
x / y = 0

recover 的正确用法:

package main

import (
	"errors"
	"fmt"
)

func main() {
	fmt.Println("Enter function main.")

	defer func() {
		fmt.Println("Enter defer function.")

		// recover函数的正确用法。
		if p := recover(); p != nil {
			fmt.Printf("panic: %s\n", p)
		}

		fmt.Println("Exit defer function.")
	}()

	// recover函数的错误用法。
	fmt.Printf("no panic: %v\n", recover())

	// 引发panic。
	panic(errors.New("something wrong"))

	// recover函数的错误用法。
	p := recover()
	fmt.Printf("panic: %s\n", p)

	fmt.Println("Exit function main.")
}

5. panicrecover 的关系

如何区别使用 panicerror 两种方式?

惯例是:导致关键流程出现不可修复性错误的使用 panic ,其他使用 error

panicrecover 的组合有如下特性:

  • panicrecover ,程序宕机。
  • panic 也有 recover ,程序不会宕机,执行完对应的 defer 后,从宕机点退出当前函数后继续执行。

注意:

虽然 panic/recover 能模拟其他语言的异常机制,但并不建议在编写普通函数时也经常性使用这种特性。

panic 触发的 defer 函数内,可以继续调用 panic ,进一步将错误外抛,直到程序整体崩溃。

如果想在捕获错误时设置当前函数的返回值,可以对返回值使用命名返回值方式直接进行设置

6. 实际项目使用

Go 并发编程中,每一个 goroutine 出现 panic,都会让整个进程退出,如果能够捕获异常,那么出现 panic 的时候,整个服务不会挂掉,只是当前导致 panic 的某个 goroutine 会出现异常,通过捕获异常可以继续执行任务,建议还是在某些有必要的条件和入口处进行异常捕获。

常见抛出异常的情况:数组越界、空指针空对象,类型断言失败等。

package main

import (
    "fmt"
    "time"
)

// 抛出异常,模拟实际 Panic 的场景
func throwException() {
    panic("An exception is thrown! Start Panic")
}

// Go 的 defer + recover 来捕获异常
func catchExceptions() {
    defer func() {
        if e := recover(); e != nil {
            fmt.Printf("Panicing %s\n", e)
        }
    }()
    go func() {
        // 做具体的实现任务
        fmt.Print("do something \n")
    }()

    throwException()
    fmt.Printf("Catched an exceptions\n")
}

func main() {
    fmt.Printf("==== start main =====\n")

    // 执行一次
    catchExceptions()

    num := 1
    for {
        num++
        fmt.Printf("\nstart circle num:%v\n", num)

        // 循环执行,如果实际项目中,这个函数是主任务的话,需要一个 for 来循环执行,避免捕获一次 Panic 之后就不再继续执行
        catchExceptions()
        time.Sleep(3 * time.Second)
        if num == 5 {
            fmt.Printf("==== end main =====\r\n")
            return
        }
    }
}

一般的建议是在请求来源入口处的函数或者关键路径上实现这么一段代码进行捕获,这样,只要通过这个入口出现的异常都能被捕获,并打印详细日志。同时,为了保证 goroutine 能够继续执行任务,因此还要考虑当出现 panic 被捕获之后,是否有主动循环或者被动触发来重新执行任务。

7. 如何应对 panic

7.1 评估程序对 panic 的忍受度

Go 标准库提供的 http server 采用的是,每个客户端连接都使用一个单独的 Goroutine 进行处理的并发处理模型。也就是说,客户端一旦与 http server 连接成功,http server 就会为这个连接新创建一个 Goroutine,并在这 Goroutine 中执行对应连接(conn)的 serve 方法,来处理这条连接上的客户端请求。

无论在哪个 Goroutine 中发生未被恢复的 panic,整个程序都将崩溃退出。所以,为了保证处理某一个客户端连接的 Goroutine 出现 panic 时,不影响到 http serverGoroutine 的运行,Go 标准库在 serve 方法中加入了对 panic 的捕捉与恢复,下面是 serve 方法的部分代码片段:

// $GOROOT/src/net/http/server.go
// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
    c.remoteAddr = c.rwc.RemoteAddr().String()
    ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
    defer func() {
        if err := recover(); err != nil && err != ErrAbortHandler {
            const size = 64 << 10
            buf := make([]byte, size)
            buf = buf[:runtime.Stack(buf, false)]
            c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)
        }
        if !c.hijacked() {
            c.close()
            c.setState(c.rwc, StateClosed, runHooks)
        }
    }()
    ... ...
}

你可以看到,serve 方法在一开始处就设置了 defer 函数,并在该函数中捕捉并恢复了可能出现的 panic。这样,即便处理某个客户端连接的 Goroutine 出现 panic,处理其他连接 Goroutine 以及 http server 自身都不会受到影响。

这种局部不要影响整体的异常处理策略,在很多并发程序中都有应用。并且,捕捉和恢复 panic 的位置通常都在子 Goroutine 的起始处,这样设置可以捕捉到后面代码中可能出现的所有 panic,就像 serve 方法中那样。

7.2 提示潜在 bug

json 包的 encode.go 中也有使用 panic 做潜在 bug 提示的例子:

// $GOROOT/src/encoding/json/encode.go
func (w *reflectWithString) resolve() error {
    ... ...
    switch w.k.Kind() {
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        w.ks = strconv.FormatInt(w.k.Int(), 10)
        return nil
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
        w.ks = strconv.FormatUint(w.k.Uint(), 10)
        return nil
    }
    panic("unexpected map key type")
}

这段代码中,resolve 方法的最后一行代码就相当于一个“代码逻辑不会走到这里”的断言。一旦触发“断言”,这很可能就是一个潜在 bug。

我们也看到,去掉这行代码并不会对 resolve 方法的逻辑造成任何影响,但真正出现问题时,开发人员就缺少了“断言”潜在 bug 提醒的辅助支持了。在 Go 标准库中,大多数 panic 的使用都是充当类似断言的作用的。

你可能感兴趣的:(Go,panic,recover)