Uber开源的Golang实用工具:NilAway

发表时间: 2023-11-21 08:29

作为一个方便快捷高效的编程语言,Golang已被业界广泛使用。很多团队用其作为主要编程语言来实现其后端和生产系的各种小工具。

在Go编程常常喜欢使用指针,可以帮助Go程序员高效的内存管理和有效的数据操作。在Go程序中广泛存在指针,其用途也灵活多样。例如就地数据修改、并发编程、轻松数据共享、优化内存使用以及促进接口和多态性。虽然go指针功能强大且使用广泛,但必须小心谨慎地使用它们,以避免常见的陷阱,例如nil指针去引用导致nil恐慌。

Uber是深度使用Golang语言的大厂之一,是其后端服务和库的主要编程语言。Go monorepo 是Uber最大的代码库,包含9000万行代码。在避免nil恐慌方面Uber也吃过很多亏,也积累了丰富的经验,所以其开源了内部一个nil恐慌工具NilAway以回馈Golang社区。

概述

nil恐慌是当程序尝试去引用nil指针时发生的运行时恐慌。当指针为nil时,表示,该指针指向的地址没有人任何有效的内容。如果此时有程序调用该指针试图访问该内存地址的值时,就会将触发Go运行时错误报错——恐慌(panic):

比如,Go标准库中net库中一个nil恐慌问题的示例(其中1859-1861是修复后的程序):

该恐慌是由于在方法直接调用方法String() 的返回值RemoteAddr()引起的,程序假设它始终为非零。但是在net.Conn的具体现实中RemoteAddr()有可能返回nil值,但是c调用时候未对其进行nil检测,从而导致nil恐慌错误。具体来说,RemoteAddr()可以在L225上返回nil接口值时,导致了nil恐慌,因为nil值不包含指向可以调用的任何具体方法的指针。

零恐慌在某些情况下可能导致拒绝服务攻击。例如,CVE-2020-29652是由于golang /x/crypto/ssh 中的nil指针去引用导致的,该指针允许远程攻击者对SSH服务器造成拒绝服务。

Go 发行版提供了一个自动化工具nilness 来检测nil恐慌。这个nil检查器是一种轻量级静态分析技术,仅报告简单的错误,例如明显的零去引用位置 (例如:

if x == nil { print(*x) })

然而,nilness简单的检查无法捕获实际程序中复杂的nil流。因此,们需要一种能够执行严格分析并对生产代码有效的技术。

为了处理Java中的 NullPointerExceptions (NPE),Uber 开发了NullAway。 注解进行注解,NullAway要求代码使用@Nullable 以保证编译时的NPE自由度。 这限制了直接采用类似NullAway的技术来实现可行性。

与Java不同,Golang没有对注释的语言支持。此外,注释大型代码库是一项繁琐的任务。此外,Go的各种独特功能和特质也带来了其独特的挑战。

为了解决在大型Golang程序中的Nil恐慌问题,Uber设计和开发了NilAway,通过采用复杂的过程间静态分析和推理技术来自动检测nil恐慌。NilAway的设计目标是让开发人员没有注释负担,对本地和CI构建时间的影响保持最小,并以Gopher自然的方式解决 Go语言习惯用法带来的许多挑战。

设计理念

主要思想是,代码中的零流可以建模为全局类型约束系统,然后可以使用2-SAT 算法来解决该系统以确定潜在的矛盾。在较高的层面上,在各个程序站点捕获结构体字段、函数参数和返回值的nilable和nonnil约束。nilable约束的一个示例是return x,其中x是未初始化的指针,而去引用*x是非零约束的示例。 然后,构建一个全局含义图,对这些程序特定于站点的约束进行建模 最后,遍历蕴涵图——向前传播已知的零值和向后传播已知的非零值——以找到矛盾。

对于站点S,如果在蕴涵图的程序路径中发现矛盾nilable(S)^ nonnil(S) ,则意味着nil值被见证从nil源流到站点S,从它到达去引用点,这可能会导致Nil恐慌。

NilAway收集这些矛盾并将其作为潜在的nil恐慌报告给开发人员。

节点是可以为nilable类型的程序站点,边是它们之间的nil流。NilAway遍历蕴含图来查找不安全流,将它们建模为矛盾。一个流中如果发现可能为nil值通过不同的程序路径流向预期为非零的目的地则该流被视为不安全(例如net.conn.RemoteAddr()具体实现通过接口声明net.Conn.RemoteAddr()上的方法调用来去引用)。NilAway报告了该nil恐慌的详细错误消息,该消息允许开发人员可以轻松调试从明显的nilability到其去引用的确切nil流程,并应用必要的修复来防止nil恐慌。

注意:一般来说,对于实际的静态类型系统,无论有或没有类型的全局推断,总是存在不满足有效静态类型的无错误程序。对于NilAway,上述算法没有捕获程序执行中微妙的过程间不变量会阻止运行时发生nil到nonnil流的情况。例如,可能会设置某些共享程序状态,以便每当c.ok()调用从conn.RemoteAddr时,它总是返回true ,在这种情况下,该代码中不存在nil恐慌。然而,在实践中,NilAway的误报率很低,并且这种复杂的执行不变量本质上阻止推断正确的零约束的情况往往与可能的代码相关。

设计与实现

NilAway的设计围绕以下四个关键要求设计和开发NilAway,使其成为规模化的实用工具:

低延迟:NilAway 在大型Go代码库上执行分析时应该只产生很低的开销。NilAway在开发人员引入潜在的nil恐慌时立即提供反馈,从而要求NilAway足够快,以便在开发管道的每个阶段(甚至在本地构建期间)以低延迟运行。高开销意味着更高的延迟(延迟反馈),从而降低开发人员的生产力。

高有效性:NilAway应具有较低的误报率;检查误报nil恐慌会浪费开发人员的时间。

完全自动化:NilAway 应该是完全自动化的,不需要开发人员的额外输入。

针对Go的特性量身定制:NilAway应该将Go的特性视为一等公民,并设计一个针对Go的系统。

NilAway是用Golang实现的,使用go/analysis框架来分析代码,其概述架构为:

NilAway以包含代码的目标包路径的形式作为输入标准Go代码,并返回通过分析识别出的潜在nil恐慌错误作为输出。NilAway被实现为一个分析器,可以用作独立工具,也可以选择轻松集成到构建系统中,例如Bazel,以及现有的分析器驱动程序(例如nogo)。

概括地说,NilAway 的实现可以分为3个组件:分析器引擎、推理引擎和错误引擎。

分析器引擎负责独立识别函数内所有潜在的零流,而推理引擎负责收集不同程序站点的见证零值,并通过构建蕴涵图。最后,错误引擎累积来自分析器引擎和推理引擎的信息,并将每个潜在的零流(过程内和过程间)标记为安全或不安全 。然后,不安全的nil流会作为潜在的nil恐慌错误报告给用户。

凭借新颖的基于约束的方法来检测nil恐慌,NilAway恰当地满足了上面列出的四个要求:

NilAway速度很快。分析器引擎中每个功能的独立分析使其适合并行化,这是一个主要的性能增强器。此外,设计了NilAway通过利用构建缓存来增量构建全局含义图,从而避免了昂贵的依赖关系重建。这种精心设计使得NilAway快速且可扩展,使其适合大型代码库。在Uber的基准测试中, NilAway只给正常构建过程增加了很小的开销(不到5%)。

NilAway很实用。为了保持NilAway的精确性,分析器引擎的设计和实现是为了支持许多常见的Go语言特性。的错误引擎也经过精心设计,仅在出现不安全的零流时才报告错误。NilAway可能会导致误报和漏报。然而,正在不断努力减少它们并使NilAway更加精确。据观察,NilAway在Uber部署时在实践中运行良好(如下所述),捕获了新代码中大部分潜在的nil恐慌,从而使 NilAway在实用性和性能开销之间保持良好的平衡。

NilAway是完全自动化的。基于约束的方法使其非常适合推理,这使得NilAway能够在完全自动化的模式下运行,无需注释。

Uber生产实践

在Uber生产环境中NilAway集中部署在Go monorepo中,与Bazel+Nogo框架紧密集成,使其能够作为默认linter在CI管道和本地构建中的每个构建上运行。然而,错误报告处于测试阶段,仅针对Go monorepo中加入NilAway的服务报告Nil恐慌错误。

对于服务所有者,目前提供两种错误报告选项:

(1)全面和阻塞,以及(2)止血和非阻塞。

在第一个选项中,如果发现任何错误,NilAway会导致构建失败(如果需要,可以通过 //nolint:nilaway 进行抑制)。NilAway全面报告所有代码(现有的和新的)的错误。此选项更适合确保零恐慌代码库。但是,它要求在允许任何构建通过之前解决服务代码中所有报告的nil恐慌。这可能会给服务开发带来高昂的前期成本,从而导致服务所有者之间的摩擦。

为了解决上述问题,在选项2中提供了轻量级版本,其中仅针对服务中更改的代码报告NilAway错误。这些错误会以非阻塞的方式直接报告到加载服务的每个差异代码修订版(即拉取请求)上。这种止血方法有助于防止新的nil恐慌被引入到服务代码中,同时允许团队逐步解决现有代码中的nil恐慌,而无需进行会减缓开发速度的前期工作。

Uber生产中一个典型实例, NilAway报告了一个服务中的一个重要错误,该服务每天在生产代码中记录超过3000个nil恐慌,其导致nil恐慌的代码的简化和编辑摘录:

该代码中使用Go的消息传递结构通道。在 L16 行,对tsfoo(…)函数调用返回一个通道ch ,该通道随后由变量a接收。不幸的是,Go允许从关闭的通道读取,在这种情况下将返回零值(即nil)。如果在函数foo中采用代码路径 L7->L8->L5,则通道将在不写入任何内容的情况下关闭。引用点a.Items[*id] 这将在L17行处导致nil恐慌。NilAway正确报告了此错误,因为它饭了了对可能从关闭通道接收的变量的不安全去引用。

这个问题的解决方法是通过正确保护来自封闭通道的接收,可以使用Go的ok 结构(例如,if a, ok := <-tsfoo(…); ok { … })或通过nilness检查L17去引用之前的结果变量a (例如,if a != nil { … } )。开发团队在 NilAway报告此错误后立即应用了nilness检查修复,效果显著:服务从每天记录3000多个nil恐慌变为0:

总结

NilAway为解决大型Golang系统中的Nil提供了一个自动化工具,可以作为独立检查器或者在DevOps流程中集成使用目前Uber,适用于个人项目或者大厂的开发环境。NilAway所有代码都已经在GitHub开源(github/uber-go/nilaway/),有类似痛点的同学可以按需取用或者本地化改造重用。