深拷贝优化:从 copier 到 go_deep_copy 的演进

背景与问题发现

在日常开发中,我们经常需要进行结构体的深拷贝操作。在 Go 中,我们广泛使用github.com/jinzhu/copier 做深拷贝操作

然而,在一次偶然的基准测试中,我们发现了令人意外的结果:copier 的深拷贝性能竟然远低于通过 JSON 序列化/反序列化的方式。这一发现严重违背了我们的性能直觉,因为反射操作理应在理论上优于涉及数据编解码的序列化过程。

性能基准测试初步结果

测试数据显示,copier 在相同数据结构的深拷贝操作中,耗时和内存分配均显著高于 JSON 方式,这促使我们深入探究其根本原因。

问题根因分析

通过 go tool pprof 对 copier 进行性能剖析,我们发现性能瓶颈主要集中在反射操作上:

关键性能瓶颈

  1. 过度依赖反射机制:copier 大量使用 reflect 包进行运行时类型检查和方法调用
  2. 频繁的 FieldByName 调用:这是反射操作中特别耗时的部分,每次字段访问都需要遍历结构体
  3. 缺乏缓存机制:重复的类型解析和函数查找导致不必要的性能开销

优化方式

基于上述分析,我们决定从四个核心方向对 copier 库进行深度优化:

1、结构体反射结果缓存 - 避免重复的类型解析

众所周知,反射的性能比较差,因为反射将编译期的工作(类型检查、方法绑定、内存布局)转移到了运行时,并且无法享受编译器的任何优化。它带来了巨大的运行时类型检查开销、潜在的内存分配以及低效的方法调用。我们在第一次解析结构体结构的时候,可以尽可能多记一下结构体信息(名称,类型,偏移量等),缓存下来,之后需要再次解析的时候可以直接使用缓存数据,避免多次反射带来的性能损耗,FieldMap记录下名称到Field的映射,将O(N)的FieldByName优化成O(1)的map取值操作

go 复制代码
func loadStructFieldsInfo(vt reflect2.Type) StructDescriptor {
    if structInfo, ok := structInfoCache.Load(vt); ok {
       return structInfo.(StructDescriptor)
    }
    structInfo := describeStruct(vt)
    structInfo.FieldMap = make(map[string]*Binding, len(structInfo.Fields))
    for i := 0; i < len(structInfo.Fields); i++ {
       structInfo.FieldMap[structInfo.Fields[i].Name] = structInfo.Fields[i]
    }
    structInfoCache.Store(vt, structInfo)
    return structInfo
}

type StructField struct {
    // Name is the field name.
    Name string

    // PkgPath is the package path that qualifies a lower case (unexported)
    // field name. It is empty for upper case (exported) field names.
    // See https://golang.org/ref/spec#Uniqueness_of_identifiers
    PkgPath string

    Type      Type      // field type
    Tag       StructTag // field tag string
    Offset    uintptr   // offset within struct, in bytes
    Index     []int     // index sequence for Type.FieldByIndex
    Anonymous bool      // is an embedded field
}

性能收益

  • 字段查找:从 O(N) 遍历优化为 O(1) Map 查找
  • 类型解析:每个类型只需解析一次,后续操作直接使用缓存
  • 内存分配:减少重复的类型描述对象创建

2、指针直接读写优化 - 减少中间内存分配

反射获取数据的时候reflect.value.Interface()创建interface{}临时变量,然后通过类型强转转成指定类型,中间多了创建临时变量的内存过程。通过结构体指针和字段的偏移量,在读取数据的时候可以通过指针强行转换的方式(unsafe.Pointer)将其直接转换成所需要的值对象,省去反射过程。

但是reflect包中的类型和指针是私有变量,不对外漏出,可以使用三方反射包github.com/modern-go/reflect2,获取字段的类型和指针(也是通过unsafe.Pointer指针强行转换的方式获取类型和指针)

go 复制代码
// 读
func (v Value) Int() int64 {
    k := v.Typ.Kind()
    p := v.Ptr
    switch k {
    case reflect.Int:
       return int64(*(*int)(p))
    case reflect.Int8:
       return int64(*(*int8)(p))
    case reflect.Int16:
       return int64(*(*int16)(p))
    case reflect.Int32:
       return int64(*(*int32)(p))
    case reflect.Int64:
       return *(*int64)(p)
    }
    return 0
}
// 写
func (v Value) SetInt(x int64) {
    switch k := v.Typ.Kind(); k {
    case reflect.Int:
       *(*int)(v.Ptr) = int(x)
    case reflect.Int8:
       *(*int8)(v.Ptr) = int8(x)
    case reflect.Int16:
       *(*int16)(v.Ptr) = int16(x)
    case reflect.Int32:
       *(*int32)(v.Ptr) = int32(x)
    case reflect.Int64:
       *(*int64)(v.Ptr) = x
    }
}

性能收益

  • 零内存分配:直接操作目标内存,避免临时对象

3、转换函数缓存机制 - 加速类型转换决策

A和B类型相互转换的函数是确定的,可以将类型和转换函数的关系缓存下来,下一次需要确定转换函数时可以直接从缓存中读取转换函数,减少确定转换函数的过程

递归类型怎样处理?

借鉴标准库 encoding/json 的方案:

  • 间接编码器:先注册一个占位函数,防止无限递归
  • 等待组同步:确保实际函数初始化完成前,调用者正确等待
  • 原子性更新:函数就绪后原子替换为实际实现
scss 复制代码
func LoadConvertFunc(v, t reflect2.Type) ConvertFunc {
    key := [2]uintptr{v.RType(), t.RType()}
    if fi, ok := mFuncMap.Load(key); ok {
       return fi.(rcuCacheInfo).ConvertFunc
    }
    var (
       wg sync.WaitGroup
       f  ConvertFunc
    )
    wg.Add(1)
    // 递归类型处理
    fi, loaded := mFuncMap.LoadOrStore(key, rcuCacheInfo{
       ConvertFunc: func(v, t rt.Value) error {
          wg.Wait()
          return f(v, t)
       },
    })
    if loaded {
       return fi.(rcuCacheInfo).ConvertFunc
    }

    f = convertOp(v, t)
    wrapFunc := ConvertFunc(func(v rt.Value, t rt.Value) error {
       if f == nil {
          return ErrNotSupported
       }
       if v.Typ.UnsafeIsNil(v.Ptr) {
          if t.Typ.Kind() == reflect.Ptr {
             *((*unsafe.Pointer)(t.Ptr)) = nil
             return nil
          }
          t.Typ.UnsafeSet(t.Ptr, t.Typ.UnsafeNew())
          return nil
       }
       return f(v, t)
    })
    wg.Done()
    mFuncMap.Store(key, rcuCacheInfo{ConvertFunc: wrapFunc})

    return wrapFunc
}

性能对比

关闭函数缓存

使用函数缓存

可以发现使用函数缓存后,耗时由3864 ns/op降低到3010 ns/op,内存分配次数由91 allocs/op降低到18 allocs/op,性能有明显提升

4、RCU 无锁读取优化 - 提升并发读取性能

函数缓存和结构体缓存是典型的读多写少场景,因此最初是使用sync.Map缓存数据,但是在读极多写极少的场景下RCU性能会优于sync.Map,因为对于sync.Map,在读数据最坏的情况下(键不在 read 中),需要加互斥锁去访问 dirty map,性能会急剧下降。RCU读取数据是完全不需要加锁的

RCU和sync.Map读取代码对比

go 复制代码
//RCU
func (c *LinerRCU) Load(key reflect2.Type) (v any, ok bool) {
    m := (*linerMap)(atomic.LoadPointer(&c.m))
    res := m.get(key)
    return res, res != nil
}
func (self *linerMap) get(vt reflect2.Type) any {
    i := self.m + 1
    h := uint32(vt.RType())
    p := h & self.m

    /* linear probing */
    for ; i > 0; i-- {
       if b := self.b[p]; b.vt == vt {
          return b.fn
       } else if b.vt == nil {
          break
       } else {
          p = (p + 1) & self.m
       }
    }

    /* not found */
    return nil
}
arduino 复制代码
//sync.Map
func (m *Map) Load(key any) (value any, ok bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok && read.amended {
       m.mu.Lock()
       read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            e, ok = m.dirty[key]
          m.missLocked()
       }
       m.mu.Unlock()
    }
    if !ok {
       return nil, false
    }
    return e.load()
}

性能对比

使用sync.Map

使用RCU

可以发现使用RCU后,耗时由3331 ns/op降低到3010 ns/op,性能有明显提升

结果对比

一、指标含义

  • ns/op:每次操作的耗时(纳秒),数值越小性能越好。
  • B/op:每次操作的内存分配大小(字节),数值越小内存开销越低。
  • allocs/op:每次操作的内存分配次数,数值越小内存分配效率越高。

二、性能对比

深拷贝方式 耗时(ns/op) 内存大小(B/op) 分配次数(allocs/op) 性能总结
copier-10 14039 4288 183 性能最差,内存和分配次数开销大
go_deep_copy-10 3010 672 18 性能最优,耗时、内存、分配次数均表现最好
json-10(JSON 序列化反序列化方式) 5813 1641 37 性能居中,开销高于go_deep_copy但优于copier

综上,在这三种结构体深拷贝方式中,go_deep_copy 的性能和内存效率显著优于 copier json 方式,是更推荐的实现选择。

使用指南

go_deep_copy使用指南

相关推荐
喵个咪4 小时前
Go 接口与代码复用:替代继承的设计哲学
后端·go
香吧香6 小时前
Go语言Panic异常服务崩溃
go
hayson7 小时前
Go 迭代器详解:为什么 Go 的迭代器看起来很难用?
后端·go
xishang1 天前
go语言ent ORM框架增强-自定义排序
go
程序员爱钓鱼1 天前
Go 语言实战 从 PDF 批量提取条码的自动化工具开发全流程解析
后端·go·trae
程序员爱钓鱼1 天前
Go 语言爬虫实战:基于 Colly 的高性能采集框架指南
后端·go·trae
百锦再2 天前
[特殊字符] HBuilder uni-app UI 组件库全方位对比
android·java·开发语言·ui·rust·uni-app·go
用户7227868123442 天前
go ants pool 协程池学习笔记
go
哈茶真的c3 天前
【书籍心得】左耳听风:传奇程序员练级攻略
java·c语言·python·go
心月狐的流火号3 天前
Go sync.Mutex 源码解析:设计哲学与工程智慧
go·源码阅读