Go 部落格
擴充 gopls 以符合不斷成長的 Go 生態系
今年稍早在今年夏天,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 倍!)。
分離編譯
1950 年代第一批編譯器的設計者很快發現單一編譯的限制。他們的解決方案是將程式分解成數個單元,並個別編譯各個單元。分離編譯讓以小型片斷方式編譯程式碼成為可能,這讓程式的規模不受限於記憶體。在 Go 中,單元為套件。不同套件的編譯無法完全分離:在編譯套件 P 時,編譯器仍需要知道 P 導入套件提供的資訊。為了安排此事,Go 編譯系統會在 P 自身之前編譯 P 導入的所有套件,而且 Go 編譯器會寫入各個套件的匯出 API 的簡略摘要。已匯入套件的 P 的摘要會作為 P 自身編譯的輸入提供。
Gopls v0.12 為 gopls 帶入分離編譯,重複使用編譯器使用的相同套件摘要格式。這個概念很簡單,但細節卻蘊含巧思。我們重寫先前檢視代表整個程式的資料結構的各個演算法,讓它現在一次處理一個套件,並將每個套件的結果儲存至檔案中,就像編譯器發出物件碼一樣。例如,尋找函式的所有參照過去只要在程式碼資料結構中搜尋特定指標值的所有個體即可。現在,gopls 在處理各個套件時,必須建立並儲存一個索引,將原始碼中每一個識別碼位置與它所參照符號的名稱關聯起來。在查詢時間,gopls 載入並搜尋這些索引。其他全域性查詢,例如「尋找執行」,也使用類似技術。
如同 go build
指令,gopls 現在使用基於檔案的快取儲存空間來記錄從每個封包所計算出來的摘要資訊,包括每個宣告的類型、交叉引用的索引,以及每個類型的函數組。因為快取會在各種程序間持續存在,因此你會發現,當你在工作區中第二次啟動 gopls 時,它會更快準備好提供服務;倘若你執行兩個 gopls 個體,它們會產生綜效並同時運作。

這個變更帶來的結果,是 gopls 的記憶體使用會與開放封包及其直接匯入成正比。這就是為什麼我們在上述的表格中會看到次線性縮放:隨著存放庫越來越大,任何一個開放封包所看到的專案部分比例會越來越小。
細緻的無效化
當你變更一個封包時,只需要重新編譯直接或間接匯入那個封包的其他封包。這個構想自 1970 年代的 Make 以來一直是所有增量建置系統賴以運作的基礎,而 gopls 從一開始就一直在使用它。實際上,只要 LSP 已啟用的編輯器中輸入任何一個按鍵,就會啟動一個增量建置!不過,在一個大型專案中,間接依賴會越來越多,導致這些增量建置過於緩慢。結果證明有許多這樣的動作其實並非絕對必要,因為大部分變更(例如新增陳述至現有的函數中)並不會影響匯入摘要。
假如你在一個檔案中做了一個小更動,我們就必須重新編譯它的封包,但如果這個更動沒有影響匯入摘要,我們就不需要編譯任何其他封包。換句話說,這個更動的效應是「修剪」過的。如果更動有影響匯入摘要,則需要重新編譯直接匯入該封包的其他封包,但是大部分這樣的更動並不會影響那些封包的匯入摘要,如此一來,效應還是會被修剪,避免重新編譯間接匯入者。多虧這個修剪程序,在低階封包中的更動很少需要重新編譯所有間接依賴該封包的封包。修剪過後的增量建置會讓工作量與每個變更的範圍成正比。這並不是什麼新構想:它是由 Vesta 引進的,並在 go build
中使用。
v0.12 版本引入了類似的剪枝技術到 gopls,進一步實作了基於語法分析的快速剪枝啟發法。藉由在記憶體中保留簡化的符號參考圖,gopls 可以快速決定套件 c
中的變更是否可能透過一連串的參考影響套件 a
。

在上面的範例中,沒有從 a
到 c
的參考鏈,因此即使 a 間接依賴 c,它也不會受到 c 中變更的影響。
新的可能性
雖然我們對於已達到的效能提升感到滿意,但我們也為 gopls 的數個新功能感到興奮,這些功能在 gopls 不再受記憶體限制後現在才可行。
第一個是穩健的靜態分析。先前,我們的靜態分析驅動程式必須作用於 gopls 中記憶體內的套件表示,因此它無法分析依賴項:這樣做會匯入過多額外的程式碼。移除這項需求後,我們能夠在 gopls v0.12 中包含一個新的分析驅動程式,它會分析所有依賴項,進而提升準確度。例如,gopls 現在會針對 fmt.Printf
週圍自訂封裝中的 Printf
格式化錯誤回報診斷。值得注意的是,go vet
多年來一直提供這種準確度層級,但 gopls 在每次編輯後無法即時執行此動作。現在已可執行。
第二個是 更簡單的工作區組態 和 改善的建置標籤處理。這兩個功能都得力於 gopls「採取正確的動作」,當你開啟機器上的任何 Go 檔案時,但兩者在沒有最佳化作業的情況下不可行,因為(例如)每個建置組態都會增加記憶體使用量!
試用看看!
除了可擴充性和效能的提升外,我們也已修正了 大量的 回報錯誤,以及在轉換期間提升測試範圍時發現的許多未回報錯誤。
安裝最新的 gopls
$ go install golang.org/x/tools/gopls@latest
下一篇文章: Go 中的 WASI 支援
上一篇文章: Go 1.21 中的 Profiler 導引最佳化
部落格索引