栏目:02 · 后端开发 | 2 月 · 后端语言入门系列(对比专题开篇)
上周为了给这个多语言对比系列攒素材,我把四个内部小工具的部署镜像重新打了一遍------C#、Go、Rust、Python 各一个,功能都只是读 CSV 出报表。结果最重的 Python 镜像 130MB 出头,最轻的 Go 镜像塞进 scratch 里不到 3MB,差了四十多倍。组里新来的同学问我:不都是"编译"吗,差这么多?
这个问题我两年前也答不好,当时我的理解是"编译型快、解释型慢",仅此而已。这次借着写对比专题开篇的机会,把四个 Hello World 从源码到二进制完整走了一遍,才发现这套二分法在 2026 年已经基本失效了。
先看四份 Hello World 和它们的产物
代码本身没有信息量,信息量在编译命令和产物上。
rust
// hello.rs --- rustc 1.90.0(edition 2024)
// 编译:rustc -O hello.rs -o hello
fn main() {
println!("hello, world");
}
go
// hello.go --- Go 1.24(linux/amd64)
// 编译:go build -o hello_go hello.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
csharp
// Program.cs --- .NET SDK 8.0.4xx,Top-level statement 单文件
Console.WriteLine("hello, world");
python
# hello.py --- CPython 3.13.1
print("hello, world")
在 Ubuntu 22.04(x64)上,四个产物体积如下:
| 语言 | 产物 | 体积 | 依赖 |
|---|---|---|---|
| Rust | rustc -O 出的 ELF |
365KB(strip 后 242KB) | glibc 动态链接 |
| Go | go build 出的 ELF |
1.9MB | 无(静态链接) |
| C#(JIT) | framework-dependent 发布 | 6KB 左右的 App.dll | 需要 .NET 8 Runtime |
| C#(NativeAOT) | PublishAot=true 出的 ELF |
1.1MB 左右 | glibc 动态链接 |
| Python | .py 源码本身 | 18 字节 | 需要 CPython 解释器 |
启动速度用 hyperfine(v1.18,--warmup 3)测了一轮:
bash
Rust hello 0.6 ms ± 0.1 ms
Go hello 1.2 ms ± 0.2 ms
C# NativeAOT hello 2.1 ms ± 0.3 ms
C# JIT hello 58 ms ± 4 ms
CPython 3.13 hello 24 ms ± 1 ms
数字先摆这里:Rust 和 Go 都是毫秒级,但 Go 比 Rust 大五倍;C# 同一份代码,JIT 启动 58ms,NativeAOT 只要 2.1ms,差 27 倍;Python 的 24ms 里一大半是解释器自身初始化,真正执行那行 print 的时间可以忽略。
我原来想错了什么
我以前把四门语言排成一条线:Rust/Go 在"编译"那头,Python 在"解释"那头,C# 居中。写这篇的时候越写越别扭,这条线对不上事实。
Python 3.13 自带实验性 JIT(默认关闭);.NET 8 一个 PublishAot=true 就能把 IL 提前编成原生机器码;Go 本身是 AOT 编译,却静态打包了一整套带 GC 和调度器的运行时。四门语言没有一个"纯解释",也没有一个能归进同一个框。
后来我换了一个维度看,就通了:不看"有没有编译",看"运行时什么时候介入"。
- Rust:运行时介入最少,边界检查和所有权的清理代码在编译期生成,标准库极薄。
- Go:运行时在编译期全部打包进去。GC、调度器、panic 栈回溯,哪怕你一行并发代码没写,1.9MB 里大部分是它们------这是 Go 二进制比 Rust 大的原因,也是它部署不用装任何东西的原因。
- C#:介入方式可选。默认 JIT,启动时才把 IL 翻译成机器码,换来运行期深度优化;NativeAOT 把翻译提前到编译期,代价是砍掉大部分反射,启动从 58ms 掉到 2.1ms。
- Python:介入最深。字节码由 PVM 逐条解释执行,对象模型、引用计数 GC 全在运行期,它的"编译"只到
.pyc为止。
说白了,差异不是"编译与不编译",而是"运行时替你兜多少底"。兜得越多,写起来越省心,产物越重、启动越慢。Rust 把底兜到了编译期,Go 把底装进了二进制,C# 让你选,Python 把底全留到运行期。
踩坑实录:Go 镜像在 scratch 里起不来
上面说 Go 静态链接,其实有个前提,而这个前提坑了我一晚上。
打 Go 镜像我习惯用两段式:golang:1.24 构建,产物丢进 scratch。第一个工具一切正常,第二个工具多了个功能------把统计结果 POST 给监控接口,我顺手 import "net/http",构建照样通过,容器一起就炸:
bash
standard_init_linux.go:228: exec user process caused: no such file or directory
这个报错特别误导人,我第一反应是二进制架构不对(amd64/arm64 搞混),对着 uname -m 查了十分钟,方向完全错了。真实原因:默认开 CGO 的 Linux 环境里,net 包走 cgo 的 DNS 解析实现,链接器随之把二进制编成动态链接 ------它需要 glibc 的动态加载器,而 scratch 里什么都没有。ldd 一看便知:
bash
$ ldd hello_go
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
修复方式是构建时把 CGO 关掉,再 ldd 就变成 not a dynamic executable,丢进 scratch 直接跑:
bash
CGO_ENABLED=0 GOOS=linux go build -o hello_go hello.go
后来我把这个变量写进了所有 Go 项目的 Dockerfile。别学我,这个坑应该在第一次写 Go Dockerfile 时就踩掉。
Rust 有个对称的坑:默认产物也是 glibc 动态链接的,直接丢 scratch 一样起不来,要么换 x86_64-unknown-linux-musl 目标做全静态,要么底镜像用 debian:bookworm-slim。
代价清单:每个模型都要付钱
没有免费的模型,只有不同的账单,按部署视角整理:
| 模型 | 付什么 | 换来什么 |
|---|---|---|
| Rust | 编译期与编译器搏斗,真实项目编译分钟级 | 产物小、启动快、无运行时依赖 |
| Go | 二进制恒定 2MB 起步 | 单文件部署、并发开箱即用 |
| C# JIT | 启动 58ms,要装 Runtime(官方镜像约 90MB) | 运行期持续优化、R2R 可调 |
| C# NativeAOT | 反射受限、部分 NuGet 库不兼容 | 启动 2.1ms、单文件 1.1MB |
| Python | 要解释器、镜像 130MB 级、CPU 密集慢 | 改完即跑,迭代最快 |
我的偏好很明确:内部 CLI 和要塞进 scratch/alpine 的服务选 Go 或 NativeAOT 的 C#;频繁改逻辑的运营脚本用 Python;对启动延迟和内存有硬指标的热路径服务上 Rust。这不是技术高低,是账单往哪开的问题。
补一句测量教训:我一开始用 time 测启动,Python 测出 40ms,hyperfine 测出 24ms------time 把 shell fork 的开销也算进去了。
留个尾巴
写到结尾我发现自己留下一个更值得追的问题:既然 NativeAOT 能把启动压到 2ms 级,"JIT 阵营"存在的理由还剩多少?运行期优化(分支预测友好、去虚化)是 JIT 独有的牌,但 ASP.NET Core 官方都在往 AOT 迁移 Minimal API 了,我打算 6 月写框架实战时用真实服务再验证一次。下一篇(选题 14)聊四门语言的类型系统设计哲学。代码都推在 GitHub 上,仓库地址在系列导航页。
(如果你有更好的镜像瘦身方案,欢迎评论区打我。)