PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
FOUNDATIONS / 03

Go 的值与内存

指针、slice、map、拷贝与生命周期。

本章目录
配套饭点图解 · 3 节(按需展开)

这一章回答一个实际问题:一段数据到底由谁保存、谁可以修改、什么时候还能使用?Go 的值、指针、数组、切片、map 和 json.RawMessage 看起来都能“装数据”,但复制规则不同。Pysolate Spine 还多了一道边界:Wasm Guest 的指针只是 Guest 线性内存中的偏移,不能当作 Go 指针使用。

先记住一句话:复制一个变量,不一定复制它指向的底层数据;跨 Guest 边界时,必须明确读取、写入和释放。 并发任务、channel 和 context 的原理留给并发章节,本章只说明它们出现时的值所有权。

1. 值和指针:地址不是数据本身

小问题: a := b 之后,修改 a 会不会修改 b?

示例: Go 的赋值和传参都复制值;关键是这个值本身是否包含指向其他存储的指针或描述。数组的元素也会被复制,但元素若含 map、slice 或指针,不会因此自动深复制更里面的对象。指针(pointer)保存的是另一个对象的位置;复制指针只会复制这个位置,所以两个指针仍可能指向同一对象:

// 不完整片段(只用于对照,不能单独运行)
x := 10
y := x
y = 20 // x 仍是 10
p := &x
*p = 30   // 通过 p 修改 x,x 变成 30

&x 取变量地址,*p 访问指针指向的值。指针不是“自动共享一切”的开关;它只表示一次间接访问。普通安全 Go 指针不会因为创建它的函数返回就自动悬空,编译器和垃圾回收会安排对象寿命;但 nil 解引用仍会出错。借用的 Guest 内存、已关闭的外部资源和 unsafe 操作还需要额外的生命周期约定。

术语: 值是变量中直接保存的数据;地址是数据在内存中的位置;指针是保存该位置的值;解引用是通过指针取得它指向的数据。Go 的垃圾回收器会管理 Go 堆对象的内存可达性,但不会替你决定业务对象何时关闭。

对应真实代码: Runner.Run 的 receiver 是 *Runner,因此方法操作的是同一个 Runner。Runner 里保存的 runtime、编译后的 code 和工具表不是每次调用都复制一份。run 创建本轮状态,方法结束时由 runState.close 负责本轮收尾。

Guest 边界要另外理解。Wasm32 的 ptr 是 Guest 线性内存中的 32 位偏移,例如 1000 表示从 Guest 内存第 1000 个字节开始,不是 Go 进程的机器地址。Go 只能通过 wazero 的有界 Memory.Read / Memory.Write 访问它。不要写 (*SomeGoType)(unsafe.Pointer(uintptr(ptr))) 来“直接访问 Guest”;这既不是当前 ABI,也没有得到 Guest 内存的有效 Go 指针。

常见误解 / 自测: *Runner 不等于“Runner 一定在堆上”,它是类型为指针的变量。为什么 bridge.go 的 hostCall 要接收 api.Module,再调用 m.Memory().Read,而不是把 Guest 的 ptr 转成 Go 地址?如果把一个 *Runner 设为 nil,调用它的方法是否必然安全?先看方法是否会解引用 receiver。

2. 数组和 slice:一个是值,一个是底层数组的视图

小问题: 为什么 []int 改一个元素,另一个 slice 也可能跟着变?

示例: 数组(array)长度写在类型中,例如 [4]int,数组赋值会复制四个元素。slice(切片)更像一个小描述:它记录指向底层数组的起点、长度 len 和容量 cap。复制 slice 只复制这个描述,多个 slice 可能共享同一个底层数组。

append 会把元素接到 slice 末尾。如果容量足够,它可能继续使用原底层数组,其他别名就能看到修改;容量不够时,Go 会分配新的底层数组并复制旧元素。调用者必须把“可能扩容”当作规则,不能假设 append 后仍与旧 slice 共享,也不能假设一定不共享。

完整程序 1:观察数组复制、slice 别名和 append 扩容

package main

import "fmt"

func main() {
	base := [4]int{10, 20, 30, 40}
	arrayCopy := base
	arrayCopy[1] = 200

	view := base[:2]
	alias := view
	alias[0] = 99
	alias = append(alias, 77)

	// alias 当前长度为 3、容量为 4;再追加两个元素必然需要新数组。
	grown := append(alias, 88, 99)
	grown[0] = 7

	fmt.Printf("base=%v arrayCopy=%v\n", base, arrayCopy)
	fmt.Printf("alias=%v\n", alias)
	fmt.Printf("grown=%v\n", grown)
	fmt.Printf("same backing array=%t\n", &alias[0] == &grown[0])
}

预期输出(已用本机 Go 执行验证):

base=[99 20 77 40] arrayCopy=[10 200 30 40]
alias=[99 20 77]
grown=[7 20 77 88 99]
same backing array=false

arrayCopy 与 base 是两个数组;view 和 alias 先共享 base 的底层存储,所以 alias[0] 和 append(alias, 77) 改变了 base。grown 需要超过原容量,于是得到新的底层数组;修改 grown[0] 不再影响 base。

术语: len 是当前可访问元素个数,cap 是该 slice 允许扩展到的最大长度;普通两索引切片通常延续到底层数组末端,也存在主动限制容量的写法;底层数组(backing array)是真正保存元素的存储。slice 不是数组的缩小版,也不是自动深拷贝。

对应真实代码: runner.go 中的 []byte 用于消息和内存副本;其中的 writeResponse 将响应写入 Guest 提供的范围。prefix.go 的 callWithBytes 把 Go 的 []byte 写入 Guest 后,再由 Guest 自己管理那次请求分配。prepared.go 的 r.image = append([]byte(nil), data...) 明确创建 Runner 自己拥有的副本,而不是保留参考 Guest 的内存视图。

常见误解 / 自测: b := a 对 slice 不等于复制全部元素;append 返回的结果必须接住,因为它可能换底层数组。直接写 append(...) 而不使用结果会被 Go 编译器拒绝。如果写 _ = append(...),则显式丢弃了返回的新 slice 描述;为什么你之后仍不能通过旧 alias 访问扩容后的新数组?

字符串、byte 和 rune

Go 字符串保存字节序列,不能通过 s[0] = ... 原地修改。byte 是 uint8 的别名;rune 是 int32 的别名,处理文本时通常表示 Unicode 码点。先把码点理解为字符的编号,具体 UTF-8 编码见 工程与数据基础。

len("书") 是 3,因为 UTF-8 编码用了三个 byte;len([]rune("书")) 是 1。码点数仍不保证等于肉眼看到的字符数,组合字符可能由多个码点组成。len 用在切片时数元素,用在字符串时数字节,不能混成“永远数字符”。

[]byte(text) 得到可修改的字节切片,string(bytes) 得到字符串表示。传源码时 len(part) 与 C 的字节长度配合,不该按界面上看见多少汉字判断 1 MiB 上限。

3. map、nil 和显式复制:引用行为不等于深拷贝

小问题: map 赋值后,为什么改副本会改到原 map?nil map 又能不能读?

示例: map 是 Go 的映射类型,保存键到值的关系。把一个 map 赋给另一个变量时,两个变量访问的是同一份映射数据;需要独立 map 时,要新建 map 并逐项复制。nil map 可以读取,找不到键时得到对应值类型的零值;向 nil map 写入会 panic。nil slice 可以读取长度为 0,也可以直接 append;这和 nil map 的写入规则不同。

接口也有一个容易混淆的 nil:接口值由“动态类型 + 动态值”组成。一个装着 nil 指针的接口本身可能不是 nil。例如 var p *int 得到 nil 指针,再令 var box any = p:p == nil 为 true,但 box == nil 为 false,因为 box 仍记录动态类型 *int。接口的类型和值都为空,才是 nil 接口。别把“里面的指针为空”误当成“整个接口为空”。

完整程序 2:map 别名、map 克隆和 RawMessage 复制

package main

import (
	"encoding/json"
	"fmt"
)

func cloneMap(src map[string]int) map[string]int {
	dst := make(map[string]int, len(src))
	for key, value := range src {
		dst[key] = value
	}
	return dst
}

func main() {
	original := map[string]int{"book": 21}
	alias := original
	alias["pen"] = 3

	independent := cloneMap(original)
	independent["book"] = 99

	var absent map[string]int
	fmt.Printf("original book=%d pen=%d independent book=%d nil=%t missing=%d\n",
		original["book"], original["pen"], independent["book"], absent == nil, absent["book"])

	wire := []byte(`{"value":{"n":1}}`)
	var envelope struct {
		Value json.RawMessage `json:"value"`
	}
	if err := json.Unmarshal(wire, &envelope); err != nil {
		panic(err)
	}
	wire[14] = '9'
	kept := append(json.RawMessage(nil), envelope.Value...)
	envelope.Value[5] = '8'

	fmt.Printf("raw from wire=%s wire=%s\n", envelope.Value, wire)
	fmt.Printf("raw=%s copy=%s\n", envelope.Value, kept)
}

预期输出(已用本机 Go 执行验证):

original book=21 pen=3 independent book=99 nil=true missing=0
raw from wire={"n":8} wire={"value":{"n":9}}
raw={"n":8} copy={"n":1}

本程序先让 alias 与 original 共享 map,所以加上 pen 后两者都能查到;cloneMap 逐项复制,independent 改价不会改 original。absent["book"] 读取 nil map 得到整数零值,但若写 absent["book"] = 1 会 panic。make(map[string]int, len(src)) 的第二个参数是容量提示,不是预先创建那些键;新 map 的长度仍从 0 开始。

示例中字段后面写在反引号里的 json:"value" 是结构体标签(struct tag):给编码库的元信息,告诉它字段在 JSON 里叫 value,不是给字段赋值。Go 的反引号也能写原始字符串,所以输入 JSON 可以直接包含双引号;它和普通双引号字符串的转义规则不同。

json.Unmarshal 要把值写入 envelope,所以传 &envelope,即目标地址。当前结构体字段首字母大写也有意义:encoding/json 处理导出的字段,而不是任意私有字段。固定演示数据若意外解析失败,例子用 panic 停止;真实工具的预期业务失败应返回 error。

术语: 别名(alias)是多个变量指向同一份可变数据;浅复制只复制外层容器或描述;深复制还要复制内部可变对象;所有权(ownership)是约定谁负责修改、保留和释放一份资源。Go 的 map、slice 和指针不会替你强制业务所有权。语言能复制一个值,也不表示库允许你在任意时刻复制它:例如使用中的 Mutex、WaitGroup,以及非零状态的 strings.Builder 都有不能随便复制的使用约定。

对应真实代码: New 用 make 建立自己的工具 map,再逐项复制调用者传入的注册表;这避免调用者后来替换原 map 的条目直接改动 Runner,但它不是闭包捕获数据的深拷贝。cmd/demo/main.go 中的 lookup 闭包仍捕获 catalog;如果 catalog 可变,多个 Run 仍可能看到同一份 Host 状态。

bridge.go 的 Tool 注释要求把 args json.RawMessage 当作只读参数;Future 还可能保留它做匹配。若工具需要修改参数,应先解码成自己的结构或复制字节,不要原地改传入的 slice。

常见误解 / 自测: “复制了 map”可能只复制了 map 变量,不代表复制键值指向的所有对象。为什么 New 复制工具注册表后,仍不能声称它深复制了 demoTools 的 catalog?如果需要一个可写的独立 map[string]any,你会如何处理 map 内部还是 slice、map、指针的值?

4. json.RawMessage:保留 JSON 字节,但不自动拥有所有后续副本

小问题: Output.Value 为什么不是 any,而是 json.RawMessage?拿到一段 JSON 后,Guest 关闭了还能读吗?

示例: json.RawMessage 是一个以 []byte 为底层类型的命名类型,可以有自己的方法;不要把它与 byte = uint8 这种别名写法混为一谈。也可以手工把任意字节转成 RawMessage,所以类型名本身不证明内容是合法 JSON;本例的合法性来自 json.Unmarshal 成功,后续乱改字节仍可能破坏它。它适合在库层保留 JSON 的原始表示,让调用者稍后决定把结果解码成什么结构;它不是一个不可变对象。直接把一个 RawMessage 赋给另一个变量仍可能共享同一字节数组,必要时要用 append(json.RawMessage(nil), src...) 建副本。

encoding/json 在把输入解码到 RawMessage 字段时,会把相应 JSON 字节复制到 Go 管理的内存。完整程序 2 的 wire[14] = '9' 后,第一次显示中 wire 已变成 n:9,解码得到的 envelope.Value 不会因为 wire 改变而回看原输入。随后直接修改 envelope.Value,又说明它本身仍是可变的 slice;kept 是显式复制,所以保持 n:1。

术语: 编码(marshal)是把 Go 值变成 JSON 字节;解码(unmarshal)是把 JSON 字节解析成 Go 值;原始消息(RawMessage)是暂不继续解释的 JSON 字节片段。复制的关键不是变量名叫 RawMessage,而是数据是否进入了新的字节存储。

对应真实代码: bridge.go 的 readRequest 从 Guest Memory.Read 得到请求字节,再 json.Unmarshal 到 callRequest;其中 Args json.RawMessage 成为 Go-owned 的参数副本。工具返回值由 encodeResponse 调用 json.Marshal 变成响应字节。Runner.run 从 Guest 读取结果后解码到 response.Value,再放入 Output,因此返回的 JSON 不依赖关闭后的 Guest 线性内存。结果 buffer 本身仍按 release 的调用约定释放。

这解释了两个层次:JSON 字节从 Guest 复制到 Go,是跨边界的所有权转移;Go 内部之后是否再复制,由持有者的生命周期决定。不要因为 RawMessage 看起来像只读“消息”,就把它当成不可变值。

常见误解 / 自测: RawMessage 不是 string,可以被改;json.Unmarshal 复制输入也不等于以后每次赋值都会深复制。为什么 Runner 可以在关闭 Guest 后返回 Output.Value?如果把 response.Value 替换成仍借用 Guest 内存的切片,关闭实例后会有什么风险?不要依赖“这次看起来还能读”:模块关闭、内存增长或重置后,旧视图不再具有同样的使用保证,不等于 Go 一定立刻让每个借用切片的读操作报错。

教学程序可以修改自己持有的 slice;真实 Tool 则应把传入的 args 当只读数据,因为 Run 也保留它做 Future 匹配。要修改业务内容,可以解码到自己的结构,而不是原地改调用方交来的字节。

5. GC 与 Close:内存回收不等于资源协议

小问题: Go 有垃圾回收,为什么还要显式调用 Close?

示例: 垃圾回收(GC)寻找不再可达的 Go 对象并回收其内存,但它不按你的业务顺序调用 Close,也不保证某个时间点发生。Close 是程序定义的显式操作:它可以关闭文件、网络连接、Wasm runtime 或其他外部资源。若函数结束就应释放,通常先登记 defer resource.Close(),让成功和错误返回都走同一个清理路径。

完整程序 3:显式 Close 与 GC 是两件事

package main

import (
	"fmt"
	"runtime"
)

type Resource struct {
	name   string
	closed bool
}

func (r *Resource) Close() {
	if r.closed {
		return
	}
	r.closed = true
	fmt.Println("close: " + r.name)
}

func useResource() {
	resource := &Resource{name: "guest"}
	defer resource.Close()
	fmt.Println("use: " + resource.name)
}

func main() {
	useResource()
	fmt.Println("after use")
	runtime.GC()
	fmt.Println("after GC")
}

预期输出(已用本机 Go 执行验证):

use: guest
close: guest
after use
after GC

Resource 只是一个可观察的示例类型,不是真实文件或 Wasm 句柄。defer 让 Close 在 useResource 返回时确定执行;runtime.GC() 触发并等待一次垃圾回收完成,最后打印的 after GC 只是表明调用已返回;它不是资源泄漏检测报告,也不表示所有延后清理回调都结束。程序不能把它当成资源关闭协议,也不应依赖 finalizer(垃圾回收相关的延后清理回调)来替代显式关闭。

对应真实代码: runner.go 中的 Runner.Close 将 image 置空并关闭 wazero runtime;调用者必须等自己的 Run 调用结束后再 Close。Runner.run 在 Guest 创建成功后登记 defer m.Close(context.Background());结果 buffer 也有对应的 release。这些动作是代码明确安排的生命周期,不是等 GC 猜测。

NewPrepared 把参考 Guest 的初始化线性内存复制到 Runner 的 image;newGuest 为每轮创建新实例,并用 Memory.Write 复制一份。这条路径是 full-copy,不是真正的 COW;另有 Linux COW 路径。对 full-copy 而言,每个实例拥有自己的可写内存,不能据此声称共享物理页或有性能收益。

常见误解 / 自测: Close 不是“让 Go 变量变成 nil”,而是调用拥有者定义的清理协议;把 runner = nil 不能代替 runner.Close(ctx)。如果工具 goroutine 还在使用 Runner 就关闭 runtime,会违反什么生命周期约定?并发等待细节留给并发章节,这里只判断“资源是否仍有使用者”。

6. 用所有权读 Pysolate Spine 的几条边界

小问题: 读到 []byte、json.RawMessage 和 *Runner 时,怎样快速判断是否安全?

示例: 对每个跨函数或跨边界的数据,问四个问题:这是谁创建的?谁可以修改?谁需要复制?谁负责释放?例如,Host 从 Guest 读取结果后必须在 Guest 关闭前解码或复制;工具收到的 args 是 Go-owned,但按约定只读;Runner 的 image 是基线副本,不是某个已关闭实例的借用。

术语: 借用(borrow)表示暂时使用别人的存储,不能超过其生命周期;拥有(own)表示负责保证存储有效,并承担约定的释放动作;边界复制(boundary copy)是在跨 Guest/Host 或跨生命周期时把数据放进新的存储。Go 类型系统不会自动写出完整的所有权证明,这些是接口约定。

对应真实代码: 可以沿这条路径阅读:

  1. Runner.run 把请求编码为 Go 字节,交给 Guest alloc 和 Memory.Write;临时请求由 callWithBytes 使用后释放。
  2. Guest execute 返回一个打包的指针和长度。Go 用 Memory.Read 取得本轮响应的视图(不是复制),再 json.Unmarshal 成 Output 所需的数据。
  3. hostCall 读取请求并调用工具;writeResponse 只在 Guest 提供的地址、容量和消息上限都满足时写回。
  4. runState.prepare 保存 Go-owned 的请求字节,未领取任务由本轮 close 逻辑处理;不要把 Guest 内存指针塞进 Future。Future 的并发执行和取消顺序不在本章展开。

常见误解 / 自测: Memory.Read 得到的字节、json.Unmarshal 产生的 RawMessage、append 产生的新 slice,它们的生命周期和所有权可能不同。请解释:为什么 Runner.image = append([]byte(nil), data...) 比 Runner.image = data 更符合“参考 Guest 关闭后仍保存基线”?为什么 prepared full-copy 仍不能让两个 Run 共享同一份可写 Python 状态?

本章小结与练习

数组赋值复制数组;slice 复制描述并可能共享底层数组;append 可能扩容;map 赋值共享映射,独立 map 需要逐项复制;nil map 可读不可写,nil slice 可以 append;RawMessage 是可变 JSON 字节,不是不可变字符串;跨 Guest 边界要用有界内存读写和明确复制;GC 回收 Go 内存,Close 关闭资源,两者不能互换。

先不要实现练习:

  1. 如果一个工具返回包含 slice 的结构体,json.Marshal 前后谁拥有那份 slice?工具是否还会在返回后修改它?
  2. 如果把 Runner 中的 image 改成借用参考 Guest 的内存,参考 Guest 关闭后会违反哪条生命周期规则?
  3. 如果只复制 tools map,不复制闭包捕获的可变 catalog,两个 Runner 是否天然隔离?为什么?

下一步再读 PLM 和 prefix;它们会使用 Future、channel 和 context,但本章的值复制与所有权边界仍然成立。