当前位置: 首页 > article >正文

Go 语言中 panic 和 recover 的代价:性能与设计的权衡

在 Go 语言的编程世界里,panicrecover是处理异常情况的重要机制。然而,它们的使用是否得当,对程序的性能和设计有着深远的影响。今天,我们就来深入探讨一下 Go 语言中panicrecover的代价,以及在实际编程中该如何正确使用它们。

在深入了解 Go 语言的panicrecover之前,让我们先回顾一下 Java 中的异常处理机制。Joshua Bloch 在他的经典著作《Effective Java》中提到,有些开发者会滥用 Java 异常来控制程序流程。比如,在遍历数组时,他们可能会这样写代码:

try {
    int i = 0;
    while (true)
        range[i++].climb(); 
} catch (ArrayIndexOutOfBoundsException e) { }

这种做法利用数组越界异常来结束循环,看似巧妙,实则存在诸多问题。相比之下,使用传统的for循环更加清晰和高效:

for (int i = 0; i < range.length; i++) {
    range[i].climb();
}

Bloch 指出,一些开发者选择异常驱动的方式,是因为他们认为数组访问的边界检查成本高且多余,因为 Java 编译器会为每次数组访问引入边界检查。但实际上,这种想法是错误的。首先,异常是为处理特殊情况设计的,JVM 实现者没有动力让异常处理像显式检查那样快速;其次,将代码放在try-catch块中会抑制 JVM 的某些优化;最后,标准的数组遍历方式不一定会导致冗余检查,很多 JVM 实现会优化掉这些检查。经测试,基于异常的遍历方式比标准方式慢很多。

Go 语言的设计者有意避免为其配备像 Java 那样的异常系统。他们认为,将异常与控制结构耦合,会导致代码复杂,还容易让开发者把普通错误(如文件打开失败)当作异常处理。在 Go 语言中,对于普通的错误处理,多值返回使得报告错误变得简单,而无需重载返回值。同时,Go 语言提供了panicrecover这两个内置函数,用于处理真正的异常情况。不过,对于一些刚接触 Go 语言的开发者来说,他们可能难以适应将预期的失败情况作为值返回,而不是当作异常处理的习惯,甚至可能会滥用panicrecover来处理一些本可以正常处理的情况。

为了更直观地了解滥用panicrecover的影响,我们将 Bloch 的示例代码转换为 Go 语言,并进行性能测试。假设我们有一个Mountain结构体,其中的Climb方法用于标记山是否被攀登过:

package main

type Mountain struct{
    climbed bool
}

func (m *Mountain) Climb() {
    m.climbed = true
}

我们定义了两个遍历Mountain切片的函数,ClimbAllPanicRecover函数滥用panicrecover来实现遍历:

func ClimbAllPanicRecover(mountains []Mountain) {
    defer func() {
        recover()
    }()
    for i := 0; ; i++ {
        mountains[i].Climb() 
    }
}

ClimbAll函数则采用更符合 Go 语言习惯的方式:

func ClimbAll(mountains []Mountain) {
    for i := range mountains {
        mountains[i].Climb()
    }
}

接下来,我们对这两个函数进行基准测试:

package main

import (
    "fmt"
    "testing"
)

var cases [][]Mountain

func init() {
    for _, size := range []int{0, 1, 1e1, 1e2, 1e3, 1e4, 1e5} {
        s := make([]Mountain, size)
        cases = append(cases, s)
    }
}

func BenchmarkClimbAll(b *testing.B) {
    benchmark(b, "idiomatic", ClimbAll)
    benchmark(b, "panic-recover", ClimbAllPanicRecover)
}

func benchmark(b *testing.B, impl string, climbAll func([]Mountain)) {
    for _, ns := range cases {
        f := func(b *testing.B) {
            for b.Loop() {
                climbAll(ns)
            }
        }
        desc := fmt.Sprintf("impl=%s/size=%d", impl, len(ns))
        b.Run(desc, f)
    }
}

在相对空闲的机器上运行这些基准测试,并使用benchstat工具分析结果:

$ go version 
go version go1.24.0 darwin/amd64
$ go test -run '^$' -bench . -count 10 -benchmem > results.txt
$ benchstat -col '/impl@(idiomatic panic-recover)' results.txt
goos: darwin
goarch: amd64
pkg: github.com/jub0bs/panicabused
cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
                       │  idiomatic  │              panic-recover              │
                       │   sec/op    │    sec/op      vs base                  │
ClimbAll/size=0-8        2.239n ± 8%   193.900n ± 1%  +8560.12% (p=0.000 n=10)
ClimbAll/size=1-8        2.638n ± 1%   196.400n ± 2%  +7346.45% (p=0.000 n=10)
ClimbAll/size=10-8       5.424n ± 1%   199.300n ± 2%  +3574.41% (p=0.000 n=10)
ClimbAll/size=100-8      44.69n ± 1%    238.65n ± 4%   +434.01% (p=0.000 n=10)
ClimbAll/size=1000-8     371.6n ± 0%     565.8n ± 1%    +52.27% (p=0.000 n=10)
ClimbAll/size=10000-8    3.646µ ± 1%     3.906µ ± 0%     +7.15% (p=0.000 n=10)
ClimbAll/size=100000-8   36.27µ ± 0%     36.54µ ± 1%     +0.73% (p=0.000 n=10)
geomean                  95.10n          759.9n        +699.03%

                       │  idiomatic  │        panic-recover         │
                       │    B/op     │    B/op     vs base          │
ClimbAll/size=0-8        0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=1-8        0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=10-8       0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=100-8      0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=1000-8     0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=10000-8    0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=100000-8   0.00 ± 0%     24.00 ± 0%  ? (p=0.000 n=10)
geomean                            ¹   24.00       ?
¹ summaries must be >0 to compute geomean

                       │  idiomatic   │        panic-recover         │
                       │  allocs/op   │ allocs/op   vs base          │
ClimbAll/size=0-8        0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=1-8        0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=10-8       0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=100-8      0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=1000-8     0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=10000-8    0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
ClimbAll/size=100000-8   0.000 ± 0%     1.000 ± 0%  ? (p=0.000 n=10)
geomean                             ¹   1.000       ?
¹ summaries must be >0 to compute geomean

从结果可以明显看出,在输入切片较小时,ClimbAllPanicRecover函数比ClimbAll函数慢得多,每次调用ClimbAllPanicRecover还会分配 24 字节的内存(在 64 位系统上),而ClimbAll函数则不会分配内存。随着输入切片长度的增加,两者的性能差距才逐渐缩小。

细心的读者可能会认为,ClimbAllPanicRecover函数性能不佳的部分原因是内联问题。内联是一种编译器优化策略,即 “用函数体替换函数调用”,通常能提高执行速度。然而,包含defer语句或调用recover的函数不能被内联。通过检查编译器的优化决策可以发现,ClimbAllPanicRecover函数及其内部的匿名函数都不能被内联,而ClimbAll函数可以被内联。为了验证内联是否是导致性能差异的主要原因,我们对ClimbAll函数禁用内联后重新进行基准测试,发现ClimbAll函数在除大输入切片外的情况下,仍然比ClimbAllPanicRecover函数性能好得多。不过,在实际场景中,无法内联某个函数可能会显著影响性能。

与 Java 类似,Go 语言也保证内存安全,当切片索引操作越界时,会触发运行时恐慌。编译器在某些情况下可以消除边界检查以提高性能。在我们的示例中,编译器可以消除ClimbAll函数循环中的边界检查,但不能消除ClimbAllPanicRecover函数循环中的边界检查。这表明,符合习惯用法的方式更有利于边界检查的消除。

在实际的开源项目中,我们可能会看到一些项目使用panicrecover来处理内部失败情况,甚至 Go 语言的标准库中也有这样的例子,如text/templateencoding/jsonencoding/gobregexp/syntax等包。这样做的主要动机似乎是为了方便,当调用栈很深(可能由于大量递归调用)时,使用panicrecover可以避免大量样板代码,将错误处理逻辑集中在恐慌恢复点,让正常流程更加清晰。此外,这种方式在某些情况下还能提高性能,比如减少中间函数结果的需求,减少代码分支,从而降低分支预测错误的可能性。

但是,使用panicrecover处理内部失败情况也存在风险。如果recover不小心吞没了恐慌,可能会掩盖触发恐慌的错误。例如:

func ClimbAllPanic(mountains []Mountain) {
    defer func() {
        recover()
    }()
    for i := 0; ; i++ {
        mountains[i-1].Climb() 
    } 
}

encoding/json包的 issue 23012 中就有这样的问题示例。

如果要在项目中采用这种方式,一定要通过注释和基准测试结果来证明设计决策的合理性,并且要将其作为包的实现细节,避免内部的恐慌泄漏到包的 API 中,给调用者带来不必要的麻烦。

Go 语言中的panicrecover是强大的工具,但使用时需要谨慎权衡。在处理异常情况时,要根据具体场景选择合适的方式,避免滥用panicrecover导致性能问题和代码维护困难。希望通过今天的分享,大家对 Go 语言的异常处理机制有更深入的理解,在今后的编程中能够更加合理地运用它们。你在使用panicrecover时遇到过哪些有趣的问题呢?欢迎在评论区分享你的经验!

科技脉搏,每日跳动。

与敖行客 Allthinker一起,创造属于开发者的多彩世界。

图片

- 智慧链接 思想协作 -


http://www.kler.cn/a/573900.html

相关文章:

  • css的毛玻璃效果backdrop-filter: blur()
  • 力扣hot100刷题——栈
  • 【C++设计模式】第七篇:桥接模式(Bridge)
  • Windows 10/11 系统下 Git 的详细安装步骤和基础设置指南
  • Power Settings Explorer官网下载地址: Windows电源选项设置管理工具
  • OSI七层网络结构和TCP/IP四层结构
  • 文件上传和下载前后端交互逻辑
  • Echarts与Vue3中获取DOM节点可能出现的异常错误
  • Springboot + nacos + dubbo 实现微服务架构
  • JavaEE基础之- ajax
  • DeepSeek大模型+RAGFlow实战指南:构建知识驱动的智能问答系统
  • ​Unity插件-Mirror使用方法(七)组件介绍(​Network Animator)
  • Freertos卡在while( uxDeletedTasksWaitingCleanUp > ( UBaseType_t ) 0U )
  • 时间复杂度分析与递归,以新南UNSW的COMP2521作业题为例
  • JVM常用概念之对象初始化的成本
  • 快速生成viso流程图图片形式
  • Scala 中的数据类型转换规则
  • 5.RabbitMQ交换机详解
  • 迷你世界脚本方块接口:Block
  • 地下井室可燃气体监测装置:守护地下安全,防患于未“燃”!