Ruby Egg:一门没有 GC、但内存安全的小语言(编译器用 Spinel 能编译的 Ruby 子集写成)

femto · 2026年11月06日 · 32 次阅读

最近 Matz 合并了我给 Spinel 的一个 PR,借这个机会介绍一下我在做的语言 Egg:https://github.com/femto/egg

Egg 是什么

Egg 编译成可移植的 C99,几个设计重点:

  • 没有 GC,但内存安全。 每个缓冲区只有一个所有者。释放后使用、重复释放、内存泄漏都是编译错误,而且不用写生命周期标注。
  • 没有未定义行为。 整数溢出、整数除以零、数组越界都会 panic,不会悄悄回绕或者踩坏内存。
  • 能力写在签名里。 语言核心不能做 IO,也不能分配内存。一个文件要先声明 uses io, alloc,再由 main 把这些值往下传,看函数签名就知道它能碰什么。
  • 线程之间不共享数据。 有所有者的值通过 channel 在线程之间转移。

长什么样

uses alloc, io

fn greet(io: Io, a: Allocator, name: str) void {
    const msg = a.concat("hello, ", name)
    defer a.free(msg)
    io.print(msg)
}

fn main(io: Io, a: Allocator) void {
    greet(io, a, "egg")
}

(GitHub 还不认识 Egg,代码块先借 Zig 的高亮。)

编译器替你抓的错

忘了写 defer a.free(msg),内存泄漏直接编译不过:

hello.egg:4:11: error[E135]: 'msg' still owns its buffer when 'greet' ends; free it (a.free(msg)), return it or pass it on

去掉 uses 里的 io,这个能力也用不了:

hello.egg:6:8: error[E130]: 'greet' uses io (Io.print), but its module does not declare it; add `uses io` at the top of the file

切片还要用,原来的缓冲区就不能改:

const path = line[4..]
a.push(&line, '!')      // 可能会挪动底层字节
io.print(path)
slice.egg:7:12: error[E149]: 'line' cannot be changed here: the slice 'path' of it is used later

path 用完之后,同样的 push 就可以了:借用在最后一次使用时结束。

再长一点:带数据的联合类型和 switch

uses io

const Shape = union(enum) {
    circle: i64
    rect: Size
    empty
}

const Size = struct { w: i64, h: i64 }

fn area(s: Shape) i64 {
    switch (s) {
        .circle => |r| { return 3 * r * r }
        .rect => |sz| { return sz.w * sz.h }
        .empty => { return 0 }
    }
}

fn total(shapes: []Shape) i64 {
    var sum = 0
    for (shapes) |s| {
        sum = sum + area(s)
    }
    return sum
}

fn main(io: Io) void {
    const shapes = [3]Shape{ Shape{ .circle = 2 }, Shape{ .rect = Size{ .w = 3, .h = 4 } }, .empty }
    io.print(total(shapes[0..]))   // 24
}

和 Spinel 的关系

Spinel 是 Matz 写的 Ruby AOT 编译器:把 Ruby 的一个子集编译成 C,再编成原生程序。

Egg 的编译器 eggc 大约 1.2 万行,全部用 Spinel 能编译的那部分 Ruby 写成。平时它跑在 CRuby 上,也可以用 Spinel 编成原生程序:

  • 早期的 v0 已经用 Spinel 编成过原生的 eggc。启动时间从约 350 ms 降到约 5 ms,而且不需要装 Ruby。
  • 现在的版本还卡在几个 Spinel 的问题上,我都提了 issue:#11078、#11079、#11080。等这些修好,就能再编成原生的。

用这个子集写编译器的体验很好:写起来就是普通 Ruby,最后又能得到一个原生程序。

性能

小程序,和 C 比

所有检查都开着(溢出、越界),在 i7-9700K 上运行,时间是 C 的多少倍:

程序 Egg Rust(开溢出检查) Zig ReleaseSafe CPython Ruby 3.2
fib(40) 1.53x 1.62x 1.51x 95x 44x
sieve,5000 万 1.10x 1.04x 1.19x 27x 23x
collatz,300 万 1.07x 1.37x 1.34x 55x 26x
matmul,800×800 2.31x 1.11x 1.23x 345x 164x

同样的循环,Egg 比 CPython 快 25 到 149 倍,比 Ruby 快 21 到 71 倍。matmul 落后 Rust 和 Zig,是因为 eggc 还没有把内层循环的越界检查优化掉。

一个真实应用:ONCE Campfire

为了在真实应用上检验 Egg,我在用 Egg 移植 Basecamp 的群聊应用 ONCE Campfire,代码在 https://github.com/femto/once-campfire-egg 。

它直接用 Rails 版生成的数据库,和 Basecamp 自己的 Rust 移植版 输出逐字节一致的 HTML,再用 Basecamp 官方的 测试工具 对比。每个响应都要通过内容校验,每条写入都会回查数据库。

GitHub Actions 的 ubuntu-latest,应用占 2 个核,每秒请求数,两边都开着缓存:

路由 Rust Egg
房间页 12,710 41,833
侧栏 14,287 51,243
搜索 14,138 50,540
静态 CSS 48,060 108,761
发消息 1,818 1,771

不开缓存时,Egg 每次都重新渲染并 gzip 整个页面,页面会比 Rust 慢很多;完整数据都在仓库的 docs/benchmark.md 里。

实时聊天(Action Cable over WebSocket):1000 个 WebSocket 客户端在同一个繁忙的房间里,每条消息都检查是否送达了每一个客户端。

Rust Egg
满负载下每秒完整送达的消息数 178 195-222
送达所有客户端的延迟,p50 37 ms 51-56 ms
没送达全部客户端的消息 0 0
内存峰值 134 MiB 35-39 MiB

两边基本持平,Egg 的内存约为四分之一。为了这一项,Egg 新加了事件循环 net.poller(),底层是 epoll、kqueue 和 WSAPoll。

现状

Egg 还很年轻,语法还会变。Campfire 目前做了官方测试的那些路由,加上完整的实时聊天,还不是整个应用 (正在做)。

欢迎试用,也欢迎提意见:

git clone https://github.com/femto/egg
cd egg
bin/eggc run examples/complex/...

只需要 Ruby 3.0 以上和一个 C 编译器。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号