关于 golang 错误处理的一些优化想法

lxt1045 · · 1902 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

<!-- # golang 错误处理的一些优化想法 --> 本文[首发于这里](https://juejin.cn/post/7121929424148103198) ## 0. 前言 1. 由于笔者水平有限,文章中难免出现各种错误,欢迎吐槽。 2. 由于篇幅所限,大部分代码细节并未讲的很清楚,如有疑问欢迎讨论。 ## 1. 当前存在的问题 ### 1.1 标准库的 error 信息量少 <span data-word-id="1024" class="abbreviate-word">golang</span> 自带的 errors.New() 和 fmt.Errorf() 都只能记录 string 信息,错误现场只能靠 log 提供,如果需要获取调用栈,需要额外的代码逻辑。即使后来增加了 "%w" 的支持也没有本质的改变。 令人欣喜的是,[pkg/errors](https://github.com/pkg/errors) 补齐了这个短板,其 [WithStack()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L143) 提供了完整的调用栈信息,且 [Wrap()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L181) 也提供了更详细的 error 路径追踪能力。 ### 1.2 [pkg/errors](https://github.com/pkg/errors) 好用,但性能较差 我们简单给 [pkg/errors](https://github.com/pkg/errors) 做个基准测试: ```go package errors import ( "errors" "testing" pkgerrs "github.com/pkg/errors" ) func deepCall(depth int, f func()) { if depth <= 0 { f() return } deepCall(depth-1, f) } func BenchmarkPkg(b *testing.B) { b.Run("pkg/errors", func(b *testing.B) { b.ReportAllocs() var err error deepCall(10, func() { for i := 0; i < b.N; i++ { err = pkgerrs.New("ye error") GlobalE = fmt.Sprintf("%+v", err) } b.StopTimer() }) }) b.Run("errors-fmt", func(b *testing.B) { b.ReportAllocs() var err error deepCall(10, func() { for i := 0; i < b.N; i++ { err = errors.New("ye error") GlobalE = fmt.Sprintf("%+v", err) } b.StopTimer() }) }) b.Run("errors-Errors", func(b *testing.B) { b.ReportAllocs() var err error deepCall(10, func() { for i := 0; i < b.N; i++ { err = errors.New("ye error") GlobalE = err.Error() } b.StopTimer() }) }) } /* 结果如下: cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkPkg BenchmarkPkg/pkg/errors BenchmarkPkg/pkg/errors-12 105876 10353 ns/op 2354 B/op 36 allocs/op BenchmarkPkg/errors-fmt BenchmarkPkg/errors-fmt-12 8025952 154.4 ns/op 24 B/op 2 allocs/op BenchmarkPkg/errors-Errors BenchmarkPkg/errors-Errors-12 42631442 26.23 ns/op 16 B/op 1 allocs/op */ ``` 由基准测试结果可知,仅 10 个调用层级 [pkg/errors](https://github.com/pkg/errors) 就需要损耗近 10us; 而 errors.New() 在同等条件下只损耗 150ns,如果用 Error() 输出甚至只要不到 30ns,差距巨大。 这也是很多 gopher 犹豫是否用 [pkg/errors](https://github.com/pkg/errors) 替换 errors.New() 和 fmt.Errorf() 的原因。 ### 1.3 [pkg/errors](https://github.com/pkg/errors) 信息冗余较严重 [WithStack()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L143) 的冗余信息应该不算多, 不过 [Wrap()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L181) 接口还带上完整的调用栈就显得没那么必要了,因为大部分和 [WithStack()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L143) 其实是重复的,而且如果多调用几次 [Wrap()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L181) 很容易会造成日志超长。 ### 1.4 panic(err) 方便,但有可能会造成程序退出 很多同学受不了 if err!=nil{ ... return } 的错误处理方式,转而使用更方便的 defer...panic(err) 方式。 这种方式虽然可以方便的处理错误,不过也会带来致命的问题----如果忘记 defer recover() 将会造成整个程序的退出。 也是因此,很多团队把 defer...panic(err) 方式列为了红线,严禁触碰。 ## 2. 有什么改进想法? 可以看到 [pkg/errors](https://github.com/pkg/errors) 实际上是使用 [runtime.Callers](https://github.com/golang/go/blob/release-branch.go1.18/src/runtime/extern.go#L215) 获取完整调用栈的,而该函数实际性能比较差,简单基准测试如下: ```go func BenchmarkStack(b *testing.B) { b.Run("runtime.Callers", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { pcs := pool.Get().(*[DefaultDepth]uintptr) n := runtime.Callers(2, pcs[:DefaultDepth]) var cs []string traces, more, f := runtime.CallersFrames(pcs[:n]), true, runtime.Frame{} for more { f, more = traces.Next() cs = append(cs, f.File+":"+strconv.Itoa(f.Line)) } pool.Put(pcs) } }) }) } /* cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkStack BenchmarkStack/runtime.Callers BenchmarkStack/runtime.Callers-12 252842 4947 ns/op 1820 B/op 24 allocs/op */ ``` 由测试结果可知,其获取 stack 的损耗接近 5us。 [笔者之前的文章](https://studygolang.com/articles/35796) 里也有涉及 [runtime.Callers](https://github.com/golang/go/blob/release-branch.go1.18/src/runtime/extern.go#L215) 的优化,该文章虽然仅优化了获取当前代码行的性能,不过其思路依然可以作为此次优化的参考。 ### 2.1 通过缓存提升 pc 到代码行号的性能 ```go package errors import ( "runtime" "strconv" "sync" "unsafe" "github.com/lxt1045/errors" ) const DefaultDepth = 32 var ( cacheStack = errors.AtomicCache[[DefaultDepth]uintptr{}, []string]{} pool = sync.Pool{ New: func() any { return &[DefaultDepth]uintptr{} }, } ) func NewStack(skip int) (stack []string) { pcs := pool.Get().(*[DefaultDepth]uintptr) n := runtime.Callers(skip+2, pcs[:DefaultDepth-skip]) for i := n; i < DefaultDepth; i++ { pcs[i] = 0 } stack = cacheStack.Get(*pcs) if len(stack) == 0 { stack = parseSlow(pcs[:n]) cacheStack.Set(*pcs, stack) } pool.Put(pcs) return } func parseSlow(pcs []uintptr) (cs []string) { traces, more, f := runtime.CallersFrames(pcs), true, runtime.Frame{} for more { f, more = traces.Next() cs = append(cs, f.File+":"+strconv.Itoa(f.Line)) } return } ``` 做个简单的基准测试: ```go func BenchmarkCacheStack(b *testing.B) { b.Run("NewStack", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { NewStack(0) } }) }) b.Run("runtime.Callers", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { pcs := pool.Get().(*[DefaultDepth]uintptr) n := runtime.Callers(2, pcs[:DefaultDepth]) var cs []string traces, more, f := runtime.CallersFrames(pcs[:n]), true, runtime.Frame{} for more { f, more = traces.Next() cs = append(cs, f.File+":"+strconv.Itoa(f.Line)) } pool.Put(pcs) } }) }) } /* 结果: cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkCacheStack BenchmarkCacheStack/NewStack BenchmarkCacheStack/NewStack-12 1285654 983.3 ns/op 0 B/op 0 allocs/op BenchmarkCacheStack/runtime.Callers BenchmarkCacheStack/runtime.Callers-12 272685 4435 ns/op 1820 B/op 24 allocs/op */ ``` 从测试结果来看,缓存的效果还是很明显的,由 4000ns 多提升到 1000ns 左右。 ### 2.2 从调用栈中获取 pc 列表 我们来先看一下 golang 的调用栈,下图出自[曹春晖老师的github文章](https://github.com/cch123/asmshare/blob/master/layout.md#%E6%9F%A5%E7%9C%8B-go-%E8%AF%AD%E8%A8%80%E7%9A%84%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8%E8%A7%84%E7%BA%A6) : ``` caller +------------------+ | | +----------------------> -------------------- | | | | | caller parent BP | | BP(pseudo SP) -------------------- | | | | | Local Var0 | | -------------------- | | | | | ....... | | -------------------- | | | | | Local VarN | -------------------- caller stack frame | | | callee arg2 | | |------------------| | | | | | callee arg1 | | |------------------| | | | | | callee arg0 | | ----------------------------------------------+ FP(virtual register) | | | | | | return addr | parent return address | +----------------------> +------------------+--------------------------- <-------------------------------+ | caller BP | | | (caller frame pointer) | | BP(pseudo SP) ---------------------------- | | | | | Local Var0 | | ---------------------------- | | | | Local Var1 | ---------------------------- callee stack frame | | | ..... | ---------------------------- | | | | | Local VarN | | SP(Real Register) ---------------------------- | | | | | | | | | | | | | | | | +--------------------------+ <-------------------------------+ callee ``` 我们看到调用栈中的两个关键信息----"parent return address" 和 "caller BP(caller frame pointer)",分别表示当前函数的栈帧的返回 pc 和 栈基地址。 通过这两者的配合,我们就可以获得较完整的调用栈: ```asm // func buildStack(s []uintptr) int TEXT ·buildStack(SB), NOSPLIT, $24-8 NO_LOCAL_POINTERS MOVQ cap+16(FP), DX // s.cap MOVQ p+0(FP), AX // s.ptr MOVQ $0, CX // loop.i = 0 loop: CMPQ CX, DX // if i >= s.cap { return } JAE return // 无符号大于等于就跳转 MOVQ +8(BP), BX // last pc -> BX MOVQ BX, 0(AX)(CX*8) // s[i] = BX ADDQ $1, CX // CX++ / i++ MOVQ +0(BP), BP // last BP; 展开调用栈至上一层 CMPQ BP, $0 // if (BP) <= 0 { return } JA loop // 无符号大于就跳转 return: MOVQ CX,n+24(FP) // ret n RET ``` 但是,我们知道,golang 为了提高函数调用的性能,会对符合条件的小函数进行内联。所以,我们通过上面的 buildStack 函数,实际上是无法获取完整的调用栈的。 我们可以在[ golang 的源码](https://github.com/golang/go/blob/release-branch.go1.18/src/runtime/traceback.go#L356) 中找到展开内联函数获取 pc 的代码: ```go // If there is inlining info, record the inner frames. if inldata := funcdata(f, _FUNCDATA_InlTree); inldata != nil { inltree := (*[1 << 20]inlinedCall)(inldata) for { ix := pcdatavalue(f, _PCDATA_InlTreeIndex, tracepc, &cache) if ix < 0 { break } if inltree[ix].funcID == funcID_wrapper && elideWrapperCalling(lastFuncID) { // ignore wrappers } else if skip > 0 { skip-- } else if n < max { (*[1 << 20]uintptr)(unsafe.Pointer(pcbuf))[n] = pc n++ } lastFuncID = inltree[ix].funcID // Back up to an instruction in the "caller". tracepc = frame.fn.entry() + uintptr(inltree[ix].parentPc) pc = tracepc + 1 } } ``` 可以看到,它是在 _FUNCDATA_InlTree 中查找内联函数,并补充到 pc 列表中。我们也可以抄它,不过没必要, 显然 buildStack() 拿到的 pc 列表和 runtime.Callers() 拿到的 pc 列表肯定是一一对应的(如果有不同看法,欢迎来讨论), 所以我们可以利用缓存就能达到这个目的。 利用缓存提升性能的代码如下: ```go func NewStack2(skip int) (stack []string) { pcs := pool.Get().(*[DefaultDepth]uintptr) n := buildStack(pcs[:]) for i := n; i < DefaultDepth; i++ { pcs[i] = 0 } stack = cacheStack.Get(*pcs) if len(stack) == 0 { pcs1 := make([]uintptr, DefaultDepth) npc1 := runtime.Callers(2, pcs1[:DefaultDepth]) stack = parseSlow(pcs1[:npc1]) cacheStack.Set(*pcs, stack) } if len(stack)<skip{ stack=nil }else{ stack = stack[skip:] } pool.Put(pcs) return } ``` 再做个简单的基准测试: ```go func BenchmarkCacheStack(b *testing.B) { b.Run("NewStack", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { NewStack(0) } }) }) b.Run("NewStack2", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { NewStack2(0) } }) }) b.Run("runtime.Callers", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { pcs := pool.Get().(*[DefaultDepth]uintptr) n := runtime.Callers(2, pcs[:DefaultDepth]) var cs []string traces, more, f := runtime.CallersFrames(pcs[:n]), true, runtime.Frame{} for more { f, more = traces.Next() cs = append(cs, f.File+":"+strconv.Itoa(f.Line)) } pool.Put(pcs) } }) }) } /* 结果: cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkCacheStack BenchmarkCacheStack/NewStack BenchmarkCacheStack/NewStack-12 1287921 932.1 ns/op 0 B/op 0 allocs/op BenchmarkCacheStack/NewStack2 BenchmarkCacheStack/NewStack2-12 23890822 49.06 ns/op 0 B/op 0 allocs/op BenchmarkCacheStack/runtime.Callers BenchmarkCacheStack/runtime.Callers-12 263892 4285 ns/op 1820 B/op 24 allocs/op */ ``` 基准测试的结果还是让人满意的,获取调用栈时间损耗从接近 4000ns 优化到了 50ns 左右了。 至此,提高获取调用栈的性能的目标我们已经达到了。 不过,这种优化方式也是有缺点的。 就是强依赖调用栈上的 BP 信息,而由 [go的下面这段代码](https://github.com/golang/go/blob/release-branch.go1.18/src/runtime/traceback.go#L265) 可知,golang 的 BP 信息只在 AMD64 和 ARM64 平台上才有。实际上在其他平台上,我们也可以通过 "debug/dwarf" 包读取 golang 的 DWARF 信息,从而解析出 pc 到栈帧的映射,不过如果这样做的话,性能可能和 runtime.Callers() 就没多大差别了。 ```go // For architectures with frame pointers, if there's // a frame, then there's a saved frame pointer here. // // NOTE: This code is not as general as it looks. // On x86, the ABI is to save the frame pointer word at the // top of the stack frame, so we have to back down over it. // On arm64, the frame pointer should be at the bottom of // the stack (with R29 (aka FP) = RSP), in which case we would // not want to do the subtraction here. But we started out without // any frame pointer, and when we wanted to add it, we didn't // want to break all the assembly doing direct writes to 8(RSP) // to set the first parameter to a called function. // So we decided to write the FP link *below* the stack pointer // (with R29 = RSP - 8 in Go functions). // This is technically ABI-compatible but not standard. // And it happens to end up mimicking the x86 layout. // Other architectures may make different decisions. if frame.varp > frame.sp && framepointer_enabled { frame.varp -= goarch.PtrSize } // src/runtime/runtime2.go // Must agree with internal/buildcfg.Experiment.FramePointer. const framepointer_enabled = GOARCH == "amd64" || GOARCH == "arm64" ``` ### 2.3 减少 stack 冗余信息,并修改日志结构 针对 [WithStack()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L143),可以压缩一下 stack 信息。 ```go var ( rootDirs = []string{"src/", "/pkg/mod/"} // file 会从rootDir开始截断 // skipPkgs里的pkg会被忽略 skipPrefixFiles = []string{ "github.com/cloudwego/kitex", "testing/benchmark.go", "testing/testing.go", } ) func parseSlow(pcs []uintptr) (cs []string) { traces, more, f := runtime.CallersFrames(pcs), true, runtime.Frame{} for more { f, more = traces.Next() c := toCaller(f) if skipFile(c.File) && len(cs) > 0 { break } cs = append(cs, c.String()) if strings.HasSuffix(f.Function, "main.main") && len(cs) > 0 { break } } return } func skipFile(f string) bool { for _, skipPkg := range skipPrefixFiles { if strings.HasPrefix(f, skipPkg) { return true } } return false } func toCaller(f runtime.Frame) caller { // nolint:gocritic funcName, file, line := f.Function, f.File, f.Line i := strings.LastIndex(funcName, pathSeparator) if i > 0 { rootDir := funcName[:i] funcName = funcName[i+1:] i = strings.Index(file, rootDir) if i > 0 { file = file[i:] } } if i <= 0 { for _, rootDir := range rootDirs { i = strings.Index(file, rootDir) if i > 0 { i += len(rootDir) file = file[i:] break } } } return caller{ File: file + ":" + strconv.Itoa(line), Func: funcName, } } ``` 对比一下 [pkg/errors](https://github.com/pkg/errors) 的打印信息,可以有个比较直观的感受: ```go // 改进后的: 88888, error msg; (github.com/lxt1045/errors/code_test.go:136) errors.Test_Text.func1.1, (github.com/lxt1045/errors/try_catch_test.go:115) errors.deepCall, (github.com/lxt1045/errors/try_catch_test.go:118) errors.deepCall, (github.com/lxt1045/errors/try_catch_test.go:118) errors.deepCall, (github.com/lxt1045/errors/try_catch_test.go:118) errors.deepCall, (github.com/lxt1045/errors/code_test.go:135) errors.Test_Text.func1; // pkg/errors 的: error msg github.com/lxt1045/errors.Test_Text.func2.1 /Users/bytedance/go/src/github.com/lxt1045/errors/code_test.go:142 github.com/lxt1045/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/errors/try_catch_test.go:115 github.com/lxt1045/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/errors/try_catch_test.go:118 github.com/lxt1045/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/errors/try_catch_test.go:118 github.com/lxt1045/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/errors/try_catch_test.go:118 github.com/lxt1045/errors.Test_Text.func2 /Users/bytedance/go/src/github.com/lxt1045/errors/code_test.go:141 testing.tRunner /usr/local/go/src/testing/testing.go:1439 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1571 ``` 针对 [Wrap()](https://github.com/pkg/errors/blob/v0.9.1/errors.go#L181),可以只保留一行源码信息即可。 比如: ```go func TestWrap(t *testing.T) { t.Run("NewCode", func(t *testing.T) { deepCall(3, func() { err := NewErr(1, "error1") err = Wrap(err, "error2") err = Wrap(err, "error3") t.Logf("err:%+v", err) }) }) } ``` 以上代码调用栈会格式化成这样: ```go 1, error1; (github.com/lxt1045/errors/warpper_test.go:25) errors.TestWrap.func2.1, (github.com/lxt1045/errors/try_catch_test.go:115) errors.deepCall, (github.com/lxt1045/errors/try_catch_test.go:118) errors.deepCall, (github.com/lxt1045/errors/try_catch_test.go:118) errors.deepCall, (github.com/lxt1045/errors/try_catch_test.go:118) errors.deepCall, (github.com/lxt1045/errors/warpper_test.go:24) errors.TestWrap.func2; error2, (github.com/lxt1045/errors/warpper_test.go:26) errors.TestWrap.func2.1; error3, (github.com/lxt1045/errors/warpper_test.go:27) errors.TestWrap.func2.1; ``` 当然,这个日志结构可以自己定义,关键是要减少无效、重复的信息量。 ### 2.4 改进 error -> string 的性能 我们先简单测试一下 pkg/errors 生成 string 的性能: ```go func BenchmarkCacheStack(b *testing.B) { b.Run("pkg.Sprintf", func(b *testing.B) { deepCall(10, func() { err := pkgerrs.WithStack(errors.New("test")) b.ResetTimer() for i := 0; i < b.N; i++ { str = fmt.Sprintf("%+v", err) } }) }) } /* //结果 cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkCacheStack BenchmarkCacheStack/pkg.Sprintf BenchmarkCacheStack/pkg.Sprintf-12 132031 8642 ns/op 2202 B/op 20 allocs/op */ ``` 基准测试结果表明,[pkg/errors](https://github.com/pkg/errors) 打印错误栈也造成了巨大的损耗,8us 多。 因此,还是得想办法改善一下。 笔者知道的改善序列化性能的途径只有两个,一个是提高序列化速度,一个是减少内存复制次数。显然在拼接 string 的时候,前者在无法再优化,只能在后者上想想办法。 于是笔者尝试了“引入 buffer”和“提前计算 string 长度”两种方式后,选择了后者。 其实现如下: ```go type Code struct { msg string //业务错误信息 code int //业务错误码 cache *callers } func (e *Code) fmt() (cs fmtCode) { return fmtCode{code: strconv.Itoa(e.code), msg: e.msg, callers: e.cache} } type callers struct { stack []string } type fmtCode struct { code string msg string *callers } func (f *fmtCode) textSize() (l int) { l = len(", ") + len(f.code) + len(f.msg) if f.callers == nil || len(f.stack) == 0 { return } l += len(f.stack) * len(", \n ") for _, str := range f.stack { l += len(str) + 3 } return } func (f *fmtCode) text(buf *writeBuffer) { buf.WriteString(f.code) buf.WriteString(", ") buf.WriteString(f.msg) if f.callers != nil && len(f.stack) > 0 { buf.WriteString(";\n") for i, str := range f.stack { if i != 0 { buf.WriteString(", \n") } buf.WriteString(" ") buf.WriteString(str) } buf.WriteByte(';') } return } func BenchmarkCaseMarshal(b *testing.B) { b.Run("text", func(b *testing.B) { err := &Code{ msg: "msg", code: 1, cache: &callers{ stack: []string{ "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", "1234567890qwertyuiopasdfghjklzxcvbnm", }, }, } b.Run("text", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { buf := &writeBuffer{} f := err.fmt() buf.Grow(f.textSize()) f.text(buf) } b.StopTimer() }) b.Run("fmt", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { fmt.Sprintf("code:%d, msg:%s, stack:%v", err.code, err.msg, err.cache.stack) } b.StopTimer() }) b.Run("bytes.NewBuffer", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() buf := bytes.NewBuffer(nil) for i := 0; i < b.N; i++ { buf.WriteString("code:") buf.WriteString(strconv.Itoa(err.code)) buf.WriteString("msg:") buf.WriteString(err.msg) buf.WriteString("stack:") for _, str := range err.cache.stack { buf.WriteString(str) } } b.StopTimer() }) } /* 测试结果: cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkCaseMarshal BenchmarkCaseMarshal/text BenchmarkCaseMarshal/text-12 4225810 287.4 ns/op 480 B/op 1 allocs/op BenchmarkCaseMarshal/fmt BenchmarkCaseMarshal/fmt-12 814053 1494 ns/op 616 B/op 13 allocs/op BenchmarkCaseMarshal/bytes.NewBuffer BenchmarkCaseMarshal/bytes.NewBuffer-12 2127382 495.9 ns/op 765 B/op 0 allocs/op */ ``` 由结果来看,当前场景下,性能比 bytes.NewBuffer 方式快了不少,令人意外。 ### 2.5 改进 panic(err) 笔者的想法是,在 panic(err) 之前检查一下该 goroutine 是否已执行过 defer Catch()。 如果未执行过,则不执行 panic(err)。 代码如下: ```go var ( mRoutineLastDefer = map[int64]struct{}{} lockRoutineDefer sync.RWMutex tryEscapeErr = func(error){ time.Sleep(time.Second*60) runtime.Goexit() // 退出当前 goroutine } ) type guard struct { gid int64 own bool noCopy } //go:noinline func NewGuard() guard { gid := Getg() // 获取 goroutine_id lockRoutineDefer.Lock() _, ok := mRoutineLastDefer[gid] if !ok { mRoutineLastDefer[gid] = struct{}{} } lockRoutineDefer.Unlock() return guard{ gid: gid, own: !ok, } } func Catch(g guard, f func(err interface{}) bool) { //nolint:govet if g.own { lockRoutineDefer.Lock() delete(mRoutineLastDefer, g.gid) lockRoutineDefer.Unlock() } e := recover() if e == nil { return } if f != nil && f(e) { return } panic(e) } func Try(err *Code) { gid := Getg() lockRoutineDefer.Lock() _, ok := mRoutineLastDefer[gid] lockRoutineDefer.Unlock() if !ok { cs := toCallers([]uintptr{GetPC()[0]}) e := fmt.Errorf(`should call "defer Catch(NewGuard(),func()bool)" before call "Try(err))"; file:%s`, cs[0].File) if err != nil { e = fmt.Errorf("%w; %+v", err, e) } tryEscapeErr(e) return } if err != nil { panic(err) } return } // test.go func Test_Catch(t *testing.T) { defer Catch(NewGuard(), func() { e := recover() if e != nil { t.Log("cache:", e) } }) ... Try(err) } ``` 原理很简单,就是在执行 defer func(){} 前,先执行 NewGuard()。在 NewGuard() 中给当前 goroutine 打上一个标签。在 Try(err) 函数里, panic(err) 前检查该 goroutine 是否已打标签。如果未打标签则打印错误信息,并退出当前 goroutine。这比因 panic 而导致整个程序退出造成的后果要轻的多。 ## 3. 不成功的探索 ### 3.1 长跳转提前返回 笔者曾经想实现这样一个接口: ```go func Do() error { tag, err1 := NewTag() // 当 tag.Try(err) 时,跳转此处并返回 err1 if err1 != nil { return } err := Do2() tag.Try(err) //如果 err!=nil 则跳转到 NewTag() 调用处 ... } ``` 显然,这个接口参考了 go2.0 的错误处理方式: ```go func Do() error { handle err { return err } data := check Dosth() } ``` 实现代码如下: ```asm // func NewTag2() (tag, error) // func tryJump(retpc, parent_pc uintptr, err error) uintptr TEXT ·tryJump(SB),NOSPLIT, $0-40 NO_LOCAL_POINTERS MOVQ (BP), R14 // 获取 parent pc (caller pc) MOVQ +8(R14), R13 MOVQ R13, ret+32(FP) // 返回 parent pc CMPQ pc+8(FP), R13 // parent pc 是否与传入参数相等? JE checkerr RET checkerr: CMPQ err+24(FP), $0 // err == nil ? JHI gototag RET gototag: MOVQ pc+0(FP), CX // retpc -> return addr MOVQ CX, 8(BP) // caller的返回地址替换为 传入的参数retpc MOVQ CX, 16(BP) // tag.pc = retpc MOVQ pc+8(FP), CX MOVQ CX, 24(BP) // tag.parent = parent_pc MOVQ pc+16(FP), CX MOVQ CX, 32(BP) // 设置caller的返回值err MOVQ pc+24(FP), CX MOVQ CX, 40(BP) // 设置caller的返回值err RET // func NewTag() (tag, error) TEXT ·NewTag(SB),NOSPLIT,$32-24 NO_LOCAL_POINTERS MOVQ $0, ret+0(FP) // 返回值清零 MOVQ $0, ret+8(FP) MOVQ $0, ret+16(FP) GO_RESULTS_INITIALIZED MOVQ pc-8(FP), R13 // pc MOVQ R13, ret+0(FP) // tag.pc = pc MOVQ (BP), R14 // tag.parent = parent_pc MOVQ +8(R14), R13 MOVQ R13, ret+8(FP) RET ``` ```go var tryTagErr func(error) func NewTag() (tag, error) func tryJump(pc, parent uintptr, err error) uintptr type tag struct { pc uintptr parent uintptr } //go:noinline func (t tag) Try(err error) { //还是要加上检查,否则报错信息太难看 // 但是检查时只要检查 更上一级的 PC 是否相等即可,不需要复杂的 map 存储了!!! parent := tryJump(t.pc, t.parent, err) if parent != t.parent { cs := toCallers([]uintptr{parent, t.parent, GetPC()[0]}) e := fmt.Errorf("tag.Try() should be called in [%s] not in [%s]; file:%s", cs[1].Func, cs[0].Func, cs[2].File) if err != nil { e = fmt.Errorf("%w; %+v", err, e) } if tryTagErr != nil { tryTagErr(e) return } panic(e) } } ``` 这里的实现原理也很简单,就是在 NewTag() 函数里,把 NewTag() 函数的返回地址(pc)记录到 tag.pc 中。后续执行 tag.Try(err) 时,如果 err!=nil 就将其返回地址替换为 tag.pc,这样就跳转到了 NewTag() 的返回处继续执行。由于此时 err1!=nil,自然就进入 if 逻辑并执行 return 让函数提前返回。 但是,这样虽然能保证按预计逻辑执行,却有一个比较致命的问题,就是 debug 模式和 release 模式下,defer 函数的执行预期不一致。 比如下面的例子: ```go func Do() error { defer func() { fmt.Printf("2") }() tag, err1 := NewTag() // 当 tag.Try(err) 时,跳转此处并返回 err1 if err1 != nil { return } defer func() { fmt.Printf("1") // release 模式下不执行 }() err := errors.New("err") tag.Try(err) // 在此处执行跳转 } ``` debug 模式下打印 "12",relese 模式下只打印 "1"。 原因是 debug 模式下, golang 编译器没有对 defer 函数做优化,会在执行到 defer 时,将 defer 函数挂到 g._defer 链表上。 而 release 模式下,golang 编译器会将 defer 函数内联到 return(即汇编的 RET)命令前。 我们通过汇编将 tag.Try(err) 的返回地址替换为 NewTag() 的返回地址,却并没有将 NewTag() 之后的 defer 函数添加到 return 命令前,所以 NewTag() 之后的 defer 函数并能被执行。 不过,也有办法处理,就是比较”反人类“。 像这样: ```go func Do() error { gototag: defer func() { fmt.Printf("2") }() tag, err1 := NewTag() // 当 tag.Try(err) 时,跳转此处并返回 err1 if err1 != nil { return } defer func() { fmt.Printf("1") }() err := errors.New("err") tag.Try(err) return goto gototag } ``` 上面代码的做法,就是把函数体用 goto tag 包裹起来,这样 golang 编译器就会取消 defer 的内联优化,把 defer 函数挂到 g._defer 链表上,从而使代码在 release 和 debug 下得到一致结果。 如果这样做,这个接口就太不友善了,所以这只是一个“失败的探索” ### 3.2 检查 g._defer 链表 同样的原因导致的“失败探索”,还有下面这个例子: ```asm // stack.asm // func GetDefer() *_defer TEXT ·GetDefer(SB), NOSPLIT, $0-8 MOVQ (TLS), AX ADDQ ·g__defer_offset(SB),AX MOVQ (AX), BX MOVQ BX, ret+0(FP) RET // func getgi() interface{} TEXT ·getgi(SB), NOSPLIT, $32-16 NO_LOCAL_POINTERS MOVQ $0, ret_type+0(FP) MOVQ $0, ret_data+8(FP) GO_RESULTS_INITIALIZED // get runtime.g MOVQ (TLS), AX // get runtime.g type MOVQ $type·runtime·g(SB), BX // return interface{} MOVQ BX, ret_type+0(FP) MOVQ AX, ret_data+8(FP) RET ``` ```go // stack.go func GetDefer() *_defer func getgi() interface{} var g__defer_offset uintptr = func() uintptr { g := getgi() if f, ok := reflect.TypeOf(g).FieldByName("_defer"); ok { return f.Offset } panic("can not find g.goid field") }() type _defer struct { started bool heap bool openDefer bool sp uintptr // sp at time of defer pc uintptr // pc at time of defer fn func() // can be nil for open-coded defers _panic uintptr link *_defer // next defer on G; can point to either heap or stack! fd unsafe.Pointer varp uintptr framepc uintptr } func (d *_defer) Next() *_defer { return d.link } func (d *_defer) PC() uintptr { return d.pc } ``` ```go //main.go func main() { GetDefer() } func GetDefer() { defer func() { log.Println("defer 1") }() d := stack.GetDefer() for ; d != nil; d = d.Next() { log.Printf("defer pc:%d", d.PC()) } return } ``` 以上代码,本意是想通过 g._defer 链表,检查 panic(err) 前是否有执行过 defer Catch() (检查代码未写出)。 不过也是由于 golang 编译器对 defer 的优化,导致无法简单的获取到正确的 g._defer 链表。 ## 4. 附录 根据以上优化想法,笔者写了一个 errors 库 [lxt1045/errors](https://github.com/lxt1045/errors),当前还只是一个玩具,欢迎来吐槽。 先来看一下调用栈输出格式: ```go package errors import ( "errors" "fmt" "runtime" "strconv" "testing" lxterrs "github.com/lxt1045/errors" pkgerrs "github.com/pkg/errors" ) var str string func TestErrors(t *testing.T) { t.Run("lxt1045/errors", func(t *testing.T) { deepCall(3, func() { err := lxterrs.NewErr(1, "test") str = fmt.Sprintf("%+v", err) t.Log(str) }) }) t.Run("lxt1045/errors", func(t *testing.T) { deepCall(3, func() { err := lxterrs.NewErr(1, "test") str = err.Error() t.Log(str) }) }) t.Run("pkg/errors", func(t *testing.T) { deepCall(3, func() { err := errors.New("test") err = pkgerrs.WithStack(err) str = fmt.Sprintf("%+v", err) t.Log(str) }) }) } /* 输出日志: === RUN TestErrors === RUN TestErrors/lxt1045/errors /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:114: 1, test; (github.com/lxt1045/blog/sample/errors/errors_test.go:112) errors.TestErrors.func1.1, (github.com/lxt1045/blog/sample/errors/errors_test.go:29) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:32) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:32) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:32) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:111) errors.TestErrors.func1; === RUN TestErrors/lxt1045/errors#01 /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:121: 1, test; (github.com/lxt1045/blog/sample/errors/errors_test.go:119) errors.TestErrors.func2.1, (github.com/lxt1045/blog/sample/errors/errors_test.go:29) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:32) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:32) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:32) errors.deepCall, (github.com/lxt1045/blog/sample/errors/errors_test.go:118) errors.TestErrors.func2; === RUN TestErrors/pkg/errors /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:129: test github.com/lxt1045/blog/sample/errors.TestErrors.func3.1 /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:127 github.com/lxt1045/blog/sample/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:29 github.com/lxt1045/blog/sample/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:32 github.com/lxt1045/blog/sample/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:32 github.com/lxt1045/blog/sample/errors.deepCall /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:32 github.com/lxt1045/blog/sample/errors.TestErrors.func3 /Users/bytedance/go/src/github.com/lxt1045/blog/sample/errors/errors_test.go:125 testing.tRunner /usr/local/go/src/testing/testing.go:1439 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1571 */ ``` 可以看到,相对于 [pkg/errors](https://github.com/pkg/errors) 来说,日志格式还是有所改善的。 再做个简单的基准测试: ```go var str string func BenchmarkErrors(b *testing.B) { b.Run("lxt1045/errors-fmt", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { err := lxterrs.NewErr(1, "test") str = fmt.Sprintf("%+v", err) } }) }) b.Run("lxt1045/errors-Errors", func(b *testing.B) { deepCall(10, func() { b.ResetTimer() for i := 0; i < b.N; i++ { err := lxterrs.NewErr(1, "test") str = err.Error() } }) }) b.Run("pkg/errors", func(b *testing.B) { deepCall(10, func() { err := errors.New("test") b.ResetTimer() for i := 0; i < b.N; i++ { err := pkgerrs.WithStack(err) str = fmt.Sprintf("%+v", err) } }) }) } /* 测试结果: cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkErrors BenchmarkErrors/lxt1045/errors-fmt BenchmarkErrors/lxt1045/errors-fmt-12 1642436 712.3 ns/op 2338 B/op 3 allocs/op BenchmarkErrors/lxt1045/errors-Errors BenchmarkErrors/lxt1045/errors-Errors-12 2753948 452.8 ns/op 1184 B/op 2 allocs/op BenchmarkErrors/pkg/errors BenchmarkErrors/pkg/errors-12 113755 9030 ns/op 2515 B/op 24 allocs/op */ ``` 可以看到,就能能来说,相对于 [pkg/errors](https://github.com/pkg/errors),有接近 10~20倍 的差距,提升还是很明显的。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1902 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传