标签 GC 下的文章

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技能再上一个新台阶!


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

Goroutine泄漏防不胜防?Go GC或将可以检测“部分死锁”,已在Uber生产环境验证

本文永久链接 – https://tonybai.com/2025/07/24/deadlock-detection-by-gc

大家好,我是Tony Bai。

Go 语言的 go 关键字让并发编程变得前所未有的简单,但也带来了新的挑战。当所有 goroutine 都陷入阻塞时,Go runtime 会报告一个“全局死锁”并终止程序。然而,更常见也更隐蔽的是部分死锁:一部分 goroutine 永久阻塞,而程序的其他部分仍在运行。


图: Uber生产服务中因部分死锁导致的goroutine数量变化

如上图所示,这些泄漏的 goroutine 会像“僵尸”一样持续占用内存和资源,在长周期运行的服务中导致内存泄漏、CPU 升高,甚至系统崩溃(Uber工作日的重新部署掩盖了泄漏,但在周末和节假日期间,数字会激增)。现有的工具如 goleak 主要用于测试环境,难以在生产中大规模部署。

这些难以追踪的“部分死锁”在长周期服务中如同定时炸弹。现在,一项革命性的Go提案(#74609)带来了希望:通过赋予垃圾收集器(GC)“新技能”,使其能够直接在运行时检测出这些永久阻塞的 goroutine。这个想法不只是停留在理论层面,其原型工具 GOLF 已经在 Uber 的生产环境中成功验证,发现了数百个此前未被察觉的死锁。本文将和大家一起解读一下这一前沿技术,揭示 Go GC 是如何被改造为并发问题“侦探”的。

核心思想:当“内存不可达”遇上“并发不可达”

这项新提案的核心洞见,是将垃圾收集中的内存可达性与并发编程中的活跃性(liveness)巧妙地联系起来。

我们知道,一个被阻塞的 goroutine(例如,等待从一个 channel 接收数据 <-ch)能否被唤醒,取决于是否有另一个“活跃”的 goroutine 能够对同一个并发原语(这里的 ch)执行配对操作(例如 ch <- data)。

提案的关键假设是:

如果一个被阻塞的 goroutine,其所等待的所有并发原语(channel、mutex 等),从所有当前可运行(runnable)的 goroutine 的视角来看,在内存中都是不可达的,那么这个被阻塞的 goroutine 永远不可能被唤醒——它已经陷入了部分死锁。

换句话说,如果没有任何一个“活人”能找到唤醒你所需的“钥匙”,那你就是一个“僵尸”。

而判断“内存可达性”,正是 Go GC 的核心工作。

GOLF:一个扩展版的 Go 垃圾收集器

研究人员将此思想实现为一个名为 GOLF (Goroutine Leak Fixer) 的工具,它对 Go 的标准 mark-and-sweep GC 进行了扩展。


图: 对GC周期的扩展

GOLF 的工作流程大致如下:

  • 修改 GC Root Set:在 GC 的标记(Marking)阶段开始时,GOLF 不再像标准 GC 那样将所有 goroutine 视为根对象(GC Roots)。相反,它只将当前处于可运行状态(runnable)的 goroutine 作为初始的根集合。

  • 迭代标记与扩展

    • a. GC 从这个最小化的根集合出发,进行第一轮内存可达性标记。
    • b. 标记完成后,GOLF 会检查所有仍处于阻塞状态的 goroutine。
    • c. 对于每个阻塞的 goroutine,它会检查其等待的并发原语(如 channel)是否在刚刚的标记过程中被标记为“可达”。
    • d. 如果一个阻塞 goroutine 等待的某个原语是“可达”的,那么这个 goroutine 就有可能被唤醒。GOLF 称其为“可达活跃”(reachably live),并将其加入到 GC 的根集合中
    • e. 重复 a-d 步骤,直到在一个完整的迭代中,根集合不再扩大。
  • 死锁判定:当迭代稳定后,所有未被加入根集合的、仍处于阻塞状态的 goroutine,都被判定为部分死锁

提案中的实现细节

Go 官方 issue #74609 中讨论的实现,是基于上述学术研究的简化和工程化版本:

  • API 触发:为了将性能影响降到最低,这种增强的 GC 周期不会默认开启,而是通过一个新的 API 来手动触发。
  • 不强制回收:与学术论文中可以强制回收泄漏 goroutine 内存的“Recovery”功能不同,提案的初步实现仅将检测到的 goroutine 标记为死锁,并将其视为永久可达,以避免破坏 Go 的内存安全语义(例如,意外触发 finalizer)。
  • 实验性标志:该功能将通过 GOEXPERIMENT=deadlockgc 标志启用,表明其仍处于实验阶段。

惊人的实验结果:在 Uber 生产环境中大显身手

这项研究的有效性在多个层面得到了验证:
* 微基准测试:在包含 121 个已知可能导致死锁的 go 语句的微基准测试中,GOLF 成功检测出了 94.75% 的部分死锁。
* 大型代码库:在 Uber 的一个包含 180 万行 Go 代码的子集上运行时,GOLF 发现了 357 个已知泄漏中的 180 个(约 50%)。
* 生产环境部署:GOLF 被部署到一个真实的 Uber 生产服务中,在 24 小时内,成功检测到了由 3 个不同编程错误导致的 252 个部分死锁实例。这些问题是之前通过测试未能发现的。

更重要的是,性能测试表明,即使在最坏的情况下,GOLF 带来的 GC 标记阶段的 slowdown 仍然在可接受的范围内,而对于存在大量泄漏的程序,它甚至可能因为减少了需要标记的内存而加速 GC

对 Go 开发者的意义

这项提案一旦被采纳并最终进入 Go 的稳定版本,将对 Go 并发编程生态产生深远影响:

  1. 新一代调试利器:开发者将获得一个强大的、内建于运行时的工具,用于诊断最棘手的并发问题,尤其是在复杂的、长周期运行的微服务中。
  2. 提升生产环境的稳定性:通过在生产中按需触发死锁检测,运维团队可以主动发现并定位潜在的内存泄漏源头,防止其演变为严重的线上事故。
  3. 补充现有工具的盲区:GOLF 的动态、在线检测能力,与 goleak 等基于测试的离线检测工具形成了完美的互补。

小结:从生产验证到 Go 1.26 的未来

将死锁检测的逻辑与垃圾收集的机制相结合,是一次天才般的跨界创新。它利用了 GC 对程序内存图谱的全局视野,以一种理论上可靠且实践中高效的方式,为解决 Go 并发编程中的“部分死锁”顽疾提供了全新的思路。事实上,Go 核心开发者 Rick Hudson 早在十年前就曾勾勒出类似的方法。

而这次,它不再仅仅是一个构想。 Uber 在生产环境中的成功部署和验证,为这项技术的可行性和实用价值提供了强有力的证明。这正是推动该提案在 Go 官方层面迅速获得关注的关键。

在最近的 Go 编译器与运行时会议上,这项来自 Uber 的提案再次成为焦点。Go 团队的核心成员 Michael Knyszek 确认,他们已经收到了 Uber 提交的补丁,并高度评价了其在生产环境中提供的“有用数据”。尽管该方法存在一些漏报(false negatives),但其不会误报(false positives)的特性使其极具实用价值。

会议讨论进一步明确了该功能的未来方向:

  • 明确的目标版本:团队计划推动这项提案在 Go 1.26 开发周期中落地,以避免其在周期后期才被仓促合入。
  • API 形式:最有可能的 API 形式是将其作为一个新的 pprof profile 类型暴露出来。这意味着开发者未来或许可以通过 http://…/debug/pprof/goroutineleak 或类似的端点来按需触发检测。
  • 集成场景
    • 在测试中:可以与 testing 包集成,但必须是可选加入(opt-in)的,因为许多现有测试可能无意中存在 goroutine 泄漏。
    • 在生产中:它将无缝集成到持续性能分析(continuous profiling)系统中,成为诊断线上问题的强大武器。

值得注意的是,Go 团队强调,这个功能的目标是检测和报告泄漏,而不是自动回收。“泄漏的 goroutine 是 bug”,团队明确表示不会冒险去运行这些卡死 goroutine 的 finalizer,因为这可能导致不可预测的行为。

虽然该实现目前尚未移植到最新的 Green Tea GC,并且在 32 位系统上支持有限,但其方向已经非常明确。一个酝酿了十年的构想,在学术界和工业界(Uber)的共同推动下,正以前所未有的速度接近现实。我们有理由期待,在 Go 1.26 中,Go 开发者将迎来一个内建于运行时的、经过生产环境检验的革命性并发问题诊断工具。

资料链接:

  • https://github.com/golang/go/issues/74609
  • https://dl.acm.org/doi/pdf/10.1145/3676641.3715990

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

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

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

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

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


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

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