Go并发并不一定最快

1/6/2026golang并发

一般来说,正如我们平常理解的那样,将串行程序修改为并行程序之后,其性能会得到提升。但也不是绝对的,在某些情况下,串行编程性能反而更好。

下面这个例子是快速排序的串行实现。

go
// 快速排序中的分区,把a分成左右两部分,左边部分小于右边部分 func partition(a []int, lo, hi int) int { pivot := a[hi] // 将最后一个值作为分界值 i := lo - 1 for j := lo; j < hi; j++ { if a[j] < pivot { //如果值小于分界值,则挪到左边 i++ a[j], a[i] = a[i], a[j] } } a[i+1], a[hi] = a[hi], a[i+1] return i + 1 } func quickSort(a []int, lo, hi int) { if lo >= hi { return } p := partition(a, lo, hi) quickSort(a, lo, p-1) quickSort(a, p+1, hi) }

我们将串行实现修改为并发实现:

go
func quickSortGo(a []int, lo, hi int, done chan struct{}) { if lo >= hi { done <- struct{}{} return } p := partition(a, lo, hi) childDone := make(chan struct{}, 2) go quickSortGo(a, lo, p-1, childDone) // 启动一个 goroutine,快速排序左边 go quickSortGo(a, p+1, hi, childDone) // 启动一个goroutine,快速排序右边 <-childDone <-childDone done <- struct{}{} }

我们期望并发的版本运行得更快一点,毕竟串行的版本使用一个CPU核来运行,而并发的版本可以并发处理,充分利用CPU多核的能力来运行。我们随机生成测试数据,测试它们运行所花费的时间:

go
func bench_quickSort() { // 生成测试数据 r := rand.New(rand.NewSource(time.Now().UnixNano())) n := 10000000 testData1, testData2 := make([]int, 0, n), make([]int, 0, n) for i := 0; i < n; i++ { val := r.Intn(n * 100) testData1 = append(testData1, val) testData2 = append(testData2, val) } // 串行程序 start := time.Now() quickSort(testData1, 0, len(testData1)-1) fmt.Println("串行执行:", time.Since(start)) // 并发程序 done := make(chan struct{}) start = time.Now() go quickSortGo(testData2, 0, len(testData2)-1, done) <-done fmt.Println("并发执行:", time.Since(start)) }

实际运行:

串行执行: 688.731667ms 并发执行: 2.915344584s

并发程序的耗时反而远远大于串行程序的耗时?为什么在多核的CPU上运行的并发程序反而比在一个核上运行的串行程序还要慢?

我们使用1000万个数据进行排序,并发程序会将数据分成两个部分,这两个部分使用两个子goroutine来运行,每个子goroutine负责其中一部分数据。同样地,每个子goroutine 也会将自己要排序的部分分成两个孙goroutine,这样整体上并发程序会创建许许多多的 goroutine。虽然我们前面说过,goroutine相对于线程是轻量级的,但这并不意味着它没有资源的占用和性能的损耗。与操作系统的线程相比,goroutine的内存占用更小:Go 1.4以后的goroutine只占用2KB,在运行中goroutine的内存占用还会按需进行调整:更进一步,Go 1.19会根据历史goroutine栈的使用率来初始化新的goroutine栈的大小,goroutine 栈的大小不再是固定的2KB。因为创建新的goroutine会伴随栈的分配,这也会损耗性能。同时大量的goroutine在调度和垃圾回收检查时也会占用一定的时间,所以整体上说,尽管使用了多个CPU核,这些额外的时间反而让程序的性能下降了。

既然我们意识到太多的goroutine 影响并发程序的性能,那么可以减少goroutine的生成,并且递归深度在3以内,采用并发的方式快速排序;如果递归深度超过3,则退化为串行的方式,这样就既利用了并发执行的优势,又减少了过多goroutine带来的管理损耗。所以并发程序的优化版本如下:

go
func quickSortGo2(a []int, lo, hi int, done chan struct{}, depth int) { if lo >= hi { done <- struct{}{} return } depth-- p := partition(a, lo, hi) if depth > 0 { childDone := make(chan struct{}, 2) go quickSortGo2(a, lo, p-1, childDone, depth) go quickSortGo2(a, p+1, hi, childDone, depth) <-childDone <-childDone } else { quickSort(a, lo, p-1) quickSort(a, p+1, hi) } done <- struct{}{} }

使用测试程序测试,串行版本、完全并发版本和优化并发版本的性能对比如下:

串行执行: 685.250833ms 完全并发执行: 3.098461584s 优化并发执行: 271.170666ms

可以看到,我们优化的并发版本可以获得更好的性能。

这里递归深度为什么取3,能不能取5或者1024呢?它基本上是一个经验值,最好通过基准测试的方式来确定这个值。如果是CPU敏感型的程序,则可以尝试使生成的goroutine的数量和CPU的逻辑核数相等;如果是I/O敏感型的程序,则可以尝试把goroutine的数量设置为CPU逻辑核数的数倍或者十几倍。当然,具体的数值最好还是通过基准测试来确定。