Golang:特定的性能问题

Go语言越来越受欢迎。 确信会议越来越多,例如GolangConf ,并且该语言是十项收入最高的技术之一。 因此,谈论其特定问题(例如性能)已经很有意义。 除了所有编译语言的常见问题外,Go都有自己的。 它们与优化器,堆栈,类型系统和多任务模型相关联。 解决这些问题的方法和解决方法有时非常具体。

丹尼尔·波多尔斯基Daniel Podolsky )虽然是Go的传教士,但在他身上也遇到了很多奇怪的事情。 收集并测试所有奇怪且最重要的是有趣的东西,然后在HighLoad ++中进行讨论。 该报告的文字记录将包括数字,图形,代码示例,分析器结果,以不同语言对相同算法的性能进行比较-以及其他所有内容,因此我们讨厌“优化”一词。 笔录中不会有任何启示-它们是从如此简单的语言中获得的-一切都可以在报纸上阅读。



关于演讲者。 Daniil Podolsky :26年的经验,有20年的运营经验,包括该小组的负责人,还有5年的Go编程经验。 Kirill DanshinGramework的创建者,维护者,快速HTTP,Black Go-mage。

该报告是由丹尼尔·波多尔斯基(Daniil Podolsky)和基里尔·丹辛(Kirill Danshin)共同编写的,但丹尼尔(Daniel)提出了报告,而西里尔(Cyril)在精神上提供了帮助。

语言构造


我们有direct的性能标准。 这是一个使变量递增且不再执行任何操作的函数。

 //   var testInt64 int64 func BenchmarkDirect(b *testing.B) { for i := 0; i < bN; i++ { incDirect() } } func incDirect() { testInt64++ } 

每个操作的功能结果为1.46 ns 。 这是最小的选择。 每次操作快于1.5 ns,可能无法正常工作。

推迟我们如何爱他


许多人知道并喜欢使用延迟语言构造。 我们经常像这样使用它。

 func BenchmarkDefer(b *testing.B) { for i := 0; i < bN; i++ { incDefer() } } func incDefer() { defer incDirect() } 

但是您不能那样使用它! 每个操作每次延迟吃掉40 ns。

 //   BenchmarkDirect-4 2000000000 1.46 / // defer BenchmarkDefer-4 30000000 40.70 / 

我以为是因为内联? 也许内联是如此之快?

Direct是内联的,而defer函数不能内联。 因此,编译了一个没有内联的单独的测试函数。

 func BenchmarkDirectNoInline(b *testing.B) { for i := 0; i < bN; i++ { incDirectNoInline() } } //go:noinline func incDirectNoInline() { testInt64++ } 

一切都没有改变,延迟花了相同的40 ns。 推迟亲爱的,但不是灾难性的。

如果函数花费的时间少于100 ns,则可以不延迟进行操作。

但是,如果该功能花费的时间超过一微秒,则完全一样-您可以使用defer。

通过引用传递参数


考虑一个流行的神话。

 func BenchmarkDirectByPointer(b *testing.B) { for i := 0; i < bN; i++ { incDirectByPointer(&testInt64) } } func incDirectByPointer(n *int64) { *n++ } 

一切都没有改变-没有什么值得。

 //     BenchmarkDirectByPointer-4 2000000000 1.47 / BenchmarkDeferByPointer-4 30000000 43.90 / 

每次延迟除了3 ns之外,但这会因波动而被撇销。

匿名函数


有时,新手会问:“匿名函数贵吗?”

 func BenchmarkDirectAnonymous(b *testing.B) { for i := 0; i < bN; i++ { func() { testInt64++ }() } } 

匿名函数并不昂贵,它需要40.4 ns。

介面


有一个实现它的接口和结构。

 type testTypeInterface interface { Inc() } type testTypeStruct struct { n int64 } func (s *testTypeStruct) Inc() { s.n++ } 

使用增量方法有三个选项。 直接来自Struct: var testStruct = testTypeStruct{}

在相应的具体接口中: var testInterface testTypeInterface = &testStruct

使用运行时接口转换: var testInterfaceEmpty interface{} = &testStruct

下面是运行时界面的直接转换和使用。

 func BenchmarkInterface(b *testing.B) { for i := 0; i < bN; i++ { testInterface.Inc() } } func BenchmarkInterfaceRuntime(b *testing.B) { for i := 0; i < bN; i++ { testInterfaceEmpty.(testTypeInterface).Inc() } } 

因此,该接口不花费任何费用。

 //  BenchmarkStruct-4 2000000000 1.44 / BenchmarkInterface-4 2000000000 1.88 / BenchmarkInterfaceRuntime-4 200000000 9.23 / 


运行时界面转换是值得的,但并不昂贵-您无需明确拒绝。 但是,请尽可能尝试不使用它。

神话:

  • 取消引用-取消引用指针-免费。
  • 匿名功能是免费的。
  • 接口是免费的。
  • 运行时界面转换-不是免费的。

切换,地图和切片


每个Go的新手都会问,如果用map替换switch会发生什么。 会更快吗?

开关有不同的尺寸。 我测试了三种尺寸:小号10例,中号100例,大号1000例。 在实际的生产代码中找到了1000个案例的开关。 当然,没有人会用手书写它们。 这是自动生成的代码,通常是类型开关。 在两种类型上进行了测试:int和string。 看来结果会更加清楚。

一点开关。 最快的选择是实际开关。 紧随其后的是slice,其中相应的整数索引包含对该函数的引用。 Map在int或string上都不是领导者。
BenchmarkSwitchIntSmall-45亿3.26 ns /运算
BenchmarkMapIntSmall-41亿11.70 ns /运算
BenchmarkSliceIntSmall-45亿3.85 ns /运算
BenchmarkSwitchStringSmall-41亿12.70 ns / op
BenchmarkMapStringSmall-41亿15.60 ns /运算

打开字符串比int慢得多。 如果您可以不切换到字符串而是切换到int,则可以这样做。

中间开关。 开关本身仍然可以控制int,但是slice已经超过了它。 地图仍然不好。 但是在字符串键上,映射比切换要快-符合预期。
BenchmarkSwitchIntMedium-43亿4.55 ns /运算
BenchmarkMapIntMedium-41亿17.10 ns /运算
BenchmarkSliceIntMedium-43亿3.76 ns /运算
BenchmarkSwitchStringMedium-450,000,00028.50 ns /运算
BenchmarkMapStringMedium-41亿20.30 ns /运算

大开关。 一千个案例表明,在“按字符串切换”提名中,地图无条件获胜。 从理论上讲,slice赢了,但实际上,我建议您在此处使用相同的开关。 即使考虑到映射具有带特殊哈希函数的整数键,映射仍然很慢。 通常,此功能不执行任何操作。 int本身具有int的哈希值。
BenchmarkSwitchIntLarge-41亿13.6 ns /运算
BenchmarkMapIntLarge-450,000,00034.3 ns /运算
BenchmarkSliceIntLarge-41亿12.8 ns /运算
BenchmarkSwitchStringLarge-420,000,000100.0 ns /运算
BenchmarkMapStringLarge-43000000037.4 ns /运算

结论 映射仅在数量较大而不是整数条件下更好。 我确信在int以外的任何条件下,它的行为都将与string相同。 当条件为整数时,Slice总是会转向。 如果您想以2 ns的速度“加速”程序,请使用它。

程序间交互


主题很复杂,我进行了许多测试,并将介绍最具启发性的测试。 我们知道以下机构间互动的方式

  • 原子的 这些是适用性有限的方法-您可以替换指针或使用int。
  • 自Java以来​​,互斥量已​​被广泛使用。
  • 频道是GO特有的。
  • 缓冲通道-缓冲通道。

当然,我在争夺一种资源的大量goroutines上进行了测试。 但是他为自己选择了三个作为指示:一点-100,中等-1000和很多-10000。

负载曲线不同 。 有时所有gorutin都想写一个变量,但这很少见。 通常,毕竟有些写,有些读。 在大多数读者(占90%的阅读者)中,有90%是写作。

这是使用的代码,因此为通道提供服务的goroutine可以提供对变量的读取和写入操作。

 go func() { for { select { case n, ok := <-cw: if !ok { wgc.Done() return } testInt64 += n case cr <- testInt64: } } }() 

如果一条消息通过我们编写的渠道到达我们,我们将执行该消息。 如果通道关闭,则完成goroutin。 在任何时候,我们都准备好写入其他goroutine用于读取的通道。
Benchmarkmutex-41亿16.30 ns /运算
Benchmarkatomic-42000000006.72 ns /运算
Benchmarkcan-45,000,000239.00 ns /运算

这是一个goroutine的数据。 通道测试是在两个goroutine上执行的:一个过程处理Channel,另一个过程写入该Channel。 这些选项已经过测试。

  • 直接写入变量。
  • 互斥锁获取日志,写入变量并释放日志。
  • 原子通过原子写入变量。 它不是免费的,但仍然比Mutex的一种garutin便宜得多。

使用少量的goroutine,Atomic是一种有效且快速的同步方式,这不足为奇。 Direct不在这里,因为我们需要同步,而同步却没有提供。 但是,原子当然有缺陷。
BenchmarkMutexFew-43000055894 ns /运算
BenchmarkAtomicFew-4100,00014585 ns /运算
BenchmarkChanFew-45000323859 ns / op
BenchmarkChanBufferedFew-45000341321 ns / op
BenchmarkChanBufferedFullFew-42000070052 ns / op
BenchmarkMutexMostlyReadFew-43000056402 ns /运算
BenchmarkAtomicMostlyReadFew-41,000,0002094 ns /运算
BenchmarkChanMostlyReadFew-43000442689 ns /运算
BenchmarkChanBufferedMostlyReadFew-43000449,666 ns / op
BenchmarkChanBufferedFullMostlyReadFew-45000442,708 ns / op
BenchmarkMutexMostlyWriteFew-42000079708 ns /运算
BenchmarkAtomicMostlyWriteFew-4100,00013358 ns /运算
BenchmarkChanMostlyWriteFew-43000449,556 ns /运算
BenchmarkChanBufferedMostlyWriteFew-43000445423 ns /运算
BenchmarkChanBufferedFullMostlyWriteFew-43000414626 ns /运算

接下来是Mutex。 我期望Channel可以和Mutex一样快,但是不可以。

通道比Mutex贵一个数量级。

而且,Channel和缓冲Channel的价格大致相同。 还有Channel,缓冲区永远不会溢出。 它比缓冲区溢出的便宜一个数量级。 仅当Channel中的缓冲区未满时,它的成本在数量级上与Mutex差不多。 这是我从测试中获得的期望。

在任何负载配置文件上都重复了此图以及其成本分配情况-在MostlyRead和MostlyWrite上均如此。 此外,完整的MostlyRead Channel的费用与未完成的费用相同。 而MostlyWrite的缓冲通道(其中缓冲区未满)的成本与其余通道相同。 我无法说出为什么-我尚未研究此问题。

传递参数


如何更快地传递参数-通过引用还是通过值? 让我们来看看。

我进行了如下检查-将嵌套类型设为1到10。

 type TP001 struct { I001 int64 } type TV002 struct { I001 int64 S001 TV001 I002 int64 S002 TV001 } 

第十个嵌套类型将具有10个int64字段,而上一个嵌套的嵌套类型也将为10。

然后他编写了创建嵌套类型的函数。

 func NewTP001() *TP001 { return &TP001{ I001: rand.Int63(), } } func NewTV002() TV002 { return TV002{ I001: rand.Int63(), S001: NewTV001(), I002: rand.Int63(), S002: NewTV001(), } } 

为了进行测试,我使用了三种类型的选项:带有嵌套2的小号,带有嵌套3的中号,带有嵌套5的大号。我不得不在晚上进行带有嵌套10的非常大的测试,但是图片与5完全相同。

在函数中,按值传递的速度至少是按引用传递的速度的两倍 。 这是由于以下事实:按值传递不会加载转义分析。 因此,我们分配的变量在堆栈上。 对于运行时和垃圾收集器而言,它便宜得多。 虽然他可能没有时间连接。 这些测试持续了几秒钟-垃圾收集器可能仍处于睡眠状态。
BenchmarkCreateSmallByValue-4200,0008942 ns / op
BenchmarkCreateSmallByPointer-4100,00015985 ns /运算
BenchmarkCreateMediuMByValue-42000862317 ns /运算
BenchmarkCreateMediuMByPointer-420001228130 ns / op
BenchmarkCreateLargeByValue-43047398456 ns /运算
BenchmarkCreateLargeByPointer-42061928751 ns / op

黑魔法


你知道这个程序会输出什么吗?

 package main type A struct { a, b int32 } func main() { a := new(A) aa = 0 ab = 1 z := (*(*int64)(unsafe.Pointer(a))) fmt.Println(z) } 

程序的结果取决于执行程序的体系结构。 在小端(例如AMD64)上,程序将显示 232。 在大端上,一个。 结果是不同的,因为在小字节序上,此单位出现在数字的中间,在大字节序上-在结尾。

世界上仍然有处理器进行字节序交换,例如Power PC。 在推断不安全技巧的作用之前,有必要在启动时找出计算机上配置了哪些字节序。 例如,如果您编写了将在某些IBM多处理器服务器上执行的Go代码。

我引用此代码来解释为什么我考虑所有不安全的黑魔法。 您不需要使用它。 但是西里尔认为这是必要的。 这就是为什么。

有一个功能与GOB相同的功能-Go Binary Marshaller。 这是编码器,但不安全。

 func encodeMut(data []uint64) (res []byte) { sz := len(data) * 8 dh := (*header)(unsafe.Pointer(&data)) rh := &header{ data: dh.data, len: sz, cap: sz, } res = *(*[]byte)(unsafe.Pointer(&rh)) return } 

实际上,它需要一块内存并从中绘制一个字节数组。

这甚至不是命令-这是两个命令。 因此,西里尔·丹辛(Cyril Danshin)在编写高性能代码时,会毫不犹豫地进入程序的胆量并使之不安全。

基准球4200,0008466 ns /运算120.94 MB /秒
BenchmarkUnsafeMut-450,000,00037 ns / op27691.06 MB /秒
我们将在10月7日的GolangConf上讨论Go的更多特定功能,该会议是为那些使用Go进行专业开发的人以及认为使用该语言作为替代语言的人的会议。 Daniil Podolsky只是计划委员会的成员,如果您想对本文进行辩论或揭示相关问题,请提交报告申请

对于其他所有方面,关于高性能,当然是HighLoad ++ 。 我们也在那里接受申请。 订阅新闻时事,并及时了解我们所有针对Web开发人员的会议的新闻。

Source: https://habr.com/ru/post/zh-CN461291/


All Articles