一般来说,正如我们平常理解的那样,将串行程序修改为并行程序之后,其性能会得到提升。但也不是绝对的,在某些情况下,串行编程性能反而更好。
下面这个例子是快速排序的串行实现。
// 快速排序中的分区,把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)
}我们将串行实现修改为并发实现:
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多核的能力来运行。我们随机生成测试数据,测试它们运行所花费的时间:
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带来的管理损耗。所以并发程序的优化版本如下:
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逻辑核数的数倍或者十几倍。当然,具体的数值最好还是通过基准测试来确定。