使用goroutines提高程序的性能

我们知道Golang语言的一个大杀器就是其goroutines机制,可以通过多核并发计算能大幅度提高程序的性能。但是Golang的协程如果使用不当反而会成为影响程序执行的瓶颈,本文中虫虫使用实例来说明Golang协程使用中存在的问题、及其原因,最后使用通道来解决问题并最终实现性能的提高。

简单循环计算

首先,我们以一个简单循环累加为例子,我们循环计算100亿次累加:

使用goroutines提高程序的性能

我们用time开头,用来简单计算程序执行的耗时,结果如下:

使用goroutines提高程序的性能

注意由于累加100亿次的结果实际上已经超出了证书最大值结果为负数,我们暂时不用管它,我们注意下程序执行时间10.299秒。

使用goroutines和slice并发计算

上面的例子中,简单循环显然是简单直接,只能使用一个CPU进行计算,所以肯定比较慢。那么我们改造一下,利用goroutines来并发使用多CPU进行计算:

使用goroutines提高程序的性能

代码解释:

代码中,我们首先使用GOMAXPROCS 获取可用的硬件线程总数。通常为物理CPU数量的两倍。建立一个整数n个元素的全局slice来sums保存计算结果。我们使用sync的WaitGroup功能来管理goroutines。 它的Wait方法可以用来阻塞并检测 goroutine的完成;Add方法用来增加或者减少goroutine的数量,Done方法其实上等于Add(-1)。然后用协程做并发计算。最后使用range函数把slice中的中间结果加起来就是总的结果。

我们执行下,结果如下:

使用goroutines提高程序的性能

对比简单循环计算的结果,不论是user用户态(多核)执行的时间,还是真正总时间都比简单循环的多。多核执行花费时间是单核的4倍(user态),基本上没有节省时间。

CPU和CPU缓存

要解释这个现象,我们就需要从现代CPU架构及CPU缓存来说明。为了提高计算系能现代CPU架构中都使用了多级缓存来缓存内存到CPU通讯来提高IO,解决内核带宽的瓶颈等问题,来极大的提高性能。

CPU缓存

一般来说,缓存是一个非常小但超快的内存块。它位于CPU芯片上,因此每次读取或写入值时,CPU都不需要直接从内存获取指令和数据。而是先将值读取到缓存中,CPU从缓存中读写值,然后通定期同内存进行数据同步。每个CPU核心都有自己的缓存,不会与任何其他核心共享。对于n颗大小的CPU内核,最多可以有n+1个相同数据备份:一个在位于内存中,还有n个CPU的缓存中。当CPU内核更改其本地缓存中的值时,必须在某个时刻将其同步到内存。同样,如果缓存中存的值在对应内存中改变了(另一个CPU内核缓存同步导致),则缓存的值就失效了,需要从主内存从新读取刷新缓存。下面动图显示了这一过程:

使用goroutines提高程序的性能

缓存行

为了高效的同步缓存和主存储器,数据会以64B大小的块进行同步。这些块称为缓存行。

当缓存值更改时,会以最小缓存行(64B)大小的块为单位同步到内存。同样同步到其他CPU缓存时候也是以此为单位。

在我们的程序中,在并发循环中使用了全局slice来存储中间的累加结果。slice的元素存储在连续的内存空间中。有极大的概率,其中相邻的切片元素间可能共同位于一个缓存行中。那么问题就来了,n具有n个高速缓存的CPU核心重复读取和写入位于同一高速缓存行中的切片元素。此时,只要一个CPU核心计算出了sum结果,并更新了对应的slice元素,其他同缓存行的缓存就会失效,必须等待CPU核心将其同步回写到内存,并更新其他CPU的缓存才能继续计算。这个过程会非常耗时,甚至超过了单核循环更新单个和变量所需的时间。这就是我们的更新的并发计算更加耗时的原因:对一个切​​片的并发更新都会导致同一缓存行下的切片都要重复大量的缓存同步。下图动态显示了这一过程:

使用goroutines提高程序的性能

使用通道和goroutines并发计算

上面我们使用CPU架构和缓存原理说说明了,并发计算没有代理性能改善的原因。那么怎么解决就简单了:我们将切片转换为n个单独的变量,这样让他们彼此孤立分散在内存各处,不让他们共享一个缓存行即可。

下面我们用一个变量存储每个goroutine的中间计算结果,并通过一个通道将结果将结果传递到主程序中。也使用通道来控制goroutine并发:

使用goroutines提高程序的性能

代码解释:

chansum函数生使用n(硬件线程总数)个goroutines,使用一个中间变量保存这n个数的和,通过一个整型通道res传回结果。

当没有要读取的元素时,通道会自动阻塞。所以,我们无需做同步管理。

我们执行下改善程序,结果如下:

使用goroutines提高程序的性能

用时只需2秒,性能提高了五倍,OK,我们多核并发计算的效果真正体现出来了。

使用testing包进行基准测试

上面例子中我们都是使用linux的time工具测试程序执行时间来进行性能对比。实际上Golang语言本身就自带了一个testing测试包,可以用来帮我们做测试,当然基准测试也是他的功能之一,那么本文最后一部分我们就介绍一下使用testing包进行基准测试,比较三种方法的性能。首先我们要添加一个测试用例文件名称必须和主程序xxx相同,同一目录名称为xxx_test.go,并且程序中要在同一package包。基准测试时候用例的函数必须以BenchmarkXXX开头,函数必须为大写。本例子我们的测试文件如下:

使用goroutines提高程序的性能

上面就是对三种方法(函数)进行基准测试的测试用例函数。

我们通过go test -bench .运行测试,结果如下:

使用goroutines提高程序的性能

总结:

本文中,我们展示用golang的goroutine并行机制进行计算性能改善,并且揭示了一个使用goroutine中常见的问题,导致并发计算实际上并没有提高性能。我们利用CPU架构和缓存的底层处理机制解释了出现这种问题的原因,最后通过通道和本地变量的方法改善了程序使得计算性能得到大幅度的提高。最后我们还介绍通过Golang自带的testing包进行基准测试的方法。


分享到:


相關文章: