使用 goroutine 减慢你的代码

295 阅读5分钟

使用 goroutine 减慢你的代码

golang 中可以很方便的使用 goroutine 调用CPU的多个核心,一般我们都会觉得一个任务让多个 goroutines 跑能够大大的提高程序的运行速度,但是实际上有可能并不会提高速度。相反,会降低程序的运行速度,花费更多的时间。

串行计算

从一个很简单的示例开始,循环求和。示例代码如下。

package goroutines

import (
	"runtime"
	"sync"
)

const (
	limit = 100000000
)

func SerialSum() int {
	sum := 0

	for i := 0; i < limit; i++ {
		sum += 1
	}

	return sum
}

并行计算

同样的任务,修改代码来实现并行计算的效果。根据CPU的核心数,将任务分成N份,并且创建N个 goroutines 同时去跑这些任务,跑完之后再将结果合并起来。

package goroutines

import (
	"runtime"
	"sync"
)

const (
	limit = 100000000
)

func ConcurrentSum() int {
	n := runtime.GOMAXPROCS(0)			// 获取CPU的核心数
	sums := make([]int, n)

	wg := sync.WaitGroup{}
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func(i int) {
			start := (limit / n) * i
			end := start + (limit / n)
			for j := start; j < end; j++ {
				sums[i] += j
			}

			wg.Done()
		}(i)
	}

	wg.Wait()

	sum := 0
	for _, s := range sums {
		sum += s
	}

	return sum
}

并行速度增益

编写golang基准测试,测试性能和速度。

package goroutines

import "testing"

func BenchmarkSerialSum(b *testing.B) {
	for i := 0; i < b.N; i++ {
		SerialSum()
	}
}

func BenchmarkConcurrentSum(b *testing.B) {
	for i := 0; i < b.N; i++ {
		ConcurrentSum()
	}
}

基准测试结果,前缀-6表示测试使用所有的6个逻辑核心。但是并行循环的时间几乎是串行循环的两倍,这是怎么回事?

❯ go test -bench .
goos: linux
goarch: amd64
pkg: learn-go/goroutines
cpu: Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz
BenchmarkSerialSum-6                  32          33879349 ns/op
BenchmarkConcurrentSum-6              18          62508012 ns/op
PASS
ok      learn-go/goroutines     2.317s

分析程序变慢的原因

为了解释这个反直觉的结果,我们需要了解CPU。CPU内存有多级高速缓存。这些缓存介于内存和CPU之间。为了方便分析,我们简化成一级缓存。

  • CPU缓存的用途

    一般来说高速缓存是一个非常小但是超快的内存。他在CPU芯片上,因此CPU每次读写数据的时候不需要每次都访问RAM。CPU直接读写高速缓存可以加快程序的运行速度。

    CPU每个核心都有一块属于他自己的高速缓存(和其他的CPU核心不共享),对于一块有N的核心的CPU,一份数据最多会有N+1份副本。一份在主内存中,其他的在每个CPU的核心的高速缓存中。

    当其中的一个CPU核心更改它高速缓存中变量的值时,它一定要同步回主存储器中。并且,当主存储器中变量的值发生改变时,CPU核心的高速缓存也需要及时得到更新。

CPU高速缓存.png

  • 高速缓存行

    为了使得主存储器和高速缓存之前的数据同步更加高效,数据通常以64字节的块为最小单位进行数据同步,这些64字节的内存块叫做高速缓存行。

    所以当高速缓存中的某一个变量发生变化,整个高速缓存行都需要同步到主存储器中。同时,包含这个高速缓存行的其他CPU核心也需要进行同步,为了防止对过期的数据进行操作。

  • 速度变慢原因

    并发循环的代码使用了一个全局的切片来存储中间结果,切片的底层数据结构是数组,数组使用连续的内存空间。很有可能,这些连续内存空间在一条高速缓存行中。

    如此,噩梦开始了。

    多核CPU的每个核心读写同一条高速缓存行,所以,无论什么时候当一个CPU核心更新他的计算结果到切片中时,其他核心的高速缓存行的数据都会失效并且从主存储器中进行更新。尽管每个核心访问的是切片的不同的数据部分。

    数据同步消耗了大量的时间,所以导致了多核计算速度反而比单核更慢。

CPU高速缓存数据同步.png

如何避免这种昂贵的数据同步

既然我们知道了速度变慢的原因,解决的方案很明显,需要将切片转换成N个独立的变量,并且这些变量之间的距离足够远,不在同一个高速缓存行中,这样他们就不会共享高速缓存行了。

golang有一个核心思想,不要通过共享内存来通信,而要通过通信来共享内存。

所以只需要改变一下并发循环中间值的存储方式,创建一个通道,通道不仅仅用来通信,还是一种优雅的同步机制。

  • 使用通道修改并发代码

    package goroutines
    
    import (
    	"runtime"
    	"sync"
    )
    
    const (
    	limit = 100000000
    )
    
    func ChannelSum() int {
    	n := runtime.GOMAXPROCS(0)
    	res := make(chan int)
    
    	for i := 0; i < n; i++ {
    		go func(i int, r chan<- int) {
    			start := (limit / n) * i
    			end := start + (limit / n)
    			s := 0
    			for j := start; j < end; j++ {
    				s += j
    			}
    
    			r <- s
    		}(i, res)
    	}
    
    	sum := 0
    	for i := 0; i < n; i++ {
    		sum += <-res
    	}
    
    	return sum
    }
    
  • 三种方式一起进行基准测试

    使用通道的并发方式是单线程的5倍多,基本符合我们的预期。通道有效的避免了高速缓存行的同步。

    但是,怎么保证每个goroutine中的变量不会出现在同一个高速缓存行中。每一个goroutine启动都会在堆栈上分配至少2K的内存,这可比64字节的高速缓存行要大的多,由于中间值sum没有在当前goroutine意外的任何地方引用,所以变量只会在当前的goroutine的堆栈中。以此不会有中间变量出现在同一个高速缓存行中。

    ❯ go test -bench .
    goos: linux
    goarch: amd64
    pkg: learn-go/goroutines
    cpu: Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz
    BenchmarkSerialSum-6                  46          35169294 ns/op
    BenchmarkConcurrentSum-6              19          61891767 ns/op
    BenchmarkChannelSum-6                214           6146505 ns/op
    PASS
    ok      learn-go/goroutines     5.647s