寫給想去字節寫 Go 的你
寫作本文的原因是回答知乎提問:為什么字節跳動選擇使用 Go 語言?
先做下自我介紹,目前人在字節做后端開發,工作語言主要是 Go,字節萬千面試官之一,出版圖書《C++ 服務器開發精髓》一書。在來字節使用 Go 之前,我寫了多年 C/C++ 和 Java。
為什么字節跳動選擇使用 Go 語言?
確實如題主所說,字節后端服務大多使用 Go。那為什么字節跳動選擇使用 Go 語言呢?
目前后端服務能勝任大型項目的編程語言有 C/C++、Java 和 Go,在給出答案之前,我們先看幾個編程語言之間對比的例子。
例子一
有下面一段 Go 代碼:
- // Go代碼
- func CreateItem(id int, name string) *Item {
- myItem := Item{ID: id, Name: name}
- return &myItem
- }
C/C++ 轉 Go 語言的同學剛開始看到這樣的代碼可以正常運行是不敢相信的,在早些年的計算機的編程語言入門課中,老師和無數課本一直告誡我們:不要返回一個局部變量(棧變量)的地址,因為在函數調用結束后,棧被銷毀,引用已經銷毀的棧中的變量可能會出現內存問題。然而,這樣的代碼在 Go 中工作的很好,也很常用。
然而 Java 開發的同學就很習慣這樣的代碼,因為在 Java 中隨處可見這樣的代碼,但是,Java 中創建 myItem 這樣的對象畢竟使用的是堆內存呀。如果熟悉 Java 虛擬機回收內存的常見方法,對 Go 語言中這種用法也會了然于心。Go 開發者之所以可以這樣寫代碼,是因為 Go 編譯器替我們做了額外的內存分配和回收工作。從這個意義上來講,C++ 也可以使用堆內存實現同樣的功能,代碼如下:
- // C++代碼
- Item* CreateItem(int id, string name) {
- Item* myItem = new Item(id, name);
- return myItem;
- }
在 C++ 中需要開發者自己記得在必要的時候釋放 myItem 占用的內存,當然,從 C++ 的角度來說,這樣的做法是一種不好的實踐:C++ 最佳實踐建議內存是誰分配的就由誰來釋放。在這個函數中分配內存,在另外一個函數中釋放內存,一般不推薦這么做。
例子二
前段時間有位同學來面試,我看這位同學簡歷上寫了熟悉 C++ 和 Go,我就問這位同學覺得 Go 語言中有哪些好用的特性,該同學提到了 defer 關鍵字,于是我們就 Go 中的 defer 關鍵字討論了一下,我的問題是:用 C++ 可以實現 defer 關鍵字的等價功能嗎?
以下是該同學的回答:
結論是可以的,因為 Go 中 defer 關鍵字是在當前函數執行完畢(或者說是出了當前函數作用域)時,自動執行一些我們指定的動作,一般用于資源的回收。代碼如下:
- //Go代碼
- func ReadFile(fileName string) {
- myFile, err := os.Open(fileName)
- if err != nil {
- return
- }
- //讀取文件的過程中,無論哪一步出錯,最終文件均會被關閉
- defer myFile.Close()
- myData1 := make([]byte, 5)
- _, err = myFile.Read(myData1)
- if err != nil {
- return
- }
- fmt.Println(string(myData1))
- myData2 := make([]byte, 5)
- _, err = myFile.Read(myData2)
- if err != nil {
- return
- }
- fmt.Println(string(myData2))
- }
上述 Go 代碼中,由于使用了 defer 語句,無論哪一步的 myFile.Read 函數操作失敗了,最終文件都會被關閉,避免了文件句柄的泄漏。
在 C++ 中我們可以使用 RAII 技術實現同樣的效果,只要構造一個 RAII 類即可,即在類的析構函數關閉文件,這樣這個文件一旦打開,只要出了函數作用域都會被關閉。代碼如下:
- //C++代碼
- class File
- {
- public:
- File()
- {
- }
- ~File()
- {
- Close();
- }
- bool Open(const std::string& fileName)
- {
- m_myFile = fopen(fileName.c_str(), "r");
- return m_myFile != NULL;
- }
- int Read(char* buf, int length)
- {
- if (m_myFile == NULL)
- return -1;
- return fread(buf, 1, length, m_myFile);
- }
- void Close()
- {
- if (m_myFile != NULL)
- {
- fclose(m_myFile);
- }
- }
- private:
- FILE* m_myFile;
- };
- void ReadFile(const char* fileName)
- {
- //當myFile出了作用域之后,會自動調用File類的析構函數,在析構函數中關閉文件句柄
- File myFile;
- bool success = myFile.Open(fileName);
- if (!success)
- return;
- char myData1[6] = {0};
- int count1 = myFile.Read(myData1, 5);
- if (count1 <= 0)
- return;
- std::cout << myData1 << std::endl;
- char myData2[6] = {0};
- int count2 = myFile.Read(myData2, 5);
- if (count2 <= 0)
- return;
- std::cout << myData2 << std::endl;
- }
上述 C++ 代碼中利用了 RAII 技術達到了和 Go defer 關鍵字一樣的效果,這位面試的同學表達的就是這個意思。
當然,這位同學回答的并不完整,Go 的 defer 關鍵字所能達到的一些作用,C++ 中沒有與之等價的功能。
在 C++ 中,雖然在大多數情況下可以使用 RAII 技術達到在出了函數作用域時執行我們指定的動作,但是有一種情況 defer 關鍵字可以做到,C++ RAII 卻做不到,那就是當函數執行過程中有崩潰問題(Go 中叫 panic)時,可以在 defer 指定的動作中恢復程序的執行流,不讓整個進程退出,而 C++ 程序是無法做到針對一個內存問題造成的 crash 恢復進程繼續執行的。Go 代碼如下:
- //Go代碼
- func RecoverFromCrash() {
- defer func() {
- //如果程序有崩潰,恢復程序
- recover()
- }()
- var pi *int
- *pi = 1
- }
- func main() {
- i := 1
- RecoverFromCrash()
- i = 2
- fmt.Println(i)
- }
上述 Go 代碼中,由于 RecoverFromCrash 函數操作了一個空指針,導致程序崩潰,然后 defer 配合 recover 函數可以恢復程序繼續運行。也就是說,如果采用這種機制,一個 goroutine 產生的崩潰不會影響到其他 goroutine。這在 C++ 中是絕對無法做到的,在 C++ 程序中,任何內存問題都會導致整個進程退出。這外在表現就是,使用 Go 開發出來的程序,一個接口有問題不會影響到同一個服務中不相關的接口,更不會導致整個服務宕機,這是 C++ 程序絕對無法做到的。
除此以外, defer 關鍵字還做了更多工作,defer 關鍵字在程序崩潰時可以恢復部分數據,這點很有用,我們可以基于此記錄一些程序崩潰時的現場值,這在 C++ 中根本做不到這一點。代碼如下:
- //Go代碼
- func ProcessRequest(req Request) (resp Response, log LoggerItem, err error) {
- defer func() {
- recover()
- }()
- resp = DoProcess1(req)
- resp = DoProcess2(req)
- //倘若程序在此處崩潰,我們可以在程序recover以后,仍然拿到前兩步處理后的resp值
- resp = DoProcess3(req)
- resp = DoProcess4(req)
- return
- }
C++ 開發者無數次幻想過有一種方法可以在進程內恢復因內存問題而崩潰的進程,更不用說記錄進程崩潰時的現場數據了,而這在 Go 中均做到了,而且如此簡單易用。C++ 開發者看到這里眼淚要流下來了。
作為面試官,該面試者能想到 C++ RAII 技術可以等價部分 Go 的 defer 關鍵字功能,我已經很滿意了,倘若能更進一步,就很完美了。
對于 Java 來說,我們可以使用 try -catch - finally 關鍵字實現 defer 的上述功能,只要將代碼如下:
- //Java代碼
- try {
- } catch (SomeException e) {
- } finally {
- //Go中的defer需要做的工作放在這里
- }
雖然在 Java 中可以把 Go 中 defer 需要做的事情放到 finally 部分,但是實際業務中導致程序出現異常有很多原因,為了避免進程不因為未處理的異常而退出,我們必須捕獲頂級 Exception,在 Java 中這是一種不推薦的方式。
好了,說完這兩個例子之后,讓我們來對比一下 Go 與 C/C++/Java 的優缺點。
性能與效率上的對比
C++ 最讓人詬病的問題是需要開發者自己管理內存,從學習這塊知識的角度來看,編碼中直接管理內存是管理不好的,開發者必須學習與內存相關的各種操作系統原理,最起碼要知道物理內存與虛擬內存、棧內存與堆內存、內存分配與釋放時機、進程地址空間的內存分布、各個內存地址區間的內存讀寫屬性、如何避免內存越界等等相關知識,而這些知識不是一蹴而就就能學會的,所以如果某個開發者能寫出一個經年累月不需要重啟或者不宕機的 C++ 服務,那他是個高手。但是在快速迭代業務的互聯網公司,怎么可能人人都是高手呢?萬一某天來了個新人加了一個新功能,導致一個隱蔽的內存問題,之后就是惱人的問題排查與定位。
C++ 自己管理內存是把雙刃劍,高手可以用來寫出高效的程序來,但是對于新手或者水平不夠的開發者來說,這將是企業產品事故甚至災難的源泉。
再者,拜當下各種焦慮的、淺嘗輒止的、急功近利的網文的宣傳,無論是個人還是公司,已經沒有多少人愿意把時間花在學習周期長、難度大的 C++ 語言之上了。只要在滿足業務要求的情況下,公司當然愿意花更低的成本去使用更能保證業務快速、穩定迭代的 Go 語言上了。
那么 Java 呢?Java 最大的問題是,其編譯出來的程序不是操作系統原生支持的可執行文件,必須運行在 Java 虛擬機之中,這樣要想運行必須依賴于 Java 虛擬機,而對于復雜業務來說,生成的 Jar 文件也偏臃腫。所以無論是安裝 Java 程序的本身需要的運行環境還是生成的 Jar 文件的執行效率大大折扣。
我列一張表來對比一下 C++、Java 與 Go 在性能與可執行文件體積上的差別,需要說明一下,這張表是針對具有復雜功能的中大型項目來說的:
執行效率 | 可執行文件體積 | 依賴運行環境 | |
---|---|---|---|
C++ | 高 | 小 | 無 |
Java | 低 | 大 | Java 虛擬機 |
Go | 高 | 小 | 無 |
語法層面上的對比
工作的早些年,我在使用 C/C++ 和 Java 進行編程時,曾思考這樣一個問題,既然大多數的代碼行末尾必須都要以分號結束,那為啥編譯器不直接代勞此事?從編譯原理的角度來說,大多數代碼行末尾的分號都是沒有任何作用的。
而更早的學生時代,我常常因為忘記在某些代碼行的結尾寫上分號而導致代碼無法編譯通過,我相信,在今天,數以萬計的剛開始接觸編程的同學也遇到和我曾經一樣的問題。
另外一個情形就是很多同學在寫 switch - case 語句的時候,有時候因為忘記在特定的 case 語句之后寫上 break 語句,從而導致程序執行時出現非預期的行為,這個問題也同樣困擾著學習編程的新人們。
一對大括號中的第一個大括號是否要單獨放在一行;if/for 等執行體只有一條語句時,是否應該使用一對大括號包裹起來,這類問題在開發者之間爭論了幾十年,并且將繼續在后來者那里爭論下去,就算是像《代碼大全》這樣經典的書籍也花了好幾頁去討論這兩種代碼風格哪種好,更不用說各個公司為了統一編碼風格而制定的各種代碼規范和 lint 檢查規則了。
- //到底哪種風格好呢?
- //風格1
- void DoTest() {
- }
- //風格2
- void DoTest()
- {
- }
- //風格1
- if (success) {
- printf("success");
- }
- //風格2
- if (success)
- printf("success");
繼往開來,Go 語言大刀闊斧地去除了一些其他語言中看起來不是很必要的功能,這些功能的去除讓 Go 的風格變得統一、簡潔,在 Go 項目中,大家不會再為上文中提到的幾個風格問題而爭論了。
讓我們來看一下 Go 語言相對于其他語言所做的一些改動,歡迎讀者在評論區補充:
1. 每一行語句的結尾不再強行要求加上分號
- fmt.Println("hello world") //末尾不建議加;
2. 一對大括號的第一個不能單獨占一行
- //錯誤的語法
- func DoTest()
- {
- }
- //正確的語法
- func DoTest() {
- }
3. if/for 等語句體只有一行時也必須使用一對大括號包裹起來
- //正確的語法
- if (success) {
- printf("success")
- }
- //錯誤的語法
- if (success)
- printf("success")
4. if/for 等條件不再需要括號
- //正確的語法
- for i := 1; i < 10; i++ {
- fmt.Println(i)
- }
- //錯誤的語法,for語句不需要括號
- for (i := 1; i < 10; i++) {
- fmt.Println(i)
- }
5. 只有 for 循環,不再支持 while 和 do - while 循環
- //支持的語法
- for i := 1; i < 10; i++ {
- fmt.Println(i)
- }
- //不支持的語法
- while i < 100 {
- fmt.Println(i)
- i++
- }
6. switch - case 語句默認加了 break 語句
- switch i {
- case 0:
- fmt.Println(0)
- case 1:
- fmt.Println(1)
- case 2:
- fmt.Println(2)
- default:
- }
- //相當于
- switch i {
- case 0:
- fmt.Println(0)
- break
- case 1:
- fmt.Println(1)
- break
- case 2:
- fmt.Println(2)
- break
- default:
- }
如果你真的想執行完一個 case 接著執行下一個 case,只要使用 fallthrough 關鍵字就可以了:
- switch i {
- case 0:
- fmt.Println(0)
- fallthrough
- case 1:
- fmt.Println(1)
- case 2:
- fmt.Println(2)
- default:
- }
7. 自增自減運算符只支持后綴形式,不支持前綴形式
- i := 0
- i++ //可以編譯通過
- ++i //無法通過編譯
8. 不支持條件運算符(? :)
- b := 9
- a := (b > 0 ? true : false) //這一行無法通過編譯
9. 給一個結構體多個字段設置值時,最后一個字段也必須以逗號結束
- type StandardResp struct {
- Code int32
- Msg string
- Data interface{}
- }
- c.JSON(http.StatusOK, commonHttp.StandardResp{
- Code: 1000,
- Msg: "token error",
- Data: nil, //注意這里nil之后有一個逗號,這在其他語法中必須沒有逗號
- })
以上列舉了 Go 精簡后的一些語法要素,精簡后的語法,讓編程初學者更容易記憶與上手。
極少的語法元素,讓 Go 簡單易學,字節的大多數同學都是入職后兩周內學習的 Go,然后開始著手業務開發。
功能完備性的對比
Go 與 Java 相比較于 C++,其語言自帶的 API 庫功能是相當完善的,從基本的字符串操作到網絡編程、文件讀寫等等應用盡有,因此 Go 的開發者可使用的原生 API 就很豐富,比如編寫一個網絡通信程序,Go 和 Java 都在 net 包中提供了大量可使用的 API,而 C++ 必須直接借助操作系統的 Socket API。
這就是我說的語言的功能完備性,如果一個編程語言自帶的 API 越豐富,那么開發者只要盡可能地掌握語言自身的 API 就可以了,自身的 API 通常會屏蔽了各個操作系統的差異性,學習成本更低,同樣是 Socket API,學習 Go 或者 Java SDK 自帶的網絡 API,要比直接學習多個操作系統的 Socket API 要容易得多。
對于 C++ 語言來說,隨著 C++ 標準的不斷發展,C++ 語言自身的功能完備性也在逐步完善,例如從 C++11 開始,就可以直接使用 stl 中的線程相關的類,而不用再使用操作系統提供的線程接口。
結論
綜上所述,我給出我的結論,正因為 Go 語言簡單易學、不容易出錯、功能完備性良好且執行效率高,特別適合字節這樣有超多超快的業務線產品迭代。當然,Go 語言想入門容易,想學好成為高手并不容易,很多從其他語言轉到 Go 開發的同學,若不刻意勤加練習,想寫出地道、高效的 Go-Style 風格的代碼也不是一件很容易的事情。
這里推薦幾本我學習 Go 的書籍:
艾倫 《Go 程序設計語言》
許式偉 呂桂華《Go 語言編程》
雨痕 《Go 語言學習筆記》
求職字節 Go 開發崗位需要如何準備?
相比較為什么字節跳動選擇使用 Go 語言,很多同學可能更關心求職字節跳動 Go 開發崗位要如何準備。
這里有先消除一個錯誤認知:和大多數 Go 崗位(字節的和非字節的)一樣,字節招 Go 開發崗位的對求職者是否熟悉 Go 語言沒有強制要求,也就是說,你可以不熟悉 Go 語言也可以應聘字節的 Go 開發崗位,但是有幾個注意事項:
一、雖然不要求熟悉 Go 開發,但要求熟悉至少一門編程語言
字節很多進來做 Go 的同學之前都是做 C、C++ 或者 Java, 甚至是 php 或者 Python 的。所以,如果你之前根本沒接觸過 Go 或者接觸過但不熟悉 Go,盡量不要在簡歷中寫自己熟悉 Go。這樣面試的時候面試官也就不會考察你任何關于 Go 本身的問題,這點很重要,比如,你原來是做 C++ 或者 Java 的,你為了應聘這個崗位強行寫上自己熟悉 Go,那么面試官可能會重點考察一下你的 Go 技術棧,這樣你相當于被考察一個不熟悉的技術棧,你很吃虧。我曾內推的幾位同事,包括最近面試的一兩位同學都是這樣,拿自己弱項來接受考察,面試結果一般都不盡人意。以上文中那位同學為例,如果他不在簡歷中寫自己熟悉 C++ 和 Go,只寫自己熟悉 C++,我就不會問他任何關于 Go 的問題了,比如 defer 關鍵字的問題。
二、盡量寫上一門自己擅長的語言即可
這里的意思是,如果你之前是做 C++ 開發的,那你就寫上你熟悉 C++,Java 也一樣,這樣面試的時候,除了通用部分,面試官會考察你相應的語言相關的內容,例如簡歷上寫了熟悉 C++,如果連 C++ 虛函數的實現機制、stl 常用容器都說不清楚,那明顯不符合預期;寫熟悉 Java 的,HashMap、Java 的線程池 ThreadPoolExecutor、Java 虛擬機等都是常考的內容。我看過一部分同學未通過面試的原因是:不管熟悉不熟悉的技術棧都一股腦兒地寫到簡歷中,結果面試時被問到又說不清楚。
三、校招看基礎,社招看經驗
基礎知識就那么多,包括算法與數據結構、操作系統原理、計算機網絡(網絡編程)、多線程、數據庫、設計模式等等;社招看經驗,所謂經驗,不僅指良好的基本功,還包括豐富的項目經驗和解決問題的能力,一般 2-1 及以上職級的基本上要求至少擅長分布式、RPC、消息中間件、緩存、數據庫等至少其中的一種。另外,就是解決問題的能力,很多算法題或者場景題都是源自于真實的業務場景,需要面試者給出自己解決問題的思路和可以落地的方法。
四、字節很注重算法能力
通常情況下, 對于校招生,算法題做不好,基本一票否決;對于社招,工作五年及五年以下的,也會考察一部分算法題。很多工作多年的同學,由于平時不注重溫習和理解算法與數據結構知識,忘記了很多算法思想和解決問題的策略。給這部分同學的建議是:
- 面試前適當復習下常見的算法與數據結構;
- 另外,平常刻意去刷一些算法題,去鍛煉一下自己的解決問題的思路;
- 面試的時候,如果遇到不會的算法題,千萬不要直接放棄,可以先嘗試暴力窮舉法或者找面試官要些提示。
除了一些算法崗位,社招的算法題一般都不難,有些算法題也不單純是考察算法,可能結合其他知識點一起考察,這里列舉一些題目給讀者做一些參考:
- 1. 實現一個字符串轉換整數的函數;
- 2. 輸入兩個遞增排序的鏈表,合并這兩個鏈表并使新鏈表中的結點仍然是按照遞增排序的,例如:
- 鏈表1:1 -> 3 -> 5 -> 7
- 鏈表2: 2 -> 4 -> 6 -> 8
- 合并后的鏈表3:
- 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8
- 鏈表定義:
- struct ListNode
- {
- int m_nValuel
- ListNode* m_pNext;
- };
- 3. 輸入n個整數,找出其中最小的 k 個數目,例如輸入4、5、1、6、2、7、3、8,則最小的4個數字是1、2、3、4。
- 4. 輸入兩個鏈表,找出它們的第一個公共結點,鏈表結點定義如下:
- struct ListNode
- {
- int m_nKey;
- ListNode* m_pNext;
- };
- 5. TCP的滑動窗口機制知道嗎?設計一個可行的滑動窗口的算法。
- 6. 中國象棋中,假設左下角的位置為坐標原點,某棋子馬的坐標是(x, y),另外一個棋子的坐標為(m, n),實現一個函數返回馬下一步可走的位置坐標。
- 7. 實現一個緩沖區類,需要支持以下功能:
- (1). 緩沖區內存要求連續
- (2). 支持擴容
- (3). 支持讀和寫
如果你對這些算法題有不明白的地方,可以通過我的公眾號加我的微信群交流。
上述題目部分是《劍指 offer》一書的原題,我向讀者推薦一下這本書,另外一本是左程云老師的《程序員代碼面試指南》。
適當刷一些算法題,不單純為了應付面試,對鍛煉思維能力也是大有裨益的。
當然,很多非科班的同學一上來就刷算法題,其實是不推薦的,如果你沒有系統地學習過算法和數據結構的課程,建議先系統地學習下這一塊的內容。
五、字節很注重動手能力
疫情仍然沒有完全過去,現在字節的面試基本上都是改在線上進行,當面試官給你一些算法題或者場景題時,需要你在自己的電腦上進行編程,在編程過程中的各種行為,例如你的編寫代碼、解決編譯錯誤、調試能力和代碼風格都是面試官一眼能看到的。平常動手多寡、高下立判,我曾遇到用 VSCode 寫 Java 代碼然后連編譯問題都解決不了的同學,顯然,最終面試肯定也未通過。
六、網上的面經適當看,帶著理解與批判精神去看
面試大廠的結果有一定的運氣成分,不同的人在不同的場景面試遇到不同的面試官其表現的面試結果可能也不一樣。
近來我發現有些校招的同學,把網絡上的面經和所謂的標準答案一字不拉地背誦下來用于應付面試,這是非常不可取的:
其一,有些面筋的答案本身就是錯的,例如網上有一篇流傳很廣的文章,談到 HTTP GET 與 POST 請求的區別時,說 GET 請求會發送一個數據包,而 POST 請求則會拆成兩個數據包去發送,這種說法明顯就是錯誤的。很多同學不加甄別的背誦下來,我迄今至少遇到兩位同學面試時這么回答;
其二,光背面經如果不加以理解很難應付靈活變化的面試題,例如,有些同學把三四握手和四次揮手的過程背誦下來了,但是當我問到連接一個 IP 不存在的主機時,握手過程是怎樣的、或者連接一個 IP 地址存在但端口號不存在的主機握手過程又是怎樣的呢?如果對三次握手過程不加以理解,是很難回答出這樣的問題的。企業需要一些理解技術原理并能靈活運用的員工,而不是死記硬背的人。
七、C++ 和 Java 太難了,直接學 Go 吧
很多同學覺得 C++ 太難了,學不好,Java 學的人太多,競爭壓力大,所以干脆學 Go 吧,上文說過,各大招 Go 崗位的公司其實對 Go 本身不做刻意要求,反而對技術原理要求不低。所以,打鐵還得自身硬,想靠學 Go 走捷徑其實行不通,技術基本功決定著你將來在技術這條路上能走多遠。那些看似難啃的技術原理,今天所欠下的技術債,總會在你的職業生涯的某一個階段爆發出來。以學習 C/C++ 為例,如果你學習 C/C++ 單純只是為了找工作或者應付面試,那一定也是學不好的,C++ 語法本身并沒有多難,難的是支持 C++ 技術背后的各種操作系統原理,這些原理你在 C/C++ 中會用到,你在學習其他語言到一定階段也不可或缺;反過來,以網絡編程為例,在 Go 中討論 epoll 模型多少有點別捏,或者是說不清道不明,但是站在 C/C++ 的角度結合操作系統的網絡 API,這個問題就很容易搞明白了。
所以,對于開發這條路來說,換一門容易學的語言并不能讓你擁有核心競爭力。相反,如果你想做好開發,尤其是后端開發,你應該掌握一門重型編程語言,如 C++ 或者 Java,這也是為什么我勸那些想做好開發的同學不要只掌握一門 Python/PHP 這樣的語言。