有个说法我觉得得先破掉:

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 的底层存储(实际取决于编译器优化和逃逸分析)。频繁调用就会持续产生分配,其中一部分对象很快不可达。

但这里有个重要区别——三件事并不等价:

  1. 对象是否已经可以被回收;
  2. 对应的堆空间是否已经可以重新用于分配;
  3. 堆内存是否已经归还给操作系统。

所以观察 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 或专门的资源管理接口。

维度RustGo
普通资源释放通常由所有权和析构决定显式关闭 + GC 共同管理
Drop / FinalizerDrop 通常在值被析构时运行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 留在栈上,甚至把指针和内存访问一起消掉。

所以要分清四件事:

  1. 源码里是否声明了变量;
  2. 编译器是否保留这个变量;
  3. 这个变量是否需要具体的内存位置;
  4. 如果需要,是否分配在堆上。

这四件事并不等价。


十六、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

两点注意:

  1. 诊断内容会随 Go 版本、优化和调用上下文变化;
  2. 有诊断不代表最终机器码里确实有一次堆分配。

还可以用基准测试看实际分配统计:

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

要区分两个对象:

  1. Box 这个拥有型指针值;
  2. 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,
}

如果两边都要把任务放堆上,那就都得承担某种形式的内存分配成本。区别主要在:

  1. 对象怎么分配;
  2. 生命周期怎么管理;
  3. 何时释放;
  4. 是否需要运行时追踪;
  5. 是否存在引用计数;
  6. 对象之间怎么共享;
  7. 分配器怎么复用内存;
  8. 编译器怎么优化。
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、缓冲区复用。

两边都能构建高效系统。真正的差别要通过具体负载衡量。


二十四、一张对照表

维度GoRust
主要内存管理模型追踪式 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 要关注所有权结构、堆分配、引用计数、数据布局和资源生命周期。

真正值得问的问题不是「这段代码会不会分配内存」,而是:

为什么它会分配?分配在哪里?谁负责释放?对象为什么还活着?这部分成本能不能通过编译器优化或数据结构设计消掉?

这才是分析系统内存性能的核心能力。