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