這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Go在Google:以軟體工程為目的的語言設計
1. 摘要
(本文是根據Rob Pike於2012年10月25日在Tucson, Arizona舉行的SPLASH 2012大會上所做的主題演講進行修改後所撰寫的。)
針對我們在Google公司內開發軟體基礎設施時遇到的一些問題,我們於2007年末構思出Go程式設計語言。當今的計算領域同建立如今所使用的程式設計語言(使用最多的有C++、Java和Python)時的環境幾乎沒什麼關係了。由多核處理器、系統的網路化、大規模電腦叢集和Web編程模型帶來的編程問題都是以迂迴的方式而不是迎頭而上的方式解決的。此外,程式的規模也已發生了變化:現在的伺服器程式由成百上千甚至成千上萬的程式員共同編寫,原始碼也以數百萬行計,而且實際上還需要每天都進行更新。更加雪上加霜的是,即使在大型編譯叢集之上進行一次build,所花的時間也已長達數十分鐘甚至數小時。
之所以設計開發Go,就是為了提高這種環境下的工作效率。Go語言設計時考慮的因素,除了大家較為瞭解的內建並發和記憶體垃圾自動回收這些方面之外,還包括嚴格的依賴管理、對隨系統增大而在體繫結構方面發生變化的適應性、跨組件邊界的健壯性(robustness)。
本文將詳細講解在構造一門輕量級並讓人感覺愉悅的、高效的編譯型程式設計語言時,這些問題是如何得到解決的。講解過程中使用的例子都是來自Google公司中所遇到的現實問題。
2. 簡介
Go語言開發自Google,是一門支援並發編程和記憶體記憶體回收的編譯型靜態類型語言。它是一個開源的項目:Google從公用的程式碼程式庫中匯入代碼而不是相反。
Go語言運行效率高,具有較強的延展性(scalable),而且使用它進行工作時的效率也很高。有些程式員發現用它編程很有意思;還有一些程式員認為它缺乏想象力甚至很煩人。在本文中我們將解釋為什麼這兩種觀點並不相互矛盾。Go是為解決Google在軟體開發中遇到的問題而設計的,雖然因此而設計出的語言不會是一門在研究領域裡具有突破性進展的語言,但它卻是大型軟體項目中軟體工程方面的一個非常棒的工具。
3. Google公司中的Go語言
為了協助解決Google自己的問題,Google設計了Go這門程式設計語言,可以說,Google有很大的問題。
硬體的規模很大而且軟體的規模也很大。軟體的程式碼數以百萬計,伺服器軟體絕大多數用的是C++,還有很多用的是Java,剩下的一部分還用到了Python。成千上萬的工程師在這些代碼上工作,這些代碼位於由所有軟體組成的一棵樹上的“頭部”,所以每天這棵樹的各個層次都會發生大量的修改動作。儘管使用了一個大型自主設計的分布式Build系統才讓這種規模的開發變得可行,但這個規模還是太大 了。
當然,所有這些軟體都是運行在無數台機器之上的,但這些無數台的機器只是被看做數量並不多若干互相獨立而僅通過網路互相串連的電腦叢集。
簡言之,Google公司的開發規模很大,速度可能會比較慢,看上去往往也比較笨拙。但很有效果。
Go項目的目標是要消除Google公司軟體開發中的慢速和笨拙,從而讓開發過程更加高效並且更加具有延展性。該語言的設計者和使用者都是要為大型軟體系統編寫、閱讀和調試以及維護代碼的人。
因此,Go語言的目的不是要在程式設計語言設計方面進行科研;它要能為它的設計者以及設計者的同事們改善工作環境。Go語言考慮更多的是軟體工程而不是程式設計語言方面的科研。或者,換句話說,它是為軟體工程服務而進行的語言設計。
但是,程式設計語言怎麼會對軟體工程有所協助呢?下文就是該問題的答案。
4. 痛之所在
當Go剛推出來時,有人認為它缺乏某些大家公認的現代程式設計語言中所特有的特性或方法論。缺了這些東西,Go語言怎麼可能會有存在的價值?我們回答這個問題的答案在於,Go的確具有一些特性,而這些特性可以解決困擾大規模軟體開發的一些問題。這些問題包括:
- Build速度緩慢
- 失控的依賴關係
- 每個程式員使用同一門語言的不同子集
- 程式難以理解(代碼難以閱讀,文檔不全面等待)
- 很多重複性的勞動
- 更新的代價大
- 版本偏斜(version skew)
- 難以編寫自動化工具
- 語言交叉Build(cross-language build)產生的問題
一門語言每個單個的特性都解決不了這些問題。這需要從軟體工程的大局觀,而在Go語言的設計中我們試圖致力於解決所有這些問題。
舉個簡單而獨立的例子,我們來看看程式結果的表示方式。有些評論者反對Go中使用象C一樣用花括弧表示塊結構,他們更喜歡Python或Haskell風格式,使用空格表示縮排。可是,我們無數次地碰到過以下這種由語言交叉Build造成的Build和測試失敗:通過類似SWIG調用的方式,將一段Python代碼嵌入到另外一種語言中,由於修改了這段代碼周圍的一些代碼的縮排格式,從而導致Python代碼也出乎意料地出問題了並且還非常難以覺察。 因此,我們的觀點是,雖然空格縮排對於小規模的程式來說非常適用,但對大點的程式可不盡然,而且程式規模越大、程式碼程式庫中的代碼語言種類越多,空格縮排造成的問題就會越多。為了安全可靠,捨棄這點便利還是更好一點,因此Go採用了花括弧表示的語句塊。
5.C和C++中的依賴
在處理包依賴(package dependency)時會出現一些伸縮性以及其它方面的問題,這些問題可以更加實質性的說明上個小結中提出的問題。讓我們先來回顧一下C和C++是如何處理包依賴的。
ANSI C第一次進行標準化是在1989年,它提倡要在標準的標頭檔中使用#ifndef這樣的”防護措施”。 這個觀點現已廣泛採用,就是要求每個標頭檔都要用一個條件編譯語句(clause)括起來,這樣就可以將該標頭檔包含多次而不會導致編譯錯誤。比如,Unix中的標頭檔<sys/stat.h>看上去大致是這樣的:
12345 |
/* Large copyright and licensing notice */#ifndef _SYS_STAT_H_#define _SYS_STAT_H_/* Types and other definitions */#endif |
此舉的目的是讓C的前置處理器在第二次以及以後讀到該檔案時要完全忽略該標頭檔。符號_SYS_STAT_H_在檔案第一次讀到時進行定義,可以“防止”後繼的調用。
這麼設計有一些好處,最重要的是可以讓每個標頭檔能夠安全地include它所有的依賴,即時其它的標頭檔也有同樣的include語句也不會出問題。 如果遵循此規則,就可以通過對所有的#include語句按字母順序進行排序,讓代碼看上去更整潔。
但是,這種設計的延展性非常差。
在1984年,有人發現在編譯Unix中ps命令的來源程式ps.c時,在整個的預先處理過程中,它包含了<sys/stat.h>這個標頭檔37次之多。儘管在這麼多次的包含中有36次它的檔案的內容都不會被包含進來,但絕大多數C編譯器實現都會把”開啟檔案並讀取檔案內容然後進行字串掃描”這串動作做37遍。這麼做可真不聰明,實際上,C語言的前置處理器要處理的宏具有如此複雜的語義,其勢必導致這種行為。
對軟體產生的效果就是在C程式中不斷的堆積#include語句。多加一些#include語句並不會導致程式出問題,而且想判斷出其中哪些是再也不需要了的也很困難。刪除一條#include語句然後再進行編譯也不太足以判斷出來,因為還可能有另外一條#include所包含的檔案中本身還包含了你剛剛刪除的那條#include語句。
從技術角度講,事情並不一定非得弄成這樣。在意識到使用#ifndef這種防護措施所帶來的長期問題之後,Plan 9的library的設計者採取了一種不同的、非ANSI標準的方法。Plan 9禁止在標頭檔中使用#include語句,並要求將所有的#include語句放到頂層的C檔案中。 當然,這麼做需要一些訓練 —— 程式員需要一次列出所有需要的依賴,還要以正確的順序排列 —— 但是文檔可以幫忙而且實踐中效果也非常好。這麼做的結果是,一個C來源程式檔案無論需要多少依賴,在對它進行編譯時間,每個#include檔案只會被讀一次。當然,這樣一來,對於任何#include語句都可以通過先拿掉然後在進行編譯的方式判斷出這條#include語句到底有無include的必要:若且唯若不需要該依賴時,拿掉#include後的來源程式才能仍然可以通過編譯。
Plan 9的這種方式產生的一個最重要的結果是編譯速度比以前快了很多:採用這種方式後編譯過程中所需的I/O量,同採用#ifndef的庫相比,顯著地減少了不少。
但在Plan 9之外,那種“防護”式的方式依然是C和C++編程實踐中大家廣為接受的方式。實際上,C++還惡化了該問題,因為它把這種防護措施使用到了更細的粒度之上。按照慣例,C++程式通常採用每個類或者一小組相關的類擁有一個標頭檔這種結構,這種分組方式要更小,比方說,同<stdio.h>相比要小。因而其依賴樹更加錯綜複雜,它反映的不是對庫的依賴而是對完整類型階層的依賴。而且,C++的標頭檔通常包含真正的代碼 —— 類型、方法以及模板聲明 ——不像一般的C語言標頭檔裡面僅僅有一些簡單的常量定義和函數簽名。這樣,C++就把更多的工作推給了編譯器,這些東西編譯起來要更難一些,而且每次編譯時間編譯器都必須重複處理這些資訊。當要build一個比較大型的C++二進位程式時,編譯器可能需要成千上萬次地處理標頭檔<string>以瞭解字串的表示方式。(根據當時的記錄,大約在1984年,Tom Cargill說道,在C++中使用C前置處理器來處理依賴管理將是個長期的不利因素,這個問題應該得到解決。)
在Google,Build一個單個的C++二進位檔案就能夠數萬次地開啟並讀取數百個標頭檔中的每個標頭檔。在2007年,Google的build工程師們編譯了一次Google裡一個比較主要的C++二進位程式。該檔案包含了兩千個檔案,如果只是將這些檔案串接到一起,總大型為4.2M。將#include完全擴充完成後,就有8G的內容丟給編譯器編譯,也就是說,C++原始碼中的每個自己都膨脹成到了2000位元組。 還有一個資料是,在2003年Google的Build系統轉變了做法,在每個目錄中安排了一個Makefile,這樣可以讓依賴更加清晰明了並且也能好的進行管理。一般的二進位檔案大小都減小了40%,就因為記錄了更準確的依賴關係。即使如此,C++(或者說C引起的這個問題)的特性使得自動對依賴關係進行驗證無法得以實現,直到今天我們仍然我發準確掌握Google中大型的C++二進位程式的依賴要求的具體情況。
由於這種失控的依賴關係以及程式的規模非常之大,所以在單個的電腦上build出Google的伺服器二進位程式就變得不太實際了,因此我們建立了一個大型分布式編譯系統。該系統非常複雜(這個Build系統本身也是個大型程式)還使用了大量機器以及大量緩衝,藉此在Google進行Build才算行得通了,儘管還是有些困難。 即時採用了分布式Build系統,在Google進行一次大規模的build仍需要花幾十分鐘的時間才能完成。前文提到的2007年那個二進位程式使用上一版本的分布式build系統花了45分鐘進行build。現在所花的時間是27分鐘,但是,這個程式的長度以及它的依賴關係在此期間當然也增加了。為了按比例增大build系統而在工程方面所付出的勞動剛剛比軟體建立的增長速度提前了一小步。
6. 走進 Go 語言
當編譯緩慢進行時,我們有充足的時間來思考。關於 Go 的起源有一個傳說,話說正是一次長達45分鐘的編譯過程中,Go 的設想出現了。人們深信,為類似Google網路服務這樣的大型程式編寫一門新的語言是很有意義的,軟體工程師們認為這將極大的改善Google程式員的生活品質。
儘管現在的討論更專註於依賴關係,這裡依然還有很多其他需要關注的問題。這一門成功語言的主要因素是:
- 它必須適應於大規模開發,如擁有大量依賴的大型程式,且又一個很大的程式員團隊為之工作。
- 它必須是熟悉的,大致為 C 風格的。Google的程式員在職業生涯的早期,對函數式語言,特別是 C家族更加熟稔。要想程式員用一門新語言快速開發,新語言的文法不能過於激進。
- 它必須是現代的。C、C++以及Java的某些方面,已經過於老舊,設計於多核電腦、網路和網路應用出現之前。新方法能夠滿足現代世界的特性,例如內建的並發。
說完了背景,現在讓我們從軟體工程的角度談一談 Go 語言的設計。