当前位置: 首页 > 后端技术 > PHP

使用Godefer时,小心这2个折腾雷区!

时间:2023-03-29 20:10:56 PHP

微信搜索【脑补炸鱼】关注这条炸肝炸鱼。本文GitHubgithub.com/eddycjy/blog已收录,附有我的系列文章、资料和开源Go书籍。大家好,我是炸鱼。defer是Go语言中一个非常有趣的关键字特性。例子如下:packagemainimport"fmt"funcmain(){deferfmt.Println("friedfish")fmt.Println("braininto")}输出结果为:brainintofriedfish群里有的朋友讨论了以下问题:简单来说,问题是for循环中的defer关键字是否会对性能造成影响?因为在Go语言底层数据结构的设计中,defer是一个链表的数据结构:大家担心如果循环太大,defer链表会很大,不够“优秀””。或者猜想Godefer的设计是不是和Redis数据结构的设计类似,自己优化过,但影响不大?在今天的文章中,我们将探讨循环Godefer。底层链表过长会不会出问题?如果有,会产生怎样的影响?开启吸鱼之路。Defer性能优化30%在Go1.13初期,对defer进行了一轮性能优化,使得defer在大多数场景下的性能提升了30%:让我们回顾一下Go1.13的变化,看看Go是如何做的延迟优化工作在哪里?这就是问题的症结所在。古今对比在Go1.12及之前,调用Godefer时的汇编代码如下:0x007000112(main.go:6)CALLruntime.deferproc(SB)0x007500117(main.go:6)TESTLAX,AX0x007700119(main.go:6)JNE1370x007900121(main.go:7)XCHGLAX,AX0x007a00122(main.go:7)CALLruntime.deferreturn(SB)0x007f00127(main.go:7)MOVQ56(SP),Go1.13及之后的BP,调用Godefer时的汇编代码如下:0x006e00110(main.go:4)MOVQAX,(SP)0x007200114(main.go:4)CALLruntime.deferprocStack(SB)0x007700119(main.go:4)TESTLAX,AX0x007900121(main.go:4)JNE1390x007b00123(main.go:7)XCHGLAX,AX0x007c00124(main..go:7)CALLruntime.deferreturn(SB)0x008100129(main.go:7)MOVQ112(SP),BP从汇编的角度看,原来调用runtime.deferproc方法好像改成了调用runtime.deferprocStack方法。难道是做了什么优化?我们带着疑惑继续阅读。defer的最小单位:_deferGodefer的最小单位_defer结构相对于之前的版本,主要是增加了堆字段:spattimeofdeferpcuintptrfn*funcval...该字段用于标识_defer是分配在堆上还是分配在栈上。其余字段没有显式更改,所以我们可以关注defer的堆栈分配,让我们看看它做了什么。deferprocStackfuncdeferprocStack(d*_defer){gp:=getg()ifgp.m.curg!=gp{throw("deferonsystemstack")}d.started=falsed.heap=falsed.sp=getcallersp()d.pc=getcallerpc()*(*uintptr)(unsafe.Pointer(&d._panic))=0*(*uintptr)(unsafe.Pointer(&d.link))=uintptr(unsafe.Pointer(gp._defer)))*(*uintptr)(unsafe.Pointer(&gp._defer))=uintptr(unsafe.Pointer(d))return0()}这段代码比较常规,主要是获取调用defer的函数栈指针function,pass输入函数的参数和PC(程序计数器)的具体地址,在之前的文章《深入理解 Go defer》中已经详细介绍过,这里不再赘述。这个deferprocStack有什么特别之处?可以看出它设置了d.heap为false,也就是说deferprocStack方法针对的是_defer分配在栈上的应用场景。deferproc的问题来了,它在哪里处理分配到堆上的应用场景?funcnewdefer(sizint32)*_defer{...d.heap=trued.link=gp._defergp._defer=dreturnd}具体newdefer在哪里调用,如下:funcdeferproc(sizint32,fn*funcval){//fn的参数跟在fn...sp:=getcallersp()argp:=uintptr(unsafe.Pointer(&fn))+unsafe.Sizeof(fn)callerpc:=getcallerpc()d:=newdefer(siz)...}就很清楚了,现在使用上一版本调用的deferproc方法来对应分配到堆上的场景。总结可以确定deferproc没有被去掉,但是流程进行了优化。Go编译器会根据应用场景选择使用deferproc或deferprocStack方法。它们分别针对分配在堆上和栈上的使用场景。优化在哪里?主要的优化在于改变了defer对象的栈分配规则。措施是:编译器分析defer的for循环迭代深度。//src/cmd/compile/internal/gc/esc.gocaseODEFER:ife.loopdepth==1{//顶层n.Esc=EscNever//强制延迟记录的堆栈分配(参见ssa.go)中断}如果Go编译器检测到循环深度(loopdepth)为1,则设置逃逸分析结果分配到栈上,否则分配到堆上。//src/cmd/compile/internal/gc/ssa.gocaseODEFER:d:=callDeferifn.Esc==EscNever{d=callDeferStack}s.call(n.Left,d)避免频繁调用systemstack、mallocgc和其他方法带来大量的性能开销,以提高大多数场景下的性能。循环调用defer回到问题本身,知道了defer优化的原理。然后“循环中的defer关键字是否对性能有影响?”最直接的影响是直接损失了大约30%的性能优化,而且由于姿势不正确,理论上defer现有的开销(链表变长)也变大,性能变差。因此,我们需要避免以下两种场景的代码:显式循环:在defer关键字的调用之外还有显式循环调用,例如:for循环语句等隐式循环:有类似循环嵌套的逻辑在调用defer关键字时,例如:goto语句等显式循环第一个例子是在代码的for循环中直接使用defer关键字:funcmain(){fori:=0;我<=99;i++{deferfunc(){fmt.Println("brainintoFriedfish")}()}}这也是最常见的模式,不管是写爬虫还是Goroutine调用,很多人都喜欢这样写。这是对循环的显式调用。隐式循环的第二个例子是在代码中使用像goto这样的关键字:funcmain(){i:=1food:deferfunc(){}()ifi==1{i-=1gotofood}}这种写法比较少见,因为goto关键字有时甚至被列为代码规范而没有使用,主要是会造成一些滥用,所以大多选择实际的方式来实现逻辑。这是一个隐式调用,会产生类似循环的效果。总结显然,Defer在设计上并不是特别精彩。主要是根据一些实际的应用场景进行优化,以获得更好的性能。defer本身虽然会带来一点开销,但是也没有想象中的那么不能用。除非你延迟的代码是需要经常执行的代码,否则你需要考虑优化它。否则,也没必要纠结太多。其实在猜测或者遇到性能问题的时候,看看PProf的分析,看看defer是不是在对应的hotpath中,然后再进行合理的优化。所谓优化,可能只是去掉defer,手动执行,并不复杂。编码时避免踩到defer的显式循环和隐式循环这两个雷区,以最大限度地提高性能。如有任何问题,欢迎在评论区反馈交流。最好的关系是相互成就。您的好评是创作炸鱼最大的动力。感谢您的支持。文章持续更新中,微信搜索【脑补炸鱼】即可阅读,回复【000】一线大厂面试算法方案和资料我都准备好了;本文已收录在GitHubgithub.com/eddycjy/blog,欢迎Star提醒。