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

    文章

    501

    浏览量

    31051
  • 数据源
    +关注

    关注

    1

    文章

    62

    浏览量

    9661
  • go语言
    +关注

    关注

    1

    文章

    158

    浏览量

    9026

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

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

收藏 人收藏

    评论

    相关推荐

    在学习go语言的过程踩过的坑

    作为一个5年的phper,这两年公司和个人都在顺应技术趋势,新项目慢慢从php转向了go语言,从2021年到现在,笔者手上也先后开发了两个go项目。在学习go
    的头像 发表于 11-11 09:22 100次阅读

    go语言如何解决并发问题

    作为一个后端开发,日常工作接触最多的两门语言就是PHP和GO了。无可否认,PHP确实是最好的语言(手动狗头哈哈),写起来真的很舒爽,没有任何心智负担,字符串和整型压根就不用区分,开发
    的头像 发表于 10-23 13:38 105次阅读
    <b class='flag-5'>go</b><b class='flag-5'>语言</b>如何解决并发问题

    三十分钟入门基础Go Java小子版

    前言 Go语言定义 Go(又称 Golang)是 Google 的 Robert Griesemer,Rob Pike 及 Ken Thompson 开发的一静态、强类型、编译型
    的头像 发表于 08-12 14:32 671次阅读
    三十分钟入门基础<b class='flag-5'>Go</b> Java小子版

    怎么判断是电机还是电容

    在这篇文章,我们将探讨如何判断是电机还是电容。 电机和电容的基本概念 电机是一将电能转换为机械能的设备,广泛应用于工业、农业、交通、家庭等领域。电机的种类繁多,按照工作原理可分
    的头像 发表于 07-13 09:37 2217次阅读

    求助,关于PSoC4000S POWER_DRILL2GO下降压摆率的疑问求解

    我不确定来自哪个版本,但数据表列出了 PSoC4000S POWER_DRILL2GO下降压摆率。(1V/ms 最小,最大 67V/ms) 目前的设计并不能满足这个要求
    发表于 05-21 08:14

    语言模型:原理与工程实践+初识2

    前言 深度学习是机器学习的分支,而大语言模型是深度学习的分支。机器学习的核心是让计算机系统通过对数据的学习提高性能,深度学习则是通过创建人工神经网络处理数据。近年人工神经网络高速发展,引发深度学习
    发表于 05-13 00:09

    【大语言模型:原理与工程实践】大语言模型的应用

    实际应用前需解决的挑战。为提升大语言模型的性能,高级的提示词技术可以促进大语言模型与环境进行动态交互,引导其生成和推理规划。 检索增强生成技术(RAG)的核心理念在于从知识库或互联网
    发表于 05-07 17:21

    【大语言模型:原理与工程实践】探索《大语言模型原理与工程实践》2.0

    《大语言模型“原理与工程实践”》是关于大语言模型内在机理和应用实践的一次深入探索。作者不仅深入讨论了理论,还提供了丰富的实践案例,帮助读者理
    发表于 05-07 10:30

    【大语言模型:原理与工程实践】大语言模型的基础技术

    热表示将每个词转化为长度为1V1的向量。在该向量,词表的第i个词在第i维上设为1,其余维均为0。这种表示方法使得词表的每个词都有独一无二的向量表示。但独热表示存在数据稀疏性问题,词表过大
    发表于 05-05 12:17

    【大语言模型:原理与工程实践】核心技术综述

    应用,需要考虑到性能、可扩展性和安全性等因素。 大语言模型正在快速发展,新技术不断涌现。未来的研究可能集中在提高模型效率、理解和可解释性以及确保模型的公平性和伦理使用。 提供几个参
    发表于 05-05 10:56

    【大语言模型:原理与工程实践】揭开大语言模型的面纱

    复用和优化效果。这些趋势共同推动了大语言模型在深度学习研究和应用的重要地位。数据效应指出大型模型需要更多数据进行训练,以提高性能。其次,表示能力使得大语言模型能够学习更复杂、更精细的
    发表于 05-04 23:55

    【大语言模型:原理与工程实践】探索《大语言模型原理与工程实践

    处理预训练架构Transformer,以及这些技术在现实世界的如何应用。通过具体案例的分析,作者展示了大语言模型在解决实际问题中的强大能力,同时也指出了当前技术面临的挑战和局限性。书中对大
    发表于 04-30 15:35

    关于go接口类型的表示方法

    go是一个静态性语言,每个变量都有静态的类型,因此每个变量在编译阶段中有明确的变量类型,比如像:int、float32、MyType。
    的头像 发表于 04-28 10:13 332次阅读

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

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

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

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