關於 Go1.14,你一定想知道的效能提升與新特性

unique_id發表於2020-02-11

Go 官方團隊將在今年 2 月份釋出 1.14 版本。相比較於之前的版本升級,Go1.14 在效能提升上做了較大改動,還加入了很多新特性,我們一起來看一下 Go1.14 都給我們帶來了哪些驚喜吧!

1.效能提升

先列舉幾個 Go1.14 在效能提升上做的改進。

1.1 defer 效能 “異常” 牛逼

異常牛逼是有多牛逼呢?我們可以通過一個簡單 benchmark 看一看。用例如下 (defer_test.go):

package main

import (
    "testing"
)

type channel chan int

func NoDefer() {
    ch1 := make(channel, 10)
    close(ch1)
}

func Defer() {
    ch2 := make(channel, 10)
    defer close(ch2)
}

func BenchmarkNoDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        NoDefer()
    }
}

func BenchmarkDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Defer()
    }
}

我們分別使用 Go1.13 版本和 Go1.14 版本進行測試,關於 Go 多個版本的管理切換,推薦大家使用gvm,非常的方便。首先使用 Go1.13 版本,只需要命令:gvm use go1.13;之後執行命令:go test -bench=. -v,結果如下:

goos: darwin
goarch: amd64
pkg: github.com/GuoZhaoran/myWebSites/data/goProject/defer
BenchmarkNoDefer-4      15759076            74.5 ns/op
BenchmarkDefer-4        11046517           102 ns/op
PASS
ok      github.com/GuoZhaoran/myWebSites/data/goProject/defer   3.526s

可以看到,Go1.13 版本呼叫 defer 關閉 channel 的效能開銷還是蠻大的,op 幾乎差了 30ns。切換到 go1.14:gvm use go1.14;再次執行命令:go test -bench=. -v,下面的結果一定會亮瞎了小夥伴的雙眼:

goos: darwin
goarch: amd64
pkg: github.com/GuoZhaoran/myWebSites/data/goProject/defer
BenchmarkNoDefer
BenchmarkNoDefer-4      13094874            80.3 ns/op
BenchmarkDefer
BenchmarkDefer-4        13227424            80.4 ns/op
PASS
ok      github.com/GuoZhaoran/myWebSites/data/goProject/defer   2.328s

Go1.14 版本使用 defer 關閉 channel 幾乎 0 開銷!

關於這一改進,官方給出的回應是:Go1.14 提高了 defer 的大多數用法的效能,幾乎 0 開銷!defer 已經可以用於對效能要求很高的場景了。

關於 defer,在 Go1.13 版本已經做了一些的優化,相較於 Go1.12,defer 大多數用法效能提升了 30%。而 Go1.14 的此次改進更是激動人心!關於 Go1.14 對 defer 優化的原理和細節,筆者還沒有收集到參考資料,相信很快就會有大神整理出來,大家可以關注一下。關於 Go 語言 defer 的設計原理、Go1.13 對 defer 做了哪些改進,推薦給大家下面幾篇文章:

1.2 goroutine 支援非同步搶佔

Go 語言排程器的效能隨著版本迭代表現的越來越優異,我們來了解一下排程器使用的 G-M-P 模型。先是一些概念:

  • G(Goroutine): goroutine,由關鍵字 go 建立
  • M(Machine): 在 Go 中稱為 Machine,可以理解為工作執行緒
  • P(Processor) : 處理器 P 是執行緒 M 和 Goroutine 之間的中間層 (並不是 CPU)

M 必須持有 P 才能執行 G 中的程式碼,P 有自己本地的一個執行佇列 runq,由可執行的 G 組成,下圖展示了 執行緒 M、處理器 P 和 goroutine 的關係。

GMP模型

Go 語言排程器的工作原理就是處理器 P 從本地佇列中依次選擇 goroutine 放到執行緒 M 上排程執行,每個 P 維護的 G 可能是不均衡的,為此排程器維護了一個全域性 G 佇列,當 P 執行完本地的 G 任務後,會嘗試從全域性佇列中獲取 G 任務執行 (需要加鎖),當 P 本地佇列和全域性佇列都沒有可執行的任務時,會嘗試偷取其他 P 中的 G 到本地佇列執行 (任務竊取)。

在 Go1.1 版本中,排程器還不支援搶佔式排程,只能依靠 goroutine 主動讓出 CPU 資源,存在非常嚴重的排程問題:

  • 單獨的 goroutine 可以一直佔用執行緒執行,不會切換到其他的 goroutine,造成飢餓問題
  • 垃圾回收需要暫停整個程式(Stop-the-world,STW),如果沒有搶佔可能需要等待幾分鐘的時間,導致整個程式無法工作

Go1.12 中編譯器在特定時機插入函式,通過函式呼叫作為入口觸發搶佔,實現了協作式的搶佔式排程。但是這種需要函式呼叫主動配合的排程方式存在一些邊緣情況,就比如說下面的例子:

package main

import (
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)

    go func() {
        for {
        }
    }()

    time.Sleep(time.Millisecond)
    println("OK")
}

其中建立一個 goroutine 並掛起, main goroutine 優先呼叫了 休眠,此時唯一的 P 會轉去執行 for 迴圈所建立的 goroutine,進而 main goroutine 永遠不會再被排程。換一句話說在 Go1.14 之前,上邊的程式碼永遠不會輸出 OK,因為這種協作式的搶佔式排程是不會使一個沒有主動放棄執行權、且不參與任何函式呼叫的 goroutine 被搶佔。

Go1.14 實現了基於訊號的真搶佔式排程解決了上述問題。Go1.14 程式啟動時,在 runtime.sighandler 函式中註冊了 SIGURG 訊號的處理函式 runtime.doSigPreempt,在觸發垃圾回收的棧掃描時,呼叫函式掛起 goroutine,並向 M 傳送訊號,M 收到訊號後,會讓當前 goroutine 陷入休眠繼續執行其他的 goroutine。

Go 語言排程器的實現機制是一個非常深入的話題。下邊推薦給讀者幾篇文章,特別值得探索學習:

1.3 time.Timer 定時器效能得到 “巨幅” 提升

我們先來看一下官方的 benchmark 資料吧。資料來源

Changes in the time package benchmarks:

name                      old time/op  new time/op  delta
AfterFunc-12              1.57ms ± 1%  0.07ms ± 1%  -95.42%  (p=0.000 n=10+8)
After-12                  1.63ms ± 3%  0.11ms ± 1%  -93.54%  (p=0.000 n=9+10)
Stop-12                   78.3µs ± 3%  73.6µs ± 3%   -6.01%  (p=0.000 n=9+10)
SimultaneousAfterFunc-12   138µs ± 1%   111µs ± 1%  -19.57%  (p=0.000 n=10+9)
StartStop-12              28.7µs ± 1%  31.5µs ± 5%   +9.64%  (p=0.000 n=10+7)
Reset-12                  6.78µs ± 1%  4.24µs ± 7%  -37.45%  (p=0.000 n=9+10)
Sleep-12                   183µs ± 1%   125µs ± 1%  -31.67%  (p=0.000 n=10+9)
Ticker-12                 5.40ms ± 2%  0.03ms ± 1%  -99.43%  (p=0.000 n=10+10)
Sub-12                     114ns ± 1%   113ns ± 3%     ~     (p=0.069 n=9+10)
Now-12                    37.2ns ± 1%  36.8ns ± 3%     ~     (p=0.287 n=8+8)
NowUnixNano-12            38.1ns ± 2%  37.4ns ± 3%   -1.87%  (p=0.020 n=10+9)
Format-12                  252ns ± 2%   195ns ± 3%  -22.61%  (p=0.000 n=9+10)
FormatNow-12               234ns ± 1%   177ns ± 2%  -24.34%  (p=0.000 n=10+10)
MarshalJSON-12             320ns ± 2%   250ns ± 0%  -21.94%  (p=0.000 n=8+8)
MarshalText-12             320ns ± 2%   245ns ± 2%  -23.30%  (p=0.000 n=9+10)
Parse-12                   206ns ± 2%   208ns ± 4%     ~     (p=0.084 n=10+10)
ParseDuration-12          89.1ns ± 1%  86.6ns ± 3%   -2.78%  (p=0.000 n=10+10)
Hour-12                   4.43ns ± 2%  4.46ns ± 1%     ~     (p=0.324 n=10+8)
Second-12                 4.47ns ± 1%  4.40ns ± 3%     ~     (p=0.145 n=9+10)
Year-12                   14.6ns ± 1%  14.7ns ± 2%     ~     (p=0.112 n=9+9)
Day-12                    20.1ns ± 3%  20.2ns ± 1%     ~     (p=0.404 n=10+9)

從基準測試的結果可以看出 Go1.14 time 包中 AfterFunc、After、Ticker 的效能都得到了 “巨幅” 提升。

在 Go1.10 之前的版本中,Go 語言使用 1 個全域性的四叉小頂堆維護所有的 timer。實現機制是這樣的:

Go舊版本timer實現機制

看圖有些抽象,下面用文字描述一下上述過程:

  • G6 呼叫函式建立了一個 timer,系統會產生一個 TimerProc,放到本地佇列的頭部,TimerProc 也是一個 G,由系統呼叫
  • P 排程執行 TimerProc 的 G 時,呼叫函式讓出 P,G 是在 M1 上執行的,執行緒休眠,G6 阻塞在 channel 上,儲存到堆上
  • 喚醒 P,獲得 M3 繼續排程執行任務 G1、G4,執行完所有任務之後讓出 P,M3 休眠
  • TimerProc 休眠到期後,重新喚醒 P,執行 TimerProc 將 G6 恢復到 P 的本地佇列,等待執行。TimerProc 則再次和 M1 休眠,等待下一次建立 timer 時被喚醒
  • P 再次被喚醒,獲得 M3,執行任務 G6

對 Timer 的工作原理可能描述的比較粗略,但我們可以看出執行一次 Timer 任務經歷了好多次 M/P 切換,這種系統開銷是非常大的,而且從全域性唯一堆上遍歷 timer 恢復 G 到 P 是需要加鎖的,導致 Go1.10 之前的計時器效能比較差,但是在對於計時要求不是特別苛刻的場景,也是完全可以勝任的。

Go1.10 將 timer 堆增加到了 64 個,使用協程所屬的 ProcessID % 64 來計算定時器存入的相應的堆,也就是說當 P 的數量小於 64 時,每個 P 只會把 timer 存到 1 個堆,這樣就避免了加鎖帶來的效能損耗,只有當 P 設定大於 64 時才會出現多個 P 分佈於同一個堆中,這個時候還是需要加鎖,雖然很少有服務將 P 設定的大於 64。

Go1.10對timer的優化

但是正如我們前邊的分析,提升 Go 計時器效能的關鍵是消除喚醒一個 timer 時進行 M/P 頻繁切換的開銷,Go1.10 並沒有解決根本問題。Go1.14 做到了!直接在每個 P 上維護自己的 timer 堆,像維護自己的一個本地佇列 runq 一樣。

Go1.14對計時器的優化

不得不說這種設計實在是太棒了,首先解決了最關鍵的問題,喚醒 timer 不用進行頻繁的 M/P 切換,其次不用再維護 TimerProc 這個系統協程了 (Go1.14 刪除了 TimerProc 程式碼的實現),同時也不用考慮因為競爭使用鎖了。timer 的排程時機更多了,在 P 對 G 排程的時候,都可以檢查一次 timer 是否到期,而且像 G 任務一樣,當 P 本地沒有 timer 時,可以嘗試從其他的 P 偷取一些 timer 任務執行。

關於 Go1.14 time.Timer 的實現,推薦給大家 B 站上的視訊,我從中受益很多:Go time.Timer 原始碼分析

2. 語言層面的變化

2.1 允許嵌入具有重疊方法集的介面

這應該是 Go1.14 在語言層面上最大的改動了,如下的介面定義在 Go1.14 之前是不允許的:

type ReadWriteCloser interface {
    io.ReadCloser
    io.WriteCloser
}

因為 io.ReadCloser 和 io.WriteCloser 中 Close 方法重複了,編譯時會提示:duplicate method Close。Go1.14 開始允許相同簽名的方法可以內嵌入一個介面中,注意是相同簽名,下邊的程式碼在 Go1.14 依然不能夠執行,因為 MyCloser 介面中定義的 Close 方法和 io.ReadCloser 介面定義的 Close 方法的簽名不同。

type MyCloser interface {
    Close()
}

type ReadWriteCloser interface {
    io.ReadCloser
    MyCloser
}

將 MyCloser 的 Close 方法簽名修改為:

type MyCloser interface {
    Close() error
}

這樣程式碼就可以在 Go1.14 版本中 build 了!輕鬆實現介面定義的過載。

2.2 testing 包的 T、B 和 TB 都加上了 CleanUp 方法

在並行測試和子測試中,CleanUp(f func()) 非常有用,它將以後進先出的方式執行 f(如果註冊多個的話)。

舉一個例子:

func TestSomeing(t *testing.T) {
    t.CleanUp(func() {
        fmt.Println("Cleaning Up!")
    })

    t.Run(t.Name(), func(t *testing.T) {

    })
}

可以在 test 或者 benchmark 結束後呼叫 t.CleanUp 或 b.CleanUp 做一些收尾統計工作,非常有用!

2.3 新增了新包 hash/maphash

這個新包提供了位元組序列上的 hash 函式。這些雜湊函式用於實現雜湊表或其他的資料結構,這些雜湊表或其他資料結構需要將任意字串或位元組序列對映為整數的均勻分佈。這些 hash 函式具有抗衝突性,但不是加密安全的。

2.4 WebAssembly 的變化

對 WebAssembly 感興趣的小夥伴注意了,Go1.14 對 WebAssembly 做了如下改動:

  • 可以通過 js.Value 物件從 Go 引用的 Javascript 值進行垃圾回收
  • js.Value 值不再使用 == 操作符來比較,必須使用 Equal 函式
  • js.Value 增加了 IsUndefined,IsNull,IsNaN 函式

2.5 reflect 包的變化

reflect 在 StructField 元素中設定了 PkgPath 欄位,StructOf 支援使用未匯出欄位建立結構型別。

2.6 語言層面其他改動

Go1.14 在語言層面還做了很多其他的改動,下面列舉一些 (不是很全面):

程式碼包 改動
crypto/tls 移除了對 SSLv3 的支援;預設開啟 TLS1.3,通過 Config.MaxVersion 欄位配置其版本而不是通過 DEBUG 環境變數進行配置
strconv NumError 型別新增加了一個 UnWrap 方法,可以用於找到轉換失敗的原因,可以用 Errors.Is 來檢視 NumError 值是否是底層錯誤:strconv.ErrRange 或 strconv.ErrSyntax
runtime runtime.Goexit 不再被遞迴的 panic/recover 終止
runtime/pprof 生成的 profile 不再包括用於內聯標記的偽 PC。行內函數的符號資訊以 pprof 工具期望的格式編碼
net/http 新的 Header 方法的 Values 可用於獲取規範化 Key 關聯的所有制,新的 Transport 欄位 DialTLSContext 可用於指定可選的以非代理 https 請求建立 TLS 連線的 dail 功能
net/http/httptest Server 的欄位 EnableHTTP2 可用於在 test server 上支援 HTTP/2
mime .js 和.mjs 檔案的預設型別是 text/javascript,而不是 application/javascirpt
mime/multipart 新的 Reader 方法 NextRawPart 支援獲取下一個 MIME 的部分,而不需要透明的解碼引用的可列印資料
signal 在 Windows 上,CTRL_CLOSE_EVENT、CTRL_LOGOFF_EVENT、CTRL_SHUTDOWN_EVENT 將生成一個 syscall.SIGTERM 訊號,類似於 Control-C 和 Control-Break 如何生成 syscall.SIGINT 訊號
math 新的 FMA 函式在浮點計算 x*y + z 的時候,不對 x*y 計算進行舍入處理(幾種體系結構使用專用的硬體指令來實現此計算,以提高效能)
math/bits 新的函式 Rem,Rem32,Rem64 即使在商溢位時也支援計算餘數
go/build Context 型別有了一個新欄位 Dir,用於設定 build 的工作目錄
unicode 整個系統中的 unicode 包和相關支援已經從 Unicode1.0 升級到了 Unicode12.0,增加了 554 個新字元,其中包括 4 個指令碼和 61 個新 emoji

3. 工具的變化

關於 Go1.14 中對工具的完善,主要說一下 go mod 和 go test,Go 官方肯定希望開發者使用官方的包管理工具,Go1.14 完善了很多功能,如果大家在業務開發中對 go mod 有其他的功能需求,可以給官方提 issue。

go mod 主要做了以下改進:

  • incompatiable versions:如果模組的最新版本包含 go.mod 檔案,則除非明確要求或已經要求該版本,否則 go get 將不再升級到該模組的不相容主要版本。直接從版本控制中獲取時,go list 還會忽略此模組的不相容版本,但如果由代理報告,則可能包括這些版本。
  • go.mod 檔案維護:除了 go mod tidy 之外的 go 命令不再刪除 require 指令,該指令指定了間接依賴版本,該版本已由主模組的其他依賴項隱含。除了 go mod tidy 之外的 go 命令不再編輯 go.mod 檔案,如果更改只是修飾性的。
  • Module 下載:在 module 模式下,go 命令支援 SVN 倉庫,go 命令現在包括來自模組代理和其他 HTTP 伺服器的純文字錯誤訊息的摘要。如果錯誤訊息是有效的 UTF-8,且包含圖形字元和空格,只會顯示錯誤訊息。

go test 的改動比較小:

  • go test -v 現在將 t.Log 輸出流式傳輸,而不是在所有測試資料結束時輸出。

4. 生態建設

關於 go 語言的生態建設主要說一下 go.dev,2019年11月14日Go 官方團隊在 golang-nuts 郵件組宣佈 go.dev 上線。我們初次使用 go.dev,發現它提供了 godoc.org 的文件,介面更加友好。godoc.org 也給出宣告將重定向到 go.dev,可以看出,Go 官方團隊會將 go.dev 作為生態建設的重點。

pkg.go.dev 是 go.org 的配套網站,裡邊有精選用例和其他資源的資訊,提供了 godoc.org 之類的 Go 文件,但它更懂模組,並提供了有關軟體包先前版本的資訊,它還可以檢測並顯示許可證,並具有更好的搜尋演算法。

推薦大家使用!

5. 未來展望

我們先來說說泛型吧!Go 語言因為一直缺少泛型被很多開發者詬病。語言的設計者需要在程式設計效率、編譯速度和執行速度三者進行權衡和選擇,泛型的引入一定會影響編譯速度和執行速度,同時也會增加編譯器的複雜度,所以社群在考慮泛型時也非常謹慎。Go 語言團隊認為加入泛型並不緊急,更重要的是完善執行時機制,包括 排程器、垃圾收集器等功能。但是開發者的呼聲日益強烈,Go 官方也承諾會在 2.0 加入泛型。小道訊息,2020 年末,Go 語言可能會推出泛型,大家期待一下!關於 Go 語言為什麼沒有泛型,推薦大家一篇文章:為什麼 Go 語言沒有泛型 · Why's THE Design?

再來說說 Go 語言的錯誤處理吧。try proposal 獲得了很多人的支援,但是也有很多人反對,大家可以關注一下 issue #32825。結論是:Go 已經放棄了這一提案!這些思想還沒有得到充分的發展,尤其考慮到更改語言的實現成本時,所以有關列舉和不可變型別,Go 語言團隊最近也是不給予考慮實現的。

Go1.14 也有一些計劃中但是未完成的工作,Go1.14 嘗試優化頁分配器(page allocator),能夠實現在 GOMAXPROCS 值比較大時,顯著減少鎖競爭。這一改動影響很大,能顯著的提高 Go 並行能力,也會進一步提升 timer 的效能。但是由於實現起來比較複雜,有一些來不及解決的問題,要 delay 到 Go1.15 完成了。

展望 Go 語言的未來發展,官方肯定會努力將排程器、執行時和垃圾回收做的更好,Go 語言的效能也會越來越出眾。對於工具鏈會不斷豐富調整相應功能,為開發者提供方便。同時,Go 也會不斷完善其生態,工具包、社群成熟的應用越來越多。讓我們一起期待吧!

更多原創文章乾貨分享,請關注公眾號
  • 關於 Go1.14,你一定想知道的效能提升與新特性
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章