0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

10种go语言编成中可能导致性能下降的坏实践

马哥Linux运维 来源:Teiva Harsanyi 作者:Teiva Harsanyi 2021-09-24 16:55 次阅读

本文总结了10种 go 语言编成中可能导致性能下降的坏实践。有代码洁癖的同学来自我检查吧!

这篇文章主要讲述了我在 Go 项目中见到过的常见错误清单,顺序无关。

未知的Enum

来看个简单的例子

typeStatusuint32

const(
StatusOpenStatus=iota
StatusClose
StatusUnknown
)

在上面的代码中,使用iota创建了一个enum类型,分别代指下面的状态信息

StatusOpen=0
StatusClose=1
StatusUnknown=2

现在,我们假设Status是一个 JSON 请求中被Marshalled / Unmarshalled的一个属性,我们可以设计出下面的数据结构:

typeRequeststruct{
IDint`json:"Id"`
Timestampint`json:"Timestamp"`
StatusStatus`json:"Status"`
}

然后,假设收到的Request 的接口返回值为:

{
"Id":1234,
"Timestamp":1563362390,
"Status":0
}

到目前为止,没有什么特殊的表达,Status将会被反序列化为StatusOpen,是吧?

好的,我们来看一个未设置status返回值的请求(不管是出于什么原因吧)。

{
"Id":1234,
"Timestamp":1563362390
}

在这个例子中,Request结构体的Status字段将会被初始化为默认零值zeroed value, 对于 uint32 类型来说,值就是0。因此,StatusOpen就替换掉了原本值应该是StatusUnknown

对于这类场景,把unknown value设置为枚举类型0应该比较合适,如下:

typeStatusuint32

const(
StatusUnknownStatus=iota
StatusOpen
StatusClose
)

这样,即时返回的 JSON 请求中没有Status属性,结构体RequestStatus属性也会按我们预期的,被初始化为StatusUnknown

性能测试

正确地进行性能测试很困难,因为过程中有太多的因素会影响测试结果了。

其中一个最常见的错误就是被一些编译器优化参数糊弄,让我们以teivah/bitvector库中的一个真实案例来进行阐述:

funcclear(nuint64,i,juint8)uint64{
return(math.MaxUint64<1<< i) - 1))&n
}

这个函数会清理给定长度n的二进制位,对这个函数进行性能测试的话,我们可能会写出下面的代码:

funcBenchmarkWrong(b*testing.B){
fori:=0;i< b.N; i++ {
  clear(1221892080809121,10,63)
}
}

在这个性能测试中,编译器发现clear函数是并没有调用其他函数,因此编译器就会进行inline处理。除此之外,编译器还发现这个函数中也没有side-effects。因此,clear就会被删除,不去计算它的耗时,因此这就会导致测试结果的不准确。

一个建议是设置全局变量,如下:

varresultuint64

funcBenchmarkCorrect(b*testing.B){
varruint64
fori:=0;i< b.N; i++ {
  r = clear(1221892080809121,10,63)
}
result=r
}

这样的话,编译器就不知道clear函数是否会造成side-effect了,因此,性能测试的结果就会变得更加准确。

拓展阅读

指针,到处都是指针!

值传递的时候,会创建一个同值变量;而指针传递的时候,只是将变量地址进行拷贝。

因此,指针传递总是会很快,是不?

如果你觉得是这样,可以看一下这个例子。在这个性能测试中,一个大小为0.3K的数据结构分别以值传递和指针传递进行测试。0.3K 不大,但是也不能和大部分我们日常用到的场景中的数据结构大小相差甚远,接近即可。

当我在自己的本地环境中执行这个性能测试代码的时候,值传递比指针传递快了4 倍还多,是不是感觉有悖常理?

关于这个现象的解释涉及到了 Go 中的内存管理,我没法解释得像 William Kennedy 解释的那样精炼,一起来整理总结下吧:

变量可以被分配到heapstack上,粗略解释为:

  • 栈包含哪些分配给了goroutine的随时消失的变量,一旦函数返回,变量就会从栈中弹出
  • 堆包含共享变量,比如全局变量等

一起通过一个简单的例子来测试下:

funcgetFooValue()foo{
varresultfoo
//Dosomething
returnresult
}

result被当前 goroutine 创建,这个变量就会被压入当前运行栈。一旦函数返回,调用方就会收到与此变量的一份拷贝,二者值相同,但是变量地址不同。变量本身会被弹出,此时变量并不会被立即销毁,直到它的内存地址被另一个变量覆盖或者被擦除,这个时候它才是真的再也不会被访问到了。

与此相对,看一个一个指针传递的例子:

funcgetFooPointer()*foo{
varresultfoo
//Dosomething
return&result
}

result依旧是被当前goroutine所创建,但是调用方收到的会是一个指针(指向变量的内存地址)。如果result被栈弹出,那么调用方不可能访问到此变量。

在这个场景下,GO 的编译器会把result放置到可以被共享的变量空间:heap。

下面来看另一个场景,比如:

funcmain(){
p:=&foo{}
f(p)
}

f的调用方与f所属为同一个goroutine,变量p不会被转换,它只是被简单放回到栈中,因此子函数依旧可以访问到。

举例来说,io.Reader中的Read方法接收指针,而不是返回一个,因为返回一个切片就会被转换到堆中。

为什么栈会这么快?这里有两个主要的原因:

  • 栈不需要垃圾收集。正如我们所说,一个变量创建时被压入栈,函数返回时从栈中弹出。根本不需要复杂的处理来回收未使用的变量。
  • 一个栈隶属于一个 goroutine,与堆中变量相比,不需要同步处理,这同样会使得栈很快。

总结一下,当我们创建一个函数的时候,我们应该使用值传递而不是指针传递。只有我们期待某个变量被共享使用时,才使用指针传递适用。

当我们下次遇到性能优化的问题时,一个可能的优化方向就是检查在某些场景下,指针传递是否真的会有所帮助。一个需要了解的常识是:当使用go build -gcflags "-m -m"时,编译器会默认将一个变量转换到堆中。

再强调下,在日常开发中,应该总是首先考虑值传递。

拓展阅读 Language Mechanics On Stacks And Pointers

干掉for/switch或者for/select

如果f函数返回了 true,会发生什么?

for{
switchf(){
casetrue:
break
casefalse:
//dosomething
}
}

break语句会被调用,这会导致switch语句退出,而不是 loop 退出。再看一个类似问题:

for{
select{
case<-ch:
        //dosomething
case<-ctx.Done():
        break
}
}

break同样只是退出select语句,而不是 for 循环。

一个可能的解决方案是使用labeled break标签,例如:

loop:
for{
select{
case<-ch:
            //dosomething
case<-ctx.Done():
            breakloop
}
}

错误管理

Go 中的错误处理机制还是有点简单,或许到了 Go2.0,它会变得好一点。

当前标准库只提供创建错误类型数据结构的方法,具体可查看 pkg/errors。

这个库很好的展示了一些本该被遵守却经常不被遵守的规则的好例子。

一个错误只应该被处理一次。把错误打印到日志中也是在处理错误。所以一个错误要么被打日志,要么被传到调用方。

当前的标准库,如果我们想分层化或者在错误中添加上下文信息是非常困难的。接下来,我们一起看个期待使用 REST 形式调用而导致 DB 出问题的例子:

unabletoserveHTTPPOSTrequestforcustomer1234
|_unabletoinsertcustomercontractabcd
|_unabletocommittransaction

如果我们使用pkg/errors库,我们可能会这么做:

funcpostHandler(customerCustomer)Status{
err:=insert(customer.Contract)
iferr!=nil{
log.WithError(err).Errorf("unabletoserveHTTPPOSTrequestforcustomer%s",customer.ID)
returnStatus{ok:false}
}
returnStatus{ok:true}
}

funcinsert(contractContract)error{
err:=dbQuery(contract)
iferr!=nil{
returnerrors.Wrapf(err,"unabletoinsertcustomercontract%s",contract.ID)
}
returnnil
}

funcdbQuery(contractContract)error{
//Dosomethingthenfail
returnerrors.New("unabletocommittransaction")
}

需要我们使用errors.New来初始化错误信息(如果内部方法调用没有返回 error 的话)。中间调用层insert, 仅仅是通过添加更多上下文信息来包装了错误。然后insert的调用方通过日志进行了打印,每一层要么返回错误,要么处理错误。

有些时候,我们可能会检查错误以便于做重试处理。假如我们有一个叫db的处理数据库的外部的包,这个库可能会返回db.DBError 这种临时错误。到底要不要做重试处理,就看错误是不是符合预期, 比如处理代码:

funcpostHandler(customerCustomer)Status{
err:=insert(customer.Contract)
iferr!=nil{
switcherrors.Cause(err).(type){
default:
log.WithError(err).Errorf("unabletoserveHTTPPOSTrequestforcustomer%s",customer.ID)
returnStatus{ok:false}
case*db.DBError:
returnretry(customer)
}

}
returnStatus{ok:true}
}

funcinsert(contractContract)error{
err:=db.dbQuery(contract)
iferr!=nil{
returnerrors.Wrapf(err,"unabletoinsertcustomercontract%s",contract.ID)
}
returnnil
}

借助pkg/errors中的errors.Cause,便可以进行实现。

一个常见的错误就是独立使用pkg/errors,比如:

switcherr.(type){
default:
log.WithError(err).Errorf("unabletoserveHTTPPOSTrequestforcustomer%s",customer.ID)
returnStatus{ok:false}
case*db.DBError:
returnretry(customer)
}

上面例子中,如果db.DBError被包装了,那么重试机制将永远不会触发。

切片初始化

有时候我们知道切片的最终长度,比如:将切片Foo转换成切片Bar,这意味着两个切片的长度会是一致的。

我经常见到有人这么初始化切片:

varbar[]Bar

bars:=make([]Bar,0)

切片不是魔术结构,实际上当空间不足时,Go来动态的维护切片的长度。在这个场景下,一个新的更大容量的数组会自动被创建,然后将旧的数组元素一个个的拷贝到新数组中。

现在,假设我们要多次数以千计的增加[]Foo,插入的时间复杂度可不是O(1),毕竟内部重复了多次拷贝。

因此,如果我们知道切片最终长度的话,可以采用以下策略:

  • 使用预定义长度
funcconvert(foos[]Foo)[]Bar{
bars:=make([]Bar,len(foos))
fori,foo:=rangefoos{
bars[i]=fooToBar(foo)
}
returnbars
}
  • 使用 0 长度,并且给一个预定义容量
funcconvert(foos[]Foo)[]Bar{
bars:=make([]Bar,0,len(foos))
for_,foo:=rangefoos{
bars=append(bars,fooToBar(foo))
}
returnbars
}

那么,这俩方法哪个更好呢?

第一个更快一点点,而第二个更符合编码预期:不考虑初始长度,每次只通过append往尾部追加数据。

上下文管理

context.Context经常被开发者所误解,下面看下官方的解释:

上下文以 API 边界形式,可携带截止时间、取消信号以及其他值。

这段描述通常让人疑惑这玩意儿有啥用,咋用啊?

我们举几个例子,看看它到底能携带什么数据:

  • 截止日期不管是遇到250 ms还是遇到2019-01-08 0100格式的时间,必须立刻终止执行(执行的内容可能是 I/O 请求,等待 channel 输入等)
  • 取消信号类似于上面,一旦接收到信号,就需要立刻终止执行后续处理。例如:接收两个请求,一个是插入数据,另一个是取消第一个的插入,这个场景就可以借助在第一个请求中加入一个可取消的上下文来实现。
  • 其他值Key-Value形式,即便都是 interface{}类型。

context 是可组合的,因此可以添加截止时间和其他 key-value 类型数据;另外,多个协程可共享同一个上下文,因此取消信号可以阻止多个执行流程。

回到正题,继续来说说错误问题。

一个 基于 urface/cli (一个用于制作命令行应用的库)Go 应用,一旦启动,开发者继承了一串上下文,使用 context 的终止信号来终止所有的执行。当我意识到请求一个 gRPC 终端的时候,context 只是直接被传递了下去。这不是我想看到的。

相反,我们想让 gRPC 库在收到终止信号或者超过 100ms 处理时间时进行取消处理。为了达到这个目标,我们可以创建一个简单的组合上下文,如果parent是应用上下文的名字(通过 urfave/cli 创建),然后我们就可以写出下面的代码:

ctx,cancel:=context.WithTimeout(parent,100*time.Millisecond)
response,err:=grpcClient.Send(ctx,request)

上下文不难理解,而且在我眼中,它是Go 语言中最棒的特色之一。

不要使用-race选项

我经常见的一个错误就是在测试时使用-race选项。

“即使 Go 是被设计成让并发更容易,更少错误的语言”, 我们仍然经受着很多并发问题的折磨。

显而易见的是,Go 语言中的 race 探查器对独立的并发问题而言并无帮助。不过,当测试我们的应用时开启它也是很有价值的。

使用文件名作为输入

另一个常见问题就是把文件名作为函数的参数。加入我们要实现一个统计文件中空行数量的函数,最自然的实现方式可能就是这样的:

funccount(filenamestring)(int,error){
file,err:=os.Open(filename)
iferr!=nil{
return0,errors.Wrapf(err,"unabletoopen%s",filename)
}
deferfile.Close()

scanner:=bufio.NewScanner(file)
count:=0
forscanner.Scan(){
ifscanner.Text()==""{
count++
}
}
returncount,nil
}

filename作为函数输入,然后我们打开文件,再实现后续的逻辑,对不?

接下来,在此函数的基础上写单测,测试使用的变量分别代表:常规文件,空文件,使用不同编码的文件等等。很快它就会变得难以管理。

同样,当我们想以同样的逻辑来处理 HTTP 响应体,我们就不得不重新写一个新函数了,因为这个函数只接受文件名。

GO 语言中有两个很棒的抽象:io.Readerio.Writer。与直接传递文件名不同的是,我们可以简单的传入一个io.Reader来抽象化数据源。

它是文件还是 HTTP 的响应体,或者是一个字节缓冲区?都不重要了,我们只需要使用Read方法就都可以搞定。在下面的例子中,我们甚至可以一行一行地读入数据。

funccount(reader*bufio.Reader)(int,error){
count:=0
for{
line,_,err:=reader.ReadLine()
iferr!=nil{
switcherr{
default:
return0,errors.Wrapf(err,"unabletoread")
caseio.EOF:
returncount,nil
}
}
iflen(line)==0{
count++
}
}
}

打开一个文件的职责交给count的调用方去代理就好了,如下:

file,err:=os.Open(filename)
iferr!=nil{
returnerrors.Wrapf(err,"unabletoopen%s",filename)
}
deferfile.Close()
count,err:=count(bufio.NewReader(file))

在第二种的实现中,数据源已经不重要了,并且单测也可以很方便的进行编写,比如使用字符串来创建一个bufio.Reader作为数据源:

count,err:=count(bufio.NewReader(strings.NewReader("input")))

协程与循环变量

最后一个常见的错误就是在循环结构中使用协程。

下面例子中的输出是什么?

ints:=[]int{1,2,3}
for_,i:=rangeints{
gofunc(){
fmt.Println("%v
",i)
}()
}

你是不是以为会是按顺序输出1 2 3?并不是哦。在这个例子中,每一个协程都会共享同一个变量实例,因此它最终大概率会输出3 3 3

有两种解决方案来解决类似问题,第一个就是把循环遍历当做参数传给闭包,比如:

ints:=[]int{1,2,3}
for_,i:=rangeints{
gofunc(iint){
fmt.Printf("%v
",i)
}(i)
}

另一种方式就是在循环内部的作用域中创建临时变量,比如:

ints:=[]int{1,2,3}
for_,i:=rangeints{
i:=i
gofunc(){
fmt.Printf("%v
",i)
}()
}

虽然看着i := i很奇怪,但是它真的有效。一个循环内部意味着在另一个作用域中,因此i := i就创建了一个新的变量实例,称之为i。当然,为了可读性我们也可以定义成一个别的名字。

转自:

guoruibiao.blog.csdn.net/article/details/108054295

编辑:jq
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • HTTP
    +关注

    关注

    0

    文章

    464

    浏览量

    30296
  • 数据源
    +关注

    关注

    1

    文章

    59

    浏览量

    9584
  • go语言
    +关注

    关注

    1

    文章

    156

    浏览量

    8919

原文标题:Go 项目中常见的 10 种错误

文章出处:【微信号:magedu-Linux,微信公众号:马哥Linux运维】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    移植标准库CLASSB的STM32F10x_SelfTestLib时报错是什么原因导致的?

    软件安全相关认证,在移植标准库CLASSB的STM32F10x_SelfTestLib时,报以下两个错误,请问是什么原因导致的? 对应这两句汇编语言
    发表于 03-18 08:30

    ADL5513数据表下降沿有拖尾的现象是什么方面的原因导致的呢?

    ADL5513数据表下降沿有拖尾的现象,我实际测试下降沿也有拖尾的现象,但拖尾的幅度比数据表的大很多,请问这有
    发表于 03-07 06:11

    使用go语言实现一个grpc拦截器

    在开发grpc服务时,我们经常会遇到一些通用的需求,比如:日志、链路追踪、鉴权等。这些需求可以通过grpc拦截器来实现。本文使用go语言来实现一个 grpc一元模式(Unary)拦截器,上报链路追踪信息。
    的头像 发表于 12-18 10:13 226次阅读
    使用<b class='flag-5'>go</b><b class='flag-5'>语言</b>实现一个grpc拦截器

    Go编程语言-你应该知道的一切

    Go 编程语言的故事始于 Google,当时三位工程师 Robert Griesemer、Rob Pike 和 Ken Thompson 对 C++ 的复杂性以及缺乏提供高效编译和执行的简单语言感到厌倦。
    的头像 发表于 12-11 17:37 291次阅读

    Go语言比Python强多少

    1.都说Go语言性能非常强大,那么到底比Python强多少? 为了比较Go语言和Python语言在单线程
    的头像 发表于 11-02 14:05 273次阅读
    <b class='flag-5'>Go</b><b class='flag-5'>语言</b>比Python强多少

    如何让Python和Go互相调度

    我们曾经研究过如何让Python和Go互相调度,当时发现,将Go语言写的模块打包成动态链接库,就能在Python中进行调度: 优劣互补! Python+Go结合开发的探讨
    的头像 发表于 11-02 11:24 240次阅读
    如何让Python和<b class='flag-5'>Go</b>互相调度

    Go在单线程计算性能上的优势

    一文中,我们讨论了Go在单线程计算性能上的优势。 现在,考虑这样的一种场景: 我们需要从某些网址中同步数据并进行计算,保存到本地redis缓存中。 现在,我们可以通过编写Go Worker的方式
    的头像 发表于 11-02 11:16 208次阅读
    <b class='flag-5'>Go</b>在单线程计算<b class='flag-5'>性能</b>上的优势

    无功补偿过补会导致功率因数下降吗?

    无功补偿是电力系统中非常重要的一项工作。它的主要作用是通过引入适当的无功功率,来提高电力系统的功率因数。然而,有时候人们会担心过度补偿无功功率会导致功率因数下降。那么,无功补偿过度会导致功率因数急剧
    的头像 发表于 10-31 14:53 820次阅读
    无功补偿过补会<b class='flag-5'>导致</b>功率因数<b class='flag-5'>下降</b>吗?

    Go语言中的整数类型

    Go 语言中,整型可以细分成两个种类十个类型。
    发表于 07-20 15:25 331次阅读

    Go语言常量的声明

    Go 语言中, 常量 表示的是固定的值,常量表达式的值在编译期进行计算,常量的值不可以修改。例如:3 、 Let's go 、 3.14 等等。常量中的数据类型只可以是 布尔型 、 数字型 (整数型、浮点型和复数)
    发表于 07-20 15:24 273次阅读

    Go语言简介和安装方法

    Go 又称 Golang ,是 Google 的 Robert Griesemer,Rob Pike 及 Ken Thompson 开发的一种静态强类型、编译型语言Go 语言语法与
    发表于 07-19 16:33 408次阅读

    ChatGPT流量下降10%

    5 月至 6 月期间,OpenAI 的 ChatGPT 网站的全球流量下降10%,这是自 2022 年 11 月推出以来,该大语言模型的访问数量首次下降
    发表于 07-11 09:51 208次阅读
    ChatGPT流量<b class='flag-5'>下降</b><b class='flag-5'>10</b>%

    Go 1.21的PGO正式GA,性能提升,更快更猛!

    Go 语言中,最初关于 PGO 的提案是建议向 Go GC 工具链增加对配置文件引导优化 (PGO) 的支持,以便工具链能根据运行时信息执行特定于应用程序和工作负载的优化。
    的头像 发表于 06-28 16:47 746次阅读
    <b class='flag-5'>Go</b> 1.21的PGO正式GA,<b class='flag-5'>性能</b>提升,更快更猛!

    浅谈SylixOS 实时操作系统中Go语言应用

    Go 语言是一门编译型语言,继承了编译型语言的高性能、类型安全以及对计算机底层的高可控性等特点,其运行
    发表于 06-08 10:41 774次阅读
    浅谈SylixOS 实时操作系统中<b class='flag-5'>Go</b><b class='flag-5'>语言</b>应用

    Go语言运算符主要包括哪些呢?

    Go语言运算符主要包括:算数运算符、关系运算符、逻辑运算符、位运算符、赋值运算符和其他运算符。
    的头像 发表于 05-26 15:54 593次阅读
    <b class='flag-5'>Go</b><b class='flag-5'>语言</b>运算符主要包括哪些呢?