上缓存,给Go的反射加速-《GO开发知识笔记》

admin 2025-11-04 00:58:54 编程 来源:ZONE.CI 全球网 0 阅读模式
  • 切入点案例
  • 反射基本版
  • 优化一:加入缓存策略
  • 优化二:利用字段偏移量
  • 优化三:更改缓存 key 类型
  • 优化四:引入描述符
  • 总结

    最近读到一篇关于 Go 反射的文章,作者通过反射给结构体填充字段值的案例,充分利用 Go 的各种内在机理,逐步探讨让代码运行得更快的姿势。文章(原文地址:https://philpearl.github.io/post/aintnecessarilyslow/)非常有学习价值,故翻译整理了下来。不要使用反射,除非你真的需要。但是当你不使用反射时,不要认为这是因为反射很慢,它也可以很快。反射允许你在运行时获得有关 Go 类型的信息。如果你曾经愚蠢地尝试编写 json.Unmarshal 之类的新版本,本文将探讨的就是如何使用反射来填充结构体值。

    切入点案例

    我们以一个简单的案例为切入点,定义一个结构体 SimpleStruct,它包括两个 int 类型字段 A 和 B。

    1. type SimpleStruct struct {
    2. A int
    3. B int
    4. }

    假如我们接收到了 JSON 数据 {“B”: 42},想要对其进行解析并且将字段 B 设置为 42。在下文,我们将编写一些函数来实现这一点,它们都会将 B 设置为 42。如果我们的代码只适用于 SimpleStruct,这完全是不值一提的。

    1. func populateStruct(in *SimpleStruct) {
    2. in.B = 42
    3. }

    反射基本版

    但是,如果我们是要做一个 JSON 解析器,这意味着我们并不能提前知道结构类型。我们的解析器代码需要接收任何类型的数据。在 Go 中,这通常意味着需要采用 interface{} (空接口)参数。然后我们可以使用 reflect 包检查通过空接口参数传入的值,检查它是否是指向结构体的指针,找到字段 B 并用我们的值填充它。代码将如下所示。

    1. func populateStructReflect(in interface{}) error {
    2. val := reflect.ValueOf(in)
    3. if val.Type().Kind() != reflect.Ptr {
    4. return fmt.Errorf("you must pass in a pointer")
    5. }
    6. elmv := val.Elem()
    7. if elmv.Type().Kind() != reflect.Struct {
    8. return fmt.Errorf("you must pass in a pointer to a struct")
    9. }
    10. fval := elmv.FieldByName("B")
    11. fval.SetInt(42)
    12. return nil
    13. }

    让我们通过基准测试看看它有多快。

    1. func BenchmarkPopulateReflect(b *testing.B) {
    2. b.ReportAllocs()
    3. var m SimpleStruct
    4. for i := 0; i < b.N; i++ {
    5. if err := populateStructReflect(&m); err != nil {
    6. b.Fatal(err)
    7. }
    8. if m.B != 42 {
    9. b.Fatalf("unexpected value %d for B", m.B)
    10. }
    11. }
    12. }

    结果如下。

    1. 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,就可以使用它来获取字段值而无需分配。新代码版本如下

    1. var cache = make(map[reflect.Type][]int)
    2. func populateStructReflectCache(in interface{}) error {
    3. typ := reflect.TypeOf(in)
    4. index, ok := cache[typ]
    5. if !ok {
    6. if typ.Kind() != reflect.Ptr {
    7. return fmt.Errorf("you must pass in a pointer")
    8. }
    9. if typ.Elem().Kind() != reflect.Struct {
    10. return fmt.Errorf("you must pass in a pointer to a struct")
    11. }
    12. f, ok := typ.Elem().FieldByName("B")
    13. if !ok {
    14. return fmt.Errorf("struct does not have field B")
    15. }
    16. index = f.Index
    17. cache[typ] = index
    18. }
    19. val := reflect.ValueOf(in)
    20. elmv := val.Elem()
    21. fval := elmv.FieldByIndex(index)
    22. fval.SetInt(42)
    23. return nil
    24. }

    因为没有任何内存分配,新的基准测试变得更快。

    1. BenchmarkPopulateReflectCache-16 35881779 30.9 ns/op 0 B/op 0 allocs/op

    优化二:利用字段偏移量

    我们能做得更好吗?好吧,如果我们知道结构体字段 B 的偏移量并且知道它是 int 类型,就可以将其直接写入内存。我们可以从接口中恢复指向结构体的指针,因为空接口实际上是具有两个指针的结构的语法糖:第一个指向有关类型的信息,第二个指向值。

    1. type eface struct {
    2. _type *_type
    3. data unsafe.Pointer
    4. }

    我们可以使用结构体中字段偏移量来直接寻址该值的字段 B。新代码如下。

    1. var unsafeCache = make(map[reflect.Type]uintptr)
    2. type intface struct {
    3. typ unsafe.Pointer
    4. value unsafe.Pointer
    5. }
    6. func populateStructUnsafe(in interface{}) error {
    7. typ := reflect.TypeOf(in)
    8. offset, ok := unsafeCache[typ]
    9. if !ok {
    10. if typ.Kind() != reflect.Ptr {
    11. return fmt.Errorf("you must pass in a pointer")
    12. }
    13. if typ.Elem().Kind() != reflect.Struct {
    14. return fmt.Errorf("you must pass in a pointer to a struct")
    15. }
    16. f, ok := typ.Elem().FieldByName("B")
    17. if !ok {
    18. return fmt.Errorf("struct does not have field B")
    19. }
    20. if f.Type.Kind() != reflect.Int {
    21. return fmt.Errorf("field B should be an int")
    22. }
    23. offset = f.Offset
    24. unsafeCache[typ] = offset
    25. }
    26. structPtr := (*intface)(unsafe.Pointer(&in)).value
    27. *(*int)(unsafe.Pointer(uintptr(structPtr) + offset)) = 42
    28. return nil
    29. }

    新的基准测试表明这将更快。

    1. 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 本身。

    1. var unsafeCache2 = make(map[uintptr]uintptr)
    2. func populateStructUnsafe2(in interface{}) error {
    3. inf := (*intface)(unsafe.Pointer(&in))
    4. offset, ok := unsafeCache2[uintptr(inf.typ)]
    5. if !ok {
    6. typ := reflect.TypeOf(in)
    7. if typ.Kind() != reflect.Ptr {
    8. return fmt.Errorf("you must pass in a pointer")
    9. }
    10. if typ.Elem().Kind() != reflect.Struct {
    11. return fmt.Errorf("you must pass in a pointer to a struct")
    12. }
    13. f, ok := typ.Elem().FieldByName("B")
    14. if !ok {
    15. return fmt.Errorf("struct does not have field B")
    16. }
    17. if f.Type.Kind() != reflect.Int {
    18. return fmt.Errorf("field B should be an int")
    19. }
    20. offset = f.Offset
    21. unsafeCache2[uintptr(inf.typ)] = offset
    22. }
    23. *(*int)(unsafe.Pointer(uintptr(inf.value) + offset)) = 42
    24. return nil
    25. }

    这是新版本的基准测试结果,它又快了很多。

    1. BenchmarkPopulateUnsafe2-16 230836136 5.16 ns/op 0 B/op 0 allocs/op

    优化四:引入描述符

    还能更快吗?通常如果我们要将数据 unmarshaling 到结构体中,它总是相同的结构。因此,我们可以将功能一分为二,其中一个函数用于检查结构是否符合要求并返回一个描述符,另外一个函数则可以在之后的填充调用中使用该描述符。以下是我们的新代码版本。调用者应该在初始化时调用describeType函数以获得一个typeDescriptor,之后调用populateStructUnsafe3函数时会用到它。在这个非常简单的例子中,typeDescriptor只是结构体中B字段的偏移量。

    1. type typeDescriptor uintptr
    2. func describeType(in interface{}) (typeDescriptor, error) {
    3. typ := reflect.TypeOf(in)
    4. if typ.Kind() != reflect.Ptr {
    5. return 0, fmt.Errorf("you must pass in a pointer")
    6. }
    7. if typ.Elem().Kind() != reflect.Struct {
    8. return 0, fmt.Errorf("you must pass in a pointer to a struct")
    9. }
    10. f, ok := typ.Elem().FieldByName("B")
    11. if !ok {
    12. return 0, fmt.Errorf("struct does not have field B")
    13. }
    14. if f.Type.Kind() != reflect.Int {
    15. return 0, fmt.Errorf("field B should be an int")
    16. }
    17. return typeDescriptor(f.Offset), nil
    18. }
    19. func populateStructUnsafe3(in interface{}, ti typeDescriptor) error {
    20. structPtr := (*intface)(unsafe.Pointer(&in)).value
    21. *(*int)(unsafe.Pointer(uintptr(structPtr) + uintptr(ti))) = 42
    22. return nil
    23. }

    以下是如何使用describeType调用的新基准测试。

    1. func BenchmarkPopulateUnsafe3(b *testing.B) {
    2. b.ReportAllocs()
    3. var m SimpleStruct
    4. descriptor, err := describeType((*SimpleStruct)(nil))
    5. if err != nil {
    6. b.Fatal(err)
    7. }
    8. for i := 0; i < b.N; i++ {
    9. if err := populateStructUnsafe3(&m, descriptor); err != nil {
    10. b.Fatal(err)
    11. }
    12. if m.B != 42 {
    13. b.Fatalf("unexpected value %d for B", m.B)
    14. }
    15. }
    16. }

    现在基准测试结果变得相当快。

    1. BenchmarkPopulateUnsafe3-16 1000000000 0.359 ns/op 0 B/op 0 allocs/op

    这有多棒?如果我们以文章开头原始的 populateStruct 函数编写基准测试,可以看到在不使用反射的情况下,填充这个结构体的速度有多快。

    1. 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 来实现了非常相似的方法。

    以太坊cppgolang区别 编程

    以太坊cppgolang区别

    以太坊是一种去中心化的开源平台,它采用智能合约技术,旨在构建和运行不受干扰的分布式应用程序。作为目前最受欢迎的区块链平台之一,以太坊提供了多种编程语言的支持,其
    progolang 编程

    progolang

    Go语言(Golang)是由Google开发的一门静态类型编程语言。作为一名专业的Golang开发者,我深知这门语言的优势和特点。在本文中,我将介绍Golang
    golangn个发送者 编程

    golangn个发送者

    Golang是一种开源的编程语言,由Google团队开发,旨在提高程序的并发性和简化软件开发过程。在Go语言中,有时需要向多个接收者发送信息。本文将介绍如何在G
    golang技能图谱 编程

    golang技能图谱

    从互联网行业的快速发展到人工智能技术的日益成熟,各种编程语言也应运而生。而在这众多的编程语言中,Golang(即Go)作为一门强大且高效的开发语言备受关注。Go
    评论:0   参与:  14