Go 官方部落格
邁向 Go 2
引言
[這是我今天在 Gophercon 2017 上的演講文字,目的是請整個 Go 社群在我們討論和規劃 Go 2 時提供幫助。]
2007 年 9 月 25 日,在 Rob Pike、Robert Griesemer 和 Ken Thompson 討論一種新程式語言幾天後,Rob 建議使用“Go”這個名字。

第二年,Ian Lance Taylor 和我加入了團隊,我們五人一起構建了兩個編譯器和一個標準庫,最終於 2009 年 11 月 10 日釋出了開源版本。

在接下來的兩年裡,在新成立的 Go 開源社群的幫助下,我們嘗試了各種大小的改動,改進了 Go,並於 2011 年 10 月 5 日提出了Go 1 計劃。

在 Go 社群的更多幫助下,我們修訂並實施了該計劃,最終於 2012 年 3 月 28 日釋出了 Go 1。

Go 1 的釋出標誌著近五年創造性、緊張工作的頂峰,這段努力將我們從一個名字和一系列想法帶到了一個穩定、可用於生產的語言。這也標誌著從變化和動盪向穩定的明確轉變。
在 Go 1 釋出之前的幾年裡,我們幾乎每週都會修改 Go 並導致所有人的 Go 程式無法執行。我們明白,這使得 Go 無法在生產環境中使用,因為在生產環境中,程式不可能每週都重寫以跟上語言的變化。正如宣告 Go 1 的部落格文章所述,主要的動機是提供一個穩定的基礎,用於建立可靠的產品、專案和出版物(部落格、教程、會議演講和書籍),讓使用者相信他們的程式在未來幾年內可以無需更改即可繼續編譯和執行。
Go 1 釋出後,我們知道需要花時間在 Go 設計用於的生產環境中使用它。我們明確地從改變語言轉向在我們自己的專案中使用 Go 並改進實現:我們將 Go 移植到許多新系統,重寫了幾乎所有效能關鍵部分以使 Go 執行得更高效,並添加了諸如競態檢測器 (race detector)之類的關鍵工具。
現在我們有五年使用 Go 構建大型、生產質量系統的經驗。我們已經形成了對哪些方法有效、哪些方法無效的認識。現在是時候開始 Go 演進和增長的下一步了,規劃 Go 的未來。今天我在這裡,請所有 Go 社群的成員,無論您是 GopherCon 的現場聽眾,還是透過影片觀看,或者稍後閱讀 Go 部落格,與我們一起規劃和實施 Go 2。
在本次演講的其餘部分,我將解釋我們對 Go 2 的目標;我們的約束和限制;總體流程;撰寫關於使用 Go 經驗的重要性,特別是與我們可能嘗試解決的問題相關的內容;可能的解決方案型別;我們將如何交付 Go 2;以及大家如何提供幫助。
目標
我們今天對 Go 的目標與 2007 年相同。我們希望提高程式設計師管理兩種規模的能力:生產規模,特別是與許多其他伺服器互動的併發系統,這在今天的雲軟體中得到體現;以及開發規模,特別是許多工程師鬆散協調工作的大型程式碼庫,這在今天的現代開源開發中得到體現。
這些規模體現在各種規模的公司中。即使是一家五人創業公司,也可能使用其他公司提供的大型雲 API 服務,並使用比自己編寫的軟體更多的開源軟體。生產規模和開發規模在該創業公司與在 Google 同等重要。
我們對 Go 2 的目標是修復 Go 在規模化方面最顯著的不足。
(關於這些目標的更多資訊,請參閱 Rob Pike 2012 年的文章“Google 的 Go:軟體工程中的語言設計”以及我在 GopherCon 2015 上的演講“Go,開源,社群”。)
約束
自 Go 起初以來,目標並未改變,但 Go 面臨的約束確實改變了。最重要的約束是現有的 Go 使用情況。我們估計全球至少有五十萬 Go 開發者,這意味著存在數百萬 Go 原始檔和至少十億行 Go 程式碼。這些程式設計師和原始碼代表了 Go 的成功,但它們也是 Go 2 的主要約束。
Go 2 必須帶上所有這些開發者。我們必須僅在回報巨大時,才要求他們忘掉舊習慣,學習新習慣。例如,在 Go 1 之前,錯誤型別實現的方法名為 String
。在 Go 1 中,我們將其重新命名為 Error
,以區分錯誤型別與其他可以格式化自身型別的能力。前幾天我在實現一個錯誤型別時,不假思索地將其方法命名為 String
而不是 Error
,結果當然無法編譯。五年過去了,我仍然沒有完全忘掉舊的方式。這種澄清性的重新命名是 Go 1 中一項重要的更改,但在 Go 2 中如果沒有非常好的理由,這將是破壞性太大的改動。
Go 2 也必須帶上所有現有的 Go 1 原始碼。我們不能分裂 Go 生態系統。在為期多年的過渡期內,Go 2 編寫的包匯入 Go 1 編寫的包,反之亦然的混合程式必須能夠輕鬆執行。我們將不得不仔細考慮如何做到這一點;像 go fix 這樣的自動化工具肯定會發揮作用。
為了最大程度地減少破壞,每項更改都需要仔細思考、規劃和工具支援,這反過來限制了我們可以進行的更改數量。也許我們可以做兩到三項,肯定不會超過五項。
我沒有計算諸如允許使用更多口語語言中的識別符號或新增二進位制整數字面量等次要的清理性更改。這些次要更改也很重要,但它們更容易處理。我今天關注的是可能的重大更改,例如對錯誤處理的額外支援,或者引入不可變或只讀值,或者新增某種形式的泛型,或者尚未提出的其他重要議題。我們只能進行少數幾項重大更改。我們必須謹慎選擇。
過程
這引出了一個重要問題。Go 的開發過程是怎樣的?
在 Go 的早期,當我們只有五個人時,我們在兩間相鄰的共享辦公室裡工作,中間隔著玻璃牆。很容易就能把大家召集到一間辦公室討論某個問題,然後各自回到座位上實現解決方案。當實現過程中出現意外情況時,也很容易再次召集大家。Rob 和 Robert 的辦公室裡有一張小沙發和一個白板,所以通常我們中的一個人會進去,開始在白板上寫一個例子。通常當例子寫完時,其他人也已在各自的工作中達到了合適的暫停點,準備坐下來討論。這種非正式方式顯然無法擴充套件到今天的全球 Go 社群。
自 Go 開源釋出以來,部分工作是將我們的非正式流程移植到更正式的郵件列表、問題跟蹤器和五十萬使用者的世界中,但我認為我們從未明確描述過我們的整體流程。我們可能從未有意識地思考過這一點。然而,回顧過去,我認為這是我們開發 Go 的基本框架,是我們自第一個原型執行以來一直遵循的過程。

第 1 步是使用 Go,積累使用經驗。
第 2 步是識別 Go 中可能需要解決的問題,並闡明它,向他人解釋,將其寫下來。
第 3 步是針對該問題提出解決方案,與他人討論,並根據討論修改方案。
第 4 步是實施解決方案,評估它,並根據評估進行改進。
最後,第 5 步是釋出解決方案,將其新增到語言、庫或人們日常使用的工具集中。
同一個人不必為某項更改執行所有這些步驟。事實上,通常許多人會協作完成任何一個步驟,並且針對同一個問題可能會提出多種解決方案。此外,在任何時候,我們都可能意識到不想進一步推進某個想法,並返回到之前的某個步驟。
雖然我認為我們從未整體討論過這個過程,但我們已經解釋過其中的一些部分。2012 年,當我們釋出 Go 1 並說現在是時候使用 Go 並停止更改它時,我們解釋的是第 1 步。2015 年,當我們引入 Go 更改提案流程 (Go change proposal process) 時,我們解釋的是第 3、4、5 步。但我們從未詳細解釋過第 2 步,所以現在我想這樣做。
(關於 Go 1 的開發以及從語言更改轉向的更多資訊,請參閱 Rob Pike 和 Andrew Gerrand 在 OSCON 2012 上的演講“Go 1 之路”。關於提案流程的更多資訊,請參閱 Andrew Gerrand 在 GopherCon 2015 上的演講“Go 是如何誕生的”以及提案流程文件。)
闡述問題

闡述問題有兩個部分。第一部分——比較容易的部分——是精確地陳述問題是什麼。我們開發者在這方面相當擅長。畢竟,我們編寫的每個測試都是一個待解決問題的陳述,其語言精確到連計算機都能理解。第二部分——更難的部分——是充分描述問題的重要性,以便每個人都能理解為什麼我們應該花時間解決它並維護一個解決方案。與精確陳述問題相反,我們不需要經常描述問題的重要性,在這方面也做得遠不如前者。計算機從不問我們“為什麼這個測試用例很重要?你確定這是你需要解決的問題嗎?解決這個問題是你現在能做的最重要的事情嗎?”也許將來有一天會問,但不是今天。
讓我們看看 2011 年的一箇舊例子。這是我在規劃 Go 1 時關於將 os.Error 重新命名為 error.Value 的記錄。

它始於對問題精確的一行陳述:在非常底層的庫中,所有東西都為了 os.Error 而匯入 “os”。然後有五行,我在這裡加了下劃線,專門描述問題的重要性:使用 “os” 的包本身無法在它們的 API 中表示錯誤,而其他包依賴於 “os” 的原因與作業系統服務無關。
這五行能讓你相信這個問題很重要嗎?這取決於你能在多大程度上填補我遺漏的背景資訊:理解需要預料到他人需要知道什麼。對於我當時的受眾——Google Go 團隊的其他十位閱讀該文件的同事——這五十個詞就足夠了。要在去年秋天向 GothamGo 的聽眾——一個背景和專業領域更加多樣化的群體——介紹同一個問題,我需要提供更多的背景資訊,我用了大約兩百個詞,還加上了實際程式碼示例和圖表。今天全球 Go 社群的現實是,描述任何問題的重要性都需要新增背景資訊,尤其要透過具體的例子來佐證,而這些內容在與同事交談時可能會省略。
說服他人某個問題很重要是至關重要的一步。當一個問題看起來不重要時,幾乎所有解決方案都會顯得過於昂貴。但對於一個重要的問題,通常有很多成本合理的解決方案。當我們對於是否採納某個特定解決方案存在分歧時,我們實際上往往是對所解決問題的重要性存在分歧。這一點非常重要,因此我想看看最近的兩個例子,它們清楚地說明了這一點,至少事後看來是如此。
例子:閏秒
我的第一個例子與時間有關。
假設你想測量一個事件需要多長時間。你記下開始時間,執行事件,記下結束時間,然後用結束時間減去開始時間。如果事件花費了十毫秒,相減結果就是十毫秒,可能加上或減去一個小的測量誤差。
start := time.Now() // 3:04:05.000
event()
end := time.Now() // 3:04:05.010
elapsed := end.Sub(start) // 10 ms
這個顯而易見的步驟在閏秒期間可能會失敗。當我們的時鐘與地球的每日自轉不太同步時,就會在午夜前插入一個閏秒——官方說法是下午 11:59 分 60 秒。與閏年不同,閏秒沒有可預測的模式,這使得它們難以適配到程式和 API 中。作業系統通常不會嘗試表示偶爾出現的 61 秒分鐘,而是透過在原應是午夜前將時鐘回撥一秒來實現閏秒,這樣下午 11:59 分 59 秒就會出現兩次。這種時鐘重置使得時間似乎倒退,因此我們耗時十毫秒的事件可能會被計時為負 990 毫秒。
start := time.Now() // 11:59:59.995
event()
end := time.Now() // 11:59:59.005 (really 11:59:60.005)
elapsed := end.Sub(start) // –990 ms
由於“日曆時鐘 (time-of-day clock)”在跨越時鐘重置(例如這種)計時事件時不夠準確,作業系統現在提供第二種時鐘,即“單調時鐘 (monotonic clock)”,它沒有絕對含義,但能計算秒數且永不重置。
除了在少數時鐘重置期間,單調時鐘並不比日曆時鐘更好,而日曆時鐘還有檢視時間的額外好處,因此為了簡潔,Go 1 的時間 API 只暴露了日曆時鐘。
2015 年 10 月,一份錯誤報告指出 Go 程式無法正確地在跨越時鐘重置(特別是典型的閏秒)時測量事件時間。建議的修復方法也是最初的問題標題:“新增一個訪問單調時鐘源的新 API”。我當時認為這個問題不夠重要,不足以增加新的 API。幾個月前,對於 2015 年中期的閏秒,Akamai、Amazon 和 Google 整天將時鐘稍微調慢了一點,透過這種方式吸收了額外的一秒,而沒有回撥時鐘。看來這種“閏秒塗抹 (leap smear)”方法的最終廣泛採用將消除生產系統中閏秒導致的時鐘重置問題。相比之下,向 Go 新增新的 API 會引入新的問題:我們將不得不解釋這兩種時鐘,教育使用者何時使用哪種,並轉換許多現有的程式碼行,所有這些都是為了一個很少發生且可能自行消失的問題。
當出現沒有明確解決方案的問題時,我們總是做同樣的事情:我們等待。等待給了我們更多時間來增加對問題的經驗和理解,也給了我們更多時間來找到一個好的解決方案。在這種情況下,等待增加了我們對問題重要性的理解,具體形式是在 Cloudflare 發生了一次幸運地算是輕微的中斷。他們的 Go 程式碼在 2016 年底的閏秒期間計時 DNS 請求,結果是大約負 990 毫秒,這導致了他們的伺服器同時崩潰,在高峰期中斷了 0.2% 的 DNS 查詢。
Cloudflare 正是 Go 設計用於的那類雲系統,他們因為 Go 無法正確計時事件而發生了生產中斷。然後,這是關鍵點,Cloudflare 在 John Graham-Cumming 撰寫的部落格文章“閏秒如何以及為何影響了 Cloudflare DNS”中報告了他們的經驗。透過分享他們在生產中使用 Go 的具體細節,John 和 Cloudflare 幫助我們理解,跨閏秒時鐘重置的精確計時問題太重要了,不能置之不理。該文章發表兩個月後,我們設計並實現了一個解決方案,它將在Go 1.9 中釋出(事實上,我們是在沒有增加新 API的情況下做到的)。
例子:別名宣告
我的第二個例子是 Go 對別名宣告 (alias declarations) 的支援。
在過去的幾年裡,Google 成立了一個專注於大規模程式碼變更的團隊,即在我們由數百萬原始檔和數十億行 C++、Go、Java、Python 及其他語言編寫的龐大程式碼庫中進行 API 遷移和錯誤修復。我從該團隊的工作中學到的一點是,在將 API 從一個名稱更改為另一個名稱時,能夠分多個步驟更新客戶端程式碼,而不是一次性完成,這一點非常重要。要做到這一點,必須能夠編寫一個宣告,將舊名稱的使用轉發到新名稱。C++ 有 #define、typedef 和 using 宣告來實現這種轉發,但 Go 沒有。當然,Go 的目標之一就是很好地擴充套件到大型程式碼庫,隨著 Google Go 程式碼量的增長,我們清楚地認識到既需要某種轉發機制,也意識到其他專案和公司隨著其 Go 程式碼庫的增長也會遇到這個問題。
2016 年 3 月,我開始與 Robert Griesemer 和 Rob Pike 討論 Go 如何處理程式碼庫的逐步更新,我們最終確定了別名宣告 (alias declarations),這正是所需的轉發機制 (forwarding mechanism)。此時,我感覺 Go 的演進方向非常好。自 Go 早期以來,我們就討論過別名 (aliases)——事實上,第一份規範草案就有一個使用別名宣告 (alias declarations) 的例子——但每次我們討論別名 (aliases),以及後來的類型別名 (type aliases) 時,都沒有明確的用例,所以我們就把它們去掉了。現在我們提議新增別名不是因為它們是一個優雅的概念,而是因為它們解決了 Go 實現可伸縮軟體開發目標的一個重大實際問題。我希望這能成為 Go 未來更改的範例。
春天晚些時候,Robert 和 Rob 寫了一份提案,Robert 在 Gophercon 2016 的閃電演講 (lightning talk) 中進行了介紹。接下來的幾個月進展並不順利,這絕對不是未來 Go 更改的範例。我們學到的許多教訓之一是闡述問題重要性的重要性。
剛才,我向你解釋了這個問題,提供了一些關於它如何產生和為什麼產生背景資訊,但沒有具體的例子來幫助你評估這個問題是否會在某個時候影響你。去年夏天的提案和閃電演講提供了一個抽象的例子,涉及包 C、L、L1 和 C1 到 Cn,但沒有開發者可以聯絡起來的具體例子。結果,來自社群的大多數反饋都基於這樣一種想法:別名只解決了 Google 的問題,而不是其他所有人的問題。
正如我們在 Google 最初沒有理解正確處理閏秒時間重置的重要性一樣,我們也沒有有效地向更廣泛的 Go 社群傳達在進行大規模更改時處理程式碼逐步遷移和修復的重要性。
秋天我們重新開始了。我做了一個演講並寫了一篇文章介紹問題,使用從開原始碼庫中提取的多個具體例子來介紹問題,展示了這個問題是如何普遍存在的,而不僅僅是在 Google 內部。現在,更多的人理解了這個問題並看到了它的重要性,我們就有了一次富有成效的討論,關於哪種解決方案最好。結果是類型別名 (type aliases)將包含在 Go 1.9 中,並將幫助 Go 擴充套件到更大的程式碼庫。
經驗報告
這裡的教訓是,以一種不同環境中工作的人也能理解的方式描述問題的重要性是困難但至關重要的。作為社群討論對 Go 的重大更改時,我們需要特別關注描述我們想要解決的任何問題的重要性。最清楚的方法是展示問題如何影響實際程式和實際生產系統,例如 Cloudflare 的部落格文章和我的重構 (refactoring) 文章中所示。
這樣的經驗報告將抽象問題轉化為具體問題,並幫助我們理解其重要性。它們也作為測試用例:任何提議的解決方案都可以透過檢查其對報告中描述的實際、真實世界問題的影響來評估。
例如,我最近一直在研究泛型 (generics),但腦海中並沒有一個清晰的圖景,不清楚 Go 使用者需要泛型來解決哪些詳細、具體的問題。因此,我無法回答一個設計問題,例如是否支援泛型方法 (generic methods),也就是與接收者 (receiver) 分開引數化 (parameterized) 的方法。如果我們有大量現實世界的用例,就可以透過 بررسی 重要的用例來開始回答這樣的問題。
另一個例子是,我看到了一些以各種方式擴充套件錯誤介面 (error interface) 的提案,但我還沒有看到任何經驗報告表明大型 Go 程式如何嘗試理解和處理錯誤,更不用說展示當前錯誤介面如何阻礙這些嘗試了。這些報告將幫助我們更好地理解問題的細節和重要性,這是我們在解決問題之前必須做的。
我可以繼續說下去。對 Go 的每一次潛在的重大更改都應該由一份或多份經驗報告來推動,這些報告記錄了人們現在如何使用 Go 以及為什麼它不夠好用。對於我們可能考慮的那些明顯的 Go 重大更改,我不知道有很多這樣的報告,尤其是那些有實際例子說明的報告。
這些報告是 Go 2 提案流程 (Go 2 proposal process) 的原始材料,我們需要大家來撰寫它們,幫助我們瞭解你們使用 Go 的經驗。你們有五十萬人,在廣泛的環境中工作,而我們的人不多。寫一篇部落格文章,或者寫一篇 Medium 文章,或者寫一個 GitHub Gist(新增 .md
副檔名以使用 Markdown),或者寫一個 Google 文件,或者使用任何你喜歡的釋出機制。釋出後,請將文章新增到我們的新維基頁面 golang.org/wiki/ExperienceReports。
解決方案

現在我們知道如何識別和闡述需要解決的問題了,我想簡單提一下,並非所有問題都最好透過語言更改來解決,這沒關係。
我們可能想要解決的一個問題是,計算機在進行基本算術運算時通常可以計算額外的結果,但 Go 沒有直接訪問這些結果的途徑。2013 年,Robert 提議我們可以將雙結果(“逗號-ok”)表示式的思想擴充套件到基本算術運算。例如,如果 x 和 y 是 uint32 型別的值,lo, hi = x * y
將不僅返回通常的低 32 位,還會返回乘積的高 32 位。這個問題似乎不太重要,所以我們記錄了潛在的解決方案,但沒有實施。我們選擇了等待。
最近,我們為 Go 1.9 設計了一個math/bits 包,其中包含各種位操作函式 (bit manipulation functions)
package bits // import "math/bits"
func LeadingZeros32(x uint32) int
func Len32(x uint32) int
func OnesCount32(x uint32) int
func Reverse32(x uint32) uint32
func ReverseBytes32(x uint32) uint32
func RotateLeft32(x uint32, k int) uint32
func TrailingZeros32(x uint32) int
...
該包中包含每個函式的良好 Go 實現,但編譯器在硬體可用時也會替換為特殊的硬體指令。基於在 math/bits 上的經驗,Robert 和我都認為透過改變語言來提供額外的算術結果是不明智的,相反,我們應該在 math/bits 這樣的包中定義適當的函式。這裡最好的解決方案是庫更改,而不是語言更改。
在 Go 1.0 之後,我們可能想要解決的另一個問題是,goroutines 和共享記憶體使得在 Go 程式中引入競態 (races) 變得太容易,導致生產環境中的崩潰和其他異常行為。基於語言的解決方案本應是找到一種方法來禁止資料競態 (data races),使得編寫或至少編譯一個帶有資料競態的程式變得不可能。如何將這一點納入像 Go 這樣的語言,在程式語言領域仍然是一個懸而未決的問題。相反,我們在主要的 Go 分發包中添加了一個工具,並使其易於使用:那個工具,即競態檢測器 (race detector),已成為 Go 體驗中不可或缺的一部分。這裡最好的解決方案是執行時和工具鏈的更改,而不是語言更改。
當然,也會有語言更改,但並非所有問題都最好在語言層面解決。
釋出 Go 2

最後,我們將如何釋出和交付 Go 2?
我認為最好的計劃是逐步交付 Go 2 的向後相容部分 (backwards-compatible parts),一項一項地作為 Go 1 釋出序列的一部分。這有幾個重要的特性。首先,它使 Go 1 版本保持在常規釋出週期 (usual schedule)上,以持續提供使用者現在依賴的及時錯誤修復和改進。其次,它避免了在 Go 1 和 Go 2 之間分散開發工作。第三,它避免了 Go 1 和 Go 2 之間的分歧,以方便所有人的最終遷移。第四,它使我們能夠專注於一次交付一項更改,這有助於保持質量。第五,它將鼓勵我們設計具有向後相容性的功能。
在任何更改開始進入 Go 1 版本之前,我們需要時間進行討論和規劃,但我認為大約一年後,在 Go 1.12 左右,我們可能會開始看到一些小的更改。這也給了我們時間先落實包管理支援。
一旦所有向後相容的工作完成,比如說在 Go 1.20 中, then 我們可以進行 Go 2.0 中的向後不相容更改 (backwards-incompatible changes)。如果最終發現沒有向後不相容的更改,也許我們直接宣佈 Go 1.20 就是 Go 2.0。無論如何,屆時我們將從 Go 1.X 釋出序列的工作轉向 Go 2.X 序列,可能還會為最終的 Go 1.X 版本提供一個延長支援期。
這多少有些推測性,我剛才提到的具體版本號是大概估計的佔位符,但我想明確表示,我們不會放棄 Go 1,事實上,我們將盡可能大地帶上 Go 1 一同前行。
招募幫助
我們需要您的幫助。
關於 Go 2 的討論今天開始,它將在公開場合進行,例如在郵件列表和問題跟蹤器等公共論壇上。請在每一步都幫助我們。
今天,我們最需要的是使用體驗報告。請告訴我們 Go 對您來說如何順利工作,以及更重要的是,如何不順利工作。請撰寫一篇博文,包含真實的示例、具體的細節和真實的使用經驗。並將其連結到我們的維基頁面。這就是我們開始討論 Go 社群可能希望對 Go 進行哪些更改的方式。
謝謝。
下一篇文章:貢獻者峰會
上一篇文章:介紹開發者體驗工作組
部落格索引