Go语言中正确使用Panic与Recover的技术解析

本文深入探讨Go语言中defer-panic-recover模式的正确使用方法,介绍Trail of Bits开发的OnEdge工具如何通过竞态检测发现全局状态变更错误,包含完整代码示例和竞态检测器原理分析。

Go语言中正确使用Panic的方法

Go语言中一个常见的惯用法是:(1) 触发panic,(2) 在defer函数中recover这个panic,(3) 继续执行。一般来说,只要在调用defer的函数入口点和panic发生点之间没有全局状态变更,这种做法是可以接受的。这类全局状态变更可能对程序行为产生持久影响,而且很容易被忽视,让人误以为所有操作都能通过recover调用被撤销。

在Trail of Bits,我们开发了一个名为OnEdge的工具来帮助检测这种"defer、panic、recover"模式的不正确使用。OnEdge将查找这类全局状态变更的问题转化为竞态检测问题,然后可以利用Go优秀的竞态检测器来发现这些错误。更重要的是,如后文所述,你可以将OnEdge集成到自己的程序中以发现这类错误。

OnEdge是我们用来验证软件的工具之一。例如,我们审计了大量用Go编写的区块链软件,这些软件通常在收到无效交易时触发panic,然后从panic中恢复并继续处理交易。但必须小心确保无效交易被完全回滚,因为部分应用的交易可能导致区块链分叉。

“Defer、Panic和Recover"模式

关于这种技术的权威参考是Andrew Gerrand的博客文章。我们不会在这里给出如此详细的说明,但会通过一个示例进行讲解。

图1展示了一个使用"defer、panic和recover"模式的简单程序。该程序随机生成存款和取款操作。如果资金不足以覆盖取款,程序会触发panic。panic在一个defer函数中被捕获并报告错误,然后程序继续执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
    "fmt"
    "log"
    "math/rand"
)

var balance = 100

func main() {
    r := rand.New(rand.NewSource(0))
    for i := 0; i < 5; i++ {
        if r.Intn(2) == 0 {
            credit := r.Intn(50)
            fmt.Printf("Depositing %d...\n", credit)
            deposit(credit)
        } else {
            debit := r.Intn(100)
            fmt.Printf("Withdrawing %d...\n", debit)
            withdraw(debit)
        }
        fmt.Printf("New balance: %d\n", balance)
    }
}

func deposit(credit int) {
    balance += credit
}

func withdraw(debit int) {
    defer func() {
        if r := recover(); r != nil {
            log.Println(r)
        }
    }()
    balance -= debit
    if balance < 0 {
        panic("Insufficient funds")
    }
}

图1:不正确使用"defer、panic和recover"模式的程序

运行图1中的程序会产生图2所示的输出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Depositing 14...
New balance: 114
Withdrawing 6...
New balance: 108
Withdrawing 96...
New balance: 12
Withdrawing 77...
<time> Insufficient funds
New balance: -65
Depositing 28...
New balance: -37

图2:图1程序的输出

注意这里存在一个bug:尽管没有足够的资金来覆盖某次取款,但取款操作仍然被应用了。这个bug是一类更普遍错误的特例:程序在panic之前进行了全局状态变更。

更好的方法是在可能发生panic的最后一点之后才进行全局状态变更。重写withdraw函数使用这种方法后,结果类似于图3。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func withdraw(debit int) {
    defer func() {
        if r := recover(); r != nil {
            log.Println(r)
        }
    }()
    if balance-debit < 0 {
        panic("Insufficient funds")
    }
    balance -= debit
}

图3:图1中withdraw函数的更好实现

在简要介绍Go的竞态检测器后,我们将描述一种检测不当全局状态变更(如图1中的情况)的方法。

Go竞态检测器

Go竞态检测器是编译器插桩和运行时库的组合。编译器对(1)无法证明无竞态的内存访问和(2)已知同步机制的使用(例如通道的发送和接收)进行插桩。基于Google的ThreadSanitizer的运行时库提供支持插桩的代码。如果两个插桩的内存访问冲突且无法证明已同步,运行时库会产生警告消息。

Go竞态检测器可能产生"假阴性”,即可能漏检某些竞态。但是,只要使用了运行时库已知的同步机制,它产生的每个警告消息都是"真阳性",即实际的竞态。

可以通过传递"-race"标志来启用Go竞态检测器,例如"go run"或"go build"。"-race"标志告诉Go编译器按照上述方式插桩代码,并链接所需的运行时库。

使用Go竞态检测器的成本不低。估计会增加5-10倍的内存使用量,并增加2-20倍的执行时间。因此,竞态检测器通常不在"发布"代码中启用,仅在开发期间使用。尽管如此,检测器报告的强保证性使得这种开销是值得的。

检测全局状态变更

检测全局状态变更的问题与检测数据竞态的问题有明显的相似性:两者都涉及内存访问。与数据竞态一样,检测全局状态变更似乎适合动态分析。因此,人们可能会问:能否利用Go竞态检测器来查找全局状态变更?或者更准确地说,能否让全局状态变更看起来像数据竞态?

我们通过两次执行可能修改全局状态的代码来解决这个问题:一次在程序的主线程中,一次在第二个"影子"线程中。如果代码确实修改了全局状态,那么将会有两个冲突的内存访问,每个线程一个。只要两个线程看起来不同步(这并不难确保),那么这两个内存访问可能会被报告为数据竞态。

OnEdge工具

OnEdge使用上述方法检测不当的全局状态变更。OnEdge是一个小型库,导出了几个函数,特别是WrapFunc和WrapRecover。要将OnEdge集成到项目中,需要做三件事:

  1. 在WrapFunc(func() { … })中包装那些defer了recover调用的函数体
  2. 在这些包装的函数体内,用WrapRecover( … )包装recover调用
  3. 启用Go的竞态检测器运行程序

如果在被WrapFunc包装的函数体中发生panic,并且该panic被WrapRecover包装的recover捕获,那么函数体会在影子线程中重新执行。如果影子线程在调用recover之前进行了全局状态变更,那么该变更将表现为数据竞态,可以被Go的竞态检测器报告。

图4是将上述步骤1和2应用于图1中withdraw函数的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func withdraw(debit int) {
    onedge.WrapFunc(func() {
        defer func() {
            if r := onedge.WrapRecover(recover()); r != nil {
                log.Println(r)
            }
        }()
        balance -= debit
        if balance < 0 {
            panic("Insufficient funds")
        }
    })
}

图4:集成了OnEdge的图1中的withdraw函数

应用了上述步骤的完整源文件可以在这里找到:account.go。使用竞态检测器运行修改后的程序,例如:

1
go run -race account.go

会产生图5所示的输出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Depositing 14...
New balance: 114
Withdrawing 6...
New balance: 108
Withdrawing 96...
New balance: 12
Withdrawing 77...
==================
WARNING: DATA RACE
Read at 0x0000012194f8 by goroutine 8:
  main.withdraw.func1()
      <gopath>/src/github.com/trailofbits/on-edge/example/account.go:61 +0x6d
  github.com/trailofbits/on-edge.WrapFunc.func1()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:82 +0x3d
  github.com/trailofbits/on-edge.shadowThread.func1()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:239 +0x50
  github.com/trailofbits/on-edge.shadowThread()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:240 +0x79

Previous write at 0x0000012194f8 by main goroutine:
  main.withdraw.func1()
      <gopath>/src/github.com/trailofbits/on-edge/example/account.go:61 +0x89
  github.com/trailofbits/on-edge.WrapFunc.func1()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:82 +0x3d
  github.com/trailofbits/on-edge.WrapFuncR()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:132 +0x3d4
  github.com/trailofbits/on-edge.WrapFunc()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:81 +0x92
  main.withdraw()
      <gopath>/src/github.com/trailofbits/on-edge/example/account.go:50 +0x84
  main.main()
      <gopath>/src/github.com/trailofbits/on-edge/example/account.go:39 +0x3cf

Goroutine 8 (running) created at:
  github.com/trailofbits/on-edge.WrapFuncR()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:126 +0x3a1
  github.com/trailofbits/on-edge.WrapFunc()
      <gopath>/src/github.com/trailofbits/on-edge/onedge_race.go:81 +0x92
  main.withdraw()
      <gopath>/src/github.com/trailofbits/on-edge/example/account.go:50 +0x84
  main.main()
      <gopath>/src/github.com/trailofbits/on-edge/example/account.go:39 +0x3cf
==================
<time> Insufficient funds
<time> Insufficient funds
New balance: -142
Depositing 28...
New balance: -114
Found 1 data race(s)
exit status 66

图5:集成了OnEdge并启用竞态检测器的图1程序的输出

这里发生了什么?和之前一样,没有足够的资金来覆盖某次取款,因此withdraw函数触发panic。panic被defer的recover调用捕获。此时,OnEdge开始工作。OnEdge在影子线程中重新执行withdraw函数体。这导致在account.go的第61行报告数据竞态:

1
balance -= debit

这行代码通过写入balance全局变量来进行全局状态变更。在主线程和影子线程中执行这行代码会导致两次写入,Go的竞态检测器将其识别为竞态。

局限性

与所有动态分析一样,OnEdge的有效性取决于对程序施加的工作负载。作为一个极端例子,如果从未让程序遇到导致panic的输入,那么OnEdge将毫无用处。

第二个局限性是,由于Go的竞态检测器可能漏检某些竞态,OnEdge可能漏检某些全局状态变更。这部分是由于ThreadSanitizer的限制,它只跟踪对任何内存位置的有限数量的内存访问。一旦达到该限制,ThreadSanitizer就会开始随机驱逐条目。

OnEdge的现状与未来

OnEdge是一个用于检测因不正确使用Go的"defer、panic和recover"模式而产生的不当全局状态变更的工具。OnEdge通过利用Go现有工具(即其竞态检测器)的优势来实现这一目标。

我们正在探索使用自动化将WrapFunc和WrapRecover集成到程序中的可能性。目前,用户必须手动完成这些操作。我们鼓励使用OnEdge并欢迎反馈。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计