Go 部落格

向後相容性、Go 1.21 和 Go 2

Russ Cox
2023 年 8 月 14 日

Go 1.21 包含了增強相容性的新特性。在您停止閱讀之前,我知道這聽起來很無聊。但無聊可能是件好事。回想 Go 1 的早期,Go 充滿活力和驚喜。每週我們都會發布新的快照版本,每個人都要擲骰子看看我們改變了什麼以及他們的程式會如何崩潰。我們釋出了 Go 1 及其相容性承諾,正是為了去除這種興奮感,讓 Go 的新版本變得無聊。

無聊是件好事。無聊意味著穩定。無聊意味著您可以專注於自己的工作,而不是關注 Go 有哪些不同。本文討論的是我們在 Go 1.21 中為保持 Go 的無聊性所做的重要工作。

Go 1 相容性

十多年來,我們一直專注於相容性。對於 Go 1,早在 2012 年,我們就釋出了一份題為“Go 1 和 Go 程式的未來”的文件,其中明確闡述了意圖

意圖在於,按照 Go 1 規範編寫的程式,在其規範的整個生命週期內,無需修改即可繼續正確編譯和執行。…… 今天可以工作的 Go 程式,即使在 Go 1 的未來版本出現時,也應繼續工作。

對此有一些限定條件。首先,相容性意味著原始碼相容性。當您更新到 Go 的新版本時,確實需要重新編譯程式碼。其次,我們可以新增新的 API,但方式不會破壞現有程式碼。

文件的末尾警告說,“不可能保證未來的任何更改都不會破壞任何程式。”然後它列舉了一些程式可能仍然會崩潰的原因。

例如,如果您的程式依賴於某個有問題的行為而我們修復了該問題,您的程式可能會崩潰,這是合理的。但我們非常努力地減少破壞,並保持 Go 的無聊性。到目前為止,我們主要使用了兩種方法:API 檢查和測試。

API 檢查

關於相容性,最清楚的一點也許是我們不能移除 API,否則使用它的程式將會崩潰。

例如,這是有人編寫的一個我們不能破壞的程式

package main

import "os"

func main() {
    os.Stdout.WriteString("hello, world\n")
}

我們不能移除 os 包;我們不能移除全域性變數 os.Stdout(它是 *os.File 型別);我們也不能移除 os.File 方法 WriteString。很明顯,移除其中任何一個都會破壞這個程式。

可能不太清楚的是,我們根本不能改變 os.Stdout 的型別。假設我們想把它變成一個具有相同方法的介面。我們剛剛看到的那個程式不會崩潰,但這個程式會

package main

import "os"

func main() {
    greet(os.Stdout)
}

func greet(f *os.File) {
    f.WriteString(“hello, world\n”)
}

這個程式將 os.Stdout 傳遞給一個名為 greet 的函式,該函式需要一個 *os.File 型別的引數。因此,將 os.Stdout 更改為介面將破壞這個程式。

為了幫助我們開發 Go,我們使用一個工具,它維護一個列表,其中包含每個包的匯出 API,這些列表儲存在與實際包檔案分開的檔案中

% cat go/api/go1.21.txt
pkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386
pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685
pkg bytes, method (*Buffer) Available() int #53685
pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488
pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488
pkg cmp, type Ordered interface {} #59488
pkg context, func AfterFunc(Context, func()) func() bool #57928
pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661
pkg context, func WithoutCancel(Context) Context #40221
pkg context, func WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc) #56661

我們的一個標準測試會檢查實際的包 API 是否與這些檔案匹配。如果我們在一個包中添加了新的 API,除非將其新增到 API 檔案中,否則測試會失敗。如果我們更改或移除了 API,測試也會失敗。這有助於我們避免錯誤。然而,像這樣的工具只能發現特定型別的問題,即 API 的更改和移除。還有其他方法可以對 Go 進行不相容的更改。

這就引出了我們用來保持 Go 無聊的第二種方法:測試。

測試

發現意外不相容性的最有效方法是針對下一個 Go 版本的開發版本執行現有測試。我們持續地使用 Google 內部所有的 Go 程式碼來測試 Go 的開發版本。當測試透過時,我們將該提交安裝為 Google 的生產環境 Go 工具鏈。

如果某個更改破壞了 Google 內部的測試,我們假設它也會破壞 Google 外部的測試,並尋找減少影響的方法。大多數情況下,我們會完全回滾該更改,或者找到一種方法重寫它,使其不破壞任何程式。然而,有時我們斷定這個更改很重要,並且是“相容的”,儘管它確實破壞了一些程式。在這種情況下,我們仍然會努力將影響降到最低,然後在釋出說明中記錄潛在的問題。

這裡有兩個我們透過在 Google 內部測試 Go 發現的、但仍然包含在 Go 1.1 中的此類微妙相容性問題的示例。

結構體字面量和新欄位

這是一些在 Go 1 中執行良好的程式碼

package main

import "net"

var myAddr = &net.TCPAddr{
    net.IPv4(18, 26, 4, 9),
    80,
}

main 包聲明瞭一個全域性變數 myAddr,它是 net.TCPAddr 型別的複合字面量。在 Go 1 中,net 包將 TCPAddr 型別定義為一個包含兩個欄位 IPPort 的結構體。這些欄位與複合字面量中的欄位匹配,因此程式可以編譯。

在 Go 1.1 中,程式停止編譯,編譯器錯誤提示“結構體字面量中初始化器太少”。問題在於我們在 net.TCPAddr 中添加了第三個欄位 Zone,而這個程式缺少第三個欄位的值。解決方法是使用帶標籤的字面量重寫程式,這樣它就可以在 Go 的兩個版本中構建

var myAddr = &net.TCPAddr{
    IP:   net.IPv4(18, 26, 4, 9),
    Port: 80,
}

由於此字面量未指定 Zone 的值,它將使用零值(在這種情況下為空字串)。

相容性文件中明確指出了標準庫結構體需要使用複合字面量的要求,並且 go vet 會報告需要標籤以確保與 Go 後續版本相容的字面量。這個問題在 Go 1.1 中非常新穎,值得在釋出說明中簡短提及。現在我們只會提及新增的欄位。

時間精度

我們在測試 Go 1.1 時發現的第二個問題與 API 毫無關係。它與時間有關。

Go 1 釋出後不久,有人指出 time.Now 返回的時間精度為微秒,但透過一些額外的程式碼,可以返回納秒精度的時間。這聽起來不錯,對吧?更高的精度更好。所以我們做了那個更改。

這破壞了 Google 內部少量結構上類似於這個的測試

func TestSaveTime(t *testing.T) {
    t1 := time.Now()
    save(t1)
    if t2 := load(); t2 != t1 {
        t.Fatalf("load() = %v, want %v", t1, t2)
    }
}

這段程式碼呼叫 time.Now,然後透過 saveload 往返處理結果,並期望獲得相同的時間。如果 saveload 使用的表示方式僅儲存微秒精度,這在 Go 1 中工作良好,但在 Go 1.1 中就會失敗。

為了幫助修復這類測試,我們添加了 RoundTruncate 方法來丟棄不必要的精度,並在釋出說明中記錄了可能的問題以及幫助解決問題的新方法。

這些例子展示了測試如何發現與 API 檢查不同型別的不相容性。當然,測試也不能完全保證相容性,但它比單純的 API 檢查更全面。我們在測試過程中發現許多問題,經判斷它們確實違反了相容性規則,並在釋出前進行了回滾。時間精度更改是一個有趣的例子,它雖然破壞了程式,但我們仍然釋出了它。我們進行更改是因為提高精度是更好的,並且在函式的文件行為允許範圍內。

這個例子表明,有時儘管付出了巨大的努力和關注,但改變 Go 意味著會破壞 Go 程式。嚴格來說,這些更改在 Go 1 文件的意義上是“相容的”,但它們仍然會破壞程式。這些相容性問題大多可歸為三類:輸出更改、輸入更改和協議更改。

輸出更改

輸出更改發生在函式給出的輸出與以前不同時,但新輸出與舊輸出一樣正確,甚至更正確。如果現有程式碼被編寫為只期望舊輸出,它就會崩潰。我們剛剛看到了一個例子,就是 time.Now 添加了納秒精度。

排序。另一個例子發生在 Go 1.6 中,當時我們更改了排序的實現,使其執行速度提高了約 10%。這是一個根據顏色名稱長度對顏色列表進行排序的示例程式

colors := strings.Fields(
    `black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)

Go 1.5:  [red blue green white black yellow orange indigo violet]
Go 1.6:  [red blue white green black orange yellow indigo violet]

更改排序演算法通常會改變相等元素的順序,這裡就發生了這種情況。Go 1.5 返回的順序是 green, white, black。Go 1.6 返回的順序是 white, green, black。

排序顯然可以按其喜歡的任何順序返回相等的結果,而這次更改使其速度提高了 10%,這很好。但期望特定輸出的程式將會崩潰。這是一個很好的例子,說明為什麼相容性如此困難。我們不想破壞程式,但我們也不想被束縛於未文件化的實現細節。

壓縮/flate。另一個例子是,在 Go 1.8 中,我們改進了 compress/flate,使其生成更小的輸出,同時 CPU 和記憶體開銷大致相同。這聽起來像是一個雙贏,但它破壞了 Google 內部一個需要可重現歸檔構建的專案:現在他們無法重現舊的歸檔檔案了。他們分叉了 compress/flatecompress/gzip 來保留舊演算法的副本。

我們對 Go 編譯器也做了類似的事情,使用了 sort 包的一個分叉(以及其他包),這樣即使使用早期版本的 Go 構建編譯器,它也能生成相同的結果。

對於這類輸出更改的不相容性,最好的解決方案是編寫接受任何有效輸出的程式和測試,並將這類破壞作為機會來改變您的測試策略,而不僅僅是更新預期的答案。如果您確實需要可重現的輸出,次優的解決方案是分叉程式碼以隔離開變化,但請記住,您同時也隔離了錯誤修復。

輸入更改

輸入更改發生在函式改變其接受的輸入或處理輸入的方式時。

ParseInt。例如,Go 1.13 添加了對大數字中使用下劃線以提高可讀性的支援。隨著語言的改變,我們讓 strconv.ParseInt 接受新語法。這個改變沒有破壞 Google 內部的任何東西,但很久以後我們聽到了一位外部使用者,他們的程式碼確實被破壞了。他們的程式使用下劃線分隔的數字作為資料格式。它首先嚐試 ParseInt,只有在 ParseInt 失敗時才回退檢查下劃線。當 ParseInt 不再失敗時,處理下劃線的程式碼就不再運行了。

ParseIP。再舉一個例子,Go 的 net.ParseIP 遵循了早期 IP RFC 中的示例,這些示例經常顯示帶有前導零的十進位制 IP 地址。它將 IP 地址 18.032.4.011 讀取為 18.32.4.11,只是多了幾個前導零。很久以後我們才發現,派生自 BSD 的 C 庫將 IP 地址中的前導零解釋為八進位制數的開始:在那些庫中,18.032.4.011 表示 18.26.4.9!

這是 Go 與世界其他地方之間的一個嚴重不匹配,但從一個 Go 版本到下一個版本改變前導零的含義也會是一個嚴重不匹配。這將是一個巨大的不相容性。最終,我們決定在 Go 1.17 中修改 net.ParseIP,使其完全拒絕前導零。這種更嚴格的解析確保了當 Go 和 C 都能成功解析 IP 地址時,或者當 Go 的舊版本和新版本解析時,它們對它的含義都達成一致。

這個改變沒有破壞 Google 內部的任何東西,但 Kubernetes 團隊擔心一些之前可以解析但在 Go 1.17 中將停止解析的已儲存配置。帶有前導零的地址可能應該從這些配置中移除,因為 Go 對它們的解釋與幾乎所有其他語言都不同,但這應該在 Kubernetes 的時間表上進行,而不是 Go 的。為了避免語義變化,Kubernetes 開始使用自己分叉的原始 net.ParseIP 副本。

應對輸入更改的最佳方法是在解析值之前,首先驗證您希望接受的語法來處理使用者輸入,但有時您需要分叉程式碼。

協議更改

最後一類常見的不相容性是協議更改。協議更改是對包進行的更改,最終在程式用於與外部世界通訊的協議中對外可見。正如我們在 ParseIntParseIP 中看到的,幾乎任何更改都可能在某些程式中對外可見,但協議更改基本上在所有程式中都是對外可見的。

HTTP/2。協議更改的一個明顯例子是 Go 1.6 添加了對 HTTP/2 的自動支援。假設一個 Go 1.5 客戶端正在透過一箇中間盒破壞 HTTP/2 的網路連線到支援 HTTP/2 的伺服器。由於 Go 1.5 只使用 HTTP/1.1,程式執行正常。但隨後更新到 Go 1.6 就會破壞程式,因為 Go 1.6 開始使用 HTTP/2,而在此上下文中,HTTP/2 無法工作。

Go 旨在預設支援現代協議,但這個例子表明,啟用 HTTP/2 可能會在程式本身(以及 Go 本身)沒有錯誤的情況下破壞程式。在這種情況下,開發者可以回到使用 Go 1.5,但這並不令人滿意。相反,Go 1.6 在釋出說明中記錄了此更改,並使得停用 HTTP/2 變得簡單。

實際上,Go 1.6 記錄了兩種停用 HTTP/2 的方法:使用包 API 顯式配置 TLSNextProto 欄位,或者設定 GODEBUG 環境變數

GODEBUG=http2client=0 ./myprog
GODEBUG=http2server=0 ./myprog
GODEBUG=http2client=0,http2server=0 ./myprog

正如我們稍後會看到的,Go 1.21 將此 GODEBUG 機制推廣,使其成為所有潛在破壞性更改的標準。

SHA1。這裡有一個更微妙的協議更改示例。現在沒有人應該再使用基於 SHA1 的 HTTPS 證書了。證書頒發機構在 2015 年停止頒發,所有主流瀏覽器在 2017 年停止接受它們。在 2020 年初,Go 1.18 預設停用了對它們的支援,並提供了一個 GODEBUG 設定來覆蓋此更改。我們還宣佈打算在 Go 1.19 中移除 GODEBUG 設定。

Kubernetes 團隊讓我們知道,一些安裝仍在使用私有 SHA1 證書。撇開安全問題不談,強迫這些企業升級其證書基礎設施不是 Kubernetes 的職責,而且分叉 crypto/tlsnet/http 以保留 SHA1 支援將會非常痛苦。因此,我們同意將覆蓋設定保留比原計劃更長的時間,以便為有序過渡創造更多時間。畢竟,我們希望破壞儘可能少的程式。

Go 1.21 中擴充套件的 GODEBUG 支援

為了即使在我們一直在研究的這些微妙情況下也能改善向後相容性,Go 1.21 擴充套件並規範化了 GODEBUG 的使用。

首先,對於任何 Go 1 相容性允許但仍可能破壞現有程式的更改,我們都會做我們剛剛看到的所有工作來理解潛在的相容性問題,並設計更改以儘可能保持現有程式的正常工作。對於剩餘的程式,新的方法是

  1. 我們將定義一個新的 GODEBUG 設定,允許單個程式選擇退出新行為。如果不可行,則可能不會新增 GODEBUG 設定,但這應該極為罕見。

  2. 為相容性而新增的 GODEBUG 設定將至少維護兩年(四個 Go 版本)。一些設定,例如 http2clienthttp2server,將維護更長時間,甚至無限期。

  3. 在可能的情況下,每個 GODEBUG 設定都有一個關聯的 runtime/metrics 計數器,命名為 /godebug/non-default-behavior/<name>:events,它統計了基於該設定的非預設值,特定程式的行為改變的次數。例如,當設定 GODEBUG=http2client=0 時,/godebug/non-default-behavior/http2client:events 統計程式配置了多少個不帶 HTTP/2 支援的 HTTP 傳輸。

  4. 程式的 GODEBUG 設定會與主包的 go.mod 檔案中列出的 Go 版本相匹配。如果您的程式的 go.mod 檔案中寫著 go 1.20,並且您更新到 Go 1.21 工具鏈,Go 1.21 中任何受 GODEBUG 控制的已更改行為將保留其舊的 Go 1.20 行為,直到您將 go.mod 更改為 go 1.21

  5. 程式可以透過在 main 包中使用 //go:debug 行來更改單個 GODEBUG 設定。

  6. 所有 GODEBUG 設定都集中記錄在一箇中心列表中,以便於參考。

這種方法意味著每個新的 Go 版本應該是舊 Go 版本的最佳實現,即使在編譯舊程式碼時,也能保留在後續版本中以相容但會破壞的方式更改的行為。

例如,在 Go 1.21 中,panic(nil) 現在會導致(非 nil)執行時 panic,這樣 recover 的結果現在可以可靠地報告當前 goroutine 是否正在 panic。這個新行為由 GODEBUG 設定控制,因此取決於主包的 go.mod 檔案中的 go 行:如果它寫著 go 1.20 或更早版本,panic(nil) 仍然被允許。如果它寫著 go 1.21 或更晚版本,panic(nil) 將變成一個帶有 runtime.PanicNilError 的 panic。並且可以透過在主包中新增如下一行程式碼來顯式覆蓋基於版本的預設設定

//go:debug panicnil=1

這些功能的組合意味著程式可以在更新到新的工具鏈時,保留它們之前使用的工具鏈的行為,可以根據需要對特定設定應用更精細的控制,並且可以使用生產環境監控來了解哪些作業在實踐中使用了這些非預設行為。總而言之,這些應該使新工具鏈的推出比過去更加順暢。

更多詳情請參閱“Go、向後相容性和 GODEBUG”。

關於 Go 2 的最新動態

在本文頂部引用的“Go 1 和 Go 程式的未來”文字中,省略號隱藏了以下限定詞

在某個不確定的時間點,可能會出現一個 Go 2 規範,但在那之前,[… 所有相容性細節 …]。

這引出了一個顯而易見的問題:我們何時應該期待會破壞舊 Go 1 程式的 Go 2 規範?

答案是永遠不會。Go 2,就打破過去、不再編譯舊程式而言,永遠不會發生。Go 2,就作為我們從 2017 年開始努力實現的 Go 1 的重大修訂而言,已經發生了。

不會有一個破壞 Go 1 程式的 Go 2。相反,我們將加倍強調相容性,這比任何可能的與過去的決裂都更有價值。事實上,我們認為優先考慮相容性是我們在 Go 1 中做出的最重要的設計決策。

因此,您將在未來幾年看到大量令人興奮的新工作,但這些工作會以謹慎、相容的方式完成,以便我們能讓您從一個工具鏈升級到下一個工具鏈的過程儘可能無聊。

下一篇文章:Go 1.21 中的向前相容性和工具鏈管理
上一篇文章:Go 1.21 釋出了!
部落格索引