咱们选择 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-20BenchmarkNew 是测验函数名,-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 文件,里边包含了 MarshalJSONUnmarshalJSON 办法,这两个办法便是咱们需求的序列化和反序列化办法。不同于规范库里边的 json.Marshaljson.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 个办法,M1M2M3M4M5,咱们需求经过称号来查找其间的办法,那么咱们能够运用 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 等。
  • 假如能够的话,尽量不运用反射便是最好的优化。
  • 反射的一些功能优化办法有如下几种(不完全,需求依据实际情况做优化):
    • 运用生成代码的办法,生成特定的序列化和反序列化办法,这样就能够防止反射的开支。
    • 将第一次反射拿到的成果缓存起来,这样假如后续需求反射的话,就能够直接运用缓存的成果,防止反射的开支。(空间换时刻
    • 假如仅仅需求进行简略的类型判别,能够先考虑一下类型断语能不能完成咱们想要的作用,它比较反射的开支要小许多。

反射是一个很巨大的论题,这儿仅仅简略的介绍了一小部分反射的功能问题,讨论了一些可行的优化计划,可是每个人运用反射的场景都不相同,所以需求依据实际情况来做优化。