那一次我用Go panic做流程控制
摘要
一位Go工程师讲述了一次事件:内存数据存储因排序缓慢而超载,他们在排序函数内部实现了上下文取消,使用panic和recover进行非局部流程控制,类似于encoding/json处理错误的方式。
<p><a href="https://lobste.rs/s/arfalj/one_time_i_used_go_panics_for_flow_control">评论</a></p>
查看缓存全文
缓存时间: 2026/05/23 10:43
# 那次我用Go的panic做流程控制
来源:https://noncrab.net/posts/panic-as-flow-control/
主人公发现支撑我们支持工作的一个关键服务竟然对过载毫无抵抗力,以及我们如何修复这个问题。
我们工作的一部分支持基础设施是一个内存数据存储,允许我们根据各种维度查询待办的支持任务,比如工作类型、是否因某种原因被搁置等。它在功能上等同于SQL数据库中的单个表,有单一数据集、布尔过滤器和可配置排序。
在工作中,我们有一个内存数据存储,为部分支持基础设施提供支持。它有点类似于位图过滤再加事后过滤,因此任何使用sort/limit都会对整个结果集排序。关键点在于,结果集可能大到排序需要一两秒钟。
背景是,当时这个服务部署没有自动扩缩容,上游服务会重试失败的请求,有时会在相对较短的超时后重试。这很有趣。
所以有一天,这个服务承受了超出其处理能力的查询负载;由于缺乏弹性,它过载了,查询开始花费**比平时长得多**的时间(比如,长达一分钟,而正常情况下最多1-2秒)。不幸的是,因为这是个事故,有时人会 panic,我的一种猜测是内存变慢了。这当然很荒谬,但在时间压力下,事故大脑是非常真实的。
然而,正如前面所暗示的,这个服务只是过载了,所以我们不仅有略高于平均水平的负载,还有来自重试的**失败**负载。在Go服务中,大多数情况下我们会传递一个 [`context`](https://go.dev/doc/database/cancel-operations),这样当调用方放弃我们时,我们可以取消操作,短路并提前退出。
但是,当我们拿到CPU profile并查看时,绝大部分CPU时间都花在了查询的排序阶段。在Go中,所有 [`sort`](https://pkg.go.dev/sort) 函数都不支持取消(这很合理,因为通常你是在批量上下文中,或者排序的数量少到时间不重要)。那么,该怎么办呢?
通常,context取消会有叶子函数检查 [`错误`](https://pkg.go.dev/context#Cause),然后通过典型的 [`错误即值`](https://go.dev/doc/tutorial/handle-errors) 机制传播错误。然而,没有任何排序函数(例如 `slices.SortFunc` (https://pkg.go.dev/slices#SortFunc))接收 context 或者允许返回错误。
幸运的是,Go还有另一种非局部信号机制来处理错误(例如,如果你解引用了一个 `nil` 指针),即 [panics](https://go.dev/blog/defer-panic-and-recover)。这种机制通常不用于错误处理,因为非局部流程控制可能更难推理,但在一个狭窄定义的上下文中是有意义的。
例如,`encoding/json` 包就是这样做的,比如通过 `json.(*encodingState).error(...)` (https://github.com/golang/go/blob/go1.26.2/src/encoding/json/encode.go#L348-L350) 抛出 panic,并在顶级函数 `json.(*encodingState).marshal(...)` (https://github.com/golang/go/blob/go1.26.2/src/encoding/json/encode.go#L334-L342) 的范围内 recover。这样,任何客户端代码都不会看到非局部流程控制,工程师也不会遇到意外的 panic。
所以我们将代码从这样:
```go
func execute(ctx context.Context) (results, error) {
resultSet := query.filter(someTable)
slices.SortFunc(resultSet, func(a, b Row) int {
return query.compare(a, b)
})
}
```
改成了这样:
```go
type nonLocalCancellation struct {err error}
func execute(ctx context.Context) (results, error) {
resultSet := query.filter(someTable)
var sortErr error
defer func() {
// 参考:https://go.dev/blog/defer-panic-and-recover
if r := recover(); r != nil {
if c, ok := r.(nonLocalCancellation); ok {
sortErr = c.err
} else {
panic(r)
}
}
}()
slices.SortFunc(resultSet, func(a, b Row) int {
if ctx.Err() != nil {
panic(nonLocalCancellation{err: ctx.Err()})
}
return query.compare(a, b)
})
if sortErr != nil {
return nil, sortErr
}
return resultSet, nil
}
```
这虽然折腾了不少(这是一个丑陋问题的丑陋解决方案),但确实意味着如果调用方放弃了查询,我们就不会浪费时间为一个永远不会关心结果的请求排序。
相似文章
Rust:不要 panic
一段视频转录文本,讨论如何通过使用组合器和 Result 类型而不是 unwrap 来避免 Rust 中的 panic,解释了三种典型的崩溃原因,并强调编写健壮的代码。
Go 中的数据竞争与内存模型
本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。
并发服务器:第 8 部分 - Go
本文是编写并发网络服务器系列文章的第 8 部分,重点介绍如何在 Go 中使用 goroutines 和 Go 运行时调度来实现并发。
C语言中的Go风格并发
一篇详细的技术文章,探讨如何在C语言中复制Go的并发模型,使用POSIX线程、互斥锁、条件变量和工作池,作为Solod转译器项目的一部分。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。