标签 Golang 下的文章

为何Go语言迟迟未能拥抱 io_uring?揭秘集成的三大核心困境

本文永久链接 – https://tonybai.com/2025/08/11/why-go-not-embrace-iouring

大家好,我是Tony Bai。

在 Linux I/O 的世界里,io_uring 如同划破夜空的流星,被誉为“终极接口”。它承诺以无与伦比的效率,为数据密集型应用带来革命性的性能提升。正如高性能数据库 ScyllaDB 在其官方博文中所展示的,io_uring 能够将系统性能推向新的高峰。

然而,一个令人费解的问题摆在了所有 Go 开发者面前:作为云原生infra和并发编程的标杆,Go 语言为何对这颗唾手可得的“性能银弹”表现得如此审慎,甚至迟迟未能将其拥抱入标准库的怀抱?一场在 Go 官方仓库持续了五年之久的 Issue 讨论(#31908),为我们揭开了这层神秘的面纱。这并非简单的技术取舍,而是 Go 在其设计哲学、工程现实与安全红线之间进行反复权衡的结果。本文将深入这场讨论,为您揭秘阻碍 io_uring 在 Go 中落地的三大核心困境。

io_uring:一场 I/O 模型的革命

要理解这场争论,我们首先需要明白 io_uring 究竟是什么,以及它为何具有革命性。

在 io_uring 出现之前,Linux 上最高效的 I/O 模型是 epoll。epoll 采用的是一种“拉(pull)”模型:应用程序通过一次 epoll_wait 系统调用来询问内核:“有我关心的文件描述符准备好进行 I/O 了吗?”。内核响应后,应用程序需要再为每个就绪的描述符分别发起 read 或 write 系统调用。这意味着,处理 N 个 I/O 事件至少需要 N+1 次系统调用

而 io_uring 则彻底改变了游戏规则。它在内核与用户空间之间建立了两个共享内存环形缓冲区:提交队列(Submission Queue, SQ)完成队列(Completion Queue, CQ)

其工作流程如下:

  1. 提交请求: 应用程序将一个或多个 I/O 请求(如读、写、连接等)作为条目(SQE)放入提交队列中。这仅仅是内存操作,几乎没有开销
  2. 通知内核: 应用通过一次 io_uring_enter 系统调用,通知内核“请处理队列中的所有请求”。在特定模式(SQPOLL)下,这个系统调用甚至可以被省略。
  3. 内核处理: 内核从提交队列中批量取走所有请求,并异步地执行它们。
  4. 返回结果: 内核将每个操作的结果作为一个条目(CQE)放入完成队列。这同样只是内存操作。
  5. 应用收获: 应用程序直接从完成队列中读取结果,无需为每个结果都发起一次系统调用。

这种模式的优势是颠覆性的:它将 N+1 次系统调用压缩为 1 次甚至 0 次,极大地降低了上下文切换的开销,并且首次为 Linux 带来了真正意义上的、无需 O_DIRECT 标志的异步文件 I/O

最初的希望:一剂治愈 Go I/O“顽疾”的良药

讨论伊始,Go 社区对 io_uring 寄予厚望,期待它能一举解决 Go 在 I/O 领域的两大历史痛点:

  1. 真正的异步文件 I/O: Go 的网络 I/O 基于 epoll 实现了非阻塞,但文件 I/O 本质上是阻塞的。为了避免阻塞系统线程,Go 运行时不得不维护一个线程池来处理文件操作。正如社区所期待的,io_uring 最大的吸引力在于“移除对文件 I/O 线程池的需求”,让文件 I/O 也能享受与网络 I/O 同等的高效与优雅。
  2. 极致的网络性能: 对于高并发服务器,io_uring 通过将多个 read/write 操作打包成一次系统调用,能显著降低内核态与用户态切换的开销,这在“熔断”和“幽灵”漏洞导致 syscall 成本飙升的后时代尤为重要。

然而,Go 核心团队很快就为这股热情泼上了一盆“冷水”。

核心困境一:运行时模型的“哲学冲突”

这是阻碍 io_uring 集成最根本、最核心的障碍。Go 的成功很大程度上归功于其简洁的并发模型——goroutine,以及对开发者完全透明的调度机制。但 io_uring 的工作模式,与 Go 运行时的核心哲学存在着深刻的冲突。

冲突的焦点在于“透明性”。Ian Lance Taylor 多次强调,问题不在于 io_uring 能否在 Go 中使用,而在于能否“透明地”将其融入现有的 os 和 net 包,而不破坏 Go 开发者早已习惯的 API 和心智模型。

io_uring 的性能优势源于批处理。但 Go 的标准库 API,如 net.Conn.Read(),是一个独立的、阻塞式的调用。Go 用户习惯于在独立的 goroutine 中处理独立的连接。如何将这些分散的独立 I/O 请求,在用户无感知的情况下,“透明地”收集起来,打包成批?这几乎是一个无解的难题。

社区也提出了“每个 P (Processor) 一个 io_uring 环”的设想,但 Ian 指出这会引入极高的复杂性,包括环的争用、空闲 P 的等待与唤醒、P 与 M 切换时的状态管理等。正如一些社区成员所总结的,io_uring 需要一种全新的 I/O 模式,而这与 Go 现有网络模型的模式完全不同。强行“透明”集成,无异于“在不破坏现有 API 的情况下进行不必要的破坏”。

核心困境二:现实世界的“安全红线”

如果说运行时模型的冲突是理论上的“天堑”,那么安全问题则是实践中不可逾越的“红线”。

在 2024 年初,社区成员 jakebailey 抛出了一个重磅消息:出于安全考虑,Docker 默认的 seccomp 配置文件已经禁用了 io_uring

引用自 Docker 的 commit 信息: “安全专家普遍认为 io_uring 是不安全的。事实上,Google ChromeOS 和 Android 已经关闭了它,所有 Google 生产服务器也关闭了它。”

这个消息对标准库集成而言几乎是致命一击。Go 程序最常见的部署环境就是容器。一个不被“普遍情况”支持的特性,无论其性能多么优越,都难以成为Go运行时和标准库的基石。

核心困境三:追赶一个“移动的目标”

在这场长达五年的讨论中,io_uring 自身也在飞速进化。其作者Jens Axboe 甚至亲自下场,解答了 Go 团队早期的疑虑,例如移除了并发数限制、解决了事件丢失问题等。

但这恰恰揭示了第三重困境:要集成一个仍在高速演进、API 不断变化的底层接口,本身就充满了风险和不确定性。标准库追求的是极致的稳定性和向后兼容性。过早地依赖一个“移动的目标”,可能会带来持续的维护负担和潜在的破坏性变更。对于一个需要支持多个内核版本的语言运行时来说,这种复杂性是难以承受的。

小结:审慎的巨人与退潮的社区热情

io_uring 未能在 Go中落地,并非因为 Go 团队忽视性能,而是其成熟与审慎的体现。三大核心困境层层递进,揭示了其迟迟未能拥抱 io_uring 的深层原因:哲学上的范式冲突、现实中的安全红线、以及工程上的稳定性质疑。

然而,现实比理论更加残酷。在讨论初期,Go 社区曾涌现出一批充满激情的用户层 io_uring 库,如 giouring、go-uring 等,它们是开发者们探索新大陆的先锋。但时至 2025 年,我们观察到一个令人沮丧的趋势:这些曾经的追星项目大多已陷入沉寂,更新寥寥,星光黯淡。

与之形成鲜明对比的是,Rust 的 tokio-uring 库依然保持着旺盛的生命力,社区活跃,迭代频繁。这似乎在暗示,问题不仅在于 io_uring 本身,更在于它与特定语言运行时模型的“契合度”。Go 运行时的 G-P-M 调度模型和它所倡导的编程范式,使得社区自发的集成尝试也步履维艰,最终热情退潮。

这是否意味着 Go 与 io_uring 将永远无缘?或许未来之路有二:一是等待 io_uring 自身和其生态环境(尤其是安全方面)完全成熟;二是 Go 也许可能会引入一套全新的、非透明的、专为高性能 I/O 设计的新标准库包。

在此之前,Go 运行时可能会选择先挖掘 epoll 的全部潜力。这场长达五年的讨论,最终为我们留下了一个深刻的启示:技术的采纳从来不是一场单纯的性能赛跑,它是一场包含了设计哲学、生态现实与工程智慧的复杂博弈。

资料链接:

  • https://github.com/golang/go/issues/31908
  • https://www.scylladb.com/2020/05/05/how-io_uring-and-ebpf-will-revolutionize-programming-in-linux/

关注io_uring在Linux kernel内核演进的小伙伴儿们,可以关注io-uring.vger.kernel.org archive mirror这个页面,或io_uring作者Jens Axboe的liburing wiki


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

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

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

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

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


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

Go json/v2实战:告别内存爆炸,掌握真流式Marshal和Unmarshal

本文永久链接 – https://tonybai.com/2025/08/09/true-streaming-support-in-jsonv2

大家好,我是Tony Bai。

Go 开发者长期以来面临一个痛点:标准库 encoding/json 在处理大型 JSON 数据时,即使使用 Encoder/Decoder,也因其内部的全量缓冲机制而导致巨大的内存开销。备受期待的 encoding/json/v2 提案(#71497)旨在从根本上解决这一问题。通过引入全新的底层包 encoding/json/jsontext,v2 实现了真正的流式处理能力。本文将通过具体的、可量化的基准测试,向你展示 v1 的内存陷阱,并演示如何使用 json/v2 高效地实现流式处理大规模 JSON 数据,彻底告别内存爆炸的烦恼。

json/v1 的“伪流”之痛:一个内存陷阱基准

为了直观地感受 json/v1 在处理大数据时的局限性,我们来建立一个基准测试。我们将分别进行编码(Marshal)和解码(Unmarshal)操作,并观察其内存使用情况。

关于内存评估:我们通过比较操作前后的 runtime.MemStats.TotalAlloc 来精确测量该操作自身导致的堆内存总分配量。一个真正的流式处理,其内存分配量应该是一个很小的常数(例如,I/O 缓冲区的大小),而与数据总量无关。

场景一:v1 编码一个巨大的 JSON 数组

我们创建一个包含 100 万个空结构体的 slice,然后使用 json.Encoder 将其写入 io.Discard。

// jsonv2-streaming/v1/marshal.go
package main

import (
    "encoding/json"
    "io"
    "log"
    "runtime"
)

func main() {
    const numRecords = 1_000_000
    in := make([]struct{}, numRecords)
    out := io.Discard

    // 多次 GC 以清理 sync.Pools,确保测量准确
    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to encode with json/v1...")
    encoder := json.NewEncoder(out)
    if err := encoder.Encode(&in); err != nil {
        log.Fatalf("v1 Encode failed: %v", err)
    }
    log.Println("Encode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Encode: %d bytes (%.2f MiB)", allocBytes, float64(allocBytes)/1024/1024)
}

分析:当你运行此程序,会看到总分配字节数是一个巨大的数字,通常是几兆字节 (MiB)。这是因为 json.Encoder 必须先在内存中将整个 slice 序列化成一个完整的、约几MB({} 乘以 100 万)的 JSON 字符串,然后才开始写入。这个巨大的字符串就是内存分配的来源。

在我的Mac上,这个程序的输出如下:

$go run unmarshal.go
2025/08/09 13:38:47 Starting to decode with json/v1...
2025/08/09 13:38:47 Decode finished.
2025/08/09 13:38:47 Total bytes allocated during Decode: 8394096 bytes (8.01 MiB)

下面再来看看v1版本的json解码。

场景二:v1 解码一个巨大的 JSON 数组

现在,我们构造一个代表百万空对象的 JSON 字符串,并使用 json.Decoder 解码。

// jsonv2-streaming/v1/unmarshal.go
package main

import (
    "encoding/json"
    "log"
    "runtime"
    "strings"
)

func main() {
    const numRecords = 1_000_000
    // 构造一个巨大的 JSON 数组字符串,约 2MB
    value := "[" + strings.TrimSuffix(strings.Repeat("{},", numRecords), ",") + "]"
    in := strings.NewReader(value)

    // 预分配 slice 容量,以排除 slice 自身扩容对内存测量的影响
    out := make([]struct{}, 0, numRecords)

    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to decode with json/v1...")
    decoder := json.NewDecoder(in)
    if err := decoder.Decode(&out); err != nil {
        log.Fatalf("v1 Decode failed: %v", err)
    }
    log.Println("Decode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Decode: %d bytes (%.2f MiB)", allocBytes, float64(allocBytes)/1024/1024)
}

分析:同样,你会看到数兆字节的内存分配。json.Decoder 在反序列化之前,会将整个 JSON 数组作为一个单独的值完整地读入其内部缓冲区。这个缓冲区的大小与输入数据的大小成正比。

运行该代码,我机器上的输出如下:

$go run unmarshal.go
2025/08/09 13:38:47 Starting to decode with json/v1...
2025/08/09 13:38:47 Decode finished.
2025/08/09 13:38:47 Total bytes allocated during Decode: 8394096 bytes (8.01 MiB)

从上面两个例子,我们看到:json/v1 的 Encoder 和 Decoder 提供的只是API 层面的流式抽象,其底层实现是全量缓冲的,导致内存复杂度和数据大小成线性关系 (O(N))。这就是“伪流”之痛。

json/v2 的革命:用正确的 API 实现真流式处理

现在,让我们见证 json/v2 的魔力。我们将使用 v2 推荐的流式 API,来完成与 v1 示例完全相同的任务,并进行内存对比。

场景一:v2 流式编码一个巨大的 JSON 数组

我们将模拟从数据源逐条获取数据,并使用 jsontext.Encoder 手动构建 JSON 数组流。

// jsonv2-streaming/v2/marshal.go
package main

import (
    "io"
    "log"
    "runtime"
    "encoding/json/v2"
    "encoding/json/jsontext"
)

func main() {
    const numRecords = 1_000_000
    out := io.Discard

    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to encode with json/v2...")

    enc := jsontext.NewEncoder(out)

    // 手动写入数组开始标记
    if err := enc.WriteToken(jsontext.BeginArray); err != nil {
        log.Fatalf("Failed to write array start: %v", err)
    }

    // 逐个编码元素
    for i := 0; i < numRecords; i++ {
        // 内存中只需要一个空结构体,几乎不占空间
        record := struct{}{}
        if err := json.MarshalEncode(enc, record); err != nil {
            log.Fatalf("v2 MarshalEncode failed for record %d: %v", i, err)
        }
    }

    // 手动写入数组结束标记
    if err := enc.WriteToken(jsontext.EndArray); err != nil {
        log.Fatalf("Failed to write array end: %v", err)
    }
    log.Println("Encode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Encode: %d bytes (%.2f KiB)", allocBytes, float64(allocBytes)/1024)
}

分析:运行此程序,你会看到总分配字节数是一个非常小的数字,通常是几十千字节 (KiB),主要用于 Encoder 内部的 I/O 缓冲。v2 将每个元素编码后立即写入,没有在内存中构建那个 2MB 的巨大字符串。

在我的机器上运行该示例,编码过程实际分配的内存仅有不到15KB:

$GOEXPERIMENT=jsonv2 go run marshal.go
2025/08/09 13:45:50 Starting to encode with json/v2...
2025/08/09 13:45:50 Encode finished.
2025/08/09 13:45:50 Total bytes allocated during Encode: 15328 bytes (14.97 KiB)

场景二:v2 流式解码一个巨大的 JSON 数组

我们将使用 jsontext.Decoder 和 jsonv2.UnmarshalDecode 的组合来逐个解码元素。

// jsonv2-streaming/v2/unmarshal.go
package main

import (
    "errors"
    "io"
    "log"
    "runtime"
    "strings"
    "encoding/json/v2"
    "encoding/json/jsontext"
)

func main() {
    const numRecords = 1_000_000
    value := "[" + strings.TrimSuffix(strings.Repeat("{},", numRecords), ",") + "]"
    in := strings.NewReader(value)
    _ = make([]struct{}, 0, numRecords) // out 变量在实际应用中会用到

    for i := 0; i < 5; i++ {
        runtime.GC()
    }

    var statsBefore runtime.MemStats
    runtime.ReadMemStats(&statsBefore)

    log.Println("Starting to decode with json/v2...")

    dec := jsontext.NewDecoder(in)

    // 手动读取数组开始标记 '['
    tok, err := dec.ReadToken()
    if err != nil || tok.Kind() != '[' {
        log.Fatalf("Expected array start, got %v, err: %v", tok, err)
    }

    // 循环解码数组中的每个元素
    for dec.PeekKind() != ']' {
        var record struct{}
        if err := json.UnmarshalDecode(dec, &record); err != nil {
            if errors.Is(err, io.EOF) {
                break
            }
            log.Fatalf("v2 UnmarshalDecode failed: %v", err)
        }
        // 在实际应用中,这里会处理 record,例如:
        // out = append(out, record)
    }
    log.Println("Decode finished.")

    var statsAfter runtime.MemStats
    runtime.ReadMemStats(&statsAfter)

    allocBytes := statsAfter.TotalAlloc - statsBefore.TotalAlloc
    log.Printf("Total bytes allocated during Decode: %d bytes (%.2f KiB)", allocBytes, float64(allocBytes)/1024)
}

分析:同样,你会看到总分配字节数非常小。UnmarshalDecode 在循环中每次只读取并解码一个 {} 对象,而 jsontext.Decoder 的内部缓冲区大小是固定的,不会因输入流的增大而膨胀。

下面是我机器上运行的结果,和v2编码一样,仅需要15KB左右的缓冲区:

$ GOEXPERIMENT=jsonv2 go run unmarshal.go
2025/08/09 13:55:29 Starting to decode with json/v2...
2025/08/09 13:55:29 Decode finished.
2025/08/09 13:55:29 Total bytes allocated during Decode: 15248 bytes (14.89 KiB)

内存占用对比总结

操作 json/v1 (伪流) 内存分配 json/v2 (真流) 内存分配 结论
Marshal ~8 MiB (与数据大小成正比) ~15 KiB (固定开销) 数量级差异
Unmarshal ~8 MiB (与数据大小成正比) ~15 KiB (固定开销) 数量级差异

这个直接的、数据驱动的对比,无可辩驳地证明了 json/v2 在流式处理方面的革命性突破。它将内存复杂度从 O(N) 降低到了 O(1),为 Go 语言处理海量 JSON 数据铺平了道路。

正如提出issue #33714的开发者flimzy所说的那样:json/v2正是json流处理的答案!

小结

encoding/json/v2 的提案和实现,标志着 Go 在大规模数据处理能力上的一个巨大飞跃。通过将语法和语义分离,并提供底层的、真正的流式 API,v2 彻底解决了 v1 长期存在的内存瓶颈问题。

对于 Go 开发者而言,这意味着:

  • 处理超大规模 JSON 不再是难题**:无论是生成还是解析 GB 级别的 JSON 文件或流,都将变得轻而易举,且内存占用可控。
  • 更高的性能和效率:根据 v2 的基准测试,新的实现在解码(Unmarshal)方面比 v1 有 2.7 到 10.2 倍的性能提升。
  • 更灵活的控制力:底层的 jsontext 包为需要进行精细化 JSON 操作的开发者提供了前所未有的能力。

虽然 Go 1.25 json/v2 会以 GOEXPERIMENT 形式落地,但它所展示的强大能力和优秀设计,已经预示了Go JSON 处理的新纪元。我们有充分的理由期待它在未来的 Go 版本中正式发布,成为所有 Go 开发者处理 JSON 数据的首选工具。

本文涉及的示例代码可以在这里下载。


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

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

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

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

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


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

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 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