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 中的配置檔案引導最佳化
部落格索引