Files
reflux/docs/flow.md
T
what ac011cd5c5 docs: 更正 map 性能数据, 并增加内部流程文档
两部分改动。

一、更正 README 里关于 map 的性能结论(这是修正一个错误的说法)

原来写的是 "map 只快 1.25 倍", 但那个数字来自 Get("Meta","k") ——
第一跳 Meta 是 struct 字段, 走了快路径, 把第二跳 map 的损失盖了过去。
标成 "map 键" 会误导人。

补测纯 map 路径(根就是 map, 全程没有 struct 跳), 复测 10 轮结果稳定:

  1 层  旧 120.7ns/8allocs -> 新 161.7ns/8allocs   0.75x
  3 层  旧 314.4ns/20allocs -> 新 439.5ns/20allocs  0.72x

也就是说纯 map 场景当前实现比旧版慢约 28%, 不是变快。原因是内部表示为
"地址 + 类型描述符", 而 map 元素不可寻址, 每跳一次 map 都要 reflect.New
拷贝一份才能拿到地址; 旧实现直接持有 reflect.Value, 没这次拷贝。

表格改成分行列出两种场景, 并加了选型建议: 数据以 map[string]any 为主的
调用方, 这次重写没有收益, 反而略有退化 —— 收益全部集中在 struct 字段访问。

二、新增 docs/flow.md 与两张手写 SVG 流程图

说明 Get/Set 如何把路径逐段分派: 每段先 normalize 剥掉指针和 interface,
再按 Kind 进入 struct/slice/array 三条 unsafe 快路径、map 的 reflect 回退,
或直接拒绝。三色区分快路径 / 回退 / 拒绝, 把两件容易被忽略的事画了出来:

  1. map 是唯一在 walk 循环内部就要堆拷贝的分支 —— 正是上面那个负收益的
     直接原因, 图里一眼能看到。
  2. Set 的 assignField 是四级递降, 第 ④ 级把复合类型整个交回 refx。
     原因写在文档里: 指针字段要设置指向的值而不是替换指针、[]any 要逐元素
     转换、map 可填充进 struct —— 重新实现必然出偏差, 是实际踩过的坑。

SVG 通过 img 引用时是隔离渲染(拿不到宿主页面的 CSS 变量和 currentColor),
所以配色和 prefers-color-scheme 明暗适配都烘在文件内部, 背景留透明。

README 性能章节开头加一行指向该文档, 正文只留结论。
2026-08-31 10:29:52 +08:00

5.6 KiB
Raw Blame History

内部流程:路径是怎么被分派的

GetSet 都不直接操作数据,而是把路径拆成段,逐段按当前值的 Kind 分派。 分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect, 也决定了它要不要分配内存。

图例:

标记 含义
青色 unsafe 快路径 —— 偏移量寻址,零分配
琥珀色 reflect 回退 —— 有堆拷贝
红色 拒绝 —— 返回无效值或 panic

一、取值:Get(path...)

Get 的分派流程

主干是 walk 的循环:每取出一段路径,先 normalize 把指针和 interface 剥掉, 再由 stepKind 分派。整个循环在栈上进行,只有最后返回给调用方时才装箱一次。

各步骤

步骤 函数 说明
构造 New(&v) 得到 rfx{td, ptr, writable},24 字节,栈上传递
取段 pathIter.next() . 切子串,不新建切片 —— 旧实现的 expandPath 每次要分配 4 次
解引用 normalize() PtrloadPtr 逐层解;Interface 退回 reflect 拆包
分派 step(seg) td.Kind 进入下面五个分支之一
装箱 boxed() 唯一一次堆分配,24 字节

五个分支

分支 做法 代价
Struct lookupField 查缓存好的偏移量 → fieldAt(ptr, off) 零分配
Slice parseIndexsliceElemAt 零分配
Array parseIndexarrayElemAt 零分配
Map tryMapFieldValueboxCopy 每跳一次堆拷贝
其它 / 未导出字段 拒绝,ok = false 返回无效值

map 是唯一在循环内部就要堆拷贝的分支。 map 元素不可寻址,而 rfx 的内部表示 需要一个地址,所以每经过一跳 map 都要 reflect.New 复制一份。层级越深,拷贝越多 —— 这是纯 map 路径反而比旧实现慢的直接原因(见下面的实测)。

未导出字段必须显式拒绝:reflect.NewAt 造出来的 Value 不带只读标记, 放行会让调用方绕过 Go 的导出规则直接读写私有字段。这是 unsafe 路径上最关键的一条防线。


二、写入:Set(key, val)

Set 的分派流程

写入比取值多两道关:目标必须可写,类型必须对得上。 两者任何一个不满足,就把整个操作交回基于 reflect 的旧实现 refx

为什么不自己实现复合类型的赋值

assignField 的四级递降是有意的,越往下越通用、也越慢:

级别 条件 做法
类型完全一致的 string/int/bool/float64 *T 直接写内存,0 分配
rv.Type() == td.rtype valueAt().Set(rv),不绕道
标量目标 assignReflect + spf13/cast 转换
复合目标(指针/切片/结构体/map/interface) 整个交回 refx

第 ④ 级不能自己实现。refx.setValue 对这些类型有一整套规则:

  • 指针字段是"设置指针指向的值"而不是替换指针
  • []any 会逐元素转成目标切片的元素类型
  • map 可以按字段名填充进 struct

重新实现一遍必然出偏差 —— 这不是假设,是实际踩过的坑: 第一版自己实现时,Set("Email", "x")(Email*string)会 panic, 而旧实现是正常的。

父级不可写的典型情况

walk 定位到父级后如果 !parent.writable,整个 Set 交回 refx。 最常见的触发是路径穿过了 map:map 元素不可寻址,取到的是副本, 直接写副本不会反映到原 map 上。refx 有完整的"取出-修改-写回 SetMapIndex"逻辑, 复用它而不是重写。


三、每条分支的实际代价

同样一次 Get,走哪条分支决定了它是 63 ns 还是 440 ns。

分支 路径 旧实现 当前 变化
Struct 三层嵌套字段 199.1 ns / 9 allocs 63.4 ns / 2 allocs 3.14×
Slice Get("Tags","1") 132.6 ns / 7 allocs 47.5 ns / 2 allocs 2.79×
Get[T] 泛型直取,跳过装箱 29.8 ns / 0 allocs 5.08×
Map struct 字段 → map 键 175.6 ns / 10 allocs 140.9 ns / 7 allocs 1.25×
Map 纯 map,三层嵌套 314.4 ns / 20 allocs 439.5 ns / 20 allocs 0.72×

三点说明:

  1. 纯 map 路径是负收益。 根就是 map、或路径深处全是 map 时,当前实现比旧版慢约 28%。 原因在第一张图里直接看得到:每跳一次 map 都要 boxCopy收益全部集中在 struct 字段访问上 —— 如果你的数据以 map[string]any 为主, 这次重写对你没有收益。

  2. 那个 1.25× 容易看错。 它的第一跳 Metastruct 字段,走了快路径, 把第二跳 map 的损失盖了过去 —— 不能当成"map 变快了"来读。

  3. 剩下的 2 次分配是 API 形状的下限。 一次是返回的 R 包装对象(24 字节), 一次是可变参数切片经接口调用时的逃逸。想彻底避开,用 Get[T]


对应源码

图中节点 位置
walk / step / normalize / fromReflect rfx.go
Set / setField / assignField / storeFast rfx.go
pathIter path.go
类型布局缓存 rfxDescriptorOf rfx_typedesc.go
fieldAt / sliceElemAt / boxCopy 等全部 unsafe 操作 unsafeptr.go
refx 慢路径实现 rfx_reflect.go

改动这几个函数时记得同步这两张图。SVG 源文件就在本目录下, 是手写的(没有用绘图工具),直接编辑坐标即可。

实测环境:Apple M4 Pro / darwin-arm64 / go1.25.5,数字取多轮中位数。