详解如何让Go语言中的反射加快

2022-10-07,,,

最近读到一篇关于 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 内部机理知识,在你的代码中随意撒上不安全的味道 ,以使其真正加速。

到此这篇关于详解如何让go语言中的反射加快的文章就介绍到这了,更多相关go语言 反射内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

《详解如何让Go语言中的反射加快.doc》

下载本文的Word格式文档,以方便收藏与打印。