Go语言中正确使用Panic的方法 - Trail of Bits博客
“Defer、Panic和Recover"模式
Go语言中一个常见的惯用法是:(1) 触发panic,(2) 在defer函数中恢复panic,(3) 继续执行。一般来说,只要在调用defer的函数入口点与发生panic的点之间没有全局状态更改,这种做法是可以接受的。此类全局状态更改可能对程序行为产生持久影响。此外,人们很容易忽略这些更改,并认为所有操作都会通过recover调用被撤销。
在Trail of Bits,我们开发了一个名为OnEdge的工具,帮助检测这种"defer、panic、recover"模式的不正确使用。OnEdge将查找此类全局状态更改的问题转化为竞态检测问题。然后可以使用Go出色的竞态检测器来发现这些错误。此外,正如我们在下文解释的,您可以将OnEdge集成到自己的程序中以发现这类错误。
OnEdge是我们用来验证软件的工具之一。例如,我们审计大量用Go编写的区块链软件,这些软件通常在收到无效交易时触发panic,从panic中恢复,然后继续处理交易。但是必须小心确保无效交易被完全回滚,因为部分应用的交易可能导致区块链分叉。
示例分析
图1展示了一个使用"defer、panic和recover"模式的简单程序。该程序随机生成存款和取款。如果没有足够资金覆盖取款,程序会触发panic。panic在延迟函数中被捕获并报告错误,程序继续执行。
|
|
图1:不正确使用"defer、panic和recover"模式的程序
运行图1中的程序会产生图2的输出:
|
|
图2:图1程序的输出
注意这里存在一个bug:即使没有足够资金覆盖其中一笔取款,取款操作仍然被应用。这个bug是一类更普遍错误的特例;程序在触发panic之前进行了全局状态更改。
更好的方法是在可能发生panic的最后一点之后才进行此类全局状态更改。重写withdraw函数使用这种方法会得到类似图3的实现:
|
|
图3:图1中withdraw函数的更好实现
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集成到项目中,需要做三件事:
- 将延迟调用recover的函数体包装在WrapFunc(func() { … })中
- 在这些包装的函数体内,将recover调用包装在WrapRecover( … )中
- 启用Go竞态检测器运行程序
如果在WrapFunc包装的函数体中发生panic,并且该panic被WrapRecover包装的recover捕获,那么函数体将在影子线程中重新执行。如果影子线程在调用recover之前进行了全局状态更改,那么该更改将显示为数据竞态,可以被Go竞态检测器报告。
图4是将上述步骤1和2应用于图1中withdraw函数的结果:
|
|
图4:集成了OnEdge的图1中的withdraw函数
运行启用了竞态检测器的修改后程序:
|
|
会产生图5的输出,显示检测到了数据竞态。
局限性
与所有动态分析一样,OnEdge的有效性取决于对程序施加的工作负载。作为一个极端例子,如果从未让程序遇到导致其panic的输入,那么OnEdge将毫无用处。
第二个局限性是,由于Go竞态检测器可能漏检某些竞态,OnEdge可能漏检某些全局状态更改。这部分是由于ThreadSanitizer的限制,它只跟踪对任何内存位置的有限数量的内存访问。一旦达到该限制,ThreadSanitizer就会开始随机驱逐条目。
OnEdge的现状与未来
OnEdge是一个用于检测因不正确使用Go的"defer、panic和recover"模式而产生的不当全局状态更改的工具。OnEdge通过利用Go现有工具(即其竞态检测器)的优势来实现这一目标。
我们正在探索使用自动化将WrapFunc和WrapRecover纳入程序的可能性。目前,用户必须手动完成。我们鼓励使用OnEdge并欢迎反馈。