Categories
程式開發

Golang 反射性能優化


Golang的反射最為人詬病的就是它極差的性能,接下來我們嘗試優化它的性能。

如果我們使用正常的流程來創建一個對象,將會是如下的代碼片段:

type People struct {
Age int
Name string
}

func New() *People {
return &People{
Age: 18,
Name: "shiina",
}
}

以上的代碼非常好讀,但是如果我們要開發一款框架,接收的類型非常有可能是動態的、不確定的,那麼就會使用到反射(Reflect)功能,使用反射來創建一個如上的Person對象大概是如下的代碼片段:

func NewUseReflect() interface{} {
var p People
t := reflect.TypeOf(p)
v := reflect.New
v.Elem().Field(0).Set(reflect.ValueOf(18))
v.Elem().Field(1).Set(reflect.ValueOf("shiina"))
return v.Interface()
}

如上是一段普通的反射代碼,既然大家都說Go的反射性能極差,那麼我們就來自己看一下它的性能和上一個我們正常創建Person對像比性能差了多少。

簡單的性能測試

讓我們先用Go自帶的go bench來分析一下它的性能

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

我們得到的測試結果如下:

BenchmarkNew
BenchmarkNew-16 1000000000 1.55 ns/op 0 B/op 0 allocs/op
BenchmarkNewUseReflect
BenchmarkNewUseReflect-16 4787185 248 ns/op 64 B/op 2 allocs/op

我們能夠發現使用反射的耗時是不使用的160倍左右

性能損耗的猜測

那麼反射創建對象,主要的性能損耗在哪裡呢? 我們先進行一個實驗:

並且當我們增加更多的結構體成員變量,比如增加兩個string類型的成員變量,進行一次性能測試,然後再去掉所有的成員變量,進行一次性能測試。

四個成員變量:

type People struct {
Age int
Name string
Test1 string
Test2 string
}

func New() interface{} {
return &People{
Age: 18,
Name: "shiina",
Test1: "test1",
Test2: "test2",
}
}

func NewUseReflect() interface{} {
var p People
t := reflect.TypeOf(p)
v := reflect.New
v.Elem().Field(0).Set(reflect.ValueOf(18))
v.Elem().Field(1).Set(reflect.ValueOf("shiina"))
v.Elem().Field(2).Set(reflect.ValueOf("test1"))
v.Elem().Field(3).Set(reflect.ValueOf("test2"))
return v.Interface()
}

——————————————————————————————————————————
BenchmarkNew
BenchmarkNew-16 1000000000 1.12 ns/op 0 B/op 0 allocs/op
BenchmarkNewUseReflect
BenchmarkNewUseReflect-16 3334735 366 ns/op 128 B/op 2 allocs/op

無成員變量:

type People struct{}

func New() interface{} {
return &People{}
}

func NewUseReflect() interface{} {
var p People
t := reflect.TypeOf(p)
v := reflect.New
return v.Interface()
}

——————————————————————————————————————————
BenchmarkNew
BenchmarkNew-16 1000000000 1.32 ns/op 0 B/op 0 allocs/op
BenchmarkNewUseReflect
BenchmarkNewUseReflect-16 17362648 62.3 ns/op 0 B/op 0 allocs/op

我們猜測,反射性能的損耗具體分為兩個部分,一個部分是reflect.New(),另一個部分是value.Field().Set()

這時候我們可以使用Go原生自帶的性能分析工具pprof來分析一下它們的主要耗時,來驗證我們的猜測。

我們對四個成員變量測試用例使用pprof:

# 生成测试数据
[email protected] ~/Downloads> go test -bench=. -benchmem -memprofile memprofile.out -cpuprofile profile.out
# 分析测试数据
[email protected] ~/Downloads> go tool pprof ./profile.out
Type: cpu
Time: Apr 24, 2020 at 7:38pm (CST)
Duration: 2.02s, Total samples = 1.92s (94.91%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list NewUseReflect

我們使用pprof得到了該函數的主要耗時,可以發現與我們的猜測無誤,耗時主要分為三個部分:reflect.TypeOf(),reflect.New(),value.Field().Set() ,其中我們可以把reflect.TypeOf()放到函數外,在初始化的時候生成,接下來我們主要關注value.Fidle().Set()

ROUTINE ======================== begonia.NewUseReflect in /Users/kieranhu/go/src/begonia/reflect_test.go
60ms 2.17s (flat, cum) 64.97% of Total
. . 29:
10ms 10ms 30:func NewUseReflect() interface{} {
. . 31: var p People
10ms 580ms 32: t := reflect.TypeOf(p)
. 440ms 33: v := reflect.New
10ms 220ms 34: v.Elem().Field(0).Set(reflect.ValueOf(18))
10ms 250ms 35: v.Elem().Field(1).Set(reflect.ValueOf("shiina"))
. 280ms 36: v.Elem().Field(2).Set(reflect.ValueOf("test1"))
10ms 220ms 37: v.Elem().Field(3).Set(reflect.ValueOf("test2"))
10ms 170ms 38: return v.Interface()
. . 39:}
. . 40:

幹掉value.Field().Set()

我們先從怎麼不用xxx=xxx進行賦值說起。

不安全的

Go中有一個包叫unsafe,顧名思義,它不安全,因為它可以直接操作內存。 我們可以使用unsafe,來對一個字符串進行賦值,具體的步驟大概如下:

獲得該字符串的地址對該地址賦值

我們通過四行就可以完成上面的操作:

str := ""
// 获得该字符串的地址
p := uintptr(unsafe.Pointer(&str))
// 在该地址上赋值
*(*string)(unsafe.Pointer(p))="test"
fmt.Println(str)
-----------------
test

當我們能夠使用unsafe來操作內存時,就可以進一步嘗試操作結構體了。

操作結構體

我們通過上述代碼,得到一個結論:

只要我們知道內存地址,就可以操作任意變量。

接下來我們可以嘗試去操作結構體了。

Go的結構體有以下的兩個特點:

結構體的成員變量是順序存儲的結構體第一個成員變量的地址就是該結構體的地址。

根據以上兩點,以及剛剛我們得到的結論,我們可能夠得到以下的方法,來幹掉value.Field().Set()

獲得結構體地址獲得結構體內成員變量的偏移量得到結構體成員變量地址修改變量值

我們逐個來獲得獲得。

Go中interface類型是以這樣的形式保存的:

// emptyInterface is the header for an interface{} value.
type emptyInterface struct {
typ *rtype
word unsafe.Pointer
}

這個結構體的定義可以在reflect/Value.go找到。

在這個結構體中typ是該interface的具體類型,word指針保存了指向結構體的地址。

現在我們了解了interface的存儲類型後,我們只需要將一個空接口interface{}轉換為emptyInterface類型,然後得到其中的word,就可以拿到結構體的地址了,即解決了第一步。

結構體類型強轉

先用下面這段代碼示例,來解決一下不同結構體之間的轉換:

type Test1 struct {
Test1 string
}

type Test2 struct {
test2 string
}

func TestStruct(t *testing.T) {
t1 := Test1{
Test1: "hello",
}

t2 := *(*Test2)(unsafe.Pointer(&t1))
fmt.Println(t2)
}
----------------
{hello}

然後我們更換兩個結構體中的成員變量類型,再嘗試一下:

type Test1 struct {
a int32
b []byte
}

type Test2 struct {
b int16
a string
}

func TestStruct(t *testing.T) {
t1 := Test1{
a:1,
b:[]byte("asdasd"),
}

t2 := *(*Test2)(unsafe.Pointer(&t1))
fmt.Println(t2)
}
----------------
{1 asdasd}

我們可以發現,後面這次嘗試兩個結構體的類型完全不同,但是其中int32和int16的存儲方式相同,[]byte和string的存儲方式相同,我們可以得出一個簡單的結論:

不論類型簽名是否相同,只要底層存儲方式相同,我們就可以強制轉換,並且可以突破私有成員變量限制。

通過上面我們得到的結論,可以將reflect/value.go裡面的emptyInterface類型複制出來。 然後我們對interface強轉並取到word,就可以拿到結構體的地址了。

type emptyInterface struct {
typ *struct{}
word unsafe.Pointer
}

func TestStruct(t *testing.T) {
var in interface{}
in = People{
Age: 18,
Name: "shiina",
Test1: "test1",
Test2: "test2",
}

t2 := uintptr(((*emptyInterface)(unsafe.Pointer(&in))).word)
*(*int)(unsafe.Pointer(t2))=111
fmt.Println(in)
}
---------------
{111 shiina test1 test2}

我們獲取了結構體地址後,根據結構體地址,修改了結構體內第一個成員變量的值,接下來我們開始進行第二步:得到結構體成員變量的偏移量

我們可以通過反射,來輕鬆的獲得每一個成員變量的偏移量,進而根據結構體的地址,獲得每一個成員變量的地址。

當我們獲得了每一個成員變量的地址後,就可以很輕易的修改它了。

var in interface{}
in = People{
Age: 18,
Name: "shiina",
Test1: "test1",
Test2: "test2",
}

typeP := reflect.TypeOf(in)
offset1 := typeP.Field(1).Offset
offset2 := typeP.Field(2).Offset
offset3 := typeP.Field(3).Offset

t2 := uintptr(((*emptyInterface)(unsafe.Pointer(&in))).word)

*(*int)(unsafe.Pointer(t2)) = 111
*(*string)(unsafe.Pointer(t2 + offset1)) = "hello"
*(*string)(unsafe.Pointer(t2 + offset2)) = "hello1"
*(*string)(unsafe.Pointer(t2 + offset3)) = "hello2"
fmt.Println(in)
---------------------
{111 hello hello1 hello2}

我們剛剛成功的利用地址修改了結構體的成員變量,沒有使用到value.Field().Set()。 接下來我們利用剛剛的技巧,修改反射函數,並再次進行性能測試。

我們保留以前的反射函數做對比,新建一個NewQuickReflect()來使用這種技巧創建對象:

var (
offset1 uintptr
offset2 uintptr
offset3 uintptr
p People
t = reflect.TypeOf(p)
)

func init() {
offset1 = t.Field(1).Offset
offset2 = t.Field(2).Offset
offset3 = t.Field(3).Offset
}

type People struct {
Age int
Name string
Test1 string
Test2 string
}

type emptyInterface struct {
typ *struct{}
word unsafe.Pointer
}

func New() *People {
return &People{
Age: 18,
Name: "shiina",
Test1: "test1",
Test2: "test2",
}
}

func NewUseReflect() interface{} {
v := reflect.New

v.Elem().Field(0).Set(reflect.ValueOf(18))
v.Elem().Field(1).Set(reflect.ValueOf("shiina"))
v.Elem().Field(2).Set(reflect.ValueOf("test1"))
v.Elem().Field(3).Set(reflect.ValueOf("test2"))
return v.Interface()
}

func NewQuickReflect() interface{} {
v := reflect.New

p := v.Interface()
ptr0 := uintptr((*emptyInterface)(unsafe.Pointer(&p)).word)
ptr1 := ptr0 + offset1
ptr2 := ptr0 + offset2
ptr3 := ptr0 + offset3
*((*int)(unsafe.Pointer(ptr0))) = 18
*((*string)(unsafe.Pointer(ptr1))) = "shiina"
*((*string)(unsafe.Pointer(ptr2))) = "test1"
*((*string)(unsafe.Pointer(ptr3))) = "test2"
return p
}

func BenchmarkNew(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ { New() } } func BenchmarkNewUseReflect(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { NewUseReflect() } } func BenchmarkNewQuickReflect(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { NewQuickReflect() } }

運行後我們的測試結果:

BenchmarkNew
BenchmarkNew-16 1000000000 1.34 ns/op 0 B/op 0 allocs/op
BenchmarkNewUseReflect
BenchmarkNewUseReflect-16 3715539 276 ns/op 64 B/op 1 allocs/op
BenchmarkNewQuickReflect
BenchmarkNewQuickReflect-16 12772573 94.7 ns/op 64 B/op 1 allocs/op

可以看出我們的性能從原生205倍提升到了70倍,並且這個優化的程度將會隨著結構體成員變量越多而越明顯。

我們對新寫的NewQuickReflect函數使用pprof分析一下,繼續觀察有沒有可以優化的點。

ROUTINE ======================== begonia.NewQuickReflect in /Users/kieranhu/go/src/begonia/reflect_test.go
120ms 1.07s (flat, cum) 28.53% of Total
. . 57:
. . 58:func NewQuickReflect() interface{} {
40ms 800ms 59: v := reflect.New
. . 60:
. 180ms 61: p := v.Interface()
. . 62: ptr0 := uintptr((*emptyInterface)(unsafe.Pointer(&p)).word)
40ms 40ms 63: ptr1 := ptr0 + offset1
10ms 10ms 64: ptr2 := ptr0 + offset2
. . 65: ptr3 := ptr0 + offset3
10ms 10ms 66: *((*int)(unsafe.Pointer(ptr0))) = 18
. 10ms 67: *((*string)(unsafe.Pointer(ptr1))) = "shiina"
. . 68: *((*string)(unsafe.Pointer(ptr2))) = "test1"
. . 69: *((*string)(unsafe.Pointer(ptr3))) = "test2"
20ms 20ms 70: return p
. . 71:}
. . 72:

我們能夠發現最多的損耗花在了reflect.New()上,我們著手嘗試對它進行優化。

幹掉reflect.New()

池化

對於改善創建對象耗時來說,最簡單的優化方式便是池化,我們利用sync.pool創建一個對像池,並且模擬對像池中資源充足的情況下的性能:

var (
/**
...........
**/
pool sync.Pool
)
func init() {
/**
............
**/
pool.New = func() interface{} {
return reflect.New
}
for i := 0; i < 100; i++ { pool.Put(reflect.New } } /** ............ **/ func NewQuickReflectWithPool() interface{} { p := pool.Get() ptr0 := uintptr((*emptyInterface)(unsafe.Pointer(&p)).word) ptr1 := ptr0 + offset1 ptr2 := ptr0 + offset2 ptr3 := ptr0 + offset3 *((*int)(unsafe.Pointer(ptr0))) = 18 *((*string)(unsafe.Pointer(ptr1))) = "shiina" *((*string)(unsafe.Pointer(ptr2))) = "test1" *((*string)(unsafe.Pointer(ptr3))) = "test2" return p } func BenchmarkQuickReflectWithPool(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { obj := NewQuickReflectWithPool() pool.Put(obj) } }

在上述這個用例中,我們一拿到這個對象幾乎就立即放回了對像池,模擬的是對像池資源充足情況下的性能:

BenchmarkNew
BenchmarkNew-16 1000000000 1.26 ns/op 0 B/op 0 allocs/op
BenchmarkNewUseReflect
BenchmarkNewUseReflect-16 5515128 226 ns/op 64 B/op 1 allocs/op
BenchmarkNewQuickReflect
BenchmarkNewQuickReflect-16 21561645 91.4 ns/op 64 B/op 1 allocs/op
BenchmarkQuickReflectWithPool
BenchmarkQuickReflectWithPool-16 40770750 55.6 ns/op 0 B/op 0 allocs/op

我們可以發現在對像池對象充足的情況下,沒有了malloc帶來的耗時,我們的性能從原生72倍提升到原生的44倍。

但是當對像池不充足情況下,就沒有這麼可喜的效率了。

另一個思路

我們能夠發現現在主要的耗時都在利用反射的創建對像上,這個時候我腦海裡有一個思路:

在我們需要的是值類型(例如Person{}),而不是指針的時候(例如&Person)時,我們是不是可以利用Go的這個特性:

值類型傳遞值而不是指針的時候會進行拷貝

來在使用反射的前提下,利用值傳遞特性獲得一個原生級別對象拷貝?

如果不使用反射,已知類型的情況下會是如下的代碼:

func TestStruct(t *testing.T) {
p1 := People{}

var p2 interface{}
p2 = p1

ptr0 := uintptr((*emptyInterface)(unsafe.Pointer(&p2)).word)
ptr1 := ptr0 + offset1
ptr2 := ptr0 + offset2
ptr3 := ptr0 + offset3

*((*int)(unsafe.Pointer(ptr0))) = 18
*((*string)(unsafe.Pointer(ptr1))) = "shiina"
*((*string)(unsafe.Pointer(ptr2))) = "test1"
*((*string)(unsafe.Pointer(ptr3))) = "test2"

fmt.Println(p1)
fmt.Println(p2)
}
------------------------
{0 }
{18 shiina test1 test2}

我們可以看到,我們使用這樣一個值傳遞的特性,得到了一份p1的拷貝

很可惜的是,當我們不能直接指定類型的時候,想像中這樣場景一直實現不了,會直接修改原變量的值,最終我找到了這樣的調用方法:

func TestNew(t *testing.T) {
elemValue := reflect.New(reflect.TypeOf(People{})).Elem()
p := elemValue.Interface()

ptr0 := uintptr((*emptyInterface)(unsafe.Pointer(&p)).word)
ptr1 := ptr0 + offset1
ptr2 := ptr0 + offset2
ptr3 := ptr0 + offset3

*((*int)(unsafe.Pointer(ptr0))) = 18
*((*string)(unsafe.Pointer(ptr1))) = "shiina"
*((*string)(unsafe.Pointer(ptr2))) = "test1"
*((*string)(unsafe.Pointer(ptr3))) = "test2"

fmt.Println(p)
fmt.Println(elemValue)
}
-------------------
{18 shiina test1 test2}
{0 }

每次elemValue.Interface()時都會拷貝一個新的對象,這是我們期待的結果,接下來我們將它和之前的池化等一起進行性能測試

BenchmarkNew
BenchmarkNew-16 1000000000 1.83 ns/op 0 B/op 0 allocs/op
BenchmarkNewUseReflect
BenchmarkNewUseReflect-16 2992928 372 ns/op 128 B/op 2 allocs/op
BenchmarkNewQuickReflect
BenchmarkNewQuickReflect-16 12648523 98.7 ns/op 64 B/op 1 allocs/op
BenchmarkQuickReflectWithPool
BenchmarkQuickReflectWithPool-16 40309711 58.2 ns/op 0 B/op 0 allocs/op
BenchmarkNewWithElemReflect
BenchmarkNewWithElemReflect-16 12700314 89.0 ns/op 64 B/op 1 allocs/op

結果比較沮喪,我們僅提升了不到10ns,從53倍提升到48倍,並且性能的提升也並不穩定。

為此我們閱讀reflect.New()和elemValue.Interface()源碼,發現瞭如下的片段:

Reflection.New()

func New(typ Type) Value {
if typ == nil {
panic("reflect: New(nil)")
}
t := typ.(*rtype)
ptr := unsafe_New
fl := flag(Ptr)
return Value{t.ptrTo(), ptr, fl}
}

elemValue.Interface()

if v.flag&flagAddr != 0 {
// TODO: pass safe boolean from valueInterface so
// we don't need to copy if safe==true?
c := unsafe_New
typedmemmove(t, c, ptr)
ptr = c
}

reflect.New()的主要耗時都在這個unsafe_New()函數上,然而對於一個elemValue取Interface()時,反射還是會調用unsafe_New()函數來創建一個新值。

當多次實驗,性能測試之後,發現這種干掉reflect.New()的方式性能不夠穩定,基本沒有使用的必要。 ( T_T )

結束

如上整個性能優化的從思路到實驗,再到實現大概總共花了一周的空閒時間。 越寫越覺得我不像是在寫Go而是在寫c了。 或許我應該讓Go寫的更像Go而不是想什麼黑魔法來讓Go更快(也更不安全)? 很感謝需求不飽和讓我還有摸魚時間來研究這個(x