Go語言反射——效能和靈活性的雙刃劍

2020-07-16 10:05:22
現在的一些流行設計思想需要建立在反射基礎上,如控制反轉(Inversion Of Control,IOC)和依賴注入(Dependency Injection,DI)。Go語言中非常有名的 Web 框架 martini(https://github.com/go-martini/martini)就是通過依賴注入技術進行中介軟體的實現,例如使用 martini 框架搭建的 http 的伺服器如下:
package main

import "github.com/go-martini/martini"

func main() {
    m := martini.Classic()
    m.Get("/", func() string {
        return "Hello world!"
    })
    m.Run()
}
第 7 行,響應路徑/的程式碼使用一個閉包實現。如果希望獲得 Go語言中提供的請求和響應介面,可以直接修改為:
m.Get("/", func(res http.ResponseWriter, req *http.Request) string {
    // 響應處理程式碼……
})
martini 的底層會自動通過識別 Get 獲得的閉包引數情況,通過動態反射呼叫這個函數並傳入需要的引數。martini 的設計廣受好評,但同時也有人指出,其執行效率較低。其中最主要的因素是大量使用了反射。

雖然一般情況下,I/O 的延遲遠遠大於反射程式碼所造成的延遲。但是,更低的響應速度和更低的 CPU 占用依然是 Web 伺服器追求的目標。因此,反射在帶來靈活性的同時,也帶上了效能低下的桎梏。

要用好反射這把雙刃劍,就需要詳細了解反射的效能。下面的一些基準測試從多方面對比了原生呼叫和反射呼叫的區別。

1) 結構體成員賦值對比

反射經常被使用在結構體上,因此結構體的成員存取效能就成為了關注的重點。下面例子中使用一個被範例化的結構體,存取它的成員,然後使用 Go語言的基準化測試可以迅速測試出結果。

反射效能測試的完整程式碼位於./src/chapter12/reflecttest/reflect_test.go,下面是對各個部分的詳細說明。
本套教學所有原始碼下載地址:https://pan.baidu.com/s/1ORFVTOLEYYqDhRzeq0zIiQ    提取密碼:hfyf
原生結構體的賦值過程:
// 宣告一個結構體, 擁有一個欄位
type data struct {
    Hp int
}

func BenchmarkNativeAssign(b *testing.B) {

    // 範例化結構體
    v := data{Hp: 2}

    // 停止基準測試的計時器
    b.StopTimer()
    // 重置基準測試計時器資料
    b.ResetTimer()

    // 重新啟動基準測試計時器
    b.StartTimer()

    // 根據基準測試資料進行迴圈測試
    for i := 0; i < b.N; i++ {

        // 結構體成員賦值測試
        v.Hp = 3
    }

}
程式碼說明如下:
  • 第 2 行,宣告一個普通結構體,擁有一個成員變數。
  • 第 6 行,使用基準化測試的入口。
  • 第 9 行,範例化 data 結構體,並給 Hp 成員賦值。
  • 第 12~17 行,由於測試的重點必須放在賦值上,因此需要極大程度地降低其他程式碼的干擾,於是在賦值完成後,將基準測試的計時器復位並重新開始。
  • 第 20 行,將基準測試提供的測試數量用於迴圈中。
  • 第 23 行,測試的核心程式碼:結構體賦值。

接下來的程式碼分析使用反射存取結構體成員並賦值的過程。
func BenchmarkReflectAssign(b *testing.B) {

    v := data{Hp: 2}

    // 取出結構體指標的反射值物件並取其元素
    vv := reflect.ValueOf(&v).Elem()

    // 根據名字取結構體成員
    f := vv.FieldByName("Hp")

    b.StopTimer()
    b.ResetTimer()
    b.StartTimer()

    for i := 0; i < b.N; i++ {

        // 反射測試設定成員值效能
        f.SetInt(3)
    }
}
程式碼說明如下:
  • 第 6 行,取v的地址並轉為反射值物件。此時值物件裡的型別為 *data,使用值的 Elem() 方法取元素,獲得 data 的反射值物件。
  • 第 9 行,使用 FieldByName() 根據名字取出成員的反射值物件。
  • 第 11~13 行,重置基準測試計時器。
  • 第 18 行,使用反射值物件的 SetInt() 方法,給 data 結構的Hp欄位設定數值 3。

這段程式碼中使用了反射值物件的 SetInt() 方法,這個方法的原始碼如下:
func (v Value) SetInt(x int64) {
    v.mustBeAssignable()
    switch k := v.kind(); k {
    default:
        panic(&ValueError{"reflect.Value.SetInt", v.kind()})
    case Int:
        *(*int)(v.ptr) = int(x)
    case Int8:
        *(*int8)(v.ptr) = int8(x)
    case Int16:
        *(*int16)(v.ptr) = int16(x)
    case Int32:
        *(*int32)(v.ptr) = int32(x)
    case Int64:
        *(*int64)(v.ptr) = x
    }
}
可以發現,整個設定過程都是指標轉換及賦值,沒有遍歷及記憶體操作等相對耗時的演算法。

2) 結構體成員搜尋並賦值對比

func BenchmarkReflectFindFieldAndAssign(b *testing.B) {

    v := data{Hp: 2}

    vv := reflect.ValueOf(&v).Elem()

    b.StopTimer()
    b.ResetTimer()
    b.StartTimer()

    for i := 0; i < b.N; i++ {

        // 測試結構體成員的查詢和設定成員的效能
        vv.FieldByName("Hp").SetInt(3)
    }

}
這段程式碼將反射值物件的 FieldByName() 方法與 SetInt() 方法放在迴圈裡進行檢測,主要對比測試 FieldByName() 方法對效能的影響。FieldByName() 方法原始碼如下:
func (v Value) FieldByName(name string) Value {
    v.mustBe(Struct)
    if f, ok := v.typ.FieldByName(name); ok {
        return v.FieldByIndex(f.Index)
    }
    return Value{}
}
底層程式碼說明如下:
  • 第 3 行,通過名字查詢型別物件,這裡有一次遍歷過程。
  • 第 4 行,找到型別物件後,使用 FieldByIndex() 繼續在值中查詢,這裡又是一次遍歷。

經過底層程式碼分析得出,隨著結構體欄位數量和相對位置的變化,FieldByName() 方法比較嚴重的低效率問題。

3) 呼叫函數對比

反射的函數呼叫,也是使用反射中容易忽視的效能點,下面展示對普通函數的呼叫過程。
// 一個普通函數
func foo(v int) {

}

func BenchmarkNativeCall(b *testing.B) {

    for i := 0; i < b.N; i++ {
        // 原生函數呼叫
        foo(0)
    }
}

func BenchmarkReflectCall(b *testing.B) {

    // 取函數的反射值物件
    v := reflect.ValueOf(foo)

    b.StopTimer()
    b.ResetTimer()
    b.StartTimer()

    for i := 0; i < b.N; i++ {
        // 反射呼叫函數
        v.Call([]reflect.Value{reflect.ValueOf(2)})
    }
}
程式碼說明如下:
  • 第 2 行,一個普通的只有一個引數的函數。
  • 第 10 行,對原生函數呼叫的效能測試。
  • 第 17 行,根據函數名取出反射值物件。
  • 第 25 行,使用 reflect.ValueOf(2) 將 2 構造為反射值物件,因為反射函數呼叫的引數必須全是反射值物件,再使用 []reflect.Value 構造多個參數列傳給反射值物件的 Call() 方法進行呼叫。

反射函數呼叫的引數構造過程非常複雜,構建很多物件會造成很大的記憶體回收負擔。Call() 方法內部就更為複雜,需要將參數列的每個值從 reflect.Value 型別轉換為記憶體。呼叫完畢後,還要將函數返回值重新轉換為 reflect.Value 型別返回。因此,反射呼叫函數的效能堪憂。

4) 基準測試結果對比

測試結果如下:
$ go test -v -bench=.
goos: linux
goarch: amd64
BenchmarkNativeAssign-4                        2000000000               0.32 ns/op
BenchmarkReflectAssign-4                       300000000               4.42 ns/op
BenchmarkReflectFindFieldAndAssign-4           20000000               91.6 ns/op
BenchmarkNativeCall-4                          2000000000               0.33 ns/op
BenchmarkReflectCall-4                         10000000               163 ns/op
PASS
結果分析如下:
  • 第 4 行,原生的結構體成員賦值,每一步操作耗時 0.32 納秒,這是參考基準。
  • 第 5 行,使用反射的結構體成員賦值,操作耗時 4.42 納秒,比原生賦值多消耗 13 倍的效能。
  • 第 6 行,反射查詢結構體成員且反射賦值,操作耗時 91.6 納秒,扣除反射結構體成員賦值的 4.42 納秒還富餘,效能大概是原生的 272 倍。這個測試結果與程式碼分析結果很接近。SetInt 的效能可以接受,但 FieldByName() 的效能就非常低。
  • 第 7 行,原生函數呼叫,效能與原生存取結構體成員接近。
  • 第 8 行,反射函數呼叫,效能差到“爆棚”,花費了 163 納秒,操作耗時比原生多消耗 494 倍。

經過基準測試結果的數值分析及對比,最終得出以下結論:
  • 能使用原生程式碼時,盡量避免反射操作。
  • 提前緩衝反射值物件,對效能有很大的幫助。
  • 避免反射函數呼叫,實在需要呼叫時,先提前緩衝函數參數列,並且盡量少地使用返回值。