如何讓 Go 反射變快
最近讀到一篇關於 Go 反射的文章,作者通過反射給結構體填充字段值的案例,充分利用 Go 的各種內在機理,逐步探討讓代碼運行得更快的姿勢。
文章(原文地址:https://philpearl.github.io/post/aintnecessarilyslow/)非常有學習價值,故翻譯整理了下來。
不要使用反射,除非你真的需要。但是當你不使用反射時,不要認爲這是因爲反射很慢,它也可以很快。
反射允許你在運行時獲得有關 Go 類型的信息。如果你曾經愚蠢地嘗試編寫 json.Unmarshal
之類的新版本,本文將探討的就是如何使用反射來填充結構體值。
切入點案例
我們以一個簡單的案例爲切入點,定義一個結構體 SimpleStruct
,它包括兩個 int 類型字段 A
和 B
。
type SimpleStruct struct {
A int
B int
}
假如我們接收到了 JSON 數據 {"B": 42},想要對其進行解析並且將字段 B
設置爲 42。
在下文,我們將編寫一些函數來實現這一點,它們都會將 B 設置爲 42。
如果我們的代碼只適用於 SimpleStruct,這完全是不值一提的。
func populateStruct(in *SimpleStruct) {
in.B = 42
}
反射基本版
但是,如果我們是要做一個 JSON 解析器,這意味着我們並不能提前知道結構類型。我們的解析器代碼需要接收任何類型的數據。
在 Go 中,這通常意味着需要採用 interface{} (空接口)參數。然後我們可以使用 reflect 包檢查通過空接口參數傳入的值,檢查它是否是指向結構體的指針,找到字段 B
並用我們的值填充它。
代碼將如下所示。
func populateStructReflect(in interface{}) error {
val := reflect.ValueOf(in)
if val.Type().Kind() != reflect.Ptr {
return fmt.Errorf("you must pass in a pointer")
}
elmv := val.Elem()
if elmv.Type().Kind() != reflect.Struct {
return fmt.Errorf("you must pass in a pointer to a struct")
}
fval := elmv.FieldByName("B")
fval.SetInt(42)
return nil
}
讓我們通過基準測試看看它有多快。
func BenchmarkPopulateReflect(b *testing.B) {
b.ReportAllocs()
var m SimpleStruct
for i := 0; i < b.N; i++ {
if err := populateStructReflect(&m); err != nil {
b.Fatal(err)
}
if m.B != 42 {
b.Fatalf("unexpected value %d for B", m.B)
}
}
}
結果如下。
BenchmarkPopulateReflect-16 15941916 68.3 ns/op 8 B/op 1 allocs/op
這是好還是壞?好吧,內存分配可從來不是好事。你可能想知道爲什麼需要在堆上分配內存來將結構體字段設置爲 42(可以看這個 issue:https://github.com/golang/go/issues/2320)。但總體而言,68ns 的時間並不長。在通過網絡發出任何類型的請求時間中,你可以容納很多 68ns。
優化一:加入緩存策略
我們能做得更好嗎?好吧,通常我們運行的程序不會只做一件事然後停止。他們通常一遍又一遍地做着非常相似的事情。因此,我們可以設置一些東西以使重複的事情速度變快嗎?
如果仔細查看我們正在執行的反射檢查,我們會發現它們都取決於傳入值的類型。如果我們將類型結果緩存起來,那麼對於每種類型而言,我們只會進行一次檢查。
我們再來考慮內存分配的問題。之前我們調用 Value.FieldByName
方法,實際是 Value.FieldByName
調用 Type.FieldByName
,其調用 structType.FieldByName
,最後調用 structType.Field
來引起內存分配的。我們可以在類型上調用 FieldByName
並緩存一些東西來獲取 B
字段的值嗎?實際上,如果我們緩存 Field.Index
,就可以使用它來獲取字段值而無需分配。
新代碼版本如下
var cache = make(map[reflect.Type][]int)
func populateStructReflectCache(in interface{}) error {
typ := reflect.TypeOf(in)
index, ok := cache[typ]
if !ok {
if typ.Kind() != reflect.Ptr {
return fmt.Errorf("you must pass in a pointer")
}
if typ.Elem().Kind() != reflect.Struct {
return fmt.Errorf("you must pass in a pointer to a struct")
}
f, ok := typ.Elem().FieldByName("B")
if !ok {
return fmt.Errorf("struct does not have field B")
}
index = f.Index
cache[typ] = index
}
val := reflect.ValueOf(in)
elmv := val.Elem()
fval := elmv.FieldByIndex(index)
fval.SetInt(42)
return nil
}
因爲沒有任何內存分配,新的基準測試變得更快。
BenchmarkPopulateReflectCache-16 35881779 30.9 ns/op 0 B/op 0 allocs/op
優化二:利用字段偏移量
我們能做得更好嗎?好吧,如果我們知道結構體字段 B 的偏移量並且知道它是 int 類型,就可以將其直接寫入內存。我們可以從接口中恢復指向結構體的指針,因爲空接口實際上是具有兩個指針的結構的語法糖:第一個指向有關類型的信息,第二個指向值。
type eface struct {
_type *_type
data unsafe.Pointer
}
我們可以使用結構體中字段偏移量來直接尋址該值的字段 B。
新代碼如下。
var unsafeCache = make(map[reflect.Type]uintptr)
type intface struct {
typ unsafe.Pointer
value unsafe.Pointer
}
func populateStructUnsafe(in interface{}) error {
typ := reflect.TypeOf(in)
offset, ok := unsafeCache[typ]
if !ok {
if typ.Kind() != reflect.Ptr {
return fmt.Errorf("you must pass in a pointer")
}
if typ.Elem().Kind() != reflect.Struct {
return fmt.Errorf("you must pass in a pointer to a struct")
}
f, ok := typ.Elem().FieldByName("B")
if !ok {
return fmt.Errorf("struct does not have field B")
}
if f.Type.Kind() != reflect.Int {
return fmt.Errorf("field B should be an int")
}
offset = f.Offset
unsafeCache[typ] = offset
}
structPtr := (*intface)(unsafe.Pointer(&in)).value
*(*int)(unsafe.Pointer(uintptr(structPtr) + offset)) = 42
return nil
}
新的基準測試表明這將更快。
BenchmarkPopulateUnsafe-16 62726018 19.5 ns/op 0 B/op 0 allocs/op
優化三:更改緩存 key 類型
還能讓它走得更快嗎?如果我們對 CPU 進行採樣,將會看到大部分時間都用於訪問 map,它還會顯示 map 訪問在調用 runtime.interhash
和 runtime.interequal
。這些是用於 hash 接口並檢查它們是否相等的函數。也許使用更簡單的 key 會加快速度?我們可以使用來自接口的類型信息的地址,而不是 reflect.Type
本身。
var unsafeCache2 = make(map[uintptr]uintptr)
func populateStructUnsafe2(in interface{}) error {
inf := (*intface)(unsafe.Pointer(&in))
offset, ok := unsafeCache2[uintptr(inf.typ)]
if !ok {
typ := reflect.TypeOf(in)
if typ.Kind() != reflect.Ptr {
return fmt.Errorf("you must pass in a pointer")
}
if typ.Elem().Kind() != reflect.Struct {
return fmt.Errorf("you must pass in a pointer to a struct")
}
f, ok := typ.Elem().FieldByName("B")
if !ok {
return fmt.Errorf("struct does not have field B")
}
if f.Type.Kind() != reflect.Int {
return fmt.Errorf("field B should be an int")
}
offset = f.Offset
unsafeCache2[uintptr(inf.typ)] = offset
}
*(*int)(unsafe.Pointer(uintptr(inf.value) + offset)) = 42
return nil
}
這是新版本的基準測試結果,它又快了很多。
BenchmarkPopulateUnsafe2-16 230836136 5.16 ns/op 0 B/op 0 allocs/op
優化四:引入描述符
還能更快嗎?通常如果我們要將數據 unmarshaling 到結構體中,它總是相同的結構。因此,我們可以將功能一分爲二,其中一個函數用於檢查結構是否符合要求並返回一個描述符,另外一個函數則可以在之後的填充調用中使用該描述符。
以下是我們的新代碼版本。調用者應該在初始化時調用describeType
函數以獲得一個typeDescriptor
,之後調用populateStructUnsafe3
函數時會用到它。在這個非常簡單的例子中,typeDescriptor
只是結構體中B
字段的偏移量。
type typeDescriptor uintptr
func describeType(in interface{}) (typeDescriptor, error) {
typ := reflect.TypeOf(in)
if typ.Kind() != reflect.Ptr {
return 0, fmt.Errorf("you must pass in a pointer")
}
if typ.Elem().Kind() != reflect.Struct {
return 0, fmt.Errorf("you must pass in a pointer to a struct")
}
f, ok := typ.Elem().FieldByName("B")
if !ok {
return 0, fmt.Errorf("struct does not have field B")
}
if f.Type.Kind() != reflect.Int {
return 0, fmt.Errorf("field B should be an int")
}
return typeDescriptor(f.Offset), nil
}
func populateStructUnsafe3(in interface{}, ti typeDescriptor) error {
structPtr := (*intface)(unsafe.Pointer(&in)).value
*(*int)(unsafe.Pointer(uintptr(structPtr) + uintptr(ti))) = 42
return nil
}
以下是如何使用describeType
調用的新基準測試。
func BenchmarkPopulateUnsafe3(b *testing.B) {
b.ReportAllocs()
var m SimpleStruct
descriptor, err := describeType((*SimpleStruct)(nil))
if err != nil {
b.Fatal(err)
}
for i := 0; i < b.N; i++ {
if err := populateStructUnsafe3(&m, descriptor); err != nil {
b.Fatal(err)
}
if m.B != 42 {
b.Fatalf("unexpected value %d for B", m.B)
}
}
}
現在基準測試結果變得相當快。
BenchmarkPopulateUnsafe3-16 1000000000 0.359 ns/op 0 B/op 0 allocs/op
這有多棒?如果我們以文章開頭原始的 populateStruct
函數編寫基準測試,可以看到在不使用反射的情況下,填充這個結構體的速度有多快。
BenchmarkPopulate-16 1000000000 0.234 ns/op 0 B/op 0 allocs/op
不出所料,這甚至比我們最好的基於反射的版本還要快一點,但它也沒有快太多。
總結
反射並不一定很慢,但是你必須付出相當大的努力,通過運用 Go 內部機理知識,在你的代碼中隨意撒上不安全的味道 ,以使其真正加速。
最後,如果你對這種方法的實際使用感興趣,可以參考 jsoniter 庫:https://github.com/json-iterator/go,它使用 reflect2 庫:_https://github.com/modern-go/reflect2 _來實現了非常相似的方法。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/8aFSgJeDKgMD2r125c_UWg