Golang中Goroutine性能下降问题分析与解决

Golang中Goroutine性能下降问题分析与解决 这是一个关于 goroutine 的学习练习。 我有一个结构体切片和一个在该结构体上工作的函数 “doClear()”。 当我运行下面的代码片段时,它大约在 355 毫秒内完成。如果我在 doClear 调用前加上 “go” 使其成为 goroutine,则耗时大约延长 10 倍(3.588 秒)。当我使用 “go run -race .” 运行 goroutine 版本时,它没有报告任何竞态条件。两个版本产生相同的结果。

func (b *Bitmap) ClearPrime(prime uint64) {
  var wg sync.WaitGroup
  for _,s := range b.segments {
    wg.Add(1)
    go s.doClear(&wg, prime)
  }
  wg.Wait()
}

有什么线索可以追踪这里发生了什么吗?


更多关于Golang中Goroutine性能下降问题分析与解决的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

你好 Samyak,

谢谢。这是一个埃拉托斯特尼筛法。我正在尝试为 SPOJ 的 PRIME 问题找到一个可行的解决方案。doClear 函数的作用是清除位图中当前质数对应的所有条目。我将筛法的位图分割成多个段,以便能够使用多个 goroutine 进行清除。这个位图非常大——问题说明中提到最多可达 1,000,000,000。 我想你是对的……我必须为每个段设置 goroutine,等待下一个质数来进行清除。

Jon

更多关于Golang中Goroutine性能下降问题分析与解决的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


除了 goroutine 启动开销外,另一个可能的原因是缓存失效,尤其是在多个 goroutine 访问同一数组的不同部分的情况下。

简而言之,所有 CPU 核心都有本地缓存。如果相同的数据集被加载到多个缓存中,并且其中一个核心更改了该数据的某一部分,那么持有该数据的缓存段在所有其他核心上都会失效,需要重新刷新。这个过程相当昂贵,可能会显著减慢并发代码的速度。

更详细的解释可以在这里找到。由于你的代码操作的是单个切片的不同部分,缓存失效可能适用于你的场景。

.doClear(&wg, prime)

如果 doClear 函数执行得非常快,那么启动一个 goroutine 的开销将远大于执行 doClear 本身的成本,因此你会看到运行时间增加了10倍。

你正在启动 b.segments 个 goroutine。请检查一下这个 b.segments 的值有多大。创建一个 goroutine 的成本似乎比创建一个 Linux 线程的成本低3倍,并且现在不需要上下文切换 goroutine 成本。不过,大量的 goroutine 确实会累积成本。

由于缺乏一些上下文信息(例如,doClear() 函数具体是做什么的?其内部的计算有多大规模/复杂?你在多少个 segments 上测试?等等),我只能进行推测。

启动一个 goroutine 的开销很小,但并非为零。如果 doClear 内部的计算只需要几纳秒,而启动一个 goroutine 的开销(我猜测大约在微秒级别)相比之下就显得非常大了。

你可以使用 pprof 来精确找出导致速度变慢的具体函数。你也可以尝试使用一组固定数量的 goroutine,通过通道来接收待处理的工作(这篇 Stack Overflow 帖子提供了一些思路)。

这是一个典型的 goroutine 性能陷阱问题。主要原因是 goroutine 创建和调度的开销超过了并行计算带来的收益,特别是当每个 goroutine 执行的任务非常轻量时。

在你的代码中,doClear() 函数可能执行的操作非常简单(比如只是设置一些位或执行少量计算),导致每个 goroutine 的工作负载太小。这种情况下:

  1. goroutine 创建和调度的开销(内存分配、上下文切换、调度器管理)主导了执行时间
  2. WaitGroup 同步开销 在大量 goroutine 时变得显著
  3. GOMAXPROCS 限制 可能限制了真正的并行执行

问题分析

让我用一个示例来演示这个问题:

package main

import (
	"fmt"
	"sync"
	"time"
)

type Segment struct {
	data []bool
}

func (s *Segment) doClear(wg *sync.WaitGroup, prime uint64) {
	defer wg.Done()
	// 假设这是一个非常轻量的操作
	for i := range s.data {
		s.data[i] = false
	}
}

func (s *Segment) doClearSync(prime uint64) {
	// 同步版本
	for i := range s.data {
		s.data[i] = false
	}
}

type Bitmap struct {
	segments []*Segment
}

// 你的 goroutine 版本
func (b *Bitmap) ClearPrimeGoroutine(prime uint64) {
	var wg sync.WaitGroup
	for _, s := range b.segments {
		wg.Add(1)
		go s.doClear(&wg, prime)
	}
	wg.Wait()
}

// 同步版本
func (b *Bitmap) ClearPrimeSync(prime uint64) {
	for _, s := range b.segments {
		s.doClearSync(prime)
	}
}

func main() {
	// 创建测试数据
	const numSegments = 10000
	const segmentSize = 100
	
	segments := make([]*Segment, numSegments)
	for i := range segments {
		segments[i] = &Segment{
			data: make([]bool, segmentSize),
		}
	}
	
	b := &Bitmap{segments: segments}
	
	// 测试同步版本
	start := time.Now()
	b.ClearPrimeSync(123)
	fmt.Printf("同步版本耗时: %v\n", time.Since(start))
	
	// 测试 goroutine 版本
	start = time.Now()
	b.ClearPrimeGoroutine(123)
	fmt.Printf("Goroutine版本耗时: %v\n", time.Since(start))
}

解决方案

1. 批量处理模式(推荐)

将工作分批,每个 goroutine 处理多个 segment:

func (b *Bitmap) ClearPrimeBatch(prime uint64, batchSize int) {
	var wg sync.WaitGroup
	numSegments := len(b.segments)
	
	for i := 0; i < numSegments; i += batchSize {
		wg.Add(1)
		go func(start int) {
			defer wg.Done()
			end := start + batchSize
			if end > numSegments {
				end = numSegments
			}
			for j := start; j < end; j++ {
				b.segments[j].doClearSync(prime)
			}
		}(i)
	}
	wg.Wait()
}

2. 使用 worker pool 模式

创建固定数量的 worker goroutine:

func (b *Bitmap) ClearPrimeWorkerPool(prime uint64, numWorkers int) {
	var wg sync.WaitGroup
	segments := make(chan *Segment, len(b.segments))
	
	// 启动 worker
	for i := 0; i < numWorkers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for s := range segments {
				s.doClearSync(prime)
			}
		}()
	}
	
	// 分发任务
	for _, s := range b.segments {
		segments <- s
	}
	close(segments)
	
	wg.Wait()
}

3. 使用 sync.Pool 重用 goroutine(高级优化)

func (b *Bitmap) ClearPrimeOptimized(prime uint64) {
	var wg sync.WaitGroup
	segmentChan := make(chan *Segment, 100) // 缓冲通道
	
	// 预先创建固定数量的 worker
	numWorkers := runtime.NumCPU() * 2
	for i := 0; i < numWorkers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for s := range segmentChan {
				s.doClearSync(prime)
			}
		}()
	}
	
	// 发送任务
	for _, s := range b.segments {
		segmentChan <- s
	}
	close(segmentChan)
	
	wg.Wait()
}

性能对比测试

func benchmarkClearPrime(b *testing.B, clearFunc func(uint64)) {
	// 准备测试数据
	const numSegments = 10000
	const segmentSize = 100
	
	segments := make([]*Segment, numSegments)
	for i := range segments {
		segments[i] = &Segment{
			data: make([]bool, segmentSize),
		}
	}
	
	bitmap := &Bitmap{segments: segments}
	
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		clearFunc(uint64(i))
	}
}

func BenchmarkClearPrimeSync(b *testing.B) {
	benchmarkClearPrime(b, func(prime uint64) {
		// 同步版本
		for _, s := range segments {
			s.doClearSync(prime)
		}
	})
}

func BenchmarkClearPrimeGoroutine(b *testing.B) {
	benchmarkClearPrime(b, func(prime uint64) {
		// 你的原始 goroutine 版本
		var wg sync.WaitGroup
		for _, s := range segments {
			wg.Add(1)
			go s.doClear(&wg, prime)
		}
		wg.Wait()
	})
}

func BenchmarkClearPrimeBatch(b *testing.B) {
	benchmarkClearPrime(b, func(prime uint64) {
		// 批量处理版本
		var wg sync.WaitGroup
		batchSize := 100
		numSegments := len(segments)
		
		for i := 0; i < numSegments; i += batchSize {
			wg.Add(1)
			go func(start int) {
				defer wg.Done()
				end := start + batchSize
				if end > numSegments {
					end = numSegments
				}
				for j := start; j < end; j++ {
					segments[j].doClearSync(prime)
				}
			}(i)
		}
		wg.Wait()
	})
}

关键建议

  1. 测量 goroutine 的工作负载:如果每个 goroutine 的执行时间小于 100 微秒,考虑批量处理
  2. 限制 goroutine 数量:通常使用 runtime.NumCPU() * 2 作为 worker 数量上限
  3. 使用缓冲通道减少通信开销
  4. 考虑使用 errgroup 如果需要错误处理:
import "golang.org/x/sync/errgroup"

func (b *Bitmap) ClearPrimeWithErrGroup(prime uint64) error {
	g := new(errgroup.Group)
	
	for _, s := range b.segments {
		s := s // 创建局部变量
		g.Go(func() error {
			return s.doClearWithError(prime)
		})
	}
	
	return g.Wait()
}

问题的根本原因是 每个 goroutine 的工作负载太小,导致调度开销主导了执行时间。通过批量处理或使用 worker pool 模式可以显著提升性能。

回到顶部