Go 博客

扩展 gopls 以满足不断增长的 Go 生态系统

Robert Findley 和 Alan Donovan
2023 年 9 月 8 日

今年夏天早些时候,Go 团队发布了 v0.12 版本的 gopls,这是 Go 的 语言服务器,其核心进行了重写,使其能够扩展到更大的代码库。这是为期一年的努力的成果,我们很高兴分享我们的进展,并谈谈新的架构及其对 gopls 未来意味着什么。

自 v0.12 版本发布以来,我们对新设计进行了微调,专注于使交互式查询(例如自动完成或查找引用)与 v0.11 一样快,尽管在内存中保存的状态少得多。如果您还没有尝试过,我们希望您能试一试。

$ go install golang.org/x/tools/gopls@latest

我们很乐意通过这份 简短调查 了解您使用 gopls 的体验。

内存使用和启动时间的减少

在我们深入了解细节之前,让我们先看看结果!下图显示了 GitHub 上 28 个最受欢迎的 Go 代码库的启动时间和内存使用量的变化。这些测量是在打开一个随机选择的 Go 文件并等待 gopls 完全加载其状态后进行的,并且由于我们假设初始索引在许多编辑会话中进行了摊销,因此我们在第二次打开文件时进行这些测量。

Relative savings
in memory and startup time

在这些代码库中,节省量平均约为 75%,但内存减少是非线性的:随着项目的规模越来越大,内存使用量的相对减少也越大。我们将在下面更详细地解释这一点。

Gopls 和不断发展的 Go 生态系统

Gopls 为与语言无关的编辑器提供了类似 IDE 的功能,例如自动完成、格式化、交叉引用和重构。自 2018 年诞生以来,gopls 已将许多不同的命令行工具(如 gurugorenamegoimports)整合在一起,并已成为 VS Code Go 扩展的默认后端 以及许多其他编辑器和 LSP 插件。也许您一直在通过编辑器使用 gopls,甚至没有意识到这一点——这就是我们的目标!

五年前,gopls 仅仅通过维护一个有状态的会话就能提高性能。而旧的命令行工具每次执行时都必须从头开始,gopls 可以保存中间结果以显著减少延迟。但所有这些状态都伴随着成本,随着时间的推移,我们越来越多地 收到用户的反馈,称 gopls 的高内存使用量几乎难以忍受。

同时,Go 生态系统也在不断发展,越来越多的代码在更大的代码库中编写。 Go 工作区 允许开发人员同时处理多个模块,而 容器化开发 将语言服务器置于资源越来越受限的环境中。代码库越来越大,开发人员环境越来越小。我们需要改变 gopls 的扩展方式才能跟上步伐。

重新审视 gopls 的编译器起源

在许多方面,gopls 类似于一个编译器:它必须读取、解析、类型检查和分析 Go 源文件,为此它使用了由 Go 标准库golang.org/x/tools 模块提供的许多编译器 构建块。这些构建块使用“符号编程”技术:在运行的编译器中,存在一个代表每个函数(例如 fmt.Println)的单个对象或“符号”。对函数的任何引用都表示为指向其符号的指针。要测试两个引用是否在谈论同一个符号,您无需考虑名称。您只需比较指针即可。指针比字符串小得多,指针比较非常便宜,因此符号是表示程序等复杂结构的有效方法。

为了快速响应请求,gopls v0.11 将所有这些符号都保存在内存中,就像 gopls 一次编译整个程序一样。结果是内存占用量与正在编辑的源代码成正比,并且远大于源代码(例如,类型化语法树通常比源文本大 30 倍!)。

单独编译

20 世纪 50 年代第一批编译器的设计者很快就发现了单片编译的局限性。他们的解决方案是将程序分解成单元,并分别编译每个单元。单独编译可以通过分块的方式构建不适合内存的程序。在 Go 中,这些单元是软件包。不同软件包的编译不能完全分离:在编译软件包 P 时,编译器仍然需要有关 P 导入的软件包提供的内容的信息。为了实现这一点,Go 构建系统在 P 本身之前编译 P 的所有导入的软件包,并且 Go 编译器会编写每个软件包导出的 API 的简洁摘要。P 的导入的软件包的摘要作为 P 本身编译的输入提供。

Gopls v0.12 将单独编译引入 gopls,重新使用编译器使用的相同软件包摘要格式。这个想法很简单,但细节上有一些微妙之处。我们重写了之前检查表示整个程序的数据结构的每个算法,以便它现在一次处理一个软件包并将每个软件包的结果保存到文件中,就像编译器发出目标代码一样。例如,查找对函数的所有引用曾经像在程序数据结构中搜索特定指针值的所有出现一样简单。现在,当 gopls 处理每个软件包时,它必须构建并保存一个索引,将源代码中的每个标识符位置与它所引用的符号的名称相关联。在查询时,gopls 加载并搜索这些索引。其他全局查询(例如“查找实现”)使用类似的技术。

go build 命令一样,gopls 现在使用 基于文件的缓存 存储来记录从每个软件包计算的信息摘要,包括每个声明的类型、交叉引用的索引以及每个类型的集合方法。由于缓存会跨进程持久化,您会注意到第二次在工作区中启动 gopls 时,它会变得更快地准备就绪,并且如果您运行两个 gopls 实例,它们会协同工作。

separate compilation

这种更改的结果是 gopls 的内存使用量与打开的软件包及其直接导入的数量成正比。这就是为什么我们在上图中观察到次线性扩展的原因:随着代码库的规模越来越大,任何一个打开的软件包观察到的项目部分也越来越小。

细粒度失效

当您在一个软件包中进行更改时,只需要重新编译直接或间接导入该软件包的软件包即可。这个想法是自 20 世纪 70 年代的 Make 以来所有增量构建系统都采用的基础,gopls 自诞生之日起就一直在使用它。实际上,您在启用 LSP 的编辑器中的每个按键都会启动一个增量构建!但是,在一个大型项目中,间接依赖关系会累积起来,使这些增量重建变得太慢。事实证明,很多工作并不是严格必要的,因为大多数更改(例如在现有函数中添加语句)不会影响导入摘要。

如果您在一个文件中进行小的更改,我们必须重新编译其软件包,但如果更改不影响导入摘要,则我们不必编译任何其他软件包。更改的影响被“修剪”掉了。影响导入摘要的更改需要重新编译直接导入该软件包的软件包,但大多数此类更改不会影响这些软件包的导入摘要,在这种情况下,影响仍然会被修剪掉,避免重新编译间接导入器。由于这种修剪,低级软件包中的更改很少需要重新编译所有间接依赖于该软件包的软件包。修剪后的增量重建使工作量与每次更改的范围成正比。这不是一个新想法:它是由 Vesta 引入的,也用于 go build

v0.12 版本引入了类似于 gopls 的修剪技术,更进一步实现了基于语法分析的更快的修剪启发式算法。通过在内存中保留符号引用简化图,gopls 可以快速确定包c中的更改是否可能通过引用链影响包a

fine-grained invalidation

在上面的示例中,从ac没有引用链,因此即使a间接依赖于c,它也不会受到c中更改的影响。

新的可能性

虽然我们对取得的性能改进感到满意,但我们也对 gopls 的一些新特性感到兴奋,这些特性现在在 gopls 不再受内存限制的情况下变得可行。

第一个是强大的静态分析。以前,我们的静态分析驱动程序必须在 gopls 的包内存表示上运行,因此它无法分析依赖关系:这样做会引入过多的额外代码。随着此要求的移除,我们能够在 gopls v0.12 中包含一个新的分析驱动程序,该驱动程序分析所有依赖关系,从而提高了精度。例如,gopls 现在会报告Printf格式错误的诊断信息,即使在您围绕fmt.Printf的自定义包装器中也是如此。值得注意的是,go vet多年来一直提供这种级别的精度,但 gopls 在每次编辑后都无法实时做到这一点。现在可以了。

第二个是更简单的工作区配置改进的构建标签处理。这两个特性都相当于在您打开机器上的任何 Go 文件时 gopls“做正确的事情”,但这两个特性在没有优化工作的情况下都是不可行的,因为(例如)每个构建配置都会增加内存占用!

试一试!

除了可扩展性和性能改进之外,我们还修复了大量 已报告的错误以及在转换期间改进测试覆盖率时发现的许多未报告的错误。

安装最新版本的 gopls

$ go install golang.org/x/tools/gopls@latest

请尝试一下并填写调查问卷——如果您遇到错误,请报告,我们会修复它。

下一篇文章:Go 中的 WASI 支持
上一篇文章:Go 1.21 中的配置文件引导优化
博客索引