在Golang中如何使用并发

原文地址:这里,省略了一些非重点片段。

本文的主要目标是展示在哪些场景下使用并发更加合适。因此本文中不会讲述并发相关的基本术语,比如goroutines、wait groups、channels、数据竞争等。 我打算创建一系列文章,详细解释最常用的并发相关示例。

如何处理问题

早些年,我最常犯的一个错误就是一开始的时候就使用并发去解决问题。这不仅仅让后面做优化变的更难,而且不正确的使用并发反而会引起其他风险。

以我的经验来说,我建议你先使用非并发的方式去解决问题,然后进行迭代,必要的情况下再去实现使用并发的解决方案。

再尝试并发之前,先使用非并发的方式去解决问题。

并发和并行

本文中不再解释并发和并行的区别了,网上有很多相关的文章,这里引用Go大神的2个定义:

“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.” — Rob Pike

“Concurrency is about structure and parallelism is about execution. In other words, concurrency is a way to structure a thing so that you can (maybe) use parallelism to do a better job.” — Rob Pike

应用类型

一种特别有用的办法区分并发是否适合你程序的办法就是看程序是哪种类型,这里主要分2种:

CPU密集型

这种程序的执行高度依赖于CPU的情况,与CPU的速度直接相关。

当有多个核心可用时,这种类型的应用是使用并发来实现并行优化的完美场景(但下面的性能测试可以看到这并不是永远成立的)。当我们在单核机器上使用多个goroutine执行这类应用时,不仅不会看到有性能提升,反而go的运行时会浪费宝贵的时间在调度和上下文切换中。

IO密集型

这种程序的执行高度依赖IO而非CPU,一旦程序进入IO等待,go调度器将会执行其他的任务。因此,在单核机器上我们可以通过多个goroutine来提升程序性能,而并行则不会提升这种程序的性能。

我们需要小心不要生成相对CPU过多的goroutine,这样反而会降低性能。

上代码

我将编写一些简单的函数,使用这些函数我们可以使用基准测试来检查利用并发和并行哪个性能更好。

// CPU Workload functions //
///////////////////////////
// Add your number here. From 1 million should be enough to see concluding 
// results.
const N =

func computeHighNumberSequentially() int {
  var res int
  for i := 0; i < N; i++ {
    res *= res
  }
  return res
}

func computeHighNumberConcurrently(goroutines int) int64 {
  numbersToProcess := N / goroutines
  var result int64

  wg := sync.WaitGroup{}
  wg.Add(goroutines)

  for i := 0; i < goroutines; i++ {
    go func() {
      defer wg.Done()
      var res int
      for j := 0; j < numbersToProcess; j++ {
        res *= res
      }
      atomic.AddInt64(&result, int64(res))
    }()
  }

  wg.Wait()
  return result
}

// Merge sort algorithm
///////////////////////
func mergeSortSequentially(items []int) []int {
	if len(items) < 2 {
		return items
	}
	first := mergeSortSequentially(items[:len(items)/2])
	second := mergeSortSequentially(items[len(items)/2:])
	return merge(first, second)
}

func mergeSortConcurrently(items []int) []int {
	if len(items) < 2 {
		return items
	}
	
	var first, second []int
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		first = mergeSortConcurrently(items[:len(items)/2])
	}()

	go func() {
		defer wg.Done()
		second = mergeSortConcurrently(items[len(items)/2:])
	}()

	wg.Wait()
	return merge(first, second)
}

// Skipped for the sake of example length. Internet is full of `merge` implementations.
func merge(a []int, b []int) []int { ... }
// IO Workload functions //
///////////////////////////
var urls = []string{} // add your urls here

func computeURLSequentially() {
  for _, url := range urls {
    _, _ = http.Get(url)
  }
}

func computeURLConcurrently() {
  wg := sync.WaitGroup{}
  wg.Add(goroutines)

  ch := make(chan string, len(urls))
  for _, u := range urls {
    ch <- u
  }
  close(ch)

  for i := 0; i < goroutines; i++ {
    go func() {
      for u := range ch {
        _, _ = http.Get(u)
      }
      wg.Done()
    }()
  }
  wg.Wait()
}

测试代码如下:

func BenchmarkComputeHighNumberSequentially(b *testing.B) {
	for i := 0; i < b.N; i++ {
		computeHighNumberSequentially()
	}
}

func BenchmarkComputeHighNumberConcurrently(b *testing.B) {
	for i := 0; i < b.N; i++ {
		computeHighNumberConcurrently(runtime.NumCPU())
	}
}

func BenchmarkComputeURLSequentially(b *testing.B) {
	for i := 0; i < b.N; i++ {
		computeURLSequentially()
	}
}

func BenchmarkComputeURLConcurrently(b *testing.B) {
	for i := 0; i < b.N; i++ {
		computeURLConcurrently(runtime.NumCPU())
	}
}

func BenchmarkMergeSortSequentially(b *testing.B) {
	for i := 0; i < b.N; i++ {
		mergeSortSequentially(list)
	}
}

func BenchmarkMergeSortConcurrently(b *testing.B) {
	for i := 0; i < b.N; i++ {
		mergeSortConcurrently(list)
	}
}

机器对性能测试的影响较大,你应该尽可能的多运行几次测试。

使用-count参数来控制执行次数,不过我这里为了演示就忽略了这个参数。

我的机器有10个核心,所以并发版本的程序goroutine数量应该等于runtime.NumCPU()也是10。

性能测试-CPU密集型

下面结果是一个没有并行、运行在一个线程的版本:

go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeHighNumberSequentially      1869           3147006 ns/op
BenchmarkComputeHighNumberConcurrently       632           9490364 ns/op

从上面的结果可以看出,非并发版比并发版快了3倍!这是符合预期的因为goroutine会消耗大量时间在上下文切换。

(Roy补充个基础知识点,性能测试结果格式为"测试名称-cpu 执行次数 每次耗时")。

下面的结果是启用并行并且运行在10个线程上:

go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeHighNumberSequentially-10           1794           3145169 ns/op
BenchmarkComputeHighNumberConcurrently-10           3162           1699410 ns/op

通过把goroutine分布在10个线程上,现在我们可以看到并发版比非并发版快了2倍。

尽管有上面的例子,但并不是所有的CPU密集型应用都适用于并发进行优化,比如下面的归并排序算法示例:

go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkMergeSortSequentially   3908991              1530 ns/op
BenchmarkMergeSortConcurrently    452580             12307 ns/op
go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkMergeSortSequentially-10        4068475              1496 ns/op
BenchmarkMergeSortConcurrently-10         533010             12462 ns/op

非并发版居然比并发版效率高多了,我们分析一下原因。

每次循环中我们都建立了2个goroutine分别计算first list和second list。而这个算法是递归的,最终我们造成了启动goroutine只计算单一元素的场景,而这是非常低效的。(Roy注:就是说启动的goroutine只干了很微小的一件事后,这个goroutine就会被销毁)这样就造成了创建和调度goroutine的成本远远超过在一个goroutine进行计算的成本。

所以,当我们的程序逻辑过于简单还不如创建调度goroutine耗时的场景,使用并发和并行就没什么必要了。

当分解工作或组合结果的代价非常高昂时,并发可能不是一个好的选择。

性能测试-IO密集型

下面是单线程运行非并行程序的结果:

go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeURLSequentially                2        2814863021 ns/op
BenchmarkComputeURLConcurrently                7         838693786 ns/op

并发版本快了将近4倍,这是符合预期的,因为在某个goroutine等待IO时,另外的goroutine会被调度到线程上执行。

下面的的是使用10个线程的并行版:

go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeURLSequentially-10             2        2548004562 ns/op
BenchmarkComputeURLConcurrently-10             7        1001367625 ns/op

当我们把goroutine分布在10个独立的线程上时,性能并没有显著的提升,正如前面说的,这是因为我们可以在单个线程内有效的控制上下文切换,所以并行并不会大幅提升性能。

结论

通常来说,我们可以认为对于CPU密集型应用,可以通过并发的手段实现并行来提升效率,而对于IO密集型应用,并行并不会有明显的提升。

然而,每种程序都需要小心的分析,正如我们上面说的CPU密集型的归并排序算法,他的自身性质导致其并不适用于使用并发并行来提效。

最后,我想说的是,使用并发总是带来着额外的复杂性,所以只有能带来显著性能提升时再明智地使用它。