為不斷增長的Go生態系統擴充套件gopls

2023-09-11 15:13:55

原文在這裡

由 Robert Findley and Alan Donovan 釋出於 2023年9月8日

今年夏天初,Go團隊釋出了goplsv0.12版本,這是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已經整合了許多不同的命令列工具,如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中,單元是包(packages)。不同包的編譯無法完全分開:當編譯一個包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的記憶體表示的包上執行,因此無法分析依賴關係:這樣做會引入太多的額外程式碼。去掉這個要求後,我們能夠在gopls v0.12中包含一個新的分析驅動程式,該驅動程式分析所有依賴關係,從而提高了精度。例如,gopls現在會報告Printf格式錯誤,即使是您在fmt.Printf周圍的使用者定義包裝器也是如此。值得注意的是,多年來,go vet一直提供了這種精度,但是gopls在每次編輯後實時進行此操作是不可能的。現在可以了。

第二個是更簡單的工作區設定更好的構建標籤處理。這兩個功能都意味著當您在計算機上開啟任何Go檔案時,gopls都會「做正確的事情」,但是在沒有優化工作的情況下都是不可行的,因為(例如)每個構建設定都會增加記憶體佔用!

趕快嘗試吧

除了可延伸性和效能改進之外,我們還修復了許多已報告的錯誤,以及在轉換期間提高測試覆蓋率時發現的許多未報告的錯誤。

要安裝最新的gopls:

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

請嘗試一下並填寫調查問卷 - 如果遇到錯誤,請報告它,我們將進行修復。


孟斯特

宣告:本作品採用署名-非商業性使用-相同方式共用 4.0 國際 (CC BY-NC-SA 4.0)進行許可,使用時請註明出處。
Author: mengbin
blog: mengbin
Github: mengbin92
cnblogs: 戀水無意