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集成到项目中,需要做三件事:
- 将defer调用recover的函数体包装在WrapFunc(func() { … })中
- 在这些包装的函数体内,将recover调用包装在WrapRecover( … )中
- 启用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行报告数据竞态;这一行:
这一行通过写入balance全局变量来进行全局状态改变。在主线程和影子线程中执行这一行会导致两次写入,Go的竞态检测器将其识别为竞态。
局限性
与所有动态分析一样,OnEdge的有效性取决于对程序施加的工作负载。作为一个极端例子,如果从未让程序遇到导致它panic的输入,那么OnEdge将没有任何作用。
第二个局限性是,由于Go的竞态检测器可能会遗漏某些竞态,OnEdge可能会遗漏某些全局状态改变。这部分是由于ThreadSanitizer的局限性,它只跟踪对任何内存位置的有限数量的内存访问。一旦达到该限制,ThreadSanitizer就会开始随机驱逐条目。
OnEdge的现在与未来
OnEdge是一个用于检测由于不正确使用Go的"defer、panic和recover"模式而产生的不当全局状态改变的工具。OnEdge通过利用Go现有工具(即其竞态检测器)的优势来实现这一目标。
我们正在探索使用自动化将WrapFunc和WrapRecover集成到程序中的可能性。目前,用户必须手动完成此操作。我们鼓励使用OnEdge并欢迎反馈。