6. 错误处理
# 6. 错误处理
本章主要讨论在并发程序中处理错误和恐慌(panics)的方法。首先,我们将研究如何在并发程序中融入错误处理机制,包括如何在协程(goroutines)之间传递错误,以便进行处理或报告。然后,我们会探讨恐慌相关的内容。
处理错误和恐慌并没有严格固定的规则,但我希望本章描述的一些指导原则能帮助你编写更健壮的代码。第一条指导原则是:永远不要忽略错误。第二条指导原则是关于何时返回错误以及何时引发恐慌:错误是面向程序用户的,而恐慌是面向程序开发者的。
本章包含以下部分:
- 错误处理
- 恐慌
在本章结束时,你将了解到几种在并发程序中处理错误的方法。
# 错误处理
在Go语言中,错误处理一直是个颇具争议的话题。由于对重复的错误处理样板代码感到厌烦,社区中的许多Go语言使用者(包括我在内)都建议改进错误处理机制。实际上,这些提议大多是关于错误传递的改进,因为说实话,错误很少被真正处理,而是被传递给调用者,有时还会包裹一些上下文信息。
很多错误处理的提议提出了不同形式的 “抛出 - 捕获(throw - catch)” 机制,而其他许多提议仅仅是对 “if err!= nil return err” 这种写法的语法糖。这些提议大多忽略了一点,即现有的错误报告和处理约定在并发环境中运行得很好,比如可以通过通道传递错误:你可以在一个协程中处理另一个协程产生的错误。
在使用Go语言编程时,我想强调的一个重点是,大多数情况下,仅依靠你在屏幕上看到的信息就可以对程序进行分析。代码中所有可能的代码路径都是明确的。从这个意义上说,Go语言是一种对读者极其友好的语言。Go语言的错误处理范式在一定程度上促成了这一点。许多函数会返回一些值以及一个错误。因此,程序如何处理错误通常在代码中是明确可见的。
当程序检测到不可接受的情况时,比如网络连接失败或用户输入无效,就会生成错误。这些错误通常会被传递给调用者,有时会被包装在另一个错误中,以添加描述上下文的信息。添加的信息很重要,因为许多这样的错误会被转换为面向程序用户的消息。例如,在一个处理多个JSON文件的程序中,一条抱怨JSON解析错误的消息如果不说明是哪个文件出现错误,那它是毫无用处的。
但是协程不会返回错误。当协程出现故障时,我们必须寻找其他方法来处理错误。当多个协程用于计算的不同部分,而其中一个协程出现故障时,其余的协程也应该被取消,或者它们的结果应该被丢弃。有时,多个协程都会出现故障,你必须处理多个错误值。关于这方面的指导原则很少,不过有许多第三方包可以让错误处理变得更轻松。首要的指导原则是:永远不要忽略错误。
让我们来看一些常见的模式。如果你向一个协程提交任务并期望稍后收到结果,要确保结果中包含错误信息。如果你有多个可以并发执行的任务,这里展示的模式会很有用。你在各自的协程中启动每个任务,然后根据需要收集结果或错误。当你使用工作池(worker pool)时,这也是处理错误的一种好方法:
// Result类型用于保存预期结果和错误信息。
type Result1 struct {
Result ResultType1
Error err
}
type Result2 struct {
Result ResultType2
Error err
}
...
result1Ch := make(chan Result1)
go func() {
result, err := handleRequest1()
result1Ch <- Result1{Result: result, Error: err}
}()
result2Ch := make(chan Result2)
go func() {
result, err := handleRequest2()
result2Ch <- Result2{Result: result, Error: err}
}()
// 执行其他工作
...
// 从协程收集结果
result1 := <-result1Ch
result2 := <-result2Ch
if result1.Error != nil { // 处理错误
...
}
if result2.Error != nil {
// 处理错误
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
当你检测到错误时,必须留意正在运行的协程。例如,前面的程序在检查错误之前会从所有结果通道读取数据。这确保了所有启动的协程都能终止,然后再进行错误处理。下面的实现会导致第二个协程泄漏:
result1 := <-result1Ch
if result1.Error != nil {
// result2Ch永远不会被读取。协程泄漏!
return result1.Error
}
result2 := <-result2Ch
...
2
3
4
5
6
7
在许多情况下,让所有协程完成任务,然后返回错误,或者如果多个协程失败,返回一个复合错误,这可能就足够了。但有时,如果另一个协程失败,你可能希望取消正在运行的协程。在第8章中,我们将讨论如何使用context.Context
来取消这类计算。现在,我们可以使用一个已取消(canceled)通道来通知协程它们应该停止处理。如果你还记得的话,这是一种常见的模式,即通过关闭通道向所有协程广播一个信号。所以,当一个协程检测到错误时,它会关闭已取消通道。所有协程会定期检查已取消通道是否关闭,如果关闭则返回一个错误。但这种方法存在一个问题:如果多个协程都失败了,它们都会尝试关闭通道,而关闭一个已经关闭的通道会引发恐慌。因此,我们不会直接关闭已取消通道,而是使用一个单独的协程来监听一个取消(cancel)通道,并且只关闭已取消通道一次:
// 为协程设置单独的结果通道
resultCh1 := make(chan Result1)
resultCh2 := make(chan Result2)
// 当一个协程向cancelCh发送信号时,canceled通道会被关闭一次
canceled := make(chan struct{})
// cancelCh可以接收多个取消请求,但只会关闭canceled通道一次
cancelCh := make(chan struct{})
// 确保cancelCh被关闭,否则读取它的协程会泄漏
defer close(cancelCh)
go func() {
// 当从cancelCh接收到信号时,关闭canceled通道一次
once := sync.Once{}
for range cancelCh {
once.Do(func() {
close(canceled)
})
}
}()
// 协程1计算Result1
go func() {
result, err := computeResult1()
if err != nil {
// 取消其他协程
cancelCh <- struct{}{}
// 返回错误。不要关闭通道
resultCh1 <- Result1{Error: err}
return
}
// 如果其他协程失败,停止计算
select {
case <-canceled:
// 关闭resultCh1,这样监听器就不会阻塞
close(resultCh1)
return
default:
}
// 进行更多计算
}()
// 协程2计算Result2
go func() {
...
}()
// 接收结果。如果协程被取消,通道将被关闭(ok将为false)
result1, ok1 := <-resultCh1
result2, ok2 := <-resultCh2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
在这里,如果协程1失败,resultCh1将返回错误,协程2将被取消,resultCh2将被关闭。如果协程2失败,resultCh2将返回错误,协程1将被取消,resultCh1将被关闭。如果它们同时失败,两个错误都会被返回。
这种方法的一个变体是使用错误通道而不是取消通道。一个单独的协程监听错误通道并捕获来自其他协程的错误:
// errCh将用于传递错误
errCh := make(chan error)
// 任何错误都会关闭canceled通道
canceled := make(chan struct{})
// 确保错误监听器终止
defer close(errCh)
// 收集所有错误
errs := make([]error, 0)
go func() {
once := sync.Once{}
for err := range errCh {
errs = append(errs, err)
// 收到错误时取消所有协程
once.Do(func() { close(canceled) })
}
}()
resultCh1 := make(chan Result1)
go func() {
defer close(resultCh1)
result, err := computeResult()
if err != nil {
errCh <- err
// 确保监听器不会阻塞
return
}
// 如果被取消,停止
select {
case <-canceled:
return
default:
}
resultCh1 <- result
}()
result, ok := <-resultCh1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
我在实际中经常看到的另一种错误处理方法是,在每个协程的封闭作用域中使用专门的错误变量。这种方法需要使用等待组(WaitGroup),并且当其中一个协程失败时,无法取消任务。不过,如果没有一个协程执行可取消的操作,这种方法还是有用的。如果你最终采用了这种模式,要确保在等待组的Wait()
调用之后读取错误,因为根据Go语言内存模型,错误变量的设置发生在Wait()
调用返回之前,但在此之前它们是并发的:
wg := sync.WaitGroup{}
wg.Add(2)
var err1 error
go func() {
defer wg.Done()
if err := doSomething1(); err != nil {
err1 = err
return
}
}()
var err2 error
go func() {
defer wg.Done()
if err := doSomething2(); err != nil {
err2 = err
return
}
}()
wg.Wait()
// 在这里收集结果并处理错误
if err1 != nil {
// 处理err1
}
if err2 != nil {
// 处理err2
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 管道
在处理异步管道时,有几种错误处理的方法。管道通常用于处理大量输入。因此,通常不希望仅仅因为其中一个输入的处理失败就停止整个管道。相反,你可以记录错误并继续处理。重要的是在错误中捕获足够的上下文信息,以便在所有处理完成后,你能够回过头来找出针对哪个输入出现了什么问题。处理管道中错误的方法包括但不限于以下几种:
- 每个阶段通过使用错误记录器函数自行处理错误。如果多个阶段尝试同时记录错误,错误记录器必须能够处理并发调用。
- 使用一个单独的错误通道和一个错误监听协程。当在管道中检测到错误时,捕获相关的上下文信息(输入文件名、标识符、完整的输入内容、出错原因、哪个阶段失败等)并发送到通道中。错误监听协程将错误信息存储在数据库中或进行日志记录。
- 将错误传递到下一个阶段。每个阶段检查输入是否包含错误,并将其传递下去,直到管道末尾生成错误输出。
# 服务器
当我谈论服务器时,主要探讨的是其面向请求的特性,而非通信特征。请求可能通过HTTP或gRPC从网络传来,也可能来自命令行。通常,每个请求会在一个单独的goroutine中处理。因此,请求处理栈有责任传播有意义的错误信息,以便构建对用户的响应。如果用户是另一个程序(例如,当我们讨论的是一个Web服务时),包含错误代码和一些诊断信息是很有必要的。结构化错误处理是非常有用的:
// 将此错误嵌入到API可能返回的所有其他结构化错误中
type Error struct {
Code int
HTTPStatus int
DiagMsg string
}
// HTTPError从错误中提取HTTP信息
type HTTPError interface {
GetHTTPStatus() int
GetHTTPMessage() string
}
func (e Error) GetHTTPStatus() int {
return e.HTTPStatus
}
func (e Error) GetHTTPMessage() string {
return fmt.Sprintf("%d: %s", e.Code, e.DiagMsg)
}
// 分别处理HTTP错误和其他无法识别的错误
func WriteError(w http.ResponseWriter, err error) {
if e, ok := err.(HTTPError); ok {
http.Error(w, e.GetHTTPStatus(), e.GetHTTPMessage())
} else {
http.Error(w, http.StatusInternalServerError, err.Error())
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
像上述这样的错误实现,有助于你向用户返回有意义的错误信息,使他们能够处理常见问题,而不会在沮丧中浪费时间。
# 恐慌(Panics)
恐慌与错误不同。恐慌要么是编程错误,要么是无法合理补救的情况(比如内存耗尽)。因此,恐慌应该尽可能向开发者传达更多诊断信息。
有些错误根据上下文可能会变成恐慌。例如,一个程序可能接受用户提供的模板,如果模板解析失败则生成错误。然而,如果解析硬编码的模板失败,那么程序应该引发恐慌。第一种情况是用户错误,第二种情况是程序漏洞。
作为并发程序的开发者,对于错误只有三种处理方式:处理它(记录错误、选择其他程序流程,或者什么都不做直接忽略)、将它传递给调用者(有时会附带一些额外的上下文信息),或者引发恐慌。当并发程序中发生恐慌时,运行时会确保所有嵌套的函数调用逐个返回,一直返回到启动该goroutine的函数。在此过程中,函数的所有延迟(deferred)代码块也会运行。这是一个从恐慌中恢复的机会,或者清理任何不会被垃圾回收的资源。如果恐慌没有被调用链中的某个函数处理,程序将会崩溃。所以,作为开发者,你需要进行一些清理工作。
在服务器程序中,通常每个请求由一个单独的goroutine处理。大多数服务器框架(包括标准库中的net/http
包)通过打印堆栈信息并使请求失败来处理此类恐慌,从而避免程序崩溃。如果你在编写服务器时没有使用这样的库,或者想要在捕获恐慌时报告更多信息,就应该自己处理恐慌:
func PanicHandler(next func(http.ResponseWriter, *http.Request))
func(http.ResponseWriter, *http.Request) {
return func(wr http.ResponseWriter, req *http.Request) {
defer func() {
if err := recover(); err != nil {
// 打印恐慌信息
}
}()
next(wr, req)
}
}
func main() {
http.Handle("/path", PanicHandler(pathHandler))
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
只有在启动恐慌的goroutine中才能恢复恐慌。这意味着,如果你启动了一个可能引发恐慌的goroutine,并且不希望该恐慌终止程序,就必须进行恢复操作:
go func(errCh chan<- error, resultCh chan<- result) {
defer func() {
if err := recover(); err != nil {
// 恢复恐慌,改为返回错误
errCh <- err
close(resultCh)
}
}()
// 执行工作
}()
2
3
4
5
6
7
8
9
10
在处理并发处理管道(比如我们在第5章中构建的那些管道)时,谨慎处理恐慌是很有必要的。恐慌通常表明程序存在漏洞,但在长时间运行的管道处理数小时后终止它并非最佳解决方案。通常,你会希望在处理完成后记录所有的恐慌和错误信息。所以,必须确保在正确的位置进行恐慌恢复。例如,在下面的代码片段中,恐慌恢复代码围绕实际的管道阶段处理函数,这样恐慌会被记录下来,而for
循环会继续处理:
func pipelineStage[IN any, OUT WithError](input <-chan IN,
output chan<- OUT, errCh chan<- error, process func(IN) OUT) {
defer close(output)
for data := range input {
// 处理下一个输入
result, err := func() (res OUT, err error) {
defer func() {
// 将恐慌转换为错误
if err = recover(); err != nil {
return
}
}()
return process(data), nil
}()
if err != nil {
// 报告错误并继续
errCh <- err
continue
}
output <- result
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
如果你熟悉C++或Java中的异常处理机制,可能会想是否可以用恐慌来替代抛出异常。一些指南强烈不建议这样做,但你也能找到其他资源支持这种做法。我把这个判断留给你,不过在标准库的JSON包中也有这样的例子。有人认为,如果你有一个大型包,其导出函数很少,那么将恐慌用作错误处理机制可能是合理的,因为这成为了实现细节。JSON反序列化就是一个例子,深度嵌套的解析器是另一种可能适用的情况。如果你决定这样做,可以按照以下方式:使用包级别的错误类型来区分真正的恐慌和错误。下面的代码片段是标准库JSON反序列化实现的修改版本:
// 所有内部函数使用这种类型的错误引发恐慌,而不是返回错误
type packageError struct{ error }
// 导出函数是顶级函数,它调用未导出的实现函数并恢复恐慌
func ExportedFunction() (err error) {
defer func() {
if r := recover(); r != nil {
// 如果恐慌是由包内抛出的错误,恢复并返回错误
if e, ok := r.(packageError); ok {
err = e.error
} else {
// 这是真正的恐慌
panic(r)
}
}
}()
unexportedFunction()
return nil
}
// unexportedFunction是实现的顶级函数
func unexportedFunction() {
if err := doThings(); err != nil {
panic(packageError{err})
}
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
这里,unexportedFunction
执行实际的工作,ExportedFunction
通过将一些恐慌转换为错误,充当unexportedFunction
的外部接口。
# 总结
你的程序必须生成有用的错误消息,告知用户出了什么问题以及如何解决。Go语言让开发者能够完全控制错误的生成和传递方式。在本章中,我们介绍了一些处理并发产生的错误的方法。
接下来,我们将学习用于调度未来事件的定时器(timers)和时钟(tickers)。