Go 部落格

邁向 Go 2

Russ Cox
2017 年 7 月 13 日

簡介

[這是 我今天在 2017 年 Gophercon 上的演講全文,要求整個 Go 社群在我們討論和規劃 Go 2 時提供協助。]

2007 年 9 月 25 日,在 Rob Pike、Robert Griesemer 和 Ken Thompson 討論了一種新的程式語言幾天後,Rob 提出了「Go」這個名稱。

隔年,Ian Lance Taylor 和我加入團隊,我們五個人一起打造了兩個編譯器和一個標準函式庫,最終在 2009 年 11 月 10 日 進行了開源版本發行。

在接下來的兩年裡,透過新的 Go 開源社群的協助,我們實驗了各種大小的變更,細緻調整 Go 並且提出了 Go 1 計劃,這項計畫於 2011 年 10 月 5 日提出。

在 Go 社群的進一步協助下,我們修正並執行了該計畫,最後在 2012 年 3 月 28 日 發布了 Go 1。

Go 1 的發布標誌著長達五年有餘的創意熱情奮鬥的圓滿終結,它將我們從一個名稱和一系列想法帶入了一個穩定的生產語言。它還標誌著一個顯著的轉變,從變化和混亂轉變為穩定。

在 Go 1 發布前的幾年裡,我們每周對 Go 進行修改,從而打亂了每個人的 Go 程式。我們明白這會阻止 Go 在生產環境中使用,在生產環境中,程式無法每週改寫以適應語言的變更。正如 宣布 Go 1 的部落格文章 中所述,推動力是為建立可靠產品、專案和出版品(部落格、自學教程、會議演講和書籍)提供穩定的基礎,讓使用者相信他們的程式在未來幾年將會繼續編譯並執行而不會發生變更。

Go 1 發布後,我們知道需要花時間在 Go 專為其設計的生產環境中使用 Go。我們明確地從進行語言變更轉移到在我們自己的專案中使用 Go 和改善執行:我們將 Go 移植到許多新的系統,我們重寫了幾乎所有效能至關重要的片段以使 Go 更有效率地執行,並且我們增加了像 競爭偵測器 這樣的關鍵工具。

現在我們已有 5 年使用 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 之前,由錯誤類型實作的方法稱為 字串。在 Go 1 中,我們將它重新命名為 錯誤,以 區分錯誤類型和自己格式化的其他類型。前幾天,我在實作錯誤類型時, 不假思索地將其方法命名為 字串 而不是 錯誤,當然 這樣不會編譯。過了五年,我仍尚未完全忘掉舊有方式。這種旨在明確化 的重新命名方式是 Go 1 中的一項重要變更,但若是沒有非常充分的理由, 對 Go 2 來說可能會造成太大的干擾。

Go 2 也必須涵蓋所有現有的 Go 1 原始程式碼。我們不能分裂 Go 生態系統。混合程式,其中使用 Go 2 編寫的套件匯入使用 Go 1 編寫的套件,反之亦然,必須在長達數年的過渡期內毫不費力地運作。我們必須找出確切做法;自動化工具,如 go 修復,必定會發揮作用。

為了將干擾降至最低,每個變更都需要謹慎思考、規劃和使用工具,這反過來限制了我們可以進行的變更數量。或許我們可以進行二到三個變更,肯定不會超過五個。

我未將微小的維護變更列入計算,例如允許在更多語言中使用識別碼,或增加二進制整數文字。這些微小的變更也很重要,但更容易弄清楚。我今天專注於可能的重大變更上,例如,進一步支援錯誤處理,或引入不變或唯讀值,或增加其他形式的泛型,或其他一些尚未建議的重要主題。我們只能進行少數幾個重大的變更。我們必須謹慎選擇。

流程

這提出了重要問題。開發 Go 的流程為何?

Go 剛開始時,只有五個成員,我們在兩間相鄰的共用辦公室裡工作,中間隔著一道玻璃牆。我們很容易把所有人叫到同一間辦公室討論問題,然後再回去自己的座位實作解決方案。如果在實作過程中遇到難題,我們也很容易再次召集大家。Rob 和 Robert 的辦公室有一張小沙發和一個白板,所以通常我們會找其中一個人進去,在白板上寫範例。當範例寫完時,其他人都已經暫告一段落,可以坐下來討論。顯然這種非正式的方法不適用於全球現今的 Go 社群。

自從 Go 開源後,我們的部分工作就是將我們的非正式流程轉換到較正式的電子郵件清單和錯誤追蹤器,以及半百萬個使用者這類世界裡,但我認為我們從未明確描述我們的整體流程。也許我們從未有意識地思考過它。但回頭看來,我認為這正是我們開發 Go 的基本架構,也就是我們從第一個原型開始運作時就已遵循的流程。

步驟 1 是使用 Go,累積經驗。

步驟 2 是找出 Go 中需要解決的問題,並且清楚說明,向其他人解釋,寫下來。

步驟 3 是為問題提出解決方案,與其他人討論,並依據討論結果修改解決方案。

步驟 4 是實作解決方案,評估解決方案,並依據評估結果改善解決方案。

最後,步驟 5 是發布解決方案,將其加入語言、函式庫或是人們每天使用的工具組中。

同一個人不必執行變更的所有這些步驟。事實上,通常許多人會協作執行任何特定步驟,並且針對單一問題可能會提出許多解決方案。此外,我們在任何時候都可能會發現我們不想要深入追究特定想法,並回到較早前的步驟。

儘管我不認為我們曾將此流程整體來說出來,但我們已解釋過它的部分內容。2012 年,當我們發布 Go 1 並表示現在是使用 Go 而停止變更 Go 的時候,我們就是在說明步驟 1。2015 年,當我們引入 Go 變更提案流程時,我們說明的是步驟 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」以取得 os.Error。然後有五行程式碼,我已在這裡加上底線,說明問題的重要性:「os」所使用的套件本身無法在其 API 中顯示錯誤,而其他套件依賴「os」的理由與作業系統服務無關。

這五行程式碼能說服這個問題很重要嗎?這取決於您能將我省去的脈絡填入到什麼程度:要讓別人理解,需要預期他們需要知道什麼。對於我當時的受眾(在 Google 閱讀該文件的 Go 團隊的另外十個人)來說,這五十個單字就已足夠。要向去年秋天的 GothamGo 觀眾(背景和專業領域差異相當大的觀眾)說明相同的問題,我需要提供更多的脈絡,而我使用了約兩百個單字,以及實際的程式碼範例和一張簡圖。事實上,在現今世界各地的 Go 社群中,描述任何問題的重要性都需要加入脈絡,特別是用具體範例說明,而這是您在與同事對話時不會做的。

說服他人某個問題非常重要,這是必要的步驟。當一個問題看似微不足道,幾乎所有解決方案都會顯得過於昂貴。但對於一個重大的問題,通常有許多合理成本的解決方案。當我們對於是否採用特定解決方案意見分歧時,我們常常實際上是在對所解決問題的重要性意見分歧。這非常重要,因此我想來看看兩個最近的範例,回顧起來,這兩個範例清楚地表明了這一點。

範例:閏秒

我的第一個範例是關於時間。

假設你想要測量一個事件花了多久的時間。你寫下開始時間,執行事件,寫下結束時間,然後再將開始時間從結束時間減去。如果事件花了 10 毫秒,此減法會得出 10 毫秒的結果,或許還會加上或減去微小的測量誤差。

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 秒的那分鐘,而是透過在所謂的午夜之前將時鐘往後調 1 秒來實作閏秒,這樣晚上 11:59 分和 59 秒就會出現兩次。這種時鐘重設會讓時間看起來往後走,因此我們 10 毫秒的事件可能會被測量為 -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

因為時鐘在像這樣的時鐘重設期間測量事件的時間不準確,因此作業系統現在提供了第二個時鐘,稱為單調時鐘,它沒有絕對意義,但會計算秒數且絕不會重設。

在奇怪的時鐘重設期間除外,單調時鐘並不比時鐘更好,而且時鐘還有其他好處,即可以用於查看時間,因此為了簡單起見,Go 1 的時間 API 僅會顯示時鐘。

2015 年 10 月,錯誤回報 指出 Go 程式無法在時脈重設期間正確計時事件,特別是一個典型的閏秒。建議的修復程式也是原始的標題:「新增一個新的 API 以存取單調時脈來源」。我認為這個問題並不嚴重到需要調用新的 API。幾個月前,Akamai、Amazon 和 Google 在 2015 年年中時減少了時脈一丁點的量,讓時脈吸收額外的秒數,而不將時脈往回調。看似最終廣泛採用的「閏秒抹除」方法將讓生產系統上的閏秒時脈重設問題消失。相較之下,新增 API 給 Go 將會增加新問題:我們必須解釋兩種時脈、讓使用者了解什麼時候使用每一個時脈,並轉換許多行程式碼中的行程式碼,而這一切都是為了罕見發生的問題,且這問題有可能自行消失。

當出現一個沒有明確解決方案的問題時,我們會做我們一直做的事情:等待。等待讓我們有更多時間來累積經驗和理解這個問題,同時也有更多時間找到好的解決方案。在這個案例中,等待讓我們更了解這個問題的重要性,所採取的方式是感謝 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 中支援別名宣告。

在過去幾年,Google 建立了一個專注於大規模程式碼變更的團隊,也就是我們在數百萬個原始檔和數十億行程式碼中,以 C++、Go、Java、Python 等語言編寫而成的 程式碼庫中 的 API 移轉和錯誤修正。從該團隊的工作,我學習到一件事,那就是當將 API 從使用一個名稱變更為另一個名稱時,能夠分為多個步驟更新用戶程式碼的重點不在一次全部完成。為執行這項工作,必須撰寫一份宣告,將舊名稱轉向新的名稱。C++ 使用 #define、typedef 和 using 宣告來促成這項轉向,但 Go 卻一無所有。當然,Go 的目標之一就是擴充到大型程式碼庫,而隨著 Google 中 Go 程式碼的數量增長,我們發現我們需要某種轉向機制,而且其他專案和公司會在他們的 Go 程式碼庫成長時遇到這個問題。

在 2016 年 3 月,我開始和 Robert Griesemer 與 Rob Pike 討論 Go 如何處理漸進式的程式碼庫更新,最後我們得出別名宣告,這正是我們需要的轉向機制。在這個時候,我對 Go 的演進方式感到很有信心。我們從 Go 的早期就討論過別名,事實上,第一份規格草案中就有一個 使用別名宣告範例,但是每次我們討論別名、以及後來的型別別名時,我們都沒有明確的使用案例,所以我們就略過了。現在我們建議加入別名,並非因為它們是優雅的概念,而是因為它們解決了 Go 程式碼軟體開發的可擴充性目標遭到一個重要的實際問題阻礙的情況。我希望這能作為未來 Go 變更的模型。

在春天晚些時候,Robert 和 Rob 撰寫了一份 提案,而 Robert 在一場 Gophercon 2016 一分鐘演講 中提出了這項提案。接下來的幾個月並不是很順利,它們絕對不是未來 Go 變更的模型。我們學到的許多教訓之一,就是說明問題重要性的重要性。

一分鐘前,我向你解釋問題,提供了一些關於問題是如何產生以及為何如此的背景說明,但沒有提供任何具體範例來協助你評估問題是否可能在你某個時間點影響你。去年夏天的提案和閃電簡報提供一個抽象範例,包含套件 C、L、L1 以及 C1 到 Cn,但沒有開發人員可以理解的具體範例。因此,社區的大部分意見回饋是以別名僅為 Google 解決問題,而非其他所有人為基礎。

正如 Google 起初並未了解正確處理閏秒時間調整的重要性,我們也未能有效地向廣大的 Go 社群傳達逐步程式碼遷移和修復在大型變更期間的重要性。

在秋天我們重新開始。我發表演說 討論 和撰文 提出問題,使用從開放原始碼程式碼庫摘錄的多個具體範例,說明這個問題如何反映在各個地方,而並非僅存在於 Google 內部。在更多人了解問題並能預見其重要性後,我們進行了一場 卓有成果的討論,探討哪種解決方案會是最棒的。結果是 類型別名 將會 包含在 Go 1.9 中,並將協助 Go 擴展到更龐大的程式碼庫。

經驗報告

這裡的教訓是,以一種不同環境下的工作人員也能了解的方式描述問題的重要性,這不僅困難而且必要。為了針對 Go 作為一個社群進行重大變更的討論,我們需要特別注意描述我們想要解決的問題的重要性。要做到這一點最清楚的方式是顯示問題如何影響實際程式和實際製作系統,例如 Cloudflare 部落格文章我的重構文章 中。

像這樣的經驗報告將一個抽象問題轉化成具體問題,並協助我們了解其重要性。它們也可用作測試案例:任何提議的解決方案都可以透過檢視其對報告中描述的實際現實世界問題的影響來評估。

舉例來說,我最近在研究泛型,但腦海中對於 Go 使用者需要泛型來解決什麼詳細具體的問題,並沒有明確的圖像。因此,我無法回答諸如是否支援泛型方法等設計問題,也就是說方法會獨立於接收者進行參數化。如果我們有一組大型的真實世界的使用案例,我們可以透過檢視這些重大案例,來開始回答類似這樣的問題。

作為另一個範例,我曾看過各種變更錯誤介面的建議,但我沒有看過任何經驗報告顯示大型 Go 程式如何嘗試了解和處理錯誤,更別提顯示目前的錯誤介面如何妨礙這些嘗試了。這些報告將有助於我們更了解問題的詳細資訊和重要性,這是我們在解決問題前必須執行的步驟。

我可以繼續舉例。對 Go 所做的每個重要的潛在變更,都應該受到一份或多份經驗報告的激勵,在報告中說明人們如何使用目前的 Go,以及為什麼使用情況不佳。對於我們可能考慮的 Go 的明顯重大變更,我並未發現有許多此類報告,尤其是沒有用真實世界範例說明的報告。

這些報告是 Go 2 提案程序的原始資料,我們需要各位撰寫它們,協助我們了解各位使用 Go 的經驗。你們有五十萬人,在各式各樣的環境中工作,我們的夥伴卻不多。在你的部落格上撰寫文章,或撰寫一篇Medium 文章,或撰寫一篇GitHub Gist(為 Markdown 新增副檔名 .md),或撰寫一篇Google 文件,或使用其他任何你喜歡的發佈機制。發佈後,請將文章加入我們新的 wiki 網頁golang.org/wiki/ExperienceReports

解決方案

現在我們了解如何辨識和說明需要解決的問題後,我想要簡短說明,並非所有問題都能靠變更語言來解決,而且這沒有關係。

我們可能想要解決的問題之一是在基本算術運算期間,電腦經常可以計算出額外的結果,但 Go 沒有提供讓你可以直接存取那些結果。2013 年,Robert 提出我們可以將雙結果(「comma-ok」)表達式的概念延伸到基本算術運算中。舉例來說,如果 x 和 y 是,舉例來說,uint32 值,lo, hi = x * y 將傳回不只一般的低 32 位元,也傳回乘積的高 32 位元。這個問題似乎不太重要,因此我們記錄了潛在的解決方案,但並未實作這項解決方案。我們持觀望態度。

最近,我們針對 Go 1.9 設計了一個包含各式各樣位元運算函數的math/bits 套件

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 之後解決的不同問題,是 goroutine 及共用記憶體讓 Go 程式過於容易出現競爭狀態,在正式環境中導致崩潰或其他異常行為。基於語言的解決方案是找到一些方法禁止資料競爭,讓無法撰寫或至少編譯載有資料競爭的程式。該如何將這功能放入類似於 Go 的語言中,在程式語言領域中仍為一個公開的問題。我們在主程式中新增一個工具,並使其使用變得非常容易:該工具 競爭偵測器 已成為 Go 體驗中不可或缺的一部分。最佳的解決方案是一個執行時期與工具變更,而非語言變更。

當然也將有語言變更,但是並非所有問題都最適合在語言中解決。

推出 Go 2

最後,我們要如何推出 Go 2?

我認為,最佳的計畫是將 Go 2 的 向後相容部分 以逐步的方式遞增發佈,各項功能依序附在 Go 1 版本發行序列中。此方法有幾個重要的特性。首先,這讓 Go 1 版本維持在 慣例的時程 上,以持續提供使用者目前依賴的及時錯誤修正及改善。其次,避免讓開發工作分散在 Go 1 與 Go 2 上。第三,避免 Go 1 與 Go 2 之間產生差異,讓所有人的最終遷移更為順利。第四,可讓我們一次專注於一項變更並實作,這有助於維護品質。第五,可鼓勵我們設計出向後相容的功能。

在任何變更登陸到 Go 1 版本之前,我們都需要時間來討論和規劃,但是在我看來,似乎有機會在大約一年後,用於 Go 1.12 等版本的微小變更可能會開始出現。這也可讓我們有時間先完成套件管理支援工作。

在所有向後相容的工作都完成後,例如在 Go 1.20 中,我們就能在 Go 2.0 中進行向後不相容的變動。如果沒有任何向後不相容的變動,也許我們只需宣告 Go 1.20 即是 Go 2.0。無論如何,到那一點後,我們將從 Go 1.X 發行序列的作業過渡到 Go 2.X 序列,也許最終的 Go 1.X 發行版本會有延展的支援時程。

這一切都有些投機,我剛才提到的特定發行號是近似估計的佔位符,但我想清楚表示我們並不會放棄 Go 1,事實上,我們將儘最大努力繼續進行 Go 1。

徵求協助

我們需要你的幫助。

有關 Go 2 的對話從今天開始,它將在公開場合進行,例如電子郵件清單和問題追蹤器等公開論壇。請在整個過程中協助我們。

今天,我們最需要的便是體驗報告。請告訴我們 Go 幫你做了些什麼,或者更重要的是,對你沒有幫助的地方。撰寫網誌文章,包括真實範例、具體細節和真實體驗。並在我們的wiki 頁面鏈結它。這就是我們開始談論我們 Go 社群可能想對 Go 進行哪些改變的方式。

謝謝。

下一篇文章:貢獻者高峰會
上一篇文章:介紹開發人員體驗工作小組
網誌索引