作者 | 楊成立(忘籬) 阿里巴巴高級技術專家
Go 開發關鍵技術指南文章目錄:
- 為什么你要選擇 Go?
- Go 面向失敗編程
- 帶着服務器編程金剛經走進 2020 年
- 敢問路在何方?
Go 開發指南大圖

Engineering
我覺得 Go 在工程上良好的支持,是 Go 能夠在服務器領域有一席之地的重要原因。這里說的工程友好包括:
- gofmt 保證代碼的基本一致,增加可讀性,避免在爭論不清楚的地方爭論;
- 原生支持的 profiling,為性能調優和死鎖問題提供了強大的工具支持;
- utest 和 coverage,持續集成,為項目的質量提供了良好的支撐;
- example 和注釋,讓接口定義更友好合理,讓庫的質量更高。
GOFMT 規范編碼
之前有段時間,朋友圈霸屏的新聞是碼農因為代碼不規范問題槍擊同事,雖然實際上槍擊案可能不是因為代碼規范,但可以看出大家對於代碼規范問題能引發槍擊是毫不懷疑的。這些年在不同的公司碼代碼,和不同的人一起碼代碼,每個地方總有人喜歡糾結於 if ()
中是否應該有空格,甚至還大開懟戒。
Go 語言從來不會有這種爭論,因為有 gofmt
,語言的工具鏈支持了格式化代碼,避免大家在代碼風格上白費口舌。
比如,下面的代碼看着真是揪心,任何語言都可以寫出類似的一坨代碼:
package main
import (
"fmt"
"strings"
)
func foo()[]string {
return []string{"gofmt","pprof","cover"}}
func main() {
if v:=foo();len(v)>0{fmt.Println("Hello",strings.Join(v,", "))}
}
如果有幾萬行代碼都是這樣,是不是有扣動扳機的沖動?如果我們執行下 gofmt -w t.go
之后,就變成下面的樣子:
package main
import (
"fmt"
"strings"
)
func foo() []string {
return []string{"gofmt", "pprof", "cover"}
}
func main() {
if v := foo(); len(v) > 0 {
fmt.Println("Hello", strings.Join(v, ", "))
}
}
是不是心情舒服多了?gofmt 只能解決基本的代碼風格問題,雖然這個已經節約了不少口舌和唾沫,我想特別強調幾點:
- 有些 IDE 會在保存時自動 gofmt,如果沒有手動運行下命令
gofmt -w .
,可以將當前目錄和子目錄下的所有文件都格式化一遍,也很容易的是不是; - gofmt 不識別空行,因為空行是有意義的,因為空行有意義所以 gofmt 不知道如何處理,而這正是很多同學經常犯的問題;
- gofmt 有時候會因為對齊問題,導致額外的不必要的修改,這不會有什么問題,但是會干擾 CR 從而影響 CR 的質量。
先看空行問題,不能隨便使用空行,因為空行有意義。不能在不該空行的地方用空行,不能在該有空行的地方不用空行,比如下面的例子:
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("show file err %v", err)
os.Exit(-1)
}
defer f.Close()
io.Copy(os.Stdout, f)
}
上面的例子看起來就相當的奇葩,if
和 os.Open
之間沒有任何原因需要個空行,結果來了個空行;而 defer
和 io.Copy
之間應該有個空行卻沒有個空行。空行是非常好的體現了邏輯關聯的方式,所以空行不能隨意,非常嚴重地影響可讀性,要么就是一坨東西看得很費勁,要么就是突然看到兩個緊密的邏輯身首異處,真的讓人很詫異。
上面的代碼可以改成這樣,是不是看起來很舒服了:
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("show file err %v", err)
os.Exit(-1)
}
defer f.Close()
io.Copy(os.Stdout, f)
}
再看 gofmt 的對齊問題,一般出現在一些結構體有長短不一的字段,比如統計信息,比如下面的代碼:
package main
type NetworkStat struct {
IncomingBytes int `json:"ib"`
OutgoingBytes int `json:"ob"`
}
func main() {
}
如果新增字段比較長,會導致之前的字段也會增加空白對齊,看起來整個結構體都改變了:
package main
type NetworkStat struct {
IncomingBytes int `json:"ib"`
OutgoingBytes int `json:"ob"`
IncomingPacketsPerHour int `json:"ipp"`
DropKiloRateLastMinute int `json:"dkrlm"`
}
func main() {
}
比較好的解決辦法就是用注釋,添加注釋后就不會強制對齊了。
Profile 性能調優
性能調優是一個工程問題,關鍵是測量后優化,而不是盲目優化。Go 提供了大量的測量程序的工具和機制,包括 Profiling Go Programs
, Introducing HTTP Tracing
,我們也在性能優化時使用過 Go 的 Profiling,原生支持是非常便捷的。
對於多線程同步可能出現的死鎖和競爭問題,Go 提供了一系列工具鏈,比如 Introducing the Go Race Detector
, Data Race Detector
,不過打開 race 后有明顯的性能損耗,不應該在負載較高的線上服務器打開,會造成明顯的性能瓶頸。
推薦服務器開啟 http profiling,偵聽在本機可以避免安全問題,需要 profiling 時去機器上把 profile 數據拿到后,拿到線下分析原因。實例代碼如下:
package main
import (
"net/http"
_ "net/http/pprof"
"time"
)
func main() {
go http.ListenAndServe("127.0.0.1:6060", nil)
for {
b := make([]byte, 4096)
for i := 0; i < len(b); i++ {
b[i] = b[i] + 0xf
}
time.Sleep(time.Nanosecond)
}
}
編譯成二進制后啟動 go mod init private.me && go build . && ./private.me
,在瀏覽器訪問頁面可以看到各種性能數據的導航:http://localhost:6060/debug/pprof/
例如分析 CPU 的性能瓶頸,可以執行 go tool pprof private.me http://localhost:6060/debug/pprof/profile
,默認是分析 30 秒內的性能數據,進入 pprof 后執行 top 可以看到 CPU 使用最高的函數:
(pprof) top
Showing nodes accounting for 42.41s, 99.14% of 42.78s total
Dropped 27 nodes (cum <= 0.21s)
Showing top 10 nodes out of 22
flat flat% sum% cum cum%
27.20s 63.58% 63.58% 27.20s 63.58% runtime.pthread_cond_signal
13.07s 30.55% 94.13% 13.08s 30.58% runtime.pthread_cond_wait
1.93s 4.51% 98.64% 1.93s 4.51% runtime.usleep
0.15s 0.35% 98.99% 0.22s 0.51% main.main
除了 top,還可以輸入 web 命令看調用圖,還可以用 go-torch 看火焰圖等。
UTest 和 Coverage
當然工程化少不了 UTest 和覆蓋率,關於覆蓋 Go 也提供了原生支持 The cover story
,一般會有專門的 CISE 集成測試環境。集成測試之所以重要,是因為隨着代碼規模的增長,有效的覆蓋能顯著的降低引入問題的可能性。
什么是有效的覆蓋?一般多少覆蓋率比較合適?80% 覆蓋夠好了嗎?90% 覆蓋一定比 30% 覆蓋好嗎?我覺得可不一定,參考 Testivus On Test Coverage。對於 UTest 和覆蓋,我覺得重點在於:
- UTest 和覆蓋率一定要有,哪怕是 0.1% 也必須要有,為什么呢?因為出現故障時讓老板心里好受點啊,能用數據衡量出來裸奔的代碼有多少;
- 核心代碼和業務代碼一定要分離,強調核心代碼的覆蓋率才有意義,比如整體覆蓋了 80%,核心代碼占 5%,核心代碼覆蓋率為 10%,那么這個覆蓋就不怎么有效了;
- 除了關鍵正常邏輯,更應該重視異常邏輯,異常邏輯一般不會執行到,而一旦藏有 bug 可能就會造成問題。有可能有些罕見的代碼無法覆蓋到,那么這部分邏輯代碼,CR 時需要特別人工 Review。
分離核心代碼是關鍵。
可以將核心代碼分離到單獨的 package,對這個 package 要求更高的覆蓋率,比如我們要求 98% 的覆蓋(實際上做到了 99.14% 的覆蓋)。對於應用的代碼,具備可測性是非常關鍵的,舉個我自己的例子,go-oryx 這部分代碼是判斷哪些 url 是代理,就不具備可測性,下面是主要的邏輯:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if o := r.Header.Get("Origin"); len(o) > 0 {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
if proxyUrls == nil {
......
fs.ServeHTTP(w, r)
return
}
for _, proxyUrl := range proxyUrls {
srcPath, proxyPath := r.URL.Path, proxyUrl.Path
......
if proxy, ok := proxies[proxyUrl.Path]; ok {
p.ServeHTTP(w, r)
return
}
}
fs.ServeHTTP(w, r)
})
可以看得出來,關鍵需要測試的核心代碼,在於后面如何判斷URL符合定義的規范,這部分應該被定義成函數,這樣就可以單獨測試了:
func shouldProxyURL(srcPath, proxyPath string) bool {
if !strings.HasSuffix(srcPath, "/") {
// /api to /api/
// /api.js to /api.js/
// /api/100 to /api/100/
srcPath += "/"
}
if !strings.HasSuffix(proxyPath, "/") {
// /api/ to /api/
// to match /api/ or /api/100
// and not match /api.js/
proxyPath += "/"
}
return strings.HasPrefix(srcPath, proxyPath)
}
func run(ctx context.Context) error {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
......
for _, proxyUrl := range proxyUrls {
if !shouldProxyURL(r.URL.Path, proxyUrl.Path) {
continue
}
代碼參考 go-oryx: Extract and test URL proxy,覆蓋率請看 gocover: For go-oryx coverage,這樣的代碼可測性就會比較好,也能在有限的精力下盡量讓覆蓋率有效。
Note: 可見,單元測試和覆蓋率,並不是測試的事情,而是代碼本身應該提高的代碼“可測試性”。
另外,對於 Go 的測試還有幾點值得說明:
- helper:測試時如果調用某個函數,出錯時總是打印那個共用的函數的行數,而不是測試的函數。比如 test_helper.go,如果
compare
不調用t.Helper()
,那么錯誤顯示是hello_test.go:26: Returned: [Hello, world!], Expected: [BROKEN!]
,調用t.Helper()
之后是 hello_test.go:18: Returned: [Hello, world!], Expected: [BROKEN!]`,實際上應該是 18 行的 case 有問題,而不是 26 行這個 compare 函數的問題; - benchmark:測試時還可以帶 Benchmark 的,參數不是
testing.T
而是testing.B
,執行時會動態調整一些參數,比如 testing.B.N,還有並行執行的testing.PB. RunParallel
,參考 Benchamrk; - main: 測試也是有個 main 函數的,參考 TestMain,可以做一些全局的初始化和處理。
- doc.go: 整個包的文檔描述,一般是在
package http
前面加說明,比如 http doc 的使用例子。
對於 Helper 還有一種思路,就是用帶堆棧的 error,參考前面關於 errors 的說明,不僅能將所有堆棧的行數給出來,而且可以帶上每一層的信息。
注意如果 package 只暴露了 interface,比如 go-oryx-lib: aac 通過
NewADTS() (ADTS, error)
返回的是接口ADTS
,無法給 ADTS 的函數加 Example;因此我們專門暴露了一個ADTSImpl
的結構體,而 New 函數返回的還是接口,這種做法不是最好的,讓用戶有點無所適從,不知道該用ADTS
還是ADTSImpl
。所以一種可選的辦法,就是在包里面有個doc.go
放說明,例如net/http/doc.go
文件,就是在package http
前面加說明,比如 http doc 的使用例子。
注釋和 Example
注釋和 Example 是非常容易被忽視的,我覺得應該注意的地方包括:
- 項目的 README.md 和 Wiki,這實際上就是新人指南,因為新人如果能懂那么就很容易了解這個項目的大概情況,很多項目都沒有這個。如果沒有 README,那么就需要看文件,該看哪個文件?這就讓人很抓狂了;
- 關鍵代碼沒有注釋,比如庫的 API,關鍵的函數,不好懂的代碼段落。如果看標准庫,絕大部分可以調用的 API 都有很好的注釋,沒有注釋怎么調用呢?只能看代碼實現了,如果每次調用都要看一遍實現,真的很難受了;
- 庫沒有 Example,庫是一種要求很高的包,就是給別人使用的包,比如標准庫。絕大部分的標准庫的包,都有 Example,因為沒有 Example 很難設計出合理的 API。
先看關鍵代碼的注釋,有些注釋完全是代碼的重復,沒有任何存在的意義,唯一的存在就是提高代碼的“注釋率”,這又有什么用呢,比如下面代碼:
wsconn *Conn //ws connection
// The RPC call.
type rpcCall struct {
// Setup logger.
if err := SetupLogger(......); err != nil {
// Wait for os signal
server.WaitForSignals(
如果注釋能通過函數名看出來(比較好的函數名要能看出來它的職責),那么就不需要寫重復的注釋,注釋要說明一些從代碼中看不出來的東西,比如標准庫的函數的注釋:
// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
//
// HTTP/2 support is only enabled if the Listener returns *tls.Conn
// connections and they were configured with "h2" in the TLS
// Config.NextProtos.
//
// Serve always returns a non-nil error and closes l.
// After Shutdown or Close, the returned error is ErrServerClosed.
func (srv *Server) Serve(l net.Listener) error {
// ParseInt interprets a string s in the given base (0, 2 to 36) and
// bit size (0 to 64) and returns the corresponding value i.
//
// If base == 0, the base is implied by the string's prefix:
// base 2 for "0b", base 8 for "0" or "0o", base 16 for "0x",
// and base 10 otherwise. Also, for base == 0 only, underscore
// characters are permitted per the Go integer literal syntax.
// If base is below 0, is 1, or is above 36, an error is returned.
//
// The bitSize argument specifies the integer type
// that the result must fit into. Bit sizes 0, 8, 16, 32, and 64
// correspond to int, int8, int16, int32, and int64.
// If bitSize is below 0 or above 64, an error is returned.
//
// The errors that ParseInt returns have concrete type *NumError
// and include err.Num = s. If s is empty or contains invalid
// digits, err.Err = ErrSyntax and the returned value is 0;
// if the value corresponding to s cannot be represented by a
// signed integer of the given size, err.Err = ErrRange and the
// returned value is the maximum magnitude integer of the
// appropriate bitSize and sign.
func ParseInt(s string, base int, bitSize int) (i int64, err error) {
標准庫做得很好的是,會把參數名稱寫到注釋中(而不是用 @param 這種方式),而且會說明大量的背景信息,這些信息是從函數名和參數看不到的重要信息。
咱們再看 Example,一種特殊的 test,可能不會執行,它的主要作用是為了推演接口是否合理,當然也就提供了如何使用庫的例子,這就要求 Example 必須覆蓋到庫的主要使用場景。舉個例子,有個庫需要方式 SSRF 攻擊,也就是檢查 HTTP Redirect 時的 URL 規則,最初我們是這樣提供這個庫的:
func NewHttpClientNoRedirect() *http.Client {
看起來也沒有問題,提供一種特殊的 http.Client,如果發現有 Redirect 就返回錯誤,那么它的 Example 就會是這樣:
func ExampleNoRedirectClient() {
url := "http://xxx/yyy"
client := ssrf.NewHttpClientNoRedirect()
Req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("failed to create request")
return
}
resp, err := client.Do(Req)
fmt.Printf("status :%v", resp.Status)
}
這時候就會出現問題,我們總是返回了一個新的 http.Client,如果用戶自己有了自己定義的 http.Client 怎么辦?實際上我們只是設置了 http.Client.CheckRedirect 這個回調函數。如果我們先寫 Example,更好的 Example 會是這樣:
func ExampleNoRedirectClient() {
client := http.Client{}
//Must specify checkRedirect attribute to NewFuncNoRedirect
client.CheckRedirect = ssrf.NewFuncNoRedirect()
Req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("failed to create request")
return
}
resp, err := client.Do(Req)
}
那么我們自然知道應該如何提供接口了。
其他工程化
最近得知 WebRTC 有 4GB 的代碼,包括它自己的以及依賴的代碼,就算去掉一般的測試文件和文檔,也有 2GB 的代碼!!!編譯起來真的是非常耗時間,而 Go 對於編譯速度的優化,據說是在 Google 有過驗證的,具體我們還沒有到這個規模。具體可以參考 Why so fast?,主要是編譯器本身比 GCC 快 (5X),以及 Go 的依賴管理做的比較好。
Go 的內存和異常處理也做得很好,比如不會出現野指針,雖然有空指針問題可以用 recover 來隔離異常的影響。而 C 或 C++ 服務器,目前還沒有見過沒有內存問題的,上線后就是各種的野指針滿天飛,總有因為野指針搞死的時候,只是或多或少罷了。
按照 Go 的版本發布節奏,6 個月就發一個版本,基本上這么多版本都很穩定,Go1.11 的代碼一共有 166 萬行 Go 代碼,還有 12 萬行匯編代碼,其中單元測試代碼有 32 萬行(占 17.9%),使用實例 Example 有 1.3 萬行。Go 對於核心 API 是全部覆蓋的,提交有沒有導致 API 不符合要求都有單元測試保證,Go 有多個集成測試環境,每個平台是否測試通過也能看到,這一整套機制讓 Go 項目雖然越來越龐大,但是整體研發效率卻很高。
Go2 Transition
Go2 的設計草案在 Go 2 Draft Designs ,而 Go1 如何遷移到 Go2 也是我個人特別關心的問題,Python2 和 Python3 的那種不兼容的遷移方式簡直就是噩夢一樣的記憶。Go 的提案中,有一個專門說了遷移的問題,參考 Go2 Transition。
Go2 Transition 還不是最終方案,不過它也對比了各種語言的遷移,還是很有意思的一個總結。這個提案描述了在非兼容性變更時,如何給開發者挖的坑最小。
目前 Go1 的標准庫是遵守兼容性原則的,參考 Go 1 compatibility guarantee,這個規范保證了 Go1 沒有兼容性問題,幾乎可以沒有影響的升級比如從 Go1.2 升級到 Go1.11。幾乎
的意思,是很大概率是沒有問題,當然如果用了一些非常冷門的特性,可能會有坑,我們遇到過 json 解析時,內嵌結構體的數據成員也得是 exposed 的才行,而這個在老版本中是可以非 exposed;還遇到過 cgo 對於鏈接參數的變更導致編譯失敗,這些問題幾乎很難遇到,都可以算是兼容的吧,有時候只是把模糊不清的定義清楚了而已。
Go2 在語言和標准庫上,會打破 Go1 的兼容性規范,也就是和 Go1 不再兼容。不過 Go 是分布式開源社區在維護,不能依賴於 flag day,還是要容許不同 Go 版本寫的 package 的互操作性。
先了解下各個語言如何考慮兼容性:
-
C 是嚴格向后兼容的,很早寫的程序總是能在新的編譯器中編譯。另外新的編譯器也支持指定之前的標准,比如
-std=c90
使用ISO C90
標准編譯程序。關鍵的特性是編譯成目標文件后,不同版本的 C 的目標文件,能完美的鏈接成執行程序;C90 實際上是對之前K&R C
版本不兼容的,主要引入了volatile
關鍵字、整數精度問題,還引入了 trigraphs,最糟糕的是引入了 undefined 行為比如數組越界和整數溢出的行為未定義。從 C 上可以學到的是:后向兼容非常重要;非常小的打破兼容性也問題不大特別是可以通過編譯器選項來處理;能將不同版本的目標文件鏈接到一起是非常關鍵的;undefined 行為嚴重困擾開發者容易造成問題; -
C++ 也是 ISO 組織驅動的語言,和 C 一樣也是向后兼容的。C++和C一樣坑爹的地方坑到吐血,比如 undefined行為等。盡管一直保持向后兼容,但是新的C++代碼比如C++11 看起來完全不同,這是因為有新的改變的特性,比如很少會用裸指針、比如 range 代替了傳統的 for 循環,這導致熟悉老C++語法的程序員看新的代碼非常難受甚至看不懂。C++毋庸置疑是非常流行的,但是新的語言標准在這方面沒有貢獻。從C++上可以學到的新東西是:盡管保持向后兼容,語言的新版本可能也會帶來巨大的不同的感受(保持向后兼容並不能保證能持續看懂)。
-
Java 也是向后兼容的,是在字節碼層面和語言層面都向后兼容,盡管語言上不斷新增了關鍵字。Java 的標准庫非常龐大,也不斷在更新,過時的特性會被標記為 deprecated 並且編譯時會有警告,理論上一定版本后 deprecated 的特性會不可用。Java 的兼容性問題主要在 JVM 解決,如果用新的版本編譯的字節碼,得用新的 JVM 才能執行。Java 還做了一些前向兼容,這個影響了字節碼啥的(我本身不懂 Java,作者也不說自己不是專家,我就沒仔細看了)。Java 上可以學到的新東西是:要警惕因為保持兼容性而限制語言未來的改變。
-
Python2.7 是 2010 年發布的,目前主要是用這個版本。Python3 是 2006 年開始開發,2008 年發布,十年后的今天還沒有遷移完成,甚至主要是用的 Python2 而不是 Python3,這當然不是 Go2 要走的路。看起來是因為缺乏向后兼容導致的問題,Python3 刻意的和之前版本不兼容,比如 print 從語句變成了一個函數,string 也變成了 Unicode(這導致和 C 調用時會有很多問題)。沒有向后兼容,同時還是解釋型語言,這導致 Python2 和 3 的代碼混着用是不可能的,這意味着程序依賴的所有庫必須支持兩個版本。Python 支持
from __future__ import FEATURE
,這樣可以在 Python2 中用 Python3 的特性。Python 上可以學到的東西是:向后兼容是生死攸關的;和其他語言互操作的接口兼容是非常重要的;能否升級到新的語言是由調用的庫支持的。
- Perl6 是 2000 年開始開發的,15 年后才正式發布,這也不是 Go2 應該走的路。這么漫長的主要原因包括:刻意沒有向后兼容,只有語言的規范沒有實現而這些規范不斷的修改。Perl 上可以學到的東西是:不要學 Perl;設置期限按期交付;別一下子全部改了。
特別說明的是,非常高興的是 Go2 不會重新走 Python3 的老路子,當初被 Python 的版本兼容問題坑得不要不要的。
雖然上面只是列舉了各種語言的演進,確實也了解得更多了,有時候描述問題本身,反而更能明白解決方案。C 和 C 的向后兼容確實非常關鍵,但也不是它們能有今天地位的原因,C11 的新特性到底增加了多少 DAU 呢,確實是值得思考的。另外 C11 加了那么多新的語言特性,比如 WebRTC 代碼就是這樣,很多老 C 程序員看到后一臉懵逼,和一門新的語言一樣了,是否保持完全的兼容不能做一點點變更,其實也不是的。
應該將 Go 的語言版本和標准庫的版本分開考慮,這兩個也是分別演進的,例如 alias 是 1.9 引入的向后兼容的特性,1.9 之前的版本不支持,1.9 之后的都支持。語言方面包括:
-
Language additions 新增的特性。比如 1.9 新增的 type alias,這些向后兼容的新特性,並不要求代碼中指定特殊的版本號,比如用了 alias 的代碼不用指定要 1.9 才能編譯,用之前的版本會報錯。向后兼容的語言新增的特性,是依靠程序員而不是工具鏈來維護的,要用這個特性或庫升級到要求的版本就可以。
-
Language removals 刪除的特性。比如有個提案 #3939 去掉
string(int)
,字符串構造函數不支持整數,假設這個在 Go1.20 版本去掉,那么 Go1.20 之后這種string(1000)
代碼就要編譯失敗了。這種情況沒有特別好的辦法能解決,我們可以提供工具,將代碼自動替換成新的方式,這樣就算庫維護者不更新,使用者自己也能更新。這種場景引出了指定最大版本,類似 C 的-std=C90
,可以指定最大編譯的版本比如-lang=go1.19
,當然必須能和 Go1.20 的代碼鏈接。指定最大版本可以在 go.mod 中指定,這需要工具鏈兼容歷史的版本,由於這種特性的刪除不會很頻繁,維護負擔還是可以接受的。 -
Minimum language version 最小要求版本。為了可以更明確的錯誤信息,可以允許模塊在
go.mod
中指定最小要求的版本,這不是強制性的,只是說明了這個信息后編譯工具能明確給出錯誤,比如給出應該用具體哪個版本。 -
Language redefinitions 語言重定義。比如 Go1.1 時,int 在 64 位系統中長度從 4 字節變成了 8 字節,這會導致很多潛在的問題。比如 #20733 修改了變量在 for 中的作用域,看起來是解決潛在的問題,但也可能會引入問題。引入關鍵字一般不會有問題,不過如果和函數沖突就會有問題,比如 error: check。為了讓 Go 的生態能遷移到 Go2,語言重定義的事情應該盡量少做,因為我們不再能依賴編譯器檢查錯誤。雖然指定版本能解決這種問題,但是這始終會導致未知的結果,很有可能一升級 Go 版本就掛了。我覺得對於語言重定義,應該完全禁止。比如 #20733 可以改成禁止這種做法,這樣就會變成編譯錯誤,可能會幫助找到代碼中潛在的 BUG。
-
Build tags 編譯 tags。在指定文件中指定編譯選項,是現有的機制,不過是指定的 release 版本號,它更多是指定了最小要求的版本,而沒有解決最大依賴版本問題。
-
Import go2 導入新特性。和 Python 的特性一樣,可以在 Go1 中導入 Go2 的新特性,比如可以顯式地導入
import "go2/type-aliases"
,而不是在 go.mod 中隱式的指定。這會導致語言比較復雜,將語言打亂成了各種特性的組合。而且這種方式一旦使用,將無法去掉。這種方式看起來不太適合 Go。
如果有更多的資源來維護和測試,標准庫后續會更快發布,雖然還是 6 個月的周期。標准庫方面的變更包括:
-
Core standard library 核心標准庫。有些和編譯工具鏈相關的庫,還有其他的一些關鍵的庫,應該遵守 6 個月的發布周期,而且這些核心標准庫應該保持 Go1 的兼容性,比如
os/signal
、reflect
、runtime
、sync
、testing
、time
、unsafe
等等。我可能樂觀的估計net
,os
, 和syscall
不在這個范疇。 -
Penumbra standard library 邊緣標准庫。它們被獨立維護,但是在一個 release 中一起發布,當前核心庫大部分都屬於這種。這使得可以用
go get
等工具來更新這些庫,比 6 個月的周期會更快。標准庫會保持和前面版本的編譯兼容,至少和前面一個版本兼容。 -
Removing packages from the standard library 去掉一些不太常用的標准庫,比如
net/http/cgi
等。
如果上述的工作做得很好的話,開發者會感覺不到有個大版本叫做 Go2,或者這種緩慢而自然的變化逐漸全部更新成了 Go2。甚至我們都不用宣傳有個 Go2,既然沒有 C2.0 為何要 Go2.0 呢?主流的語言比如 C、C++ 和 Java 從來沒有 2.0,一直都是 1.N 的版本,我們也可以模仿它們。事實上,一般所認為的全新的 2.0 版本,若出現不兼容性的語言和標准庫,對用戶也不是個好結果,甚至還是有害的。
Others
關於 Go,還有哪些重要的技術值得了解呢?下面將進行詳細的分享。
GC
GC 一般是 C/C 程序員對於 Go 最常見、也是最先想到的一個質疑,GC 這玩意兒能行嗎?我們以前 C/C 程序都是自己實現內存池的,我們內存分配算法非常牛逼的。
Go 的 GC 優化之路,可以詳細讀 Getting to Go: The Journey of Go's Garbage Collector
。
2014 年 Go1.4,GC 還是很弱的,是決定 Go 生死的大短板。
上圖是 Twitter 的線上服務監控。Go1.4 的 STW(Stop the World) Pause time 是 300 毫秒,而 Go1.5 優化到了 30 毫秒。
而 Go1.6 的 GC 暫停時間降低到了 3 毫秒左右。
Go1.8 則降低到了 0.5 毫秒左右,也就是 500 微秒。從 Go1.4 到 Go1.8,優化了 600 倍性能。
如何看 GC 的 STW 時間呢?可以引入 net/http/pprof
這個庫,然后通過 curl 來獲取數據,實例代碼如下:
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
http.ListenAndServe("localhost:6060", nil)
}
啟動程序后,執行命令就可以拿到結果(由於上面的例子中沒有 GC,下面的數據取的是另外程序的部分數據):
$ curl 'http://localhost:6060/debug/pprof/allocs?debug=1' 2>/dev/null |grep PauseNs
# PauseNs = [205683 79032 202102 82216 104853 142320 90058 113638 152504
145965 72047 49690 158458 60499 99610 112754 122262 52252 49234 68420 159857
97940 226085 103644 135428 245291 141997 92470 79974 132817 74634 65653 73582
47399 51653 86107 48619 62583 68906 131868 111903 85482 44531 74585 50162
31445 107397 10903081771 92603 58585 96620 40416 29763 102248 32804 49394
83715 77099 108983 66133 47832 35379 143949 69235 27820 35677 99430 104303
132657 63542 39434 126418 63845 167969 116438 68904 77899 136506 119708 47501]
可以用 python 計算最大值是 322 微秒,最小是 26 微秒,平均值是 81 微秒。
Declaration Syntax
關於 Go 的聲明語法 Go Declaration Syntax
,和 C 語言有對比,在 The "Clockwise/Spiral Rule"
這個文章中也詳細描述了 C 的順時針語法規則。其中有個例子:
int (*signal(int, void (*fp)(int)))(int);
這是個什么呢?翻譯成 Go 語言就能看得很清楚:
func signal(a int, b func(int)) func(int)int
signal 是個函數,有兩個參數,返回了一個函數指針。signal 的第一個參數是 int,第二個參數是一個函數指針。
當然實際上 C 語言如果借助 typedef 也是能獲得比較好的可讀性的:
typedef void (*PFP)(int);
typedef int (*PRET)(int);
PRET signal(int a, PFP b);
只是從語言的語法設計上來說,還是 Go 的可讀性確實會好一些。這些點點滴滴的小傲嬌,是否可以支撐我們夠浪程序員浪起來的資本呢?至少 Rob Pike 不是拍腦袋和大腿想出來的規則嘛,這種認真和嚴謹是值得佩服和學習的。
Documents
新的語言文檔支持都很好,不用買本書看,Go 也是一樣,Go 官網歷年比較重要的文章包括:
- 語法特性及思考:
Go Declaration Syntax
,The Laws of Reflection
,Constants
,Generics Discussion
,Another Go at Language Design
,Composition not inheritance
,Interfaces and other types
- 並發相關特性:
Share Memory By Communicating
,Go Concurrency Patterns: Timing out, moving on
,Concurrency is not parallelism
,Advanced Go Concurrency Patterns
,Go Concurrency Patterns: Pipelines and cancellation
,Go Concurrency Patterns: Context
,Mutex or Channel
- 錯誤處理相關:
Defer, Panic, and Recover
,Error handling and Go
,Errors are values
,Stack traces and the errors package
,Error Handling In Go
,The Error Model
- 性能和優化:
Profiling Go Programs
,Introducing the Go Race Detector
,The cover story
,Introducing HTTP Tracing
,Data Race Detector
- 標准庫說明:
Go maps in action
,Go Slices: usage and internals
,Arrays, slices (and strings): The mechanics of append
,Strings, bytes, runes and characters in Go
- 和C的結合:
C? Go? Cgo!
- 項目相關:
Organizing Go code
,Package names
,Effective Go
,versioning
,Russ Cox: vgo
- 關於GC:
Go GC: Prioritizing low latency and simplicity
,Getting to Go: The Journey of Go Garbage Collector
,Proposal: Eliminate STW stack re-scanning
其中,文章中有引用其他很好的文章,我也列出來哈:
Go Declaration Syntax
,引用了一篇神作,介紹 C 的螺旋語法,寫 C 的多,讀過這個的不多,The "Clockwise/Spiral Rule"
Strings, bytes, runes and characters in Go
,引用了很好的一篇文章,號稱每個人都要懂的,關於字符集和 Unicode 的文章,Every Software Developer Must Know (No Excuses!)
- 為何錯誤碼模型,比異常模型更有優勢,參考 Cleaner, more elegant, and wrong 以及 Cleaner, more elegant, and harder to recognize。
- Go 中的面向對象設計原則 SOLID。
- Go 的版本語義 Semantic Versioning,如何在大型項目中規范版本,避免導致依賴地獄 (Dependency Hell) 問題。
SRS
SRS 是使用 ST,單進程單線程,性能是 EDSM 模型的 nginx-rtmp 的 3 到 5 倍,參考 SRS: Performance,當然不是 ST 本身性能是 EDSM 的三倍,而是說 ST 並不會比 EDSM 性能低,主要還是要根據業務上的特征做優化。
關於 ST 和 EDSM,參考本文前面關於 Concurrency 對於協程的描述,ST 它是 C 的一個協程庫,EDSM 是異步事件驅動模型。
SRS 是單進程單線程,可以擴展為多進程,可以在 SRS 中改代碼 Fork 子進程,或者使用一個 TCP 代理,比如 TCP 代理 go-oryx: rtmplb。
在 2016 年和 2017 年我用 Go 重寫過 SRS,驗證過 Go 使用 2CPU 可以跑到 C10K,參考 go-oryx,v0.1.13 Supports 10k(2CPUs) for RTMP players
。由於僅僅是語言的差異而重寫一個項目,沒有找到更好的方式或理由,覺得很不值得,所以還是放棄了 Go 語言版本,只維護 C++ 版本的 SRS。Go 目前一般在 API 服務器用得比較多,能否在流媒體服務器中應用?答案是肯定的,我已經實現過了。
后來在 2017 年,終於找到相對比較合理的方式來用 Go 寫流媒體,就是只提供庫而不是二進制的服務器,參考 go-oryx-lib。
目前 Go 可以作為 SRS 前面的代理,實現多核的優勢,參考 go-oryx。
**關注“阿里巴巴雲原生”公眾號,回復 ****Go **即可獲取清晰知識大圖及最全腦圖鏈接!
作者簡介
楊成立(花名:忘籬),阿里巴巴高級技術專家。他發起並維護了基於 MIT 協議的開源流媒體服務器項目 - SRS(Simple Rtmp Server)。感興趣的同學可以掃描下方二維碼進入釘釘群,直面和大神進行交流!
雲原生技術公開課
本課程是由 CNCF 官方與阿里巴巴強強聯合,共同推出的以“雲原生技術體系”為核心、以“技術解讀”和“實踐落地”並重的系列[技術公開課](https://edu.aliyun.com/roadmap/cloudnative)。
“阿里巴巴雲原生關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,做最懂雲原生開發者的技術圈。”