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

我们非常希望通过这次 简短调查 了解您的使用体验。

内存使用量和启动时间降低

在深入细节之前,让我们先看看结果!下图显示了 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 在每次编辑后无法实时做到这一点。现在它能做到了。

第二个是 更简单的工作区配置对构建标签的改进处理。这两项功能都意味着 gopls 在您打开机器上的任何 Go 文件时都能“正常工作”,但如果没有优化工作,这两项功能都不可行(例如,每个构建配置都会成倍增加内存占用!)。

试用一下!

除了可扩展性和性能改进之外,我们还修复了 许多 已报告的 bug 以及许多未报告的 bug,这些 bug 是我们在转换过程中提高测试覆盖率时发现的。

安装最新的 gopls

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

请试用并填写 调查——如果您遇到 bug,请 报告,我们会修复它。

下一篇文章:Go 中的 WASI 支持
上一篇文章:Go 1.21 中的 Profile-guided 优化
博客索引