标签 Interface 下的文章

从 Go 迁移到 Rust

本文永久链接 – https://tonybai.com/2026/05/27/migrate-go-to-rust

大家好,我是Tony Bai。

在现代后端系统编程领域,Go 和 Rust 无疑是最耀眼的两大双子星。它们都拥有静态类型、编译型、单二进制文件分发等优异特性。然而,这两门语言在底层的设计哲学、运行时权衡以及开发者体验上,走向了截然不同的方向。

Matthias Endler(Corrode 咨询公司创始人)撰写的《从 Go 迁移到 Rust》(Migrating from Go to Rust)是近年来系统编程领域极具深度的一篇迁移指南。作为在生产环境中同时大规模部署过 Go 和 Rust 系统的资深架构师,Matthias 并没有陷入单纯的“谁比谁快”的无意义争论,而是从正确性保证、运行时权衡、工程重构成本等多个维度,客观地为准备进行语言迁移的团队提供了一份极其务实的工程路线图。

以下是该迁移指南的完整简体中文译文,以及技术社区对于此文的精彩技术辩论与观点。


在我协助团队进行的所有迁移中,从 Go 到 Rust 的迁移是一个特例。

这并不是“Rust 会更快吗?”或“Go 是否拥有类型系统?”的问题,因为 Go 在这些方面已经做得很好了。这里的讨论主要围绕正确性保证运行时权衡以及开发人员体验展开。

在开始之前,先做一个简短的免责声明:本指南高度侧重于后端。后端服务是 Go 的强项所在——小巧的静态二进制文件、专注于网络连接的标准库,以及用于 HTTP 服务器、gRPC、数据库等的庞大生态系统。

这也是大多数考虑使用 Rust 的团队的来源(至少是那些联系我的团队),因此我认为这是在实践中最有用的对比。如果你正在编写命令行工具(CLI)、嵌入式固件或游戏引擎,本文中的一些内容仍然适用,但老实说,我恐怕这不是最适合你的资源。

作为背景,我之前曾写过关于 Go 和 Rust 对比的文章,比如 2017 年的《Go vs Rust?选择 Go》,以及后来与 Shuttle 团队合作撰写的《Go vs Rust:实操对比》,后者通过一个小型后端服务展示了两种语言的具体差异。

你将在本文中学到什么

  • Go 与 Rust 的重叠点和分歧点。
  • Go 的模式如何映射到 Rust。
  • 你能从借用检查器中获得什么。
  • 我在什么情况下会建议人们保留 Go,以及在什么情况下 Rust 值得进行迁移。
  • 如何渐进式地迁移 Go 服务。

我的背景与立场

坦白说:我不是 Go 的粉丝。我认为它是一门设计糟糕的语言,尽管它非常成功。它混淆了简单性(simplicity)与易用性(easiness),并且它的几个核心设计折中——无处不在的 nil、作为纪律规则而非类型的错误处理、长期缺失的泛型——都将设计引向了我所不认同的方向。尽管如此,成功才是硬道理!Go 已经捕获了庞大且持久的活跃开发者份额,在 JetBrains 开发者生态系统调查中一直维持在 17-19% 左右。Rust 正在稳步增长,但目前仍然只占一小部分:


图:2017-2024 年开发者中 Go 和 Rust 的使用情况

Go 显然对很多人都非常适用,而一个假装其不适用的指南是毫无帮助的。因此,在这份指南中,我将尽最大努力保持客观,而不是去重新争论那些老问题。但你应该了解我的先验立场,以便进行校准。

另一个值得披露的前提是:我运行着一家 Rust 咨询公司;所以,我当然是有偏见的!更多人使用 Rust 对我的业务是有利的。但我也在专业领域中使用过这两门语言,并曾将 Go 服务推向生产环境。

本指南适用于那些希望诚实对比迁移到 Rust 时会有什么变化的 Go 开发者。

如果想看一个故意持相反立场的观点,我推荐阅读 Blain Smith 的《就用 Go语言好了,别他妈的废话了!》(Just Fucking Use Go)。在脑海中同时保留这两种观点,比只持其中一种更有用。

如果你更喜欢观看视频而不是阅读,这里有一段来自 The Primeagen 对上述 Shuttle 文章的视频阅读和点评:

(视频:Rust vs Go: Hands On Comparison)

初看最重要的命令

Go 开发者已经拥有了行业内最干净的工具链之一。在很久以前,它就开启了“自带电池(batteries included)”式工具链的潮流,为你提供了一个单一、一致的界面,用于构建、测试、格式化、lint 和管理依赖项。我很高兴 Rust 也效仿了这种做法,因为这是一个极好的模式。这是我最喜欢的这两个生态系统的部分之一。

cargo 甚至拥有更多内置功能:

最大的区别在于,在 Go 中你通常需要借助第三方工具(golangci-lint、mockgen、air、goreleaser)来填补空白。而在 Rust 中,原生(第一方)生态系统开箱即用的功能要丰富得多。有些需要外部 crate 的工具(例如 cargo watch、cargo nextest)只需一个命令即可完成安装并开始使用,例如运行 cargo install cargo-nextest 即可立即获得 cargo nextest。

两个社区在格式化工具上都达成了相同的共识:一个单一的、规范的风格,即使不是完美的,也远比在琐碎的争论(bikeshedding)上浪费时间要好。

“Gofmt 的风格不是任何人的最爱,但 gofmt 却是每个人的最爱。”

— Rob Pike, Go Proverbs

对于 rustfmt 也是如此;并非每个人都喜欢它的每个细节,但代码评审中不再存在关于代码风格的争端,远比偶尔遇到你不喜欢的格式化偏好要有价值得多。

Go 与 Rust 的关键差异

核心结论是,Go 和 Rust 都是编译型、静态类型、单二进制文件部署、具有强大并发能力的语言。不同之处在于编译器向你保证了什么,以及你对运行时行为拥有多少控制力

在深入探讨之前,有一个概念框架很有帮助:当你从 Go 迁移到 Rust 时,大部分变化都会被推入类型系统。 空值处理、错误传播、数据竞争、资源生命周期、取消机制、泛型,这些在 Go 中要么依赖运行时规范、工具链(go vet、errcheck、golangci-lint、-race),要么依赖运行时的自觉性。而 Rust 则将它们编码为类型,以便编译器在编译时强制执行。

常见的反对意见是这带来了“更多的认知负荷”。我不认同这种说法。我认为,这其实是将认知负荷从你由于必须记住规则而产生的焦虑中释放出来,转移到了编译器身上。一旦你内化了这种模式,并发现它在代码中无处不在(Option、Result、&mut T、Send/Sync、RAII 守卫),Rust 就会停止让你感到沉重,并开始感觉编译器正在为你做你以前必须在大脑中做的工作。

为什么 Go 开发者会考虑 Rust

Go 开发者通常不会因为 Go “太慢”而转向 Rust。对于大多数后端工作负载,Go 已经足够快了。人们普遍是对 Go 的一些由于设计不严密而产生的问题感到沮丧:nil 指针带来的隐患、段错误(segmentation faults)的风险、缺乏泛型(长期以来)或任何更复杂的类型系统特性(如枚举和强大的 trait),以及标准库中存在一些怪异的缺失,例如缺少一个内置的 Set 类型(惯用的替代方案是 map[T]struct{},它在实践中行得通,但感觉类型系统并没有真正起到作用)。

生产环境中的 nil panics

你部署了一个 Go 服务,它运行得很好,持续了几个月。然后,某条代码路径被执行,而其中有人忘记检查某个指针是否为 nil,导致 goroutine 崩溃。一个常见的例子是查找操作,它返回零值,或者反序列化后未填充结构体中的某个指针字段:

func (s *Service) Handle(req *Request) error {
    // Find 返回 (*User, error)。如果是 "not found",error 为 nil;
    // 调用者应该检查 user != nil,但这非常容易被遗漏。
    user, err := s.repo.Find(req.UserID)
    if err != nil {
        return err
    }

    return user.Account.Notify() // 如果 user 为 nil,或 Account 为 nil,则会发生崩溃
}

Linter 和 IDE 会捕获其中一些情况(通过 nilaway、staticcheck),但它们是选择性开启的、概率性的,而且不能可靠地跨越包边界。Rust 的编译器则根本不允许你忽略这种情况。Rust 的 Option 可以做到:

fn handle(&self, req: &Request) -> Result<(), ServiceError> {
    let user = self.repo.find(req.user_id)?; // 返回 Option<User>; ? 运算符进行短路处理
    user.notify()
}

如果没有显式处理 None 的情况,你甚至无法解引用一个 Option。一整类导致 pager-duty(线上紧急警报)事件的事故就这样消失了。

-race 未能捕获的数据竞争

go test -race 是一个优秀的工具,但它是一个运行时检测器,意味着它只能找到测试中实际执行到的竞争。在线上高负载下,多个 goroutine 在没有锁的情况下修改同一个 map 会轻松绕过该测试,并导致生产环境崩溃。

在 Rust 中,跨线程共享可变状态需要实现 Send 和 Sync。尝试共享一个普通的 HashMap 并且程序甚至无法编译。你被迫将其封装在 Arc<Mutex<…>> 或 Arc<RwLock<…>> 中,否则编译器会报错。这样,数据竞争在编译时就成了一个类型错误。

Paul Dix 对于什么促使了 InfluxDB 3.0 的重写非常坦诚,而数据竞争的故事就排在最前面:

“【最主要的好处是】无畏并发——消除了此前我们从未消除的数据竞争。在 Influx 1.x 版本中,确实存在一些非常棘手的 bug。”

— Paul Dix, InfluxData 创始人兼 CTO,摘自 Rust in Production

可组合的错误处理

在 Go 中,你会写:

if err != nil {
    return err
}

在一两年的开发后,你通常会注意到三件事:

  1. 样板代码冲淡了你函数的实际业务逻辑。
  2. 使用 fmt.Errorf(“doing X: %w”, err) 包装错误是一项纪律要求,而不是编译器强制的规则。这很容易丢失上下文。
  3. 通过 errors.Is/errors.As 使用哨兵错误可以工作,但当你忘记处理新变体时,编译器不会提醒你。

对反方观点保持诚实也很重要,因为在关于我的 Shuttle 文章的 Lobste.rs 讨论线程中,经验丰富的 Go 开发者指出,errcheck 和 golangci-lint 捕获了绝大多数“忘记处理错误”的情况,并且显式的 if err != nil 比深层嵌套的 ? 链更容易阅读。这两个观点都很中肯,显式风格是一个刻意的文化抉择,而不是一次疏忽:

“我认为错误处理应该是显式的,这应该是该语言的核心价值。”

— Peter Bourgon, GoTime #91,引用自 Dave Cheney 的 Zen of Go

我的看法是,lint 是一个你必须记住去配置的选择性安全网,而 Rust 的 Result<T, E> 是类型签名本身,无法被遗忘。样板代码与可读性之间的折中是非常真实且见仁见智的。

在 Rust 中:

#[derive(Debug, thiserror::Error)]
pub enum UserError {
    #[error("user {0} not found")]
    NotFound(UserId),
    #[error("user already exists")]
    AlreadyExists,
    #[error(transparent)]
    Repo(#[from] RepoError),
}

pub fn rename(id: UserId, name: &str) -> Result<User, UserError> {
    let mut user = repo::get(id)?; // ? 自动将 RepoError 转换为 UserError
    user.name = name.to_string();
    Ok(user)
}

? 运算符处理了错误传播,#[from] 处理了类型转换,而针对 UserError 的 match 是穷尽检查的。如果明天你添加一个新的错误变体,编译器会向你展示每一个需要更新的地方。

不装箱的泛型

Go 在 1.18 中引入了泛型,它们很有用,但实现上有一些限制(不支持类型参数上的方法GC shape stenciling,偶尔会有令人失望的性能表现)。Rust 泛型采用单态化(monomorphize),为每个实例生成具有零运行时开销的专门代码。结合 trait,这为你提供了真正的零成本抽象。

这在处理程序(handler)代码中不那么重要,而在共享基础设施(中间件、通用存储库、解码器、解析器)中更重要,在 Go 中,你常常被迫退回到 interface{} / any 外加类型断言。

可预测的延迟

Go 的 GC 非常优秀、并发、低停顿,针对典型的服务工作负载进行了很好的调优。但“低停顿”不等于“无停顿”。在重载情况下,P99 延迟尾部明显差于一个不在热路径上分配内存的 Rust 等效程序。

我不会过分夸大这一点,对于绝大多数服务来说,Go 的 GC 根本不是问题。但对于延迟敏感的系统(交易、实时竞价、网络代理、高吞吐量数据摄入),没有 GC 停顿是一个巨大的卖点。Stephen Blum 把它说得很直接:

“Go 在我们的规模下表现很好,但我们确实需要一些能给我们带来高性价比性能的东西,而 Rust 能够让我们达到那个目标。这就是为什么如今基本上所有的东西都在朝着 Rust 发展的原因。”

— Stephen Blum, PubNub CTO, 摘自 Rust in Production


总结

Go 像是遭受了千刀万剐(death by a thousand paper cuts)。它是一门非常实用的语言,如果你愿意忽略上述问题,你可以在其中获得极高的生产力。但在达到一定的代码规模后,问题就会开始累积。Go 失去吸引力并没有单一的瞬间,但团队会发现自己渴望更多(更多的安全性、更多的控制、更多的表现力),这就是他们开始寻找替代方案的时候。


Side By Side的对比两种语言

在 Rust 中感到舒适的最快方法是映射你已经知道的模式。如果要看在两种语言中构建相同后端服务的更长、包含大量代码的完整示例,请参阅 Shuttle 对比文章,本节重点介绍最常出现的模式。

错误处理:if err != nil 对比 Result<T, E>

Go:

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }

    return &cfg, nil
}

Rust:

fn read_config(path: &Path) -> Result<Config, ConfigError> {
    let data = fs::read_to_string(path)?;
    let cfg = serde_json::from_str(&data)?;
    Ok(cfg)
}

? 运算符替你完成了 if err != nil { return err } 的繁琐工作,如果为 E2 实现了 From,它还会进行类型转换(这在使用 thiserror 的 #[from] 时是惯用)。

空值:nil 对比 Option

Go:

func GetUser(id string) *User {
    for _, u := range users {
        if u.ID == id {
            return &u
        }
    }
    return nil
}

u := GetUser("123")
fmt.Println(u.Name) // 如果u 为 nil 则会发生panic

Rust:

let user = get_user("123");
println!("{}", user.name); // 编译错误:user 的类型是 Option<User>,而不是 User

// 你必须处理这两种情况:
match get_user("123") {
    Some(u) => println!("{}", u.name),
    None => println!("not found"),
}

在安全的 Rust 中没有 nil。引用不能是空的。指针可以是空的,但你几乎永远不会在应用程序代码中使用裸指针。

接口 对比 Traits

Go 的接口是结构化的,一个类型隐式地满足一个接口:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Rust 的 trait 是标称的,你需要显式地实现它们:

pub trait Reader {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
}

impl Reader for MyType {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { /* ... */ }
}

Go 的风格非常适合临时性的鸭子类型。Rust 的风格非常适合重构和可发现性,你可以用 grep 搜索某个 trait 的每个实现者。

Rust 中与 interface{} / any 最接近的等价物是 Box,但你几乎永远不会想要它。Go 社区习惯于伸手去拿 interface{},也是因为:

“interface{} 什么也没表达。”

— Rob Pike, Go Proverbs

带有 trait 约束的泛型函数(fn handle(r: R))涵盖了绝大多数情况,并通过单态化提供无运行时开销。在 Go 1.18 之前,这迫使你退回到 interface{} 加上类型断言,而 Rust 的 trait + 泛型让你能够非常具体。

当你确实需要运行时分发(例如,不同实现者的异构存储)时,你会选择 Box 或 Arc。这是 Go 中持有 interface 值最直接的 Rust 对应物。

Goroutines 对比 异步任务

Go 的并发模型以简单著称:

go doWork(ctx, input)

Goroutine 很廉价,运行时会在操作系统线程之间调度它们,而通道(chan T)是主要的协同原语。Go 谚语捕获了这一理念:

“不要通过共享内存来通信;而要通过通信来共享内存。”

— Rob Pike, Go Proverbs

这是 Go 真正大放异彩的地方,并且它对为什么非常明确:在 Go 中,顺序代码和并行代码之间没有语法上的区别。函数签名、它的调用者,或关于它如何编写的任何内容都毫无二致。没有 async fn,没有 .await,没有执行器可供选择,也没有 Send / Sync 约束。只要你不共享可变状态而不进行同步,顺序代码和并发代码看起来是一样的。

这种属性,即没有函数着色(function colouring),是 Go 相比 Rust 最大的日常生产力优势,而在迁移之后,这也是 Go 开发者最怀念的东西。Lobste.rs 讨论中的几位评论者准确地指出了这一点,他们说得很对。Rust 的 async 更加强大且经过更多检查,但它的显式度也更高,这带来了真正的开发体验成本。

Rust 在执行器(对于后端服务几乎总是 tokio)之上使用 async/await:

tokio::spawn(async move {
    do_work(input).await;
});

形式很相似。不同之处在于:

  • Rust 的异步函数返回 Future。除非被 .await 或 spawn,否则它们不会运行。
  • 编译器会跨 .await 点验证 Send/Sync 约束。如果你在跨 .await 期间持有一个非 Send 的值,你会得到一个非常精确的编译器错误,解释其原因。
  • 没有内置的 goroutine 风格的抢占。异步任务中长时间运行的 CPU 工作会使执行器饥饿;你需要将其卸载到 tokio::task::spawn_blocking 或 rayon。
  • 通道(tokio::sync::mpsc、broadcast、watch)是一流的,但存在于库中,而不是语言本身。

对于大多数后端代码,日常体验是类似的:启动一个任务,通过通道进行通信,并大方地使用超时。

context.Context 对比 CancellationToken

在 Go 中,你将 context.Context 传给每个阻塞调用:

func (s *Service) Fetch(ctx context.Context, id string) (*User, error) {
    return s.client.Get(ctx, "/users/"+id)
}

Rust 没有内置的 context.Context。最接近取消的等价物是 tokio_util::sync::CancellationToken:

pub async fn fetch(&self, token: CancellationToken, id: &str) -> Result<User, FetchError> {
    tokio::select! {
        _ = token.cancelled() => Err(FetchError::Cancelled),
        res = self.client.get(&format!("/users/{}", id)) => res,
    }
}

对于超时,tokio::time::timeout(dur, fut) 可以包装任何 future。对于截止时间/值,你通常将它们作为显式参数传递,或者使用 tracing span 而不是单一的上下文对象。

一些 Go 开发者怀念 ctx 的隐式感。但在实践中,显式的 Rust 风格更容易让人推断,因为你总是确切地知道什么是可以取消的,什么是不可以的。更深层次的观点是,没有任何一种语言可以免费给你取消机制,只是规约出现在不同的层面上:

“Go 并没有办法告诉一个 goroutine 退出。没有停止或杀死函数,这是出于充分的理由。如果我们不能命令一个 goroutine 退出,那么我们就必须礼貌地请求它。”

— Dave Cheney, The Zen of Go

在 Go 中,这种“礼貌地请求”是通过约定俗成地在每个调用点传递并检查 context.Context。在 Rust 中,则是 CancellationToken(或 watch 通道)传给每个调用点,但编译器实际上可以在你忘记时提醒你。

通道

两种语言都有通道。翻译很直接:

Go:

ch := make(chan int, 10)
go func() {
    ch <- 42
}()
v := <-ch

Rust:

let (tx, mut rx) = tokio::sync::mpsc::channel::<i32>(10);
tokio::spawn(async move {
    tx.send(42).await.unwrap();
});
let v = rx.recv().await.unwrap();

Rust 的通道将发送端(Sender)和接收端(Receiver)区分为不同的类型,这使得所有权和 Send 属性在类型层面是显式的。

结构体与方法

Go:

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

Rust:

pub struct Circle {
    pub radius: f64,
}

impl Circle {
    pub fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

Rust 的 &self 相当于 Go 的值接收者;&mut self 是一个带有修改权限的指针接收者。拥有的 self(消耗该值)在 Go 中没有对应物,但在(类型状态、构建器)模式中偶尔非常有用。

字符串:string 对比 String 与 &str

Go 的 string 是一个具有赋值时拷贝语义的 UTF-8 字节切片(头部被复制,底层数据是不可变且共享的)。Rust 将其分为两种类型:

  • String:拥有的、堆分配的、可增长的。相当于你打算修改的 []byte。
  • &str:借用的视图,指向别人的字符串数据。大部分时间相当于作为 Go 的 string 参数使用。

作为一条经验法则,参数中接收 &str,在生成新数据时返回 String。

fn greet(name: &str) -> String {
    format!("Hello, {name}")
}

一旦你内化了这一点,这基本上是无痛的。&str 与 String 的划分是 Rust 更广泛的“借用与拥有”模型的一个缩影。

Go 泛型:太少,太迟

Go 在 1.18(2022 年 3 月)引入了泛型,在语言出货十三年之后。它们很有用,但由于它们是后期补丁(tacked on),在实践中它们具有大多数你期望从 Rust、Haskell 甚至现代 C++ 获得的泛型系统的缺点,却没有任何优点

这是一个很强烈的说法,所以让我来支持它。

标准库几乎不使用它们

最明显的信号是,在泛型落地三年后,Go 自己的标准库仍然主要避免使用它们。sort.Slice 仍然接受一个 func(i, j int) bool 闭包,而不是 cmp.Ordered 约束。sync.Map 仍然被类型化为 any / any。除了 slices、maps 和少数组件外,几乎没有它们的身影。

公平地指出,向后兼容性是这里的主要原因:Go 1 的兼容性承诺意味着现有的非泛型 API 无法重构,因此任何泛型版本都必须与其并存(或在新的包中)。但这只是解释的一部分。已经有足够的时间来引入泛型替代方案,而几乎没有出现这一事实表明语言设计者并不倾向于将泛型作为他们使用的主要工具。

将其与 Rust 进行对比,在 Rust 中,泛型从第一天起就渗透到了标准库中:Option、Result<T, E>、Vec、HashMap<K, V>、Iterator、From、Into、AsRef、Borrow,每个集合、每个智能指针。在不使用泛型的情况下,你根本无法写出惯用的 Rust,因为标准库本身就是泛型的。

在 Go 中,泛型是库作者在确实需要时才选择使用的功能。在 Rust 中,它们是构建一切事物的底层基石。

没有 Trait 系统,只有结构化约束

Rust 的泛型与 trait 绑定,trait 兼作该语言进行多态、超类、关联类型、毯子实现(blanket impls)和一致性的机制。

Go 的约束只是带有一个额外 ~ 运算符的接口,用于类型集成员资格。这里没有:

  • 超类 / 约束继承体系: 在 Rust 中,你写 trait Ord: Eq + PartialOrd,任何满足 T: Ord 的类型自动满足 Eq 和 PartialOrd。Go 没有等价物;你可以嵌入接口,但约束求解器并不推断关于层次结构的任何信息。
  • 关联类型: Rust 的 Iterator 有 type Item;,因此 T::Item 是第一等公民,这体现在每个方法的签名中。Go 最接近的等价物是第二个类型参数,这会泄露到每个方法签名中。
  • 毯子实现(Blanket impls): 在 Rust 中,impl ToString for T 会自动为每一个实现了 Display 的类型实现 ToString 方法。在 Go 中,没有办法在定义包之外,为一个类型添加方法。
  • 拥有自己类型参数的方法: 这是一个显式且有文档记录的 Go 缺失功能 (译注:Go 1.27将补全泛型方法这一特性)。你不能写 func (s Set[T]) Map[U](f func(T) U) Set[U]。在 Rust 中,泛型方法是家常便饭。

实际的后果是,当你的抽象需要不仅仅是一个“适用于任何 T 的函数外加这几个操作”时,Go 就会迫使你退回到 any 以及类型断言、代码生成或运行时反射。

类型推导止于函数边界

Rust 使用 Hindley-Milner 风格的推导引擎,可以跨整个表达式传播类型信息,包括跨闭包、迭代器链和 ? 运算符。你经常写:

let evens: Vec<_> = (0..100).filter(|n| n % 2 == 0).collect();

而编译器会推断出 _ 是 i32,而 Vec<_> 目标是 Vec

Go 的推导要浅得多。它通常可以推断出函数参数的类型,但它不能从返回位置上下文中推断,不能通过泛型构建器跨链推断,并且经常在调用处强制使用显式的类型参数:

result := slices.Collect[int](iter) // 经常需要

在 Rust 中这是例外;在 Go 中这仍然很常见。

单态化 对比 GC Shape Stenciling

泛型没有免费的午餐:你必须要么在编译时买单,要么在运行时买单,要么通过代码膨胀(JIT)买单。C++ 和 Rust 在编译时通过单态化买单。Java 在运行时通过装箱买单。Go 选择了折中路线,采用了 GC 形状模板和字典,这有一篇众所周知的 PlanetScale 文章正好展示了这一点。

Rust 进行单态化:每个 Vec 和 Vec 都会产生专门的机器代码,具有零运行时开销。泛型代码是快速路径,而退回到 dyn Trait(相当于 Go 的接口分发)是一个深思熟虑的选择,在你需要运行时多态时做出。你要为单态化付出编译时间的代价,这和 C++ 几十年来付出的代价一样,但它们只是针对不同的事情进行了优化。

它们没有填补类型系统中的漏洞

这是最让我困扰的部分。

一个好的泛型系统可以消除退回到逃生舱口的理由。在 Rust 中,泛型 + trait 消除了你对 Box 或运行时反射的大部分需求。类型系统变得更强大了。

在 Go 中,泛型并没有消除 any,没有消除 reflect,没有消除代码生成作为诸如 ORM、解码器和 mock 等事物的首选模式。encoding/json 仍在使用反射。database/sql 仍在使用 any。mockgen 仍会生成代码。如果泛型系统能够大放异彩,最应该发挥作用的地方,正是 Go 在 1.18 之前就伸手去拿运行时机制的那些地方。

Go 中的泛型感觉是累加的,只是箱子里的一个新工具,在狭隘的案例中很有用。Rust 中的泛型感觉是基石般的;将它们移去,语言就会崩溃。

这就是区别所在,也是为什么在我的经验中,泛型 Go 代码读起来并不比它取代的基于 interface{} 的代码好;它只是读法不同,有更多标点符号罢了。

流行的 Go 包及其 Rust 对应物

如果你已经在 Go 中有了自己的偏好,Rust 生态系统已经趋于相似级别的“默认选择”。对于一个典型的后端服务:axum + sqlx + tokio + tracing + serde + clap 覆盖了你 90% 的需求。

过渡到 Rust 的关键挑战

我想坦率地说。从 Go 过来,你将会碰壁。这堵墙有一个名字。

借用检查器

Go 的运行时替你处理内存和别名。Rust 将这个决定推入类型系统。前几个星期你会写出“显然应该工作”的代码,然后编译器会拒绝它。

最常困扰 Go 开发者的模式有:

  1. 长生命周期引用: 在 Go 中,你可以很开心地在 map 中持有一个 *User,只要你愿意。在 Rust 中,该借用会在整个生命周期中锁住 map。解决方案通常是克隆(clone),或者缩小借用范围。
  2. 自引用结构体: 在 Go 中很常见(一个结构体同时持有数据和其上的迭代器)。在 Rust 中,这需要 Pin、ouroboros 或重新设计。几乎总是选择:重新设计。
  3. 跨 goroutine 共享可变状态: 在 Go 中你写成:mu sync.Mutex; data map[K]V,而在 Rust 中则变成 Arc<Mutex<HashMap<K, V>>>。稍微啰嗦一些,但经过了更多检查。
  4. 从函数返回引用: 生命周期标注(Lifetime annotations)就此出现。它们并不像其声誉那样糟糕,但对新手来说确实很陌生。

在所有的这些规则下,借用检查器确实听起来像一个“守门人”,不断阻碍,并且让人感到沮丧。但是,当你开始使用 Rust 时,不应该带着那样的心态。借用检查器真正揭示了你代码中现有的非常真实、非常微妙的 bug,如果你不解决它们,你的程序就会存在安全问题。因此,每当你从 rustc 得到编译器错误时,请退后一步,问自己以下几个问题:

  • 如果一个值被移动(moved)了,之后如果原位置试图再次使用它会发生什么?
  • 如果一个值被共享(shared)了,如果在另一个线程使用它的同时,有一个线程对其进行了修改会发生什么?
  • 如果一个指针被解引用(dereferenced),如果它是空值或悬空指针会发生什么?
  • 当一个值超出作用域(goes out of scope)时,如果其他地方仍然持有的引用正在被使用会发生什么?

这就是你需要理解借用检查器的心态。人类在推理内存方面真的很糟糕。我们很容易忘记指针可以为空,忘记旧的引用可以比它们指向的数据存活得更久,忘记多个线程可以同时修改同一块数据。我们倾向于对数据在程序中如何流动有一个“线性”的心理模型,但现实中它更接近于一个具有多条路径和交互的复杂图形。每一个 if 条件都会强制你考虑这两种分支中会发生什么。这正是借用检查器旨在为你做的事情!它强制考虑那些极其罕见但确实存在的、当你觉得可能不会发生但就是发生了的代码路径。

借用检查器其实是一个巨大的解脱。一旦它通过了,你就知道你的内存状态是 100% 连贯的,你可以专注于更高层次的问题。这也就是 Ed Page(clap 的维护者)说的:

“当你们刚开始接触它时:会感到沮丧。它让我想起了第一次学习编程的感觉,因为它太不一样了。由于借用检查器和生命周期,我不想去处理那些东西——但我被迫去了。”

— Stephen Blum, CTO, PubNub, 摘自 Rustacean Station

“……能够专注于更高层次的问题。在我进行自我分析并失败时,它帮助我发现了问题。”

— Ed Page, 摘自 Rustacean Station: clap with Ed Page

编译时间

对你的团队保持诚实,Rust 的编译时间相比 Go 的近乎瞬时的编译确实是一个退步。对于中等规模的服务,全新发布构建可能需要几分钟。增量构建和 cargo check 是合理的,并且编译时间在这些年里已经好了很多,但你仍然会感觉到差异。

为了缓解这种情况,在你的编辑循环中使用 cargo check,在项目见效后将其拆分进 workspace 中,并让你自己的 crate 中不要包含过程宏(proc-macro-heavy)重度依赖,这样它们就只在发生变化时才重新编译。请参阅《加速 Rust 编译时间的技巧》以进行更深入的探讨。

异步着色

正如《Goroutine 对比 异步任务》中所讨论的,Rust 的 async fn / fn 拆分是从 Go 迁移过来时最大的开发体验退步之一。异步 trait 自 Rust 1.75 以来已经稳定,但在将它们与动态分发结合时,仍然存在一些粗糙的边缘,你偶尔需要借助 async-trait crate 来解决。

某些细分领域中生态系统较小

Rust 的 crate 生态系统正在增长,并且库在整体上具有很高的质量,但 Go 在一些后端相邻领域具有领先优势:Kubernetes operator、云提供商 SDK、某些特定生态系统的数据库驱动。在做出承诺之前,请花一天时间检查你依赖的库是否具有你愿意使用的 Rust 对应物。我协助的团队经常不得不自己动手实现至少一两个核心库——例如,他们可能需要更新一个废弃的 XML 架构验证 crate,或为较少人知的协议编写自己的客户端。

集成策略

你不需要一次性重写所有内容。我听到的每一个成功的 Go 到 Rust 迁移案例都是战术性的,而不是大爆炸式的重写。Microsoft 的 Victor Ciura 总结得很到位:

“我们并不是疯狂地到处为了好玩而用 Rust 重写一切。我们在做出这些战术性选择,我们会说:好的,这个新组件,如果我们用 Rust 编写会更好。”

— Victor Ciura, 首席工程师, Microsoft, 摘自 Rust in Production

最有效的策略,按照我通常推荐的顺序如下:

1. 将“开辟热门路径”作为一种服务来提供

如果你的系统中某个特定服务一直存在各种问题(比如高 CPU 使用率、对延迟敏感,或者经常出现可靠性问题),那么你可以只用 Rust 重新编写这个服务,同时保持与原有 API 的兼容性。这是风险最低的迁移方式。其他用 Go 编写的服务仍然可以通过 HTTP/gRPC 与这个服务进行交互,而无需关心其底层编程语言是什么。Radar 公司的 Jeff Kao 指出,Discord 上的那些成功案例往往能激发团队尝试这种迁移方式的勇气。

如果你在 Hacker News 上搜索“迁移到 Rust”,第一个搜索结果一定是关于 Discord 从 Go 语言切换到 Rust 的报道。这一消息激励了我们,让我们也想看看自己是否也能做到同样的事情。
——Radar 公司的首席技术官 Jeff Kao 谈 Rust 在实际生产环境中的应用

2. 更换 Sidecar/Worker 进程

后台任务、队列消费者、数据摄取管道以及那些依赖 CPU 处理的批量作业,都是绝佳的优化目标。这些任务通常具有明确的输入/输出边界(比如队列或主题),且不会与系统的其他部分共享任何状态信息。

3. 使用 cgo 是可行的,但过程相当繁琐/麻烦

可以通过 cgo 在 Go 语言中调用 Rust 代码,关于如何操作的详细指南也很容易找到。(如果你需要我提供相关的指南,请随时联系我。)不过,实际上我并不推荐将 Rust 用于后端服务。与“直接创建一个 Rust 服务并将其置于网络调用之后”相比,其构建的复杂性以及 FFI 相关的开销通常会超过其带来的好处。不过,对于库和 CLI 工具来说,使用 Rust 则更为合适。

4. 网关背后的“绞杀者”模式

如果你使用了 API 网关或反向代理,就可以将特定的端点指向新的 Rust 服务,而其余部分则继续使用 Go 语言来实现。当某个特定的业务领域(如身份验证、搜索、计费)适合被迁移时,这种做法尤为有效。这种模式通常被称为“绞杀者模式”:新服务会逐渐取代旧服务,最终完全取代它。

实用的迁移技巧

  • 从一个边界清晰的服务开始。 不要选择你机群中最核心、部署最多的服务。挑一个与其他系统的契约定义清晰且影响范围较小的服务。
  • 保持相同的 API 契约。 如果你的 Go 服务暴露了 REST API,你的 Rust 服务也应该如此:相同的路径、相同的 JSON 格式、相同的错误响应。这样迁移对客户端是透明的,你可以通过网关安全地切换流量。
  • 不要逐字翻译习语。 克制住写“Go 风格 Rust”的冲动。将 if err != nil { return err } 转换为 ?。将 goroutine-per-request 转换为 tokio::spawn。只在真正需要时(axum 会并发地为你处理请求)才使用它们。带有单一方法的接口通常在 Rust 中表现为泛型约束,而不是 Box
  • 将编译器作为结对程序员。 Rust 的编译器错误通常非常有帮助。仔细阅读它们。它们几乎总会告诉你正确的答案。挣扎最久的团队成员通常是将编译器视为敌人而不是合作者的那些人。
  • 尽早投资于培训。 我经常看到团队试图通过“边做边学”来进行 Rust 迁移。这很少有好的结果。这有点像通过直接去跑马拉松并试图在跑的过程中摸索来为马拉松训练。你可以做到,但这将是极其痛苦的,而且你可能无法坚持到终点。为学习留出一些不被打扰的时间:一场研讨会,一个在线课程,以及在真实代码上进行结对。前期投入在团队流利掌握后会数倍地回报。(顺便说一下,如果你想讨论培训方案,我很乐意聊聊。)

保持 Go 语言的优势所在

并非所有东西都需要被迁移。Go 语言在以下方面表现优异:

  • Kubernetes 原生工具:Operator、controllers、CRD。该生态系统几乎完全由 Go 语言构建而成。
  • CLI 工具和开发工具:编译速度快、跨平台编译简单、部署便捷。
  • 胶水层服务:包括薄的 API 层、代理(proxy)服务器以及格式转换器。在 Rust 中,编写这些重复性的代码并不值得。
  • 在任何情况下,团队的工作效率都比追求绝对的准确性更为重要。

这并非什么小众职位。对于一家能够大规模提供这两种语言服务的公司来说,这一职位的设立显然意味著更重要的意义:

Go 语言是构建网络服务的绝佳选择。在 Canonical 公司,我们大量使用 Go 语言来开发软件——Juju 就是一个由 Go 语言编写的庞大软件项目。
——Canonical 公司工程部副总裁 Jon Seager 谈 Rust 在现实生产环境中的应用

混合策略其实很不错,也很常见。与我合作的许多团队都会采用这种策略:对于那些“没什么特别要求”的服务,使用 Go 语言来开发;而对于那些需要确保可靠性和性能的服务,则使用 Rust 语言来开发。

预期的改进/有望取得的提升

根据工作量的不同,具体数字会有很大差异,因此这些数据仅供参考而已。请不要把它们当作绝对的承诺!不过,以下是我在协助进行从 Go 语言到 Rust 语言的迁移过程中所得到的一些大致数据:

  • CPU 使用率:降低了 20%到 60%。这一效果不如将代码从 Python 转换为 Rust 时那么显著,因为 Go 本身的效率就已经很高了。其优势主要体现在无需进行垃圾回收,以及代码循环的效率更高。
  • 内存占用:减少了 30%到 50%,这主要得益于无需进行垃圾回收操作,以及运行时的开销更低。
  • P99 延迟方面:Rust 服务的稳定性明显更高。Go 服务则容易出现由垃圾回收引起的延迟波动。不过,自从 Go 语言引入了低延迟垃圾回收机制后,这种情况已经有所改善,但在高负载情况下,两者之间的差异依然存在。
  • 生产环境中的问题:这是各团队最乐于报告的问题类型。那些在测试阶段被发现,但最终还是进入了生产环境的错误类型(如数据竞争、空指针引用、错误处理路径被遗漏等),在 Rust 中根本无法编译通过。在从其他语言切换到 Rust 之后,处理这些问题的过程通常相当繁琐。Andrew Lamb 在 InfluxDB 的重写过程中也详细描述了这种现象。

“我不需要去追踪崩溃,或者某些奇怪的多线程竞争条件,或者其他那些实际上消耗了我之前大部分时间的事情。”

— Andrew Lamb, 软件工程师, InfluxData, 摘自 Rustacean Station: Rebuilding InfluxDB with Rust

说实话,与从 Python 转向 Rust 相比,从 Go 转向 Rust 后,很难实现 10 倍的性能提升。不过,你确实能减少“愚蠢的错误”,降低延迟,同时还能继续使用同一种语言来开发嵌入式系统或进行系统编程。这往往是代码迁移带来的最令人惊喜的副作用:那些原本需要使用不同编程语言的团队,现在可以共享代码了。Rust 几乎可以用于所有类型的开发场景。

结论

从 Go 迁移到 Rust 是与从 Python 或 TypeScript 迁移完全不同的一种类型。从 Go 过来,你深知静态类型、编译型语言的好处。所以你并不是在用动态类型或缓慢的运行时去交易。你是在交易 nil,换来一个漏洞更少、更健壮的代码库、更严格的编译器(可在编译时捕获更多错误)。不过,这里有一条更陡峭的学习曲线。

对于基础服务(你的组织所依赖的、需要极高可靠性、对你的业务至关重要的服务),这个迁移方式显然是值得的。对于其他服务,Go 仍然是正确的答案。迁移的目的是在最适合的语言中解决对应的问题。

准备好迈向 Rust 了吗?

我协助后端团队评估、规划并执行 Go 到 Rust 的迁移。无论你需要架构评审、培训,还是协助将关键服务进行移植,让我们聊聊你的需求吧。

原文正文到此为止!

社区深度观点

Matthias 的这篇文章在 Hacker News 上也引发了热烈的辩论。支持者、怀疑者、以及拥有多年双语言实战经验的系统架构师们纷纷下场,就 Go 与 Rust 的工业级博弈分享了大量第一手观点。我对其中的核心争议与洞察进行了系统性汇总:

1. 核心分水岭:你是否需要一个“托管运行时(Managed Runtime)”?

在 HN 的讨论中,社区普遍赞同的一个终极共识是:Go 与 Rust 的选择,90% 程度上取决于你是否想要一个托管运行时(垃圾回收,GC)。

  • Go 拥护者认为:世界上 95% 的应用都是普通的商业业务系统(LOB)。在这类场景下,Go 拥有世界上最优秀的并发 GC。它的高并发开销极小,虽然在 P99 停顿指标上存在微弱的抖动(Jitter),但对于绝大多数企业级 Web 后端而言,这完全可以忽略不计。
  • Rust 拥护者反驳:GC 不仅带来时延抖动,更重要的是它占用了额外的内存(通常需要 30%-50% 的额外物理内存作为缓冲来减少 GC 频率)。在超大规模云原生部署中,Rust 消除 GC 后带来的物理内存节省,可以直接转变为服务器账单上极具说服力的“降本增效”数字。

2. 编译速度与迭代效率的残酷现实

编译速度是 Go 阵营攻击 Rust 最锋利的武器之一。

  • Go 的快:Go 从设计之初就将编译速度作为核心优先级(由汇编器和简化的类型系统支撑)。在开发中,修改代码到重新运行几乎是“即时”发生的,这带来了极佳的开发体验和迭代速度。
  • Rust 的痛:由于采用了复杂的宏系统(Macros)和深度的单态化(Monomorphization)编译期展开,即使是增量编译,Rust 在大型项目中的等待时间依然可能长达数分钟。多位开发者抱怨:“在使用 AI 辅助编程或高频调试时,Rust 漫长的编译等待时间严重降低了开发者的心智流畅度。”

3. 错误处理理念的终极碰撞

在错误处理上,两个阵营各执一词,表现出截然不同的“开发文化”:

  • Go 的显式哲学:Go 拥护者(包括知名技术领袖 Peter Bourgon)强调,错误处理应当是显式的,这应该作为语言的核心价值观。 尽管 if err != nil 冗长,但它逼迫你在每一行可能出错的代码旁停下来,思考当前上下文的应对策略,而不是用一个抽象的 ? 闭着眼睛把错误向上抛出。
  • Rust 的类型保障:Rust 拥护者则认为,Go 的显式是一种“依靠肉体纪律维持的低效工程学”。一旦团队规模扩大,总有人会遗漏处理。而 Rust 将错误融入 Result<T, E> 类型签名,由编译器在底层进行穷尽性校验(Exhaustive checks),在代码简洁度(使用 ?)与安全性(不漏掉任何一种分支)之间找到了近乎完美的工程平衡。

4. 生态系统的对比:标准库(Batteries-Included)与模块化 Crates 依赖

开发者对两门语言的第三方生态设计表现出了明显的温度差:

  • Go 的稳定:Go 拥护者非常自豪于 Go 极其庞大且强大的核心标准库。你不需要引入任何第三方库,就能用纯标准库写出高可用的 HTTP 服务器、加解密引擎和网络代理。这避免了类似 Node.js 社区的“Dependency Hell(依赖地狱)”和安全供应链攻击风险。
  • Rust 的模块化:Rust 的标准库非常克制,甚至连异步运行时(tokio)、序列化(serde)和命令行解析(clap)都是第三方包。一些 Go 开发者迁往 Rust 后表达了这种不适:“在 Rust 里,写个简单的后台服务,一不小心就引入了上百个第三方 Crates,这让人有些缺乏安全感。”

5. AI 与 LLM 时代的编码体验

这是一个极具 2026 年时代特色的前沿议题。讨论区多位开发者分享了在使用大模型(如 Claude Code、Cursor)编写这两门语言时的反差体验:
* AI 写的 Rust 质量低下:由于 Rust 的生命周期(Lifetimes)和借用规则极度精密,AI 经常会生成那些无法通过编译的“幻觉代码”,试图滥用 Mutex、RefCell 等高级特权,或者在多线程中引入生命周期冲突。
* 但 Rust 拥有最强“安全网”:然而,反直觉的是,很多开发者表示他们更喜欢让 AI 写 Rust 而非 Go。因为如果 AI 写的 Go 逻辑错了(比如漏了 nil 检查或并发读写未加锁),代码依然能完美编译通过,并在生产环境中引发极其隐蔽的线上故障。而在 Rust 中,“只要 AI 写的代码能通过编译器的金睛火眼,我们几乎就可以闭着眼睛放心地把它部署上线。”

编辑结语:如何选择你的下一张船票?

Go 和 Rust 的博弈,本质上是“高带宽易上手的生产效率”“编译期极致安全的正确性承诺”之间的路线之争。

如果你正在构建一个高速迭代、团队规模庞大、需要快速抢占市场的业务系统,Go 依然是那张最稳健、最不容易出错且极其务实的船票。

但如果你的系统已经走过了野蛮生长阶段,开始面临极其严苛的 P99 停顿要求、高并发下的内存与 CPU 账单压力,或者是不容许有任何运行时恐慌(Panics)的国防级、金融级系统,那么正如 Matthias 团队所验证的那样,忍受 Rust 的学习曲线和编译成本,将为你换来长达数年、在睡梦中都无比踏实的“终极安全感”。

资料链接:

  • https://corrode.dev/learn/migration-guides/go-to-rust/
  • https://news.ycombinator.com/item?id=48259808

还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 从0 开始构建 Agent Harness 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。


原「Gopher部落」已重装升级为「Go & AI 精进营」知识星球,快来加入星球,开启你的技术跃迁之旅吧!

我们致力于打造一个高品质的 Go 语言深度学习AI 应用探索 平台。在这里,你将获得:

  • 体系化 Go 核心进阶内容: 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,夯实你的 Go 内功。
  • 前沿 Go+AI 实战赋能: 紧跟时代步伐,学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等,掌握 AI 时代新技能。
  • 星主 Tony Bai 亲自答疑: 遇到难题?星主第一时间为你深度解析,扫清学习障碍。
  • 高活跃 Gopher 交流圈: 与众多优秀 Gopher 分享心得、讨论技术,碰撞思想火花。
  • 独家资源与内容首发: 技术文章、课程更新、精选资源,第一时间触达。

衷心希望「Go & AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚,享受技术精进的快乐!欢迎你的加入!

img{512x368}


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

真相调查:Go 语言真的消灭了 Undefined Behavior 吗?

本文永久链接 – https://tonybai.com/2026/03/16/go-language-eliminated-undefined-behavior-truth-investigation

大家好,我是Tony Bai。

在系统编程的古老传说中,流传着一个关于“鼻恶魔”(Nasal Demons)的笑话。

这个梗源自 comp.std.c 新闻组,它是对 C/C++ 语言中“未定义行为”(Undefined Behavior,以下简称 UB)最生动也最恐怖的诠释。根据 ISO C++ 标准,如果你的代码触犯了 UB(例如数组越界、有符号整数溢出、空指针解引用),编译器可以“为所欲为”。

这种“为所欲为”不仅包括程序崩溃,还包括产生错误的结果、损坏数据,甚至——虽然只是笑话——让恶魔从你的鼻孔里飞出来。换句话说,一旦触碰 UB,程序的所有保证瞬间失效。

2009 年,Go 语言横空出世,高举“云原生时代系统语言”的旗帜,承诺提供比 C++ 更高的安全性、更快的编译速度和更简单的并发模型。Go 的拥趸们津津乐道于它的内存安全特性,仿佛 Go 已经彻底终结了 UB 的噩梦。

但真相果真如此吗?

近日,我翻阅了一份珍贵的历史资料——2013 年发生在 golang-nuts 邮件组的一场深度辩论。对话的一方是 Go 语言曾经的顶级贡献者 Dave Cheney,另一方是 Go 核心团队成员、gccgo 的作者 Ian Lance Taylor。

这场发生在这个语言童年时期的对话,揭示了一个令人背脊发凉又引人深思的事实:Go 并没有完全消灭未定义行为,它只是将 UB 赶进了一个更隐秘、更危险的角落——并发。

本文将带你层层剥开 Go 语言规范的表皮,调查“未定义行为”在 Go 中的真实生存状态,并探讨这对我们编写高质量代码意味着什么。

用“定义”换取“安全”——Go 的显式哲学

要理解 Go 做了什么,我们首先得明白 C/C++ 为什么保留 UB。Ian Lance Taylor 指出,C/C++ 保留 UB 本质上是为了性能——允许编译器假设“坏事永远不会发生”,从而进行激进的优化。

Dave Cheney 的疑问直击灵魂:“Go 规范中几乎看不到‘undefined’这个词,这种设计如何影响了 Go 的安全性与性能?”

答案是:Go 选择了一条确定性(Determinism)优先的道路。Go 语言规范以一种近乎偏执的态度,将绝大多数在 C/C++ 中属于 UB 的行为,都进行了严格的“定义”。即便是在错误场景下,Go 也要保证行为是可预测的

整数溢出的“确定性”承诺

在 C 语言中,有符号整数(Signed Integer)的溢出是经典的 UB。编译器有权假设溢出永远不会发生,从而将 x + 1 > x 优化为恒真(Always True),这曾导致过无数的安全漏洞。

但在 Go 语言规范中,对此有着截然不同的定义:

无符号整数:运算结果严格按照 2^n 取模。这意味着高位被丢弃,程序可以依赖这种“回绕(Wrap-around)”行为。

有符号整数:运算可以合法地溢出(legally overflow)。结果由有符号整数的表示方式(通常是补码)、运算类型和操作数确定性地定义。溢出不会导致运行时 Panic。

最关键的是,Go 规范明确禁止编译器进行危险的假设:“编译器不得假设溢出不会发生。例如,它不得假设 x < x + 1 总是为真。”

代码实证:

// https://go.dev/play/p/5CZVVU-SITX
package main

import "fmt"

func main() {
    // 1. 有符号整数溢出 (Signed Overflow)
    var a int8 = 127
    // 在 C 语言中这是 UB,但在 Go 中这是明确定义的
    b := a + 1
    fmt.Printf("int8: %d + 1 = %d\n", a, b)
    // 输出: 127 + 1 = -128 (确定性的回绕)

    // 2. 编译器禁止做的优化
    // 如果编译器假设溢出不发生,它会把这个判断优化掉
    if b < a {
        fmt.Println("发生溢出:b 确实小于 a")
    } else {
        fmt.Println("未发生溢出逻辑(Go 中不会走到这里)")
    }

    // 3. 无符号整数溢出 (Unsigned Overflow)
    var c uint8 = 255
    d := c + 1
    fmt.Printf("uint8: %d + 1 = %d\n", c, d)
    // 输出: 255 + 1 = 0 (严格的 Modulo 2^n)
}

Go这么做的代价是Go 编译器失去了一些数学优化机会(例如不能简单地消除某些循环边界检查)。但也消除了因编译器“自作聪明”而导致的逻辑崩塌,保证了不同平台下的行为一致性。

数组越界的“必杀令”

缓冲区溢出(Buffer Overflow)是网络安全史上最大的杀手。C/C++ 将越界访问视为 UB,允许攻击者通过越界读取敏感内存或覆盖返回地址,进而控制系统。

Go 对此零容忍:越界必须触发 Panic。

无论是在栈上分配的数组,还是在堆上分配的切片,Go 编译器都会在每一次访问操作前(除非能静态证明安全)插入一段 Bounds Check(边界检查)指令。一旦越界,程序立即停止,绝不含糊。

代码实证:

// https://go.dev/play/p/-CqDpIDr0BC
package main

import "fmt"

func main() {
    // 定义一个长度为 3 的切片
    s := []int{1, 2, 3}

    // 模拟一个动态索引(避免编译器在编译期直接报错)
    index := getIndex() 

    fmt.Println("尝试访问索引:", index)

    // 这里会触发 Runtime Panic
    // 错误信息明确:runtime error: index out of range [3] with length 3
    val := s[index] 

    fmt.Println("这行代码永远不会执行", val)
}

func getIndex() int {
    return 3
}

这种边界检查是在运行时(Runtime)介入,抛出 Panic,打印堆栈信息。因此会带来运行时性能损耗。虽然现代 Go 编译器引入了 BCA(边界检查消除)技术,但在无法静态分析的场景下,这就是必须缴纳的“安全税”。

空指针的“硬着陆”

在 C 语言中,解引用一个空指针是 UB。编译器有时会优化掉判空逻辑,因为它认为“既然你解引用了,那指针肯定不为空”,导致后续的安全检查失效。

Go 规定:解引用 nil 指针必须触发 Panic。

这通常是通过 CPU 的硬件异常(SIGSEGV)来捕获的。Go 运行时会接管这个硬件信号,并将其转化为一个可恢复的 Go Panic,而不是让进程直接 Core Dump 或进入不可预测的僵死状态。

代码实证:

// https://go.dev/play/p/hlyZks1dGRf
package main

import "fmt"

type User struct {
    Name string
}

func main() {
    var u *User // u 默认为 nil

    fmt.Println("准备访问 nil 指针...")

    // 在 C 中这是 UB,可能导致程序崩溃或更糟的情况
    // 在 Go 中,这不仅会 Panic,还可以被 Recover 捕获
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("捕获到恐慌:", r)
            // 输出: runtime error: invalid memory address or nil pointer dereference
        }
    }()

    // 触发 Panic
    fmt.Println(u.Name)
}

综上,我们可知:在单线程维度,Go 确实几乎消灭了 Undefined Behavior。它通过强制规定行为(Wrapping, Panicking),将“未定义”变成了“定义明确的错误”。即使程序写错了,它的错误方式也是确定的,而非随机的。

房间里的大象——数据竞争

如果文章到这里结束,那么 Go 就是一个完美的、绝对安全的语言。

但 Ian Lance Taylor 随后抛出了一个重磅炸弹:

“However, Go does have undefined behavior: if your program has a race condition, the behaviour is undefined.”
(然而,Go 确实存在未定义行为:如果你的程序存在数据竞争,那么行为就是未定义的。)

这就是 Go 语言安全神话中最大的裂痕。

在 Rust 中,编译器借用检查器(Borrow Checker)会在编译期阻止数据竞争,因此 Rust 可以自豪地宣称“无数据竞争”。但 Go 选择了更简单的并发模型,允许 Goroutine 共享内存。

一旦发生数据竞争(Data Race),即多个 Goroutine 同时访问同一块内存且至少有一个是写操作,Go 就不再提供任何保证。

为什么数据竞争是真正的 UB?

很多 Gopher 认为数据竞争只是“读到了旧数据”或者“计数器少加了 1”。这是一种极其危险的误解。在多核 CPU 和现代编译器优化的加持下,数据竞争在 Go 中可能导致内存安全破坏

这主要源于 Go 的多字数据结构(Multi-word Data Structures)

接口(Interface)的“撕裂”

Go 的 interface 在底层是由两个机器字组成的:{type_ptr, data_ptr}。

  • type_ptr 指向具体类型的元数据(如方法表)。
  • data_ptr 指向具体的数据值。

假设我们有一个全局接口变量 var i interface{},以及两个实现类型 type A 和 type B。

  • Goroutine 1 试图将 i 赋值为 A{}。
  • Goroutine 2 试图将 i 赋值为 B{}。

如果没有加锁,Goroutine 3 可能会读到一个“弗兰肯斯坦”般的怪物接口:它的 type_ptr 来自 A,但 data_ptr 却指向 B 的数据!

当你调用这个接口的方法时,程序会尝试用 A 的方法表去操作 B 的内存布局。这会导致什么?

如果运气好,你会得到Panic(类型断言失败或非法内存访问)。

反之,如果运气不好,那远程代码执行(RCE)的攻击者可以精心构造内存布局,利用这种类型混淆(Type Confusion)来劫持控制流。

切片(Slice)的“越界”

切片由 {ptr, len, cap} 三个字组成。数据竞争可能导致你读到了新的 len(变得很大),但 ptr 还是旧的(指向一个小数组)。结果是你拥有了一个长度远超底层数组容量的切片,这让你能够读取甚至修改不属于该切片的任意内存——这正是 C 语言缓冲区溢出的翻版。

这,就是 Go 中的 Undefined Behavior。 它不是“鼻恶魔”,但它是真实存在的安全黑洞。

那些“未指明”的灰色地带

除了致命的 UB,讨论中还涉及了 Go 语言规范中的另一种存在:未指明行为(Unspecified Behavior)实现定义行为(Implementation-Defined Behavior)

这些行为虽然不会导致内存破坏,但同样破坏了程序的“确定性”。

Map 的迭代顺序

在 Go 中,for k, v := range m 的顺序是故意未定义的。

Ian 解释说,这是为了防止开发者依赖某种特定的哈希实现顺序。Go 运行时甚至在每次迭代开始时引入了随机种子(迭代器会在map bucket 数组中随机选取一个起始位置向后遍历),强制让顺序变得不可预测。

这是一个非常有智慧的设计:通过强制随机化,逼迫开发者编写不依赖顺序的健壮代码。

表达式求值顺序:在“确定”与“未指明”之间

在 C/C++ 中,f(g(), h()) 中 g() 和 h() 谁先执行是未定义的(Undefined Behavior 或 Unspecified Behavior),这取决于编译器实现。

Go 语言规范对此做了更严格的规定,但依然保留了一块微妙的“灰色地带”。

确定的部分(Defined):

Go 规定,在求值表达式的操作数、赋值语句或返回语句时,所有的函数调用、方法调用和通信操作(Channel receive)都必须按照词法上从左到右的顺序执行。

例如,在赋值语句 y[f()], ok = g(h(), i()+x[j()], <-c), k() 中,函数调用和通信的发生顺序被严格锁定为:

f() -> h() -> i() -> j() -> <-c -> g() -> k()。

未指明的部分(Unspecified):

然而,规范同时也指出:并没有规定上述事件与表达式求值、索引操作、以及变量 y 的求值之间的顺序。

这意味着,虽然函数调用的相对顺序是固定的,但涉及副作用(Side Effects)的变量读写顺序可能是不确定的。来看 Spec 中的经典反例:

a := 1
f := func() int { a++; return a }

// x 可能是 [1, 2] 也可能是 [2, 2]
// 因为 a 的求值与 f() 的执行顺序未定义
x := []int{a, f()}
println(a, x)

// --- 示例:map 字面量中 key/value 的求值顺序未定义 ---
b := 1
g := func() int { b++; return b } // g() 会修改 b

// 若 b 先被求值:key=1, value=2  → m = {1: 2}
// 若 g() 先被执行:key=2, value=2 → m = {2: 2}
// Go 规范不保证 key 表达式与 value 表达式谁先求值
m2 := map[int]int{b: g()}
println(b, m2[b])

虽然 Go 比 C/C++ 确定得多,但在编写依赖于求值顺序的副作用代码(例如在参数列表中修改全局变量)时,依然可能会掉进“未指明行为”的陷阱。因此,最好不要在单行表达式中依赖复杂的副作用顺序。

浮点数转换的幽灵

讨论中有开发者 提到了 float64 转换为 uint8 的行为。在早期的 Go 版本中,对于溢出值的处理可能依赖于底层硬件指令(x86 vs ARM),从而表现出不一致。

虽然 Go 正在逐步收紧这些规范,例如 #76264 提案(尚未落地)正试图统一浮点转整数的饱和行为,但这提醒我们:即使是强类型语言,在跨平台移植时也可能遇到底层架构带来的“方言”差异。

如何在充满 UB 的世界里生存?

既然 Go 没有彻底消灭 UB,作为开发者,我们该如何自保?

视 -race 为生命线

Ian Lance Taylor 的警告应该被打印在每个 Go 开发者的工位上。

建议

  • 单元测试必须开启 -race 标志运行。
  • 在 CI/CD 流水线中,竞态检测是不可跳过的阻断性步骤。
  • 不要相信“我的并发逻辑很简单,不会出错”,人脑无法模拟现代 CPU 的乱序执行。

敬畏 unsafe

Go 的 unsafe 包是通往 C 语言 UB 世界的后门。使用 unsafe.Pointer 进行类型转换时,你实际上是在对编译器说:“我知道我在做什么,出了事我负责。”

除非你是编写底层运行时或极致性能库的专家,否则在业务代码中绝对禁止使用 unsafe。一旦使用,你必须熟读《Go 内存模型》和《垃圾回收器写屏障规则》。

理解“实现定义”与“未定义”的区别

  • 未定义(UB):可能导致 Crash、数据损坏、安全漏洞(如数据竞争)。零容忍。
  • 未指明/实现定义:不同版本或平台可能表现不同(如 Map 顺序)。不要依赖它。
  • 已定义:Go 承诺的行为(如整数回绕)。可以依赖,但需知晓代价。

小结:完美的幻象与工程的现实

通过这次“真相调查”,我们得出的结论可能有些令人沮丧,但也足够清醒:

Go 语言并没有彻底消灭 Undefined Behavior。它只是通过牺牲一部分性能和增加运行时检查,将 UB 的“攻击范围”从 C/C++ 的“随处可见”缩小到了“并发数据竞争”和“不安全代码”这两个特定的领域。

这是一种极其成功的工程权衡。它让 Go 在保持高性能的同时,为 99% 的日常编码提供了坚实的安全保障。

然而,作为 Gopher,我们不能沉浸在“绝对安全”的幻象中。我们必须意识到,当我们敲下 go func() 的那一刻,当我们试图共享一个指针的那一刻,我们正行走在悬崖的边缘。

Go 给了我们围栏(定义明确的行为),但也给了我们梯子(并发与 Unsafe)。能否不跌入 UB 的深渊,最终取决于我们是否遵守工程的纪律。

资料链接:https://groups.google.com/g/golang-nuts/c/MB1QmhDd_Rk


你遇到过“鼻恶魔”吗?

哪怕是 Go 这样严谨的语言,在并发面前也会露出锋利的牙齿。在你的开发生涯中,是否遇到过那种因为没开 -race 而在生产环境产生的“灵异事件”?你对 Go 这种“用性能换确定性”的哲学怎么看?

欢迎在评论区分享你的“探案”心得!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 AI原生开发工作流实战 从 0 开始构建 Agent Harness Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com
这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats