C語言中文網 目錄

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

現在的一些流行設計思想需要建立在反射基礎上,如控制反轉(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 倍。

經過基準測試結果的數值分析及對比,最終得出以下結論:
  • 能使用原生代碼時,盡量避免反射操作。
  • 提前緩沖反射值對象,對性能有很大的幫助。
  • 避免反射函數調用,實在需要調用時,先提前緩沖函數參數列表,并且盡量少地使用返回值。

精美而實用的網站,提供C語言C++STLLinuxShellJavaGo語言等教程,以及socketGCCviSwing設計模式JSP等專題。

Copyright ?2011-2018 biancheng.net, 陜ICP備15000209號

底部Logo