Go 博客
为不断壮大的 Go 生态系统扩展 gopls
今年夏天早些时候,Go 团队发布了 v0.12 版本的 gopls,这是 Go 的语言服务器,其核心经过重写,使其能够扩展到更大的代码库。这是一年努力的结晶,我们很高兴分享我们的进展,并稍微谈谈新架构以及它对 gopls 未来意味着什么。
自 v0.12 版本发布以来,我们一直在微调新设计,尽管在内存中保存的状态少得多,但我们专注于使交互式查询(例如自动补全或查找引用)的速度与 v0.11 版本一样快。如果您还没有尝试,我们希望您能试用一下
$ go install golang.org/x/tools/gopls@latest
我们非常希望通过这份简短的调查了解您的使用体验。
内存使用和启动时间的减少
在我们深入细节之前,先来看看结果!下面的图表显示了 GitHub 上 28 个最受欢迎的 Go 仓库在启动时间和内存使用方面的变化。这些测量是在打开一个随机选择的 Go 文件并等待 gopls 完全加载其状态后进行的,并且由于我们假设初始索引是在许多编辑会话中摊销的,因此我们在第二次打开文件时进行这些测量。
在这些仓库中,平均节省约 75%,但内存减少是非线性的:随着项目变大,内存使用的相对下降也越大。我们将在下面更详细地解释这一点。
gopls 与不断演进的 Go 生态系统
gopls 为与语言无关的编辑器提供了类似 IDE 的功能,例如自动补全、格式化、交叉引用和重构。自 2018 年问世以来,gopls 整合了许多不同的命令行工具,如 guru、gorename 和 goimports,并成为 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 实例,它们会协同工作。

这一变化的结果是,gopls 的内存使用量与打开的包数量及其直接导入的包数量成正比。这就是我们在上面的图表中观察到亚线性扩展的原因:随着仓库变大,任何一个打开的包所观察到的项目比例变小。
细粒度失效
当您在一个包中进行更改时,只需要重新编译直接或间接导入该包的包即可。这个想法是自 20 世纪 70 年代 Make 以来的所有增量构建系统的基础,并且 gopls 自诞生以来一直使用它。实际上,在支持 LSP 的编辑器中的每一次击键都会启动一次增量构建!然而,在一个大型项目中,间接依赖关系会累积起来,使得这些增量重建变得太慢。事实证明,很多这类工作并非绝对必要,因为大多数更改(例如在现有函数内添加语句)都不会影响导入摘要。
如果您在一个文件中进行细微更改,我们必须重新编译其包,但如果更改不影响导入摘要,则无需编译任何其他包。更改的效果被“剪枝”。影响导入摘要的更改需要重新编译直接导入该包的包,但大多数此类更改不会影响那些包的导入摘要,在这种情况下,效果仍然被剪枝,避免重新编译间接导入者。多亏了这种剪枝,低层级包的更改很少需要重新编译间接依赖于该包的所有包。剪枝的增量重建使得工作量与每次更改的范围成正比。这不是一个新想法:它由 Vesta 引入,并在 go build
中也有使用。
v0.12 版本为 gopls 引入了类似的剪枝技术,并在此基础上更进一步,实现了基于语法分析的更快剪枝启发式算法。通过在内存中维护一个简化的符号引用图,gopls 可以快速确定包 c
中的更改是否可能通过引用链影响包 a
。

在上面的示例中,从 a
到 c
没有引用链,因此即使 a 间接依赖于 c,a 也不受 c 中更改的影响。
新的可能性
虽然我们对已取得的性能改进感到满意,但我们也对 gopls 现在不再受内存限制而变得可行的几项功能感到兴奋。
首先是健壮的静态分析。以前,我们的静态分析驱动程序必须操作 gopls 在内存中的包表示,因此无法分析依赖关系:这样做会引入太多额外的代码。随着这一要求的取消,我们能够在 gopls v0.12 中包含一个新的分析驱动程序,它分析所有依赖关系,从而提高了精度。例如,gopls 现在甚至在您围绕 fmt.Printf
定义的用户包装器中也会报告 Printf
格式错误诊断。值得注意的是,go vet
多年来一直提供这种级别的精度,但 gopls 无法在每次编辑后实时执行此操作。现在它可以了。
其次是更简单的工作区配置和改进的构建标签处理。这两个功能都意味着 gopls 在您打开机器上的任何 Go 文件时都能“做正确的事”,但如果没有优化工作,这两者都不可行,因为(例如)每个构建配置都会使内存占用翻倍!
试用一下!
除了可扩展性和性能改进之外,我们还修复了大量已报告的 bug 和许多未报告的 bug,这些是在过渡期间改进测试覆盖率时发现的。
安装最新版本的 gopls
$ go install golang.org/x/tools/gopls@latest
下一篇文章:Go 中的 WASI 支持
上一篇文章:Go 1.21 中的配置文件引导优化
博客索引