咱们选择 go 言语的一个重要原因是,它有十分高的功能。可是它反射的功能却一直为人所诟病,本篇文章就来看看 go 反射的功能问题。
go 的功能测验
在开端之前,有必要先了解一下 go 的功能测验。在 go 里边进行功能测验很简略,只需求在测验函数前面加上 Benchmark
前缀,
然后在函数体里边运用 b.N
来进行循环,就能够得到每次循环的耗时。如下面这个比方:
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-20
:BenchmarkNew
是测验函数名,-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
类型):
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
类型。
比较之下,假如是确认的类型,咱们根本不需求判别类型,直接相加就能够了:
func add1(a, b int64) int64 {
return a + b
}
咱们能够经过以下的 benchmark 来比照一下:
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 序列化/反序列化:
// person.go
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
运用 easyjson
的话,咱们需求为结构体生成代码,这儿咱们运用 easyjson
的指令行工具来生成代码:
easyjson -all person.go
这样,咱们就会在当时目录下生成 person_easyjson.go
文件,里边包含了 MarshalJSON
和 UnmarshalJSON
办法,这两个办法便是咱们需求的序列化和反序列化办法。不同于规范库里边的 json.Marshal
和 json.Unmarshal
,这两个办法是不需求反射的,它们的功能会比规范库的办法要好许多。
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
的序列化操作现在只需求几行代码就能够完成了,可是也有很明显的缺点,生成的代码会许多。
功能距离:
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 个办法,M1
、M2
、M3
、M4
、M5
,咱们需求经过称号来查找其间的办法,那么咱们能够运用 reflect
包来完成:
p := &Person{}
v := reflect.ValueOf(p)
v.MethodByName("M4")
这是很容易想到的办法,可是功能怎么呢?经过功能测验,咱们能够看到,这种办法的功能是十分差的:
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
比较之下,咱们假如运用索引来获取其间的办法的话,功能会好许多:
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 来获取反射的办法目标。
// 缓存办法名对应的办法下标
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]
}
功能测验:
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 办法,能够采用同样的优化办法。这个或许是愈加常见的操作,反序列化或许需求经过字段名查找字段,然后进行赋值。
运用类型断语代替反射
在实际运用中,假如仅仅需求进行一些简略的类型判别的话,比方判别是否完成某一个接口,那么能够运用类型断语来完成:
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
等。 - 假如能够的话,尽量不运用反射便是最好的优化。
- 反射的一些功能优化办法有如下几种(不完全,需求依据实际情况做优化):
- 运用生成代码的办法,生成特定的序列化和反序列化办法,这样就能够防止反射的开支。
- 将第一次反射拿到的成果缓存起来,这样假如后续需求反射的话,就能够直接运用缓存的成果,防止反射的开支。(空间换时刻)
- 假如仅仅需求进行简略的类型判别,能够先考虑一下类型断语能不能完成咱们想要的作用,它比较反射的开支要小许多。
反射是一个很巨大的论题,这儿仅仅简略的介绍了一小部分反射的功能问题,讨论了一些可行的优化计划,可是每个人运用反射的场景都不相同,所以需求依据实际情况来做优化。