多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
@sabitor 非常感谢分享源代码,这对我非常有帮助。
更多关于多Goroutine场景下Golang性能意外下降问题排查的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
我之前不知道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
}
我进行了更多研究,并进一步优化了函数的运行时间。 除了关于随机源的更改(感谢您的提示),我还修改了以下两点:
- for循环退出条件的计算在进入循环前只执行一次
- 新元素通过适当的切片索引插入到切片中
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))
}
关键改进点:
- 每个goroutine使用独立的
rand.New()创建随机数生成器,避免全局锁竞争 - 为每个随机源使用不同的种子(
time.Now().UnixNano() + int64(i)) - 预分配结果切片容量,减少内存分配次数
如果还需要进一步优化,可以考虑使用更高效的随机数算法或减少内存分配:
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
}
这个版本直接操作最终结果切片的对应区间,完全避免了额外的切片合并操作。


