多Goroutine场景下Golang性能意外下降问题排查

多Goroutine场景下Golang性能意外下降问题排查 我在思考为什么在启动两个Go协程来生成数据时,我的算法性能没有变得更好。 我在一台多核机器上运行了测试,这意味着两个Go协程可以并行运行。这两个Go协程各自只生成总数据量的一半。因此,我原本预计总运行时间应该比单个Go协程减少大约一半。

如果有人能审阅下面附带的代码示例,我将不胜感激。我的方法可能存在问题,但我没能找出来。 谢谢!

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

func gendata(numGoRoutines, numElements int) []int {
	var wg sync.WaitGroup
	wg.Add(numGoRoutines)
	rand.Seed(time.Now().UnixNano())

	ssi := make([][]int, numGoRoutines)
	for i := 0; i < numGoRoutines; i++ {
		ssi[i] = make([]int, 0, numElements/numGoRoutines)
		go func(i int) {
			defer wg.Done()
			for j := 0; j < numElements/numGoRoutines; j++ {
				ssi[i] = append(ssi[i], rand.Intn(10))
			}
		}(i)
	}
	wg.Wait()

	// Create the result by appending all the sub-slices into one result slice
	var res []int
	for i := 0; i < numGoRoutines; i++ {
		res = append(res, ssi[i]...)
	}
	return res
}

func main() {
	n := 100000000

	// One Go routine
	g := 1
	t1 := time.Now()
	gendata(g, n)
	t2 := time.Now()
	fmt.Println("Data generation of", n, "elements with", g, "Go routines took", t2.Sub(t1))

	// Two Go routines
	g = 2
	t1 = time.Now()
	gendata(g, n)
	t2 = time.Now()
	fmt.Println("Data generation of", n, "elements with", g, "Go routines took", t2.Sub(t1))
}

更多关于多Goroutine场景下Golang性能意外下降问题排查的实战教程也可以访问 https://www.itying.com/category-94-b0.html

12 回复

@sabitor 非常感谢分享源代码,这对我非常有帮助。

更多关于多Goroutine场景下Golang性能意外下降问题排查的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我建议研究一下 runtime/pprof 包,以查明时间消耗在何处。

我之前不知道L1缓存是每个CPU独立的。这确实会造成一种情况:CPU越多,缓存失效就越频繁(一个goroutine修改了1个缓存行,那么所有其他拥有该缓存行本地副本的CPU都需要重新加载它)。

我确信第二种情况永远不会因为缓存行而发生,因为缓存行上存在隐式锁定(这就是共享内存的含义)。

skillian:

这很可能导致伪共享,从而抵消并行化的任何优势。

那么,每次访问 ssi 都会导致缓存未命中。当一个 goroutine 或多个 goroutine 都经历相同次数的缓存未命中时,这如何抵消并行化的优势呢?

变量名 ssi 的选择非常糟糕。给它一个有意义的名称,将极大地提高可读性。

这是个很好的观点。它确实提升了性能。现在,单个 goroutine 生成数据和两个 goroutine 生成数据的运行时间几乎相同了。

尽管如此,我原本期望使用两个 goroutine 的方法能将运行时间大致减半。 我还测试了向切片追加一个固定值的情况,例如:

ssi[i] = append(ssi[i], 1)

结果是一样的。两种变体的运行时间相同。似乎还有其他因素影响着两个 goroutine 的运行时间。我仍然想知道为什么会发生这种情况……

math/rand 会产生一个可预测的伪随机值序列。为了确保连续调用 math/rand.Intn 总是产生相同的值,其随机源受互斥锁保护,因此添加 goroutine 并无帮助。

相反,应使用 math/rand.NewSource 并为每个 goroutine 分别将其传递给 rand.New。你不能在 goroutine 之间共享一个 rand.Source,因为只有全局的随机源才是并发安全的。这样做应该能让多个核心生成随机值。

所以每次访问 ssi 都会导致缓存未命中

不,不是缓存未命中:ss[0]ss[1] 切片在内存中是相邻的,每次向切片追加元素时,长度字段都会被写入,并且会“破坏”这两个切片所在的缓存行,导致其他核心需要不断从主内存重新加载它们各自的切片副本。

如果你预先分配切片,这样你写入的是它们的底层数组,但不是实际的切片头部,那么每个核心都在不同的缓存行中写入自己的内存,因此你就不必处理伪共享的情况了。

sabitor:

ssi[i] = append(ssi[i], rand.Intn(10))

sabitor:

ssi[i][j] = r.Intn(10)

当然!你说得对;我对自己没注意到这一点感到很沮丧!每次你向切片追加元素时,都会将新的长度写回 ss[i],而 ss[i] 在内存中与 ss[i±1] 是相邻的,这很可能导致了伪共享,从而可能抵消并行化带来的任何好处。

我通过在一开始就生成结果切片,并为每个参与的 goroutine 在该切片中分配其专属的分区,从而进一步优化了它。这样,我就可以省去最后一步,即从不同 goroutine 产生的中间结果生成结果切片。

func gendata(numGoRoutines, numElements int) []int {
	var wg sync.WaitGroup
	wg.Add(numGoRoutines)

	res := make([]int, numElements)                  // 结果切片
	partitionElements := numElements / numGoRoutines // 每个 goroutine 处理的元素数量

	for i := 0; i < numGoRoutines; i++ {
		go func(i int) {
			defer wg.Done()
			s := rand.NewSource(time.Now().UnixNano())
			r := rand.New(s)
			offset := i * partitionElements // 用于计算数据插入索引的偏移量
			for j := 0; j < partitionElements; j++ {
				res[j+offset] = r.Intn(10)
			}
		}(i)
	}
	wg.Wait()

	return res
}

我进行了更多研究,并进一步优化了函数的运行时间。 除了关于随机源的更改(感谢您的提示),我还修改了以下两点:

  1. for循环退出条件的计算在进入循环前只执行一次
  2. 新元素通过适当的切片索引插入到切片中
func gendata(numGoRoutines, numElements int) []int {
	var wg sync.WaitGroup
	wg.Add(numGoRoutines)

	ssi := make([][]int, numGoRoutines)
	for i := 0; i < numGoRoutines; i++ {
		ssi[i] = make([]int, numElements/numGoRoutines)
		go func(i int) {
			defer wg.Done()
            s := rand.NewSource(time.Now().UnixNano())
	        r := rand.New(s)
            cond := numElements / numGoRoutines
			for j := 0; j < cond; j++ {
				ssi[i][j] = r.Intn(10)
			}
		}(i)
	}
	wg.Wait()

	// 通过将所有子切片追加到一个结果切片中来创建结果
	var res []int
	for i := 0; i < numGoRoutines; i++ {
		res = append(res, ssi[i]...)
	}
	return res
}

为什么其他 goroutine 会持续重新加载?这在实践中是如何运作的? 假设 goroutine A 修改了一个切片 X,并将其写入到慢速内存中。该切片的长度和容量也会随之改变。

然后,另一个 goroutine B 想要对同一缓存行中的另一个切片 Y 进行操作。这是如何工作的?当 B 想要修改 Y 的长度或容量时(在修改其内容之前),整个机器都需要读取该缓存行。所有的 goroutine 共享同一个缓存。

你所说的重新加载是什么意思?"重新加载"这个词似乎有些令人困惑。一旦 goroutine B 读取了切片 Y 的容量/长度/指针,就完成了。在它下次执行另一个从该缓存行读取任何内容的指令之前,它不会再重新读取这些数据(以及该缓存行)。这只是一个简单的缓存未命中(当 A 修改 X 时,B 拥有的缓存行恰好被置为无效)。

并没有机制将信息推送给 B(重新加载),只是 B 导致机器再次拉取该缓存行(缓存未命中)。

我的理解正确吗?我遗漏了什么?在我看来,我似乎没有理解使缓存行失效是如何工作的,特别是关于 goroutine 共享其单一副本这一点,我的假设是它们只会在需要从中获取信息时再次读取它(仅一次)。

你的代码性能问题主要源于 rand.Intn() 的全局锁竞争。Go的 math/rand 包使用全局共享的随机数生成器,内部有互斥锁保护。当多个goroutine并发调用时,会产生严重的锁竞争,导致性能下降甚至比单goroutine更差。

以下是优化后的代码示例:

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

func gendata(numGoRoutines, numElements int) []int {
	var wg sync.WaitGroup
	wg.Add(numGoRoutines)

	// 每个goroutine使用独立的随机数生成器
	ssi := make([][]int, numGoRoutines)
	for i := 0; i < numGoRoutines; i++ {
		ssi[i] = make([]int, 0, numElements/numGoRoutines)
		go func(i int) {
			defer wg.Done()
			// 为每个goroutine创建独立的随机源
			rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(i)))
			for j := 0; j < numElements/numGoRoutines; j++ {
				ssi[i] = append(ssi[i], rng.Intn(10))
			}
		}(i)
	}
	wg.Wait()

	// 预分配结果切片避免多次扩容
	res := make([]int, 0, numElements)
	for i := 0; i < numGoRoutines; i++ {
		res = append(res, ssi[i]...)
	}
	return res
}

func main() {
	n := 100000000

	// One Go routine
	g := 1
	t1 := time.Now()
	gendata(g, n)
	t2 := time.Now()
	fmt.Println("Data generation of", n, "elements with", g, "Go routines took", t2.Sub(t1))

	// Two Go routines
	g = 2
	t1 = time.Now()
	gendata(g, n)
	t2 = time.Now()
	fmt.Println("Data generation of", n, "elements with", g, "Go routines took", t2.Sub(t1))
}

关键改进点:

  1. 每个goroutine使用独立的 rand.New() 创建随机数生成器,避免全局锁竞争
  2. 为每个随机源使用不同的种子(time.Now().UnixNano() + int64(i)
  3. 预分配结果切片容量,减少内存分配次数

如果还需要进一步优化,可以考虑使用更高效的随机数算法或减少内存分配:

func gendataOptimized(numGoRoutines, numElements int) []int {
	var wg sync.WaitGroup
	wg.Add(numGoRoutines)

	// 直接分配最终结果切片,避免额外的内存拷贝
	res := make([]int, numElements)
	chunkSize := numElements / numGoRoutines

	for i := 0; i < numGoRoutines; i++ {
		go func(i int) {
			defer wg.Done()
			rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(i)))
			start := i * chunkSize
			end := start + chunkSize
			if i == numGoRoutines-1 {
				end = numElements // 处理余数
			}
			for j := start; j < end; j++ {
				res[j] = rng.Intn(10)
			}
		}(i)
	}
	wg.Wait()
	return res
}

这个版本直接操作最终结果切片的对应区间,完全避免了额外的切片合并操作。

回到顶部