Rust 与 Go 的内存管理:GC、所有权与分配
有个说法我觉得得先破掉:
Rust 不是「用所有权实现了一套没有 GC 的垃圾回收器」,而是从根上换了内存管理的模型。
Go 靠运行时的垃圾回收器,在执行过程中识别不可达对象并回收。Rust 靠所有权、借用、生命周期和确定性析构,在编译期约束内存的使用方式,运行时通过所有权转移、作用域结束和 Drop 来管理资源。
两者解决的是同一个大问题——怎么让程序安全地管理内存,又不要求开发者手动追踪每一块内存的释放时机——但路线完全不同。
这篇从内存管理的基本问题出发,讲到 Go GC 的工作原理、Rust 的所有权机制、循环引用、并发、性能,最后落到「一行代码到底会不会分配」这个最实际的问题上。
一、GC 到底解决什么问题
先不谈语言。C 里分配一块内存:
int *p = malloc(sizeof(int));
*p = 42;
free(p);
开发者要负责两件事:申请,以及在适当的时候释放。
什么叫适当的时候?看这段:
int *p = malloc(sizeof(int));
*p = 42;
int *q = p;
free(p);
printf("%d\n", *q);
p 和 q 指向同一块内存。释放 p 不会让 q 失效,于是 q 成了悬垂指针。
再看这段:
int *p = malloc(sizeof(int));
*p = 42;
p = NULL;
原来那块内存已经没有任何指针能访问,但我们没 free。这就是泄漏。
手动管理内存的核心风险:
| 问题 | 原因 | 后果 |
|---|---|---|
| Use-after-free | 内存释放后仍在使用 | 内存安全问题 |
| Double-free | 同一块内存被释放多次 | 内存破坏 |
| Memory leak | 不再需要的内存没释放 | 内存持续增长 |
| Dangling pointer | 指针指向已失效对象 | 非法访问 |
| Data race | 多线程不安全地访问共享数据 | 数据竞争、UB 或逻辑错误 |
这里要划一条边界:
GC 主要解决对象生命周期与内存回收,不自动解决并发安全问题。
Go 有 GC,多个 Goroutine 照样能同时改同一个对象造成数据竞争。Rust 的所有权系统也不只是管内存——它还通过借用规则约束引用,再配合 Send/Sync 支持线程安全。
二、Go 的路线:对象自由共享,靠可达性回收
package main
import "fmt"
type User struct {
Name string
}
func main() {
u := &User{Name: "Alice"}
v := u
fmt.Println(u.Name)
fmt.Println(v.Name)
}
u 和 v 指向同一个 User。Go 不要求你在最后一次使用 u 之后手动释放它。
当程序不再需要这个对象,并且从 GC 根集合出发已经没有引用路径能到达它时,GC 会在后续回收过程中释放它的内存。
关键点在这:
Go 不需要知道「哪个变量是唯一所有者」,因为它用的是可达性分析,不是 Rust 那种单一所有权模型。
这让 Go 里构建共享对象、缓存、树、图、复杂对象关系都很自然。代价是运行时得干活:
- 追踪对象之间的引用关系;
- 判断对象是否可达;
- 在合适时机执行回收;
- 控制 GC 对 CPU、内存和延迟的影响。
GC 不是免费的。
三、标记-清除与写屏障
理解 Go 的 GC,从经典的 Mark-and-Sweep 开始。
Root
|
v
Object A
/ \
v v
Object B Object C
Object D
Object E
假设从根集合能到 A、B、C,到不了 D、E。GC 通过可达性分析识别出哪些对象还活着。
Mark:标记存活对象
Root
│
▼
Object A [marked]
├── Object B [marked]
└── Object C [marked]
D、E 不可达,没被标记。
Sweep:回收未标记对象
Object A 保留
Object B 保留
Object C 保留
Object D 可回收
Object E 可回收
这是最基本的模型。但 Go 的实际实现远比「停下来、遍历、清除」复杂:它用并发标记-清除,让应用在 GC 标记的同时继续跑。
并发就带来一个问题。
为什么需要写屏障
假设 GC 正在标记,同时程序修改了对象之间的引用:
原来是: A ──► B
改成: A ──► C
如果 GC 只在开始时观察一次关系,就会出错——可能把 C 当作不可达回收掉,而 A 还指着它。
写屏障就是干这个的:程序修改特定指针关系时,额外执行一些操作,让 GC 能正确维护可达性信息。
程序运行
│
├── 创建和修改对象
│
├── GC 触发
│
├── 并发标记
│ └── 结合写屏障维护对象关系
│
├── 标记终止阶段
│
└── 清除不可达对象
实际实现还有别的阶段和优化,这是核心思路不是完整状态机。
Go 用的是混合写屏障(hybrid write barrier),具体实现在不同版本持续演进。参考 The Go GC Guide 和 Go 源码里的 GC 文档。
一句话总结这条路:Go 把内存管理成本放到运行时,换来程序不需要手动决定大多数对象何时释放。
四、GC 什么时候工作
这是个经常被误解的点:Go 的 GC 不是等内存耗尽才动。
它有自己的触发策略,会综合考虑堆内存增长、配置参数(GOGC、GOMEMLIMIT)、分配压力等因素。
func processRequest() {
data := make([]byte, 1024*1024)
_ = data
}
每次调用申请约 1 MiB 的底层存储(实际取决于编译器优化和逃逸分析)。频繁调用就会持续产生分配,其中一部分对象很快不可达。
但这里有个重要区别——三件事并不等价:
- 对象是否已经可以被回收;
- 对应的堆空间是否已经可以重新用于分配;
- 堆内存是否已经归还给操作系统。
所以观察 Go 程序内存时,不能只看某一刻的进程 RSS 就断定 GC 是否正常。
另外,Go 也不是所有数据都在堆上:
func add(a, b int) int {
return a + b
}
这种函数通常不需要为返回值创建堆对象。GC 只管需要回收的堆对象,不是每个变量都被它追踪。
五、Rust 的路线:靠所有权,不靠追踪
struct User {
name: String,
}
fn main() {
let user = User {
name: String::from("Alice"),
};
println!("{}", user.name);
}
user 离开作用域就被析构:
创建 user
│
▼
使用 user
│
▼
离开作用域
│
▼
调用 Drop
│
▼
释放 User 持有的资源
这里没有后台 GC 线程定期扫描对象是否可达。Rust 用所有权规则在编译时约束:
- 哪个值拥有资源;
- 什么时候所有权转移;
- 哪些地方可以借用;
- 借用能持续多久;
- 哪些资源要在生命周期结束时清理。
Rust 把许多传统上需要运行时追踪的问题,转化成了编译期的静态约束。
但这不代表 Rust 不需要运行时内存管理。它仍然可以:
- 在堆上分配内存;
- 用
Box<T>、Vec<T>、String; - 用引用计数;
- 用自定义分配器;
- 通过
unsafe实现底层数据结构。
Rust 没有默认的追踪式 GC,但它既不是不分配内存,也不是没有运行时成本。
六、所有权、借用、生命周期怎么配合
所有权解决「谁负责释放」
fn main() {
let a = String::from("hello");
let b = a;
println!("{b}");
}
let b = a; 之后,String 的所有权从 a 转到 b,a 不能再用。
a ──► String
│
│ move
▼
b ──► String
为什么要这么设计?假设 Rust 允许两个变量都当所有者,那程序结束时两边都可能去释放同一块缓冲区。
注意关键点不是「Rust 只能有一个指针」——Rust 允许多个引用指向同一个值。关键是:对于普通拥有型值,所有权转移不会自动产生第二个独立的所有者。 真需要共享所有权,有 Rc/Arc。
借用与生命周期解决「引用能用多久」
fn main() {
let r;
{
let value = String::from("hello");
r = &value;
}
println!("{r}");
}
value 在内部作用域结束时销毁,r 还想在外面用它。编译器直接拒绝。
外部作用域
┌──────────────────────────┐
│ r │
│ │
│ 内部作用域 │
│ ┌──────────────────┐ │
│ │ value │ │
│ └──────────────────┘ │
│ ▲ │
│ │ │
│ r 试图继续引用它 │
└──────────────────────────┘
生命周期标注不是「手动控制内存释放的命令」,而是描述引用约束的一种方式:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a 不表示「编译器要分配一段名为 'a 的内存」,它表达的是:返回的引用不能比输入引用满足的共同生命周期约束更长。
生命周期本身通常不会成为运行时对象,也不会产生一个追踪引用的 GC。它主要参与编译器的静态分析。
两个容易搞混的点:
- 生命周期不等于对象的实际存活时间,它是编译器检查引用有效性的约束;
- 反过来,标注也不会让一个已经被销毁的对象复活。
三者组合起来
struct Buffer {
data: Vec<u8>,
}
fn process() {
let buffer = Buffer {
data: vec![1, 2, 3],
};
let view = &buffer.data;
println!("{view:?}");
}| 机制 | 负责什么 |
|---|---|
| 所有权 | 谁负责管理资源 |
| 借用与生命周期 | 引用能在什么范围内有效 |
Drop | 资源生命周期结束时怎么清理 |
不过要说清楚:Rust 的生命周期系统不是通用的内存回收算法。 它不会在运行时扫描堆、找不可达对象。它的作用是约束引用关系,让很多资源的清理时机能由所有权和作用域确定下来。
七、Arc 是不是一种小型 GC
Rust 没有默认的追踪式 GC,但提供了引用计数类型:
use std::sync::Arc;
fn main() {
let a = Arc::new(String::from("hello"));
let b = Arc::clone(&a);
println!("{a}");
println!("{b}");
}a ──┐
├──► Arc 管理的对象
b ──┘
Arc 维护引用计数,计数归零时析构内部对象并释放分配。所以它确实提供了某种自动管理共享对象生命周期的机制。
但和 Go 的 GC 有根本区别。
区别一:Arc 依赖显式引用计数
每次 clone 增加强引用计数,每个 Arc 析构时减少。最后一个强引用消失,对象才被析构。
区别二:Arc 不追踪任意对象图的可达性
假设两个对象互相引用:
A ──► B
▲ │
└─────┘
如果两条边都是强 Arc,即使外部已经没有引用,内部计数还是非零,两个对象都释放不掉。这就是引用循环。
Go 的追踪式 GC 通常能回收这种对象图——只要整体从根集合不可达就行。
但这里也有个细节:如果 Go 对象仍然能从某个根到达,即使业务上已经不要了,GC 也不会自动知道。
GC 判断的是可达性,不是业务意义上的「还需不需要」。
Rust 怎么破环?
用 Weak<T>,它不增加强引用计数:
use std::sync::{Arc, Weak};
struct Node {
parent: Weak<Node>,
}
(简化片段,完整树结构还得有子节点集合,并在创建时建立正确的引用关系。)
if let Some(node) = weak.upgrade() {
// 对象仍然存活
}
对象已释放时 upgrade() 返回 None。
所以:
Arc<T>适合共享所有权;Weak<T>适合「不应维持对象存活」的关联;Rc<T>是单线程版;Rc<T>也有对应的Weak。
但引用计数不等于追踪式 GC:
| 判断依据 | |
|---|---|
| 引用计数 | 强引用数量 |
| 追踪式 GC | 从根集合出发的可达性 |
八、Drop 与 Finalizer
struct Resource {
id: u64,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("dropping {}", self.id);
}
}
fn main() {
let resource = Resource { id: 1 };
println!("using resource");
}
resource 离开作用域就被析构。对普通的、没被遗忘的拥有型值,资源释放和所有权、作用域绑得很紧。
但也不保证总执行:
std::mem::forget(resource);
forget 消耗这个值,但不跑析构。所以 Rust 的安全抽象不能假定所有 Drop 一定执行。
Go 这边:
type Resource struct {
ID int
}
如果一个对象只是普通堆对象,GC 不会因为它不可达就调用一个类 Rust Drop 的方法。Go 有 runtime.SetFinalizer,但终结器不是确定性析构机制,不适合承担必须及时执行的资源释放或业务逻辑。
文件、数据库连接这类,通常还是靠显式 Close()、defer 或专门的资源管理接口。
| 维度 | Rust | Go |
|---|---|---|
| 普通资源释放 | 通常由所有权和析构决定 | 显式关闭 + GC 共同管理 |
| Drop / Finalizer | Drop 通常在值被析构时运行 | Finalizer 不保证及时执行 |
| 是否依赖后台追踪 | 默认不依赖 | 堆对象依赖 GC 回收 |
| 清理确定性 | 普通析构路径通常更确定 | 重要资源需要显式关闭 |
| 业务事务回滚 | 需要显式实现 | 同样需要显式实现 |
Rust 的确定性析构也有边界:进程被强杀、系统崩溃、用 abort 时,不能依赖普通析构完成清理。而且 Drop 不能撤销已经发生的外部副作用。
RAII 特别适合管文件句柄、锁、内存分配,但不能理解成自动回滚系统。
九、Rust 是不是一定比 Go 快
不能这么下结论。
Rust 没有默认追踪式 GC,确实不用承担同样的追踪回收工作。在分配量很大、对象生命周期短、延迟要求严格的程序里,避免追踪式 GC 有利于控制尾延迟。
但 Rust 也不是零成本。
Rust 仍然会分配堆内存
let data = Vec::<u8>::with_capacity(1024);
这里可能发生堆分配。分配器本身有成本,Vec 扩容要重新分配和复制,String 也可能重分配,Arc 要维护引用计数。锁、channel、异步任务、动态分发同样有开销。
没有 GC,不代表没有内存管理成本。
Go 也不是每个对象都在堆上
func sum(a, b int) int {
return a + b
}
这里一般不需要创建堆对象。编译器会分析变量用法决定放哪。
所以:
Rust = 栈分配
Go = 堆分配
这个说法是错的。
Go 的 GC 换来的是工程便利
type Cache struct {
data map[string][]byte
}
缓存可以被多个组件持有。开发者要考虑并发安全、淘汰策略、内存占用,但一般不用手动算每个缓存条目的引用计数。
Rust 也能构建同样的缓存,但要先想清楚:谁拥有缓存、怎么借用、要不要 Arc、要不要 Mutex/RwLock、条目什么时候移除。
这些机制提供了明确的所有权和同步关系,但设计工作量更多。
比较两种语言要看完整系统,不能只盯着 GC。
十、延迟、内存占用,以及生命周期系统的边界
尾延迟
解析请求
│
▼
创建任务对象
│
▼
执行调度
│
▼
调用工具
│
▼
保存结果
整个过程产生临时对象。Go 可能在分配积累到一定程度后触发 GC。
Rust 不会因为普通堆对象不可达就启动追踪式扫描,但仍受这些因素影响:
- 分配器竞争;
- 堆分配与释放成本;
- 引用计数的原子操作;
- 锁竞争;
- 内存碎片;
- 数据结构的局部性;
- 异步任务和队列的开销。
所以不能这么推:
Rust 没有 GC
↓
Rust 的尾延迟必然更低
更准确的结论:Rust 避免了追踪式 GC 这一类运行时成本,但最终延迟还取决于分配行为、数据结构、调度、同步、I/O 和系统负载。
同样,Go 的 GC 也不是单纯的「性能损失」——它降低了内存管理的编程负担,很多业务场景下这是非常合理的工程选择。
为什么没有 GC 也会占很多内存
use std::collections::HashMap;
let mut cache: HashMap<String, Vec<u8>> = HashMap::new();
如果只往里插不删除,内存就会一直涨。Rust 没有 GC,但这个问题照样存在。
原因很简单:这些对象仍然被 cache 持有。从所有权角度看,它们仍是有效资源。Rust 不会擅自推断「这个缓存条目业务上已经没价值了,删了吧」。
你得自己定策略:什么时候淘汰、最大容量多少、要不要 TTL、按访问频率还是按时间、允不允许后台清理。
Go 一模一样:
var globalCache = make(map[string][]byte)
只要缓存还存着这些数据,GC 就认为对象可达。
GC 能回收不可达对象,但判断不了一个仍然可达的对象是否已经失去业务价值。
这也是为什么内存泄漏不是 GC 语言独有的问题。GC 语言照样会遇到:缓存无限增长、Goroutine 泄漏、订阅者没退出、channel 里积压、全局变量意外保留对象、闭包长期捕获不再需要的数据。
Rust 也会出现逻辑层面的泄漏,尤其是用引用计数、全局状态或故意泄漏资源时。
生命周期系统的边界
Rust 的所有权和生命周期能在安全 Rust 里静态排除很多内存错误,但不保证:
- 不会发生逻辑内存泄漏;
- 不会资源使用过度;
- 不会死锁;
- 不会算法错误;
- 不会业务上的资源泄漏;
- 不会因为
unsafe出现内存安全问题。
use std::sync::Arc;
let value = Arc::new(42);
let cloned = Arc::clone(&value);
如果把 Arc 存进一个长期存活的全局结构,相关对象就一直活着。这是所有权关系的正常结果,不是编译器有问题。
unsafe 更要小心:用裸指针、手动管理初始化状态、实现自定义容器时,可能绕过部分静态保证。自己写一个类 Vec 的结构,得处理内存布局、分配释放、初始化状态、元素析构、指针有效性、别名规则、Send/Sync 的实现条件。这些不变量写错了,外围 API 再漂亮也可能有 UB。
所以 Rust 的优势是让安全抽象能建立在严格的静态约束之上,而不是让程序从此不需要内存管理设计。
十一、并发内存管理
Agent Runtime 有很多并发任务,每个任务都要维护状态、记录事件、调用工具、保存检查点、更新统计。这些会产生大量临时对象。
Go 的优势是对象能自然地在 Goroutine 之间传递和共享,GC 管堆内存,但数据竞争和同步还得自己处理:
var counter int
go func() { counter++ }()
go func() { counter++ }()
这里存在数据竞争,GC 不会解决它。
Rust 用类型系统约束线程间共享:
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let counter = Arc::new(AtomicUsize::new(0));
let a = Arc::clone(&counter);
let b = Arc::clone(&counter);
let t1 = std::thread::spawn(move || {
a.fetch_add(1, Ordering::Relaxed);
});
let t2 = std::thread::spawn(move || {
b.fetch_add(1, Ordering::Relaxed);
});
t1.join().unwrap();
t2.join().unwrap();
Arc管共享所有权;AtomicUsize提供原子计数;Ordering::Relaxed保证这个计数操作本身是原子的,但不提供其他内存访问之间的同步顺序;join等两个线程结束。
这些原子操作不需要 GC 也能工作。Go 也有 sync/atomic、sync.Mutex、channel。
所以主要区别不是「Go 有 GC 所以不安全」:
- Go 用 GC 管对象内存,通过同步原语和运行时检测工具帮助管理并发访问;
- Rust 用所有权、借用、
Send/Sync与同步原语共同约束内存和线程安全。
两个体系都需要正确设计任务状态、共享数据和同步关系。
十二、Box、Rc、Arc 怎么选
想真正理解 Rust 为什么不用 GC,得先理解这几个类型的分工。
| 类型 | 核心用途 | 所有权模式 | 特点 |
|---|---|---|---|
T | 普通值 | 直接拥有 | 通常按值存储 |
Box<T> | 堆分配 | 单一所有者 | 管理一块堆分配 |
Rc<T> | 单线程共享 | 引用计数 | 共享所有权,不保证线程安全 |
Arc<T> | 多线程共享 | 原子引用计数 | 能否跨线程仍取决于 T |
Weak<T> | 非拥有式关联 | 弱引用 | 不增加强引用计数 |
&T | 只读借用 | 不拥有 | 受生命周期和借用规则约束 |
&mut T | 可变借用 | 不拥有 | 受独占可变借用规则约束 |
特别强调:Arc<T> 不意味着内部的 T 可以被任意多个线程同时修改。
use std::sync::{Arc, Mutex};
let value = Arc::new(Mutex::new(0));
Arc 解决共享所有权,Mutex 解决互斥访问——两个不同问题。只有 T 本身满足相应的 Send/Sync 条件,Arc<T> 才能满足跨线程使用要求。
Rust 的内存管理从来不是单靠生命周期标注,而是多种机制共同作用。
十三、内存生命周期 ≠ 业务对象生命周期
这个区别我觉得比前面所有技术细节都重要。
一个任务被创建,业务生命周期是:
Created
│
▼
Ready
│
▼
Running
│
▼
Completed
业务状态可能要持久化,但内存里的结构体可能早就释放了:
数据库中的 Task 记录:仍然存在
内存中的 Task 对象:已经释放
这完全合理。
反过来也一样:任务在数据库里已经 Completed,但某个监控组件还持有内存对象——因为它正在读最终统计信息。也合理。
所以要区分四种生命周期:
| 生命周期 | 代表什么 |
|---|---|
| 内存生命周期 | 对象何时创建、持有、释放 |
| 任务生命周期 | 任务何时创建、执行、完成、失败或取消 |
| 进程生命周期 | 运行时何时启动和退出 |
| 持久化生命周期 | 数据何时写入、保留、归档或删除 |
GC 只处理第一种,Rust 的所有权和析构也只处理第一种。
它们都不能单独保证任务执行正确、检查点一致或故障恢复成功。这对分布式系统尤其重要:内存对象被释放不等于业务任务被取消,业务任务完成也不意味着所有内存对象立即消失。
十四、一行代码怎么变成内存分配
从这里开始进入第二块:分配本身。
package main
type User struct {
ID int64
Name string
}
func NewUser(id int64, name string) *User {
u := User{
ID: id,
Name: name,
}
return &u
}
NewUser 返回一个指向局部变量 u 的指针。C 里这行是错的——函数返回后栈帧就失效了。Go 却允许。
因为 Go 编译器会分析 u 的用法。如果它需要在函数返回后继续存在,编译器就把它放到堆上。
源代码
│
▼
编译器分析
│
├── 对象布局
├── 逃逸分析
├── 栈与堆分配决策
└── 代码生成
│
▼
运行时内存管理
│
├── 栈空间
├── 堆分配器
└── GC
这是概念图,不是精确执行顺序。
编译器还会做内联、死代码消除、寄存器分配。内联尤其重要:一个函数单独看可能有堆分配,被内联后分配可能被消除,或者变量留在调用者的栈上。
所以不能只看源码里的 &、new、make 就判断一定在堆上。
Rust 里写了 Box::new 也不绝对——编译器可能在不影响可观察行为的前提下消除某些分配。
十五、Go 逃逸分析到底分析什么
func f() *int {
x := 42
return &x
}
x 在哪?
很多人会直接回答「因为返回了 &x,所以 x 一定在堆上」。这作为直觉可以,但不够严谨。
更准确的说法:返回的指针可能在函数返回后继续被使用,所以编译器必须保证它指向的对象在需要时仍然有效。 在常见编译结果里,x 会被放到堆上。
编译器并不是逐个查找所有 & 然后机械决定,它分析指针和引用之间的流动关系,结合上下文和其他优化作决定。
我实测了几种写法,go build -gcflags="-m=2" 的输出很能说明问题。
返回指针 → 逃逸
./main.go:10:2: x escapes to heap in f:
./main.go:10:2: moved to heap: x
只在内部用 → 没有逃逸记录
func sum() int {
x := 42
return x + 1
}
编译器可能直接算出结果,连 x 都不保留。
取地址但只在内部用 → 也不逃逸
func readValue() int {
x := 42
p := &x
return *p
}
输出里根本没有 x 的逃逸记录。虽然写了 &x,但 p 只在当前函数用,编译器有机会把 x 留在栈上,甚至把指针和内存访问一起消掉。
所以要分清四件事:
- 源码里是否声明了变量;
- 编译器是否保留这个变量;
- 这个变量是否需要具体的内存位置;
- 如果需要,是否分配在堆上。
这四件事并不等价。
十六、new、make、取地址为什么不等同于堆分配
这是分析 Go 分配时最实用的一部分,我全部实测了。
new 不保证堆分配
func newLocal() {
p := new(int)
*p = 10
println(*p)
}
实测输出:
./main.go:46:10: new(int) does not escape
new(int) 得到的是指向零值 int 的指针。但编译器能证明它只在本函数用,就有机会放栈上,甚至消除分配。
new表示「创建并返回指针」,不表示「强制堆分配」。
make 也不一定
func makeValues() []int {
values := make([]int, 3)
values[0] = 10
return values
}
因为切片被返回,底层数组要在函数外继续存在:
./main.go:53:16: make([]int, 3) escapes to heap in makeValues:
但如果切片不外传,比如只取第一个元素返回:
func f() int {
values := make([]int, 3)
values[0] = 10
return values[0]
}
实测里这条是 does not escape——底层数组可以留在栈上。
切片本身只是个描述符:
slice
├── data pointer
├── length
└── capacity
接口值也不一定导致分配
func printValue(v any) {
println(v.(int))
}
调用 printValue(42) 时,实测:
./main.go:59:17: v does not escape
接口值需要保存动态类型信息和数据,但具体表示和是否需要额外分配,取决于值的类型、大小、用法和编译器优化。
所以「传入接口就会分配」也是过于绝对的结论。
一个有意思的现象
func cacheExample() {
m := make(map[string][]byte)
m["k"] = make([]byte, 128)
_ = m
}
实测:
./main.go:65:11: make(map[string][]byte) does not escape
./main.go:66:15: make([]byte, 128) escapes to heap
map 本身没逃逸(因为整个函数里它就没出去),但塞进去的 128 字节切片逃逸了——map 的底层存储必须能在增长时重新分配,所以数据会被放到堆上。
这类细节只能实测,光看源码猜不出来。
十七、怎么亲自观察逃逸分析
这一步别省。逃逸分析不能只靠理论推断。
go build -gcflags="-m=2" .
输出编译器优化诊断,包括内联和逃逸信息。你可能会看到:
u escapes to heap
moved to heap: u
两点注意:
- 诊断内容会随 Go 版本、优化和调用上下文变化;
- 有诊断不代表最终机器码里确实有一次堆分配。
还可以用基准测试看实际分配统计:
go test -bench=. -benchmem
输出格式类似:
BenchmarkExample-8 1000000 120 ns/op 32 B/op 1 allocs/op
(这是格式示例,不是某个真实程序的结果。)
ns/op:每次操作耗时;B/op:每次操作平均分配字节数;allocs/op:每次操作平均分配次数。
对 Agent Runtime 这种服务,这些指标能帮你定位高频路径上的分配压力。
十八、Rust 是不是「默认栈分配」
很多人把 Rust 总结成「默认栈分配,Go 默认堆分配」。这个说法不准确。
Rust 没有一条简单规则规定所有普通变量都在栈上,也没有一条规则规定所有拥有型对象都在堆上。更准确的理解是:
- 局部值通常由编译器根据使用方式安排存储;
Box<T>等类型通过分配器管理堆内存;Vec<T>和String通常在堆上管理动态数据;- 编译器可能优化掉不必要的存储和分配;
- 对象在哪,不能仅凭类型名字判断。
struct User {
id: u64,
}
fn main() {
let user = User { id: 42 };
println!("{}", user.id);
}
user 是普通值,不需要像 Box<User> 那样通过堆分配器管理主体存储。常见情况下作为局部值存储;优化后编译器甚至可能把字段直接放寄存器里。
所以即使 Rust 没有默认追踪式 GC,也不能认为所有值都一定拥有可观察的栈内存位置。
十九、Box 与 Vec
Box:把值放堆上,但不引入 GC
fn main() {
let user = Box::new(42);
println!("{user}");
}栈上的 Box<i32>
│
▼
堆上的 i32
要区分两个对象:
Box这个拥有型指针值;Box管理的堆分配里的T。
Box 的指针值本身通常很小,被管理的 T 在堆上。这只是概念模型,编译器仍可能优化掉不影响可观察行为的操作。
Box 最常见的用途是构造递归类型:
enum List {
Cons(i32, Box<List>),
Nil,
}
不用间接存储,递归类型的大小无法有限确定。Box<List> 提供了一个已知大小的间接引用,让整个 List 有有限大小。这和 GC 无关,只是让数据结构能在堆上组织。
Box 与 Go 指针的区别
p := &User{ID: 42}let p = Box::new(User { id: 42 });
看起来都是拿到指向数据的指针,语义不同:
- Go 的
*User是普通指针,不表达独占所有权; - Rust 的
Box<User>表达对所管理值的所有权。
所以 Rust 能在类型系统里追踪所有权转移:
let a = Box::new(42);
let b = a;
// a 不再拥有该 Box
b 析构时负责释放资源。Go 的普通指针不会在类型系统里提供同样的独占语义——当然 Go 可以通过 API 约定和封装建立所有权规则,只是这些规则不由 *T 本身表达。
Vec:栈上的管理对象 + 堆上的元素缓冲区
let mut values = Vec::with_capacity(4);
values.push(10);
values.push(20);
values.push(30);Vec<i32>
├── pointer ──────────────┐
├── length = 3 │
└── capacity = 4 │
▼
堆分配
┌─────────┐
│ 10 │
├─────────┤
│ 20 │
├─────────┤
│ 30 │
├─────────┤
│ 未初始化 │
└─────────┘
图里「未初始化」只是为了说明 len 和 capacity 的差别:剩余容量不代表那里已经构造了可用的 T。
Vec 的堆内存不由 GC 决定何时释放,而是由 Vec 的所有权和析构逻辑管理。
扩容时:
let mut values = Vec::with_capacity(4);
for i in 0..1000 {
values.push(i);
}
容量不足就重新分配,可能移动元素数据。这意味着:
Vec自己的管理信息可以留在原位;- 但元素缓冲区可能换地址。
这正好接上 Pin 那一篇:固定外层对象的地址,不等于固定它拥有的所有堆分配。
Go 切片 vs Rust Vec
Go slice Rust Vec
├── data ├── pointer
├── len ├── len
└── cap └── capacity
结构相似,所有权语义不同:
- Go 切片是对底层数组的描述,多个切片可以共享同一个数组;
- Rust
Vec<T>拥有底层缓冲区。
Rust 的 &[T]、&mut [T] 才更接近 Go 的切片视图,但它们受借用规则约束。这直接影响数据共享、缓冲区复用和零拷贝设计。
二十、对象布局:Rust 自动重排,Go 要手动重排
这块的实测结果我觉得最有意思。
struct Example {
a: u8,
b: u64,
c: u8,
}
直觉是 1 + 8 + 1 = 10 字节。实际不是。
Rust 的情况(实测)
repr(Rust): size = 16, align = 8
repr(C) : size = 24, align = 8
repr(Rust) 的字段偏移:b = 0, a = 8, c = 9
默认 repr(Rust) 下编译器重排了字段:把 u64 放前面,两个 u8 紧凑排在后面,总大小 16。
加上 #[repr(C)] 后必须保持声明顺序,于是 a 占 1 字节 + 7 字节填充 + b 8 字节 + c 1 字节 + 7 字节尾部填充 = 24。
Go 的情况(实测)
type Example struct {
A byte
B uint64
C byte
}size=24 align=8
A offset=0 B offset=8 C offset=16
Go 不重排,严格按声明序,结果和 Rust 的 repr(C) 一样是 24。
手动把字段从大到小排:
type Packed struct {
B uint64
A byte
C byte
}size=16 align=8
B offset=0 A offset=8 C offset=9
16 —— 和 Rust 的默认布局完全一致。
这个对照挺能说明问题:
Rust 的
repr(Rust)自动做了 Go 需要手动做的事;而 Rust 的repr(C)才等价于 Go 的默认行为。
所以「Go 结构体布局更省内存」或「Rust 布局更紧凑」都是错的——取决于你有没有加 repr(C)、有没有手动排字段。
怎么观察
Rust:
use std::mem::{align_of, size_of};
fn main() {
println!("size = {}", size_of::<Example>());
println!("align = {}", align_of::<Example>());
}
Go:
fmt.Println("size:", unsafe.Sizeof(Example{}))
fmt.Println("align:", unsafe.Alignof(Example{}))
fmt.Println("A offset:", unsafe.Offsetof(Example{}.A))
fmt.Println("B offset:", unsafe.Offsetof(Example{}.B))
fmt.Println("C offset:", unsafe.Offsetof(Example{}.C))
注意:size_of::<T>() 只告诉你总大小,不告诉你每个字段的偏移。默认 Rust 布局下字段顺序不被语言保证,不能靠推断。
Go 这边 unsafe.Offsetof 是能用的,因为 Go 保证字段按声明序排列。
布局优化的前提
调整字段顺序确实能减少填充,但不能因为「少占几个字节」就断定程序会更快。还得看:
- 缓存局部性;
- 数据访问模式;
- 分配数量;
- 结构体是否被复制;
- 并发访问模式;
- ABI 和序列化兼容性。
先测量,再调整。
二十一、分配器
Rust 侧
let value = Box::new(42);
let values = Vec::<u64>::with_capacity(1000);
两者都可能向堆分配器请求内存:
Box / Vec
│
▼
Rust 标准库的分配接口
│
▼
当前配置的全局分配器
│
▼
底层分配实现
│
▼
操作系统提供的虚拟内存
注意实际分配可能被优化掉,或被分配器内部缓存复用。这张图描述的是典型的资源管理路径,不是每次 Box::new 都触发一次系统调用。
分配器要处理:按大小和对齐分配、释放、管理不同大小的块、适当时候向 OS 要更多内存、适当时候归还、处理碎片和内部元数据。
操作系统通常不会为每个小对象单独执行系统调用——分配器管理较大的区域,再从中划分小块。
Rust 允许自定义全局分配器,但自定义不意味着更快。不同分配器在小对象速度、多线程竞争、碎片、峰值内存、大块分配、归还策略上表现不同,而且换全局分配器会影响整个程序和所有依赖库。
先确认分配真是瓶颈,再在真实负载下比较。
Go 侧
Go 运行时也有自己的堆分配器,为高效处理大量小对象,它管理不同大小类别的内存块,并尽量复用已取得的内存。
Go 程序
│
▼
Go runtime allocator
│
├── 小对象分配路径
├── 较大对象分配路径
├── 堆空间管理
└── 与 GC 协同
具体路径取决于对象大小、对齐、运行时实现和版本。但肯定不是「每次 new 就调一次操作系统」。
为什么 Go 特别关注分配速率
假设服务每秒 10 万请求,每个请求平均分配 20 个小对象:
100,000 requests/sec × 20 allocations/request
= 2,000,000 allocations/sec
(假设数字,用来说明量级。)
即使每个对象很小,持续产生也可能增加:分配器工作量、GC 要处理的对象数、扫描工作、CPU 使用率、内存占用。
所以 Go 性能优化里常关注 allocs/op、B/op、alloc_space、alloc_objects。
但也不能因为分配次数多就断定代码有问题。某些分配是合理的,硬优化可能让代码难维护。测真实负载,再决定要不要减。
二十二、分配成本到底差在哪
假设两边都要创建任务对象:
type Task struct {
ID string
State int
}struct Task {
id: String,
state: i32,
}
如果两边都要把任务放堆上,那就都得承担某种形式的内存分配成本。区别主要在:
- 对象怎么分配;
- 生命周期怎么管理;
- 何时释放;
- 是否需要运行时追踪;
- 是否存在引用计数;
- 对象之间怎么共享;
- 分配器怎么复用内存;
- 编译器怎么优化。
Go: Rust:
分配对象 分配对象
│ │
▼ ▼
运行时管理 某个值拥有资源
│ │
▼ ▼
对象变得不可达 所有权转移或继续使用
│ │
▼ ▼
GC 后续回收 资源生命周期结束
│
▼
析构与释放
这不意味着 Rust 每次释放都没成本,也不意味着 Go 的 GC 一定在某个固定时间点回收对象。两者都要管理分配器和内存空间。
主要差别是默认的生命周期管理模型不同。
二十三、对 Agent Runtime 来说该减少什么
三个层次。
第一层:减少不必要的对象分配
READY
│
▼
LEASED
│
▼
RUNNING
│
▼
COMPLETED
如果每次状态变化都构造大量临时对象,分配成本会累积。
Go 用基准测试、逃逸分析、CPU/内存剖析找热点;Rust 用分配剖析、基准测试、数据结构分析找热点。
但别为了省几个小对象就把代码改成复杂的手动内存池。
第二层:控制对象生命周期
事件流和任务上下文可能比单次执行活得更久。某个事件缓存了很久,相关对象也就长期占内存。
- Go:长期引用会阻止 GC 回收;
- Rust:长期持有
Arc或放全局结构里,资源同样持续存活。
要控制:缓存大小、事件保留量、任务历史、临时上下文的释放、长期订阅、不再使用的共享引用。
这一层是业务生命周期管理,不只是语言层面的内存管理。
第三层:降低大规模并发下的分配与同步成本
多个任务同时更新共享状态时:
Go 可能需要互斥锁、channel、对象复用、有界队列、GC 指标监控。
Rust 可能需要所有权转移、借用、Arc、锁、有界 channel、缓冲区复用。
两边都能构建高效系统。真正的差别要通过具体负载衡量。
二十四、一张对照表
| 维度 | Go | Rust |
|---|---|---|
| 主要内存管理模型 | 追踪式 GC | 所有权 + 确定性析构 |
| 逃逸分析 | 编译器的重要优化,判断对象是否要超出当前栈帧存活 | 也有生命周期、别名与优化分析,但没有 Go 那种统一的堆逃逸模型作为默认 GC 决策机制 |
| 栈/堆分配 | 编译器结合逃逸分析和其他优化决定 | 编译器根据使用方式、类型和优化决定 |
new / make | 不保证堆分配 | 没有直接对应的统一语义 |
Box<T> | 无等价的所有权类型 | 管理堆分配并表达所有权 |
Vec<T> / 切片 | Vec<T> 拥有缓冲区;Go 切片是共享底层数组的视图 | Rust 切片是受借用约束的视图 |
| 对象回收 | GC 根据可达性回收堆对象 | 所有权与析构规则管理释放 |
| 共享所有权 | 指针和引用天然可共享,GC 追踪可达性 | 需借用、Rc、Arc 明确表达 |
| 循环引用 | 不可达的环通常能被 GC 回收 | 强引用环可能泄漏,需要 Weak 或重构 |
| 字段布局 | 严格按声明序,需手动优化填充 | 默认重排,repr(C) 才按声明序 |
| 分配器 | 运行时堆分配器(大小分级 + 复用) | 可替换的全局分配器 |
| 优化重点 | 逃逸分析、减少分配、控制 GC 工作量 | 数据结构设计、减少分配、优化所有权与共享 |
| 内存安全 | GC 不解决并发与业务资源问题 | 安全 Rust 提供较强静态约束,unsafe 仍需自查 |
最后一个认识我想强调:
Go 不是把内存管理交给 GC 之后就完事,Rust 也不是没有 GC 就自动拿到最优性能。
Go 要关注分配速率、逃逸、GC 工作量和长期引用。Rust 要关注所有权结构、堆分配、引用计数、数据布局和资源生命周期。
真正值得问的问题不是「这段代码会不会分配内存」,而是:
为什么它会分配?分配在哪里?谁负责释放?对象为什么还活着?这部分成本能不能通过编译器优化或数据结构设计消掉?
这才是分析系统内存性能的核心能力。