一个 `&` 引发的血案:改完配置 pipeline 装聋作哑,顺便重学了 Python/Go/Java

有一类 bug 会让你怀疑自己的理智。UI 显示「config saved」。日志也确认了。你启动一次新的 run。它却还是用了旧 config。

这是其中一个 bug 的故事 ------ 一个十三行修复对应一行错误的故事 ------ 以及它揭示出的那个意外普遍的原则。

症状

用户打开 app,把 model 从 gpt-4o 改成更新的东西,点击 save,然后启动一次 fresh run。这个 run 用的还是 gpt-4o。修复这个问题的 patch,在自己的注释里原封不动地写出了这个症状:

"started a new run, still using gpt-4o"

从 app 的视角看,一切都没问题。SaveConfig 没有返回错误,磁盘上的 config 文件是正确的,而且再读 a.cfg.Model 也能拿到新值。从 pipeline 的视角看,也没问题 ------ 它只是在忠实地用自己拿到的 config 执行。只是这两个视角刚好对 config 到底是什么 这件事有分歧。

改动

go 复制代码
// Before
a.cfg = &cfg

// After
if a.cfg == nil {
    a.cfg = &cfg
} else {
    *a.cfg = cfg           // ← 修复
}
analysis.InvalidateAllCapabilities()

为什么旧代码会失败

把内存想象成一排邮箱。app 启动时,有一个 Config 对象放在其中一个邮箱里。有两个变量保存着这个邮箱的地址:a.cfg(app 的 handle)和 pipeline.cfg(pipeline 的 handle,在构造时传给了它)。

css 复制代码
a.cfg ─────────┐
               ├──> 📦 Config{model: "gpt-4o"}
pipeline.cfg ──┘

现在 SaveConfig 运行了。它构造了一个全新的 Config ------ 一个新的邮箱,在内存里的其他地方 ------ 而旧代码做的是:

ini 复制代码
a.cfg = &cfg

这会改变 a.cfg 指向哪个邮箱。它不会碰原来那个邮箱里的内容。所以:

css 复制代码
a.cfg ─────────> 📦 Config{model: "gpt-4.1"}   (新邮箱)

pipeline.cfg ──> 📦 Config{model: "gpt-4o"}    (原邮箱,没被动过)

pipeline 还在看原来的邮箱,而里面仍然写着 gpt-4o。从它的视角看,什么都没有发生。而且确实什么都没发生 ------ 对它的那个邮箱来说。

patch 注释说得很精确:pointer swap "would leave a.pl.cfg dangling on the old Config." 修复方式 *a.cfg = cfg 的意思是:「去 a.cfg 指向的那个邮箱,把它里面的内容覆盖掉。」两个 handle 仍然指向同一个邮箱 ------ 但现在内容已经变了。pipeline 下一次读取时就会看到新的 model。

原则:Rebind vs Mutate

去掉 Go 语法之后,剩下的是一个存在于所有有引用语义语言里的区别。

Rebind 改变的是变量里存的地址。其他变量不关心,因为它们存的地址没有变。你更新的是一个本地标签,而不是这个标签指向的东西。

Mutate 改变的是某个地址里的内容。所有持有这个地址的变量都会看到变化,因为它们都是从同一个地方读。

pipeline 持有的是一个地址。不是变量名。不是抽象意义上的 config。就是一个地址。app 后面怎么改自己的变量 a.cfg,对 pipeline 都是不可见的。唯一能影响 pipeline 读取结果的方式,是改变它持有的那个地址里的内容 ------ 也就是 mutate。

同一个坑,不同语言

一旦掌握这个原则,你会发现它到处都是。

Python 没有 * 或 &,并且假装这个问题不存在。坑是完全一样的:

py 复制代码
a = [1, 2, 3]
b = a
a = [9, 9, 9]      # b 仍然是 [1, 2, 3] ------ 你 rebind 了 a

a = [1, 2, 3]
b = a
a[0] = 999         # b 现在是 [999, 2, 3] ------ 你 mutate 了这个 list

上面的 Go bug,完全等价于 Python 里写了 self.cfg = new_cfg,但你真正想写的是 self.cfg.update(new_cfg)。每个 Python 开发者都至少写过一次这个 bug,只是当时不知道该用什么词描述它。

Go 是那门把这个区别显式化、并强迫你一开始就做选择的语言:

go 复制代码
a := Config{Model: "x"}; b := a; a.Model = "y"   // b.Model 仍然是 "x"  (value copy)
a := &Config{Model: "x"}; b := a; a.Model = "y"  // b.Model 现在是 "y"    (shared pointer)

当你声明 *Config 而不是 Config 时,你就是在选择共享。一旦你选择了共享,rebind vs mutate 就开始变得重要。这里还有一个更隐蔽的坑:slice、map 和 channel 是偷偷像 pointer 的东西 ------ 即使没有显式的 *,它们也共享底层数据。对 slice 做 b := a 会复制 header,但不会复制 backing array,所以 a[0] = 999 对 b 可见,但 a = append(a, ...) 可能会在 append 触发重新分配的那一刻悄悄变得不可见。

Java 按类型切得很清楚:

java 复制代码
int a = 5; int b = a; a = 10;                  // b 保持 5
List<Integer> x = new ArrayList<>();
List<Integer> y = x; x.add(1);                 // y 现在是 [1]

primitive 的行为像 Go 的 value type。object 的行为像 Python ------ 到处都是 reference。同一条规则仍然成立:rebind 是本地的,mutation 是共享的。

这三个语言里的结论都是:重新绑定一个变量,永远不会影响其他任何持有旧 reference 的人。 这不是 Go 的怪癖。这是所有允许两个变量持有同一个地址的 memory model 的属性。

你今天就能用的规则

当你盯着代码,想判断某个改动是否会被同一个 reference 的其他持有者看到时,看 = 左边:

如果左边以 * 开头(在 Go 里),或者是在索引、属性、方法调用某个东西(Python 里的 x[0] = ...、x.field = ...、x.append(...),Java 里的 obj.setField(...)),你就是在 mutating。所有共享这个 reference 的人都会看到它。

如果左边只是一个裸变量名,你就是在 rebinding。只有这个变量的本地 pointer 变了。pipeline 不关心。另一个 thread 不关心。捕获了变量的 closure 不关心。

本文开头的 bug 就发生在你本来想做第一种事,却实际做了第二种事的时候。它是沉默的。compiler 很高兴。tests 也通过了 ------ 直到某个 test 刚好跨过「save config,然后看正在运行的系统到底做了什么」这一步,那个裂缝才会打开。

这个 patch 里十三行解释性注释存在的原因只有一个:下一个读者必须理解这个 * 是在真正发挥作用。没有那条注释,一个出于好意的 refactor 很可能会在一个季度内把 *a.cfg = cfg "清理"回 a.cfg = &cfg,然后这个幽灵又会回来。

相关推荐
打工仔折腾 AI4 分钟前
极空间用 Docker 部署 Photopea:网页版 PS 的私有化实践
运维·后端·python·docker·容器·langchain·ai agent 实战
掘金者阿豪34 分钟前
别只会docker stats了:cAdvisor把CPU、内存、磁盘和网络监控搬进Web页面
后端
企业数字化笔记39 分钟前
多租户系统从哪里开始设计?租户身份、数据隔离、权限与套餐的整体方案
java·spring boot·后端·junit
10年前端老司机41 分钟前
一次业务迭代:聊聊知识库多版本可追溯审核流程怎么做
人工智能·后端·agent
邱杰4701 小时前
多 Agent 客服的短期记忆改造:用 Redis + Checkpointer 管理会话状态
后端
高级程序源1 小时前
springboot校园外卖平台13676-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·tomcat·maven·课程设计
韩振方1 小时前
删掉一个 Pod 后,谁把它补回来?
后端
Gopher_HBo1 小时前
grpc客户端调用链
后端
时间海8711 小时前
对象键清洗之前,先拦住重名
javascript·后端
歌谣1 小时前
07|【30岁前端转型全栈】前后端遇到报错怎么处理?当然是console.log和System.out.println?
后端