深入理解 go reflect - 反射為什麼慢

語言: CN / TW / HK

我們選擇 go 語言的一個重要原因是,它有非常高的效能。但是它反射的效能卻一直為人所詬病,本篇文章就來看看 go 反射的效能問題。

go 的效能測試

在開始之前,有必要先了解一下 go 的效能測試。在 go 裡面進行效能測試很簡單,只需要在測試函式前面加上 Benchmark 字首, 然後在函式體裡面使用 b.N 來進行迴圈,就可以得到每次迴圈的耗時。如下面這個例子:

go func BenchmarkNew(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { New() } }

我們可以使用命令 go test -bench=. reflect_test.go 來執行這個測試函式,又或者如果使用 goland 的話,直接點選執行按鈕就可以了。

說明:

  • *_test.go 檔案中 Benchmark* 字首函式是效能測試函式,它的引數是 *testing.B 型別。
  • b.ReportAllocs():報告記憶體分配次數,這是一個非常重要的指標,因為記憶體分配相比單純的 CPU 計算是比較耗時的操作。在效能測試中,我們需要關注記憶體分配次數,以及每次記憶體分配的大小。
  • b.N:是一個迴圈次數,每次迴圈都會執行 New() 函式,然後記錄下來每次迴圈的耗時。

go 裡面很多優化都致力於減少記憶體分配,減少記憶體分配很多情況下都可以提高效能。

輸出:

BenchmarkNew-20 1000000000 0.1286 ns/op 0 B/op 0 allocs/op

輸出說明:

  • BenchmarkNew-20BenchmarkNew 是測試函式名,-20 是 CPU 核數。
  • 1000000000:迴圈次數。
  • 0.1286 ns/op:每次迴圈的耗時,單位是納秒。這裡表示每次迴圈耗時 0.1286 納秒。
  • 0 B/op:每次迴圈記憶體分配的大小,單位是位元組。這裡表示每次迴圈沒有分配記憶體。
  • 0 allocs/op:每次迴圈記憶體分配的次數。這裡表示每次迴圈沒有分配記憶體。

go 反射慢的原因

動態語言的靈活性是以犧牲效能為代價的,go 語言也不例外,go 的 interface{} 提供了一定的靈活性,但是處理 interface{} 的時候就要有一些效能上的損耗了。

我們都知道,go 是一門靜態語言,這意味著我們在編譯的時候就知道了所有的型別,而不是在執行時才知道型別。 但是 go 裡面有一個 interface{} 型別,它可以表示任意型別,這就意味著我們可以在執行時才知道型別。 但本質上,interface{} 型別還是靜態型別,只不過它的型別和值是動態的。 在 interface{} 型別裡面,儲存了兩個指標,一個指向型別資訊,一個指向值資訊。具體可參考《go interface 設計與實現》

go interface{} 帶來的靈活性

有了 interface{} 型別,讓 go 也擁有了動態語言的特性,比如,定義一個函式,它的引數是 interface{} 型別, 那麼我們就可以傳入任意型別的值給這個函式。比如下面這個函式(做任意整型的加法,返回 int64 型別):

```go func convert(i interface{}) int64 { typ := reflect.TypeOf(i) switch typ.Kind() { case reflect.Int: return int64(i.(int)) case reflect.Int8: return int64(i.(int8)) case reflect.Int16: return int64(i.(int16)) case reflect.Int32: return int64(i.(int32)) case reflect.Int64: return i.(int64) default: panic("not support") } }

func add(a, b interface{}) int64 { return convert(a) + convert(b) } ```

說明:

  • convert() 函式:將 interface{} 型別轉換為 int64 型別。對於非整型的型別,會 panic。(當然不是很嚴謹,還沒涵蓋 uint* 型別)
  • add() 函式:做任意整型的加法,返回 int64 型別。

相比之下,如果是確定的型別,我們根本不需要判斷型別,直接相加就可以了:

go func add1(a, b int64) int64 { return a + b }

我們可以通過以下的 benchmark 來對比一下:

```go func BenchmarkAdd(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { add(1, 2) } }

func BenchmarkAdd1(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { add1(1, 2) } } ```

結果:

BenchmarkAdd-12 179697526 6.667 ns/op 0 B/op 0 allocs/op BenchmarkAdd1-12 1000000000 0.2353 ns/op 0 B/op 0 allocs/op

我們可以看到非常明顯的效能差距,add() 要比 add1() 慢了非常多,而且這還只是做了一些簡單的型別判斷及型別轉換的情況下。

go 靈活性的代價(慢的原因)

通過這個例子我們知道,go 雖然通過 interface{} 為我們提供了一定的靈活性支援,但是使用這種動態的特性是有一定代價的,比如:

  • 我們在執行時才知道型別,那麼我們就需要在執行時去做型別判斷(也就是通過反射),這種判斷會有一定開銷(本來是確定的一種型別,但是現在可能要在 20 多個型別中匹配才能確定它的型別是什麼)。同時,判斷到屬於某一型別之後,往往需要轉換為具體的型別,這也是一種開銷。
  • 同時,我們可能需要去做一些屬性、方法的查詢等操作(Field, FieldByName, Method, MethodByName),這些操作都是在執行時做的,所以會有一定的效能損耗。
  • 另外,在做屬性、方法之類的查詢的時候,查詢效能取決於屬性、方法的數量,如果屬性、方法的數量很多,那麼查詢效能就會相對慢。通過 index (Field, Method)查詢相比通過 name (FieldByName, MethodByName)查詢快很多,後者有記憶體分配的操作
  • 在我們通過反射來做這些操作的時候,多出了很多操作,比如,簡單的兩個 int 型別相加,本來可以直接相加。但是通過反射,我們不得不先根據 interface{} 建立一個反射物件,然後再做型別判斷,再做型別轉換,最後再做加法。

總的來說,go 的 interface{} 型別雖然給我們提供了一定的靈活性,讓開發者也可以在 go 裡面實現一些動態語言的特性, 但是這種靈活性是以犧牲一定的效能來作為代價的,它會讓一些簡單的操作變得複雜,一方面生成的編譯指令會多出幾十倍,另一方面也有可能在這過程有記憶體分配的發生(比如 FieldByName)。

慢是相對的

從上面的例子中,我們發現 go 的反射好像慢到了讓人無法忍受的地步,然後就有人提出了一些解決方案, 比如:通過程式碼生成的方式避免執行時的反射操作,從而提高效能。比如 easyjson

但是這類方案都會讓程式碼變得繁雜起來。我們需要權衡之後再做決定。為什麼呢?因為反射雖然慢,但我們要知道的是,如果我們的應用中有網路呼叫,任何一次網路呼叫的時間往往都不會少於 1ms,而這 1ms 足夠 go 做很多次反射操作了。這給我們什麼啟示呢?如果我們不是做中介軟體或者是做一些高效能的服務,而是做一些 web 應用,那麼我們可以考慮一下效能瓶頸是不是在反射這裡,如果是,那麼我們就可以考慮一下程式碼生成的方式來提高效能,如果不是,那麼我們真的需要犧牲程式碼的可維護性、可讀性來提高反射的效能嗎?優化幾個慢查詢帶來的收益是不是更高呢?

go 反射效能優化

如果可以的話,最好的優化就是不要用反射

通過程式碼生成的方式避免序列化和反序列化時的反射操作

這裡以 easyjson 為例,我們來看一下它是怎麼做的。假設我們有如下結構體,我們需要對其進行 json 序列化/反序列化:

go // person.go type Person struct { Name string `json:"name"` Age int `json:"age"` }

使用 easyjson 的話,我們需要為結構體生成程式碼,這裡我們使用 easyjson 的命令列工具來生成程式碼:

bash easyjson -all person.go

這樣,我們就會在當前目錄下生成 person_easyjson.go 檔案,裡面包含了 MarshalJSONUnmarshalJSON 方法,這兩個方法就是我們需要的序列化和反序列化方法。不同於標準庫裡面的 json.Marshaljson.Unmarshal,這兩個方法是不需要反射的,它們的效能會比標準庫的方法要好很多。

```go func easyjsonDb0593a3EncodeGithubComGinGonicGinCEasy(out *jwriter.Writer, in Person) { out.RawByte('{') first := true _ = first { const prefix string = ","name":" out.RawString(prefix[1:]) out.String(string(in.Name)) } { const prefix string = ","age":" out.RawString(prefix) out.Int(int(in.Age)) } out.RawByte('}') }

// MarshalJSON supports json.Marshaler interface func (v Person) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} easyjsonDb0593a3EncodeGithubComGinGonicGinCEasy(&w, v) return w.Buffer.BuildBytes(), w.Error } ```

我們看到,我們對 Person 的序列化操作現在只需要幾行程式碼就可以完成了,但是也有很明顯的缺點,生成的程式碼會很多。

效能差距:

go goos: darwin goarch: amd64 pkg: github.com/gin-gonic/gin/c/easy cpu: Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz BenchmarkJson BenchmarkJson-12 3680560 305.9 ns/op 152 B/op 2 allocs/op BenchmarkEasyJson BenchmarkEasyJson-12 16834758 71.37 ns/op 128 B/op 1 allocs/op

我們可以看到,使用 easyjson 生成的程式碼,序列化的效能比標準庫的方法要好很多,好了 4 倍以上。

反射結果快取

這種方法適用於需要根據名稱查詢結構體欄位或者查詢方法的場景。

假設我們有一個結構體 Person,其中有 5 個方法,M1M2M3M4M5,我們需要通過名稱來查詢其中的方法,那麼我們可以使用 reflect 包來實現:

go p := &Person{} v := reflect.ValueOf(p) v.MethodByName("M4")

這是很容易想到的辦法,但是效能如何呢?通過效能測試,我們可以看到,這種方式的效能是非常差的:

```go func BenchmarkMethodByName(b *testing.B) { p := &Person{} v := reflect.ValueOf(p)

b.ReportAllocs() for i := 0; i < b.N; i++ { v.MethodByName("M4") } } ```

結果:

BenchmarkMethodByName-12 5051679 237.1 ns/op 120 B/op 3 allocs/op

相比之下,我們如果使用索引來獲取其中的方法的話,效能會好很多:

```go func BenchmarkMethod(b *testing.B) { p := &Person{} v := reflect.ValueOf(p)

b.ReportAllocs() for i := 0; i < b.N; i++ { v.Method(3) } } ```

結果:

BenchmarkMethod-12 200091475 5.958 ns/op 0 B/op 0 allocs/op

我們可以看到兩種效能相差幾十倍。那麼我們是不是可以通過 Method 方法來替代 MethodByName 從而獲得更好的效能呢?答案是可以的,我們可以快取 MethodByName 的結果(就是方法名對應的下標),下次通過反射獲取對應方法的時候直接通過這個下標來獲取:

這裡需要通過 reflect.Type 的 MethodByName 來獲取反射的方法物件。

```go // 快取方法名對應的方法下標 var indexCache = make(map[string]int)

func methodIndex(p interface{}, method string) int { if _, ok := indexCache[method]; !ok { m, ok := reflect.TypeOf(p).MethodByName(method) if !ok { panic("method not found!") }

  indexCache[method] = m.Index

}

return indexCache[method] } ```

效能測試:

```go func BenchmarkMethodByNameCache(b *testing.B) { p := &Person{} v := reflect.ValueOf(p)

b.ReportAllocs() var idx int for i := 0; i < b.N; i++ { idx = methodIndex(p, "M4") v.Method(idx) } } ```

結果:

// 相比原來的 MethodByName 快了將近 20 倍 BenchmarkMethodByNameCache-12 86208202 13.65 ns/op 0 B/op 0 allocs/op BenchmarkMethodByName-12 5082429 235.9 ns/op 120 B/op 3 allocs/op

跟這個例子類似的是 Field/FieldByName 方法,可以採用同樣的優化方式。這個可能是更加常見的操作,反序列化可能需要通過欄位名查詢欄位,然後進行賦值。

使用型別斷言代替反射

在實際使用中,如果只是需要進行一些簡單的型別判斷的話,比如判斷是否實現某一個介面,那麼可以使用型別斷言來實現:

```go type Talk interface { Say() }

type person struct { }

func (p person) Say() { }

func BenchmarkReflectCall(b *testing.B) { p := person{} v := reflect.ValueOf(p)

for i := 0; i < b.N; i++ { idx := methodIndex(&p, "Say") v.Method(idx).Call(nil) } }

func BenchmarkAssert(b *testing.B) { p := person{}

for i := 0; i < b.N; i++ { var inter interface{} = p if v, ok := inter.(Talk); ok { v.Say() } } } ```

結果:

goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz BenchmarkReflectCall-12 6906339 173.1 ns/op BenchmarkAssert-12 171741784 6.922 ns/op

在這個例子中,我們就算使用了快取版本的反射,效能也跟型別斷言差了將近 25 倍。

因此,在我們使用反射之前,我們需要先考慮一下是否可以通過型別斷言來實現,如果可以的話,那麼就不需要使用反射了。

總結

  • go 提供了效能測試的工具,我們可以通過 go test -bench=. 這種命令來進行效能測試,執行命令之後,資料夾下的測試檔案中的 Benchmark* 函式會被執行。
  • 效能測試的結果中,除了平均執行耗時之外,還有記憶體分配的次數和記憶體分配的位元組數,這些都是我們需要關注的指標。其中記憶體分配的次數和記憶體分配的位元組數是可以通過 b.ReportAllocs() 來進行統計的。記憶體分配的次數和記憶體分配的位元組數越少,效能越好。
  • 反射雖然慢,但是也帶來了一定的靈活性,它的慢主要由以下幾個方面的原因造成的:
  • 執行時需要進行型別判斷,相比確定的型別,執行時可能需要在 20 多種型別中進行判斷。
  • 型別判斷之後,往往需要將 interface{} 轉換為具體的型別,這個轉換也是需要消耗一定時間的。
  • 方法、欄位的查詢也是需要消耗一定時間的。尤其是 FieldByName, MethodByName 這種方法,它們需要遍歷所有的欄位和方法,然後進行比較,這個比較的過程也是需要消耗一定時間的。而且這個過程還需要分配記憶體,這會進一步降低效能。
  • 慢不慢是一個相對的概念,如果我們的應用大部分時間是在 IO 等待,那麼反射的效能大概率不會成為瓶頸。優化其他地方可能會帶來更大的收益,同時也可以在不影響程式碼可維護性的前提下,使用一些時空複雜度更低的反射方法,比如使用 Field 代替 FieldByName 等。
  • 如果可以的話,儘量不使用反射就是最好的優化。
  • 反射的一些效能優化方式有如下幾種(不完全,需要根據實際情況做優化):
  • 使用生成程式碼的方式,生成特定的序列化和反序列化方法,這樣就可以避免反射的開銷。
  • 將第一次反射拿到的結果快取起來,這樣如果後續需要反射的話,就可以直接使用快取的結果,避免反射的開銷。(空間換時間
  • 如果只是需要進行簡單的型別判斷,可以先考慮一下型別斷言能不能實現我們想要的效果,它相比反射的開銷要小很多。

反射是一個很龐大的話題,這裡只是簡單的介紹了一小部分反射的效能問題,討論了一些可行的優化方案,但是每個人使用反射的場景都不一樣,所以需要根據實際情況來做優化。