Featured image of post Go高性能编程 EP4: 反射

Go高性能编程 EP4: 反射

提升反射性能的实用技巧与最佳实践

 

reflect 为 Go 语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。Go 语言标准库以及很多开源软件中都使用了 Go 语言的反射能力,例如用于序列化和反序列化的 json、ORM 框架 gorm/xorm 等。本文的目标是学习reflect,以及如何提高reflect的性能。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

Example Code

如何使用反射简化代码

我们利用反射实现一个简单的功能,来看看反射如何帮助我们简化代码的。
假设有一个配置类 Config,每个字段是一个配置项。我们需要从环境变量中 读取 CONFIG_xxxx 。然后初始化Config。
list 1 :MySQL config

1
2
3
4
5
6
7
8
9
// https://github.com/go-sql-driver/mysql/blob/v1.8.1/dsn.go#L37  
type Config struct {  
    User   string `json:"user"`    // Username  
    Passwd string `json:"passwd"`  // Password (requires User)  
    Net    string `json:"net"`     // Network (e.g. "tcp", "tcp6", "unix". default: "tcp")  
    Addr   string `json:"addr"`    // Address (default: "127.0.0.1:3306" for "tcp" and "/tmp/mysql.sock" for "unix")  
    DBName string `json:"db_name"` // Database name  
    // 。。。。。  
}

Footguns

我们很容易写出这样的代码,
list2: 使用 for 循环初始化 Config

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func InitConfig() *Config {  
    cfg := &Config{}  
    keys := []string{"CONFIG_MYSQL_USER", "CONFIG_MYSQL_PASSWD", "CONFIG_MYSQL_NET", "CONFIG_MYSQL_ADDR", "CONFIG_MYSQL_DB_NAME"}  
    for _, key := range keys {  
       if env, exist := os.LookupEnv(key); exist {  
          switch key {  
          case "CONFIG_MYSQL_USER":  
             cfg.User = env  
          case "CONFIG_MYSQL_PASSWORD":  
             cfg.Passwd = env  
          case "CONFIG_MYSQL_NET":  
             cfg.Net = env  
          case "CONFIG_MYSQL_ADDR":  
             cfg.Addr = env  
          case "CONFIG_MYSQL_DB_NAME":  
             cfg.DBName = env  
          }  
       }  
    }  
    return cfg  
}

但是使用硬编码的话,如果Config 结构发生改变,比如了修改 json 对应的字段、删除或新增了一个配置项等,这块的逻辑也需要发生改变。而更大的问题在于:非常容易出错,不好测试。

如果我们改成使用 反射实现,实现的代码就是这样的:

list3: 使用reflect 实现初始化Config

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func InitConfig2() *Config {  
    config := Config{}  
    typ := reflect.TypeOf(config)  
    value := reflect.Indirect(reflect.ValueOf(&config))  
    for i := 0; i < typ.NumField(); i++ {  
       f := typ.Field(i)  
       if v, ok := f.Tag.Lookup("json"); ok {  
          key := fmt.Sprintf("CONFIG_MYSQL_%s", strings.ToUpper(v))  
          if env, exist := os.LookupEnv(key); exist {  
             value.FieldByName(f.Name).Set(reflect.ValueOf(env))  
          }  
       }  
    }  
    return &config  
}

实现逻辑其实是非常简单的:

  • 在运行时,利用反射获取到 Config 的每个字段的 Tag 属性,拼接出对应的环境变量的名称。
  • 查看该环境变量是否存在,如果存在就将环境变量的值赋值给该字段。
    这样,无论Config 添加还是删除字段,InitConfig 函数都不需要修改,是不是比使用for循环的方式简洁多了?

反射的性能

我们在很多地方都听说过:反射的性能很差,并且我们在对比json解析库的时候也验证了官方库JSON Unmarshal 的性能比较低.因为他需要执行更多的指令。那么反射的性能到底有多差呢? 我们做一次benchmark就知道了。
list 4 : benchmark test New And Reflect Performance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func BenchmarkNew(b *testing.B) {  
    var config *Config  
    for i := 0; i < b.N; i++ {  
       config = new(Config)  
    }  
    _ = config  
}  
  
func BenchmarkReflectNew(b *testing.B) {  
    var config *Config  
    typ := reflect.TypeOf(Config{})  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       config, _ = reflect.New(typ).Interface().(*Config)  
    }  
    _ = config  
}
----
  go_reflect git:(main)  go test --bench . 
goos: darwin
goarch: arm64
pkg: blog-example/go/go_reflect
BenchmarkNew-10                 47675076                25.40 ns/op
BenchmarkReflectNew-10          36163776                32.51 ns/op
PASS
ok      blog-example/go/go_reflect      3.895s

如果只是创建的场景,两者的性能差距不是特别大。
我们再测试一下修改字段场景,通过反射修改字段有两种方式
FieldByName
Field 下标模式
我们分别测试两种模式的性能
list5: 测试修改Field 的性能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func BenchmarkFieldSet(b *testing.B) {  
    config := new(Config)  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       config.Net = "tcp4"  
       config.Addr = "127.0.0.1:3306"  
       config.Passwd = "123456"  
       config.User = "admin"  
    }  
}  
  
func BenchmarkFieldSetFieldByName(b *testing.B) {  
    config := new(Config)  
    value := reflect.ValueOf(config).Elem()  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       value.FieldByName("Net").SetString("tcp4")  
       value.FieldByName("Addr").SetString("127.0.0.1:3306")  
       value.FieldByName("Passwd").SetString("123456")  
       value.FieldByName("User").SetString("admin")  
    }  
}  
func BenchmarkFieldSetField(b *testing.B) {  
    config := new(Config)  
    value := reflect.Indirect(reflect.ValueOf(config))  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       value.Field(0).SetString("tcp4")  
       value.Field(1).SetString("127.0.0.1:3306")  
       value.Field(2).SetString("123456")  
       value.Field(3).SetString("admin")  
    }  
}
----
  go_reflect git:(main)  go test --bench="BenchmarkFieldSet*"          
goos: darwin
goarch: arm64
pkg: blog-example/go/go_reflect
BenchmarkFieldSet-10                    1000000000               0.3282 ns/op
BenchmarkFieldSetFieldByName-10          6471114               185.3 ns/op
BenchmarkFieldSetField-10               100000000               11.88 ns/op
PASS
ok      blog-example/go/go_reflect      3.910s

差距很大,非reflect 方式跟 reflect Field 下标模式 ,差两个数量级,跟reflect FieldByName 模式 甚至达到了三个数量级,比较疑问的一个点是FieldByName 模式 跟 Field 下标模式 差距竟然也有一个数量级。不过我们能够从源码中找到答案。
list 6: reflect/value.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func (v Value) FieldByName(name string) Value {  
    v.mustBe(Struct)  
    if f, ok := toRType(v.typ()).FieldByName(name); ok {  
       return v.FieldByIndex(f.Index)  
    }  
    return Value{}  
}

// FieldByIndex returns the nested field corresponding to index.// It panics if evaluation requires stepping through a nil  
// pointer or a field that is not a struct.  
func (v Value) FieldByIndex(index []int) Value {  
    if len(index) == 1 {  
       return v.Field(index[0])  
    }  
    v.mustBe(Struct)  
    for i, x := range index {  
       if i > 0 {  
          if v.Kind() == Pointer && v.typ().Elem().Kind() == abi.Struct {  
             if v.IsNil() {  
                panic("reflect: indirection through nil pointer to embedded struct")  
             }  
             v = v.Elem()  
          }  
       }  
       v = v.Field(x)  
    }  
    return v  
}

list 7:reflect/type.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (t *rtype) FieldByName(name string) (StructField, bool) {  
    if t.Kind() != Struct {  
       panic("reflect: FieldByName of non-struct type " + t.String())  
    }  
    tt := (*structType)(unsafe.Pointer(t))  
    return tt.FieldByName(name)  
}
// FieldByName returns the struct field with the given name// and a boolean to indicate if the field was found.  
func (t *structType) FieldByName(name string) (f StructField, present bool) {  
    // Quick check for top-level name, or struct without embedded fields.  
    hasEmbeds := false  
    if name != "" {  
       for i := range t.Fields {  
          tf := &t.Fields[i]  
          if tf.Name.Name() == name {  
             return t.Field(i), true  
          }  
          if tf.Embedded() {  
             hasEmbeds = true  
          }  
       }  
    }  
    if !hasEmbeds {  
       return  
    }  
    return t.FieldByNameFunc(func(s string) bool { return s == name })  
}

在反射的内部,字段是按顺序存储的,因此按照下标访问查询效率为 O(1),而按照 Name 访问,则需要遍历所有字段,查询效率为 O(N)。结构体所包含的字段(包括方法)越多,那么两者之间的效率差距则越大。但是我们需要记忆字段的顺序,这很容易出错。

如何提高性能

尽量避免使用reflect

使用反射赋值,效率非常低下,如果有替代方案,尽可能避免使用反射,特别是会被反复调用的热点代码。例如 RPC 协议中,需要对结构体进行序列化和反序列化,这个时候避免使用 Go 语言自带的 json 的 Marshal 和 Unmarshal 方法,因为标准库中的 json 序列化和反序列化是利用反射实现的。我们可以使用fastjson等替代标准库,应该能带来十倍左右的提升。

尽量使用 Field 下标模式

从前面的benchmark 看来, Field 下标模式比FieldByName 的方式快接近一个数量级,在字段数量多的时候,更加明显,但是使用Field 下标模式会比较麻烦,我们需要记忆下标,并且很难修改。这个时候,我们可以使用一个Map 将Name 跟下标缓存起来。
比如:
list8: cache FieldByName and Field index

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func BenchmarkFieldSetFieldByNameCache(b *testing.B) {  
    config := new(Config)  
    typ := reflect.TypeOf(Config{})  
    value := reflect.ValueOf(config).Elem()  
    cache := make(map[string]int)  
    for i := 0; i < typ.NumField(); i++ {  
       cache[typ.Field(i).Name] = i  
    }  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       value.Field(cache["Net"]).SetString("tcp4")  
       value.Field(cache["Addr"]).SetString("127.0.0.1:3306")  
       value.Field(cache["Passwd"]).SetString("123456")  
       value.Field(cache["User"]).SetString("admin")  
    }  
}
----
BenchmarkFieldSetFieldByNameCache-10            32121740                36.85 ns/op

比直接使用FieldByName 提升了4倍,很可观的提升。

总结

  1. 本文没有深入介绍 reflect 的细节,如果您想要进一步学习reflect 可以参考下面的文章:
    1. https://medium.com/capital-one-tech/learning-to-use-go-reflection-822a0aed74b7
    2. https://go101.org/article/reflection.html
  2. 在各个基础库中,大量使用reflect ,合理的使用reflect 有助于简化代码,但是reflect 有性能损耗,最极端的情况可能降低3个数量级。
  3. 可以用 Field index + cache 的方式来优化 性能。
    对于reflect 您有什么其他想法嘛?留言跟我一起讨论。
true
最后更新于 Jul 09, 2024 09:44 CST
使用 Hugo 构建
主题 StackJimmy 设计