控制 Goroutine 的并发数量的方式
写在前面
在 Go 语言中创建协程(Goroutine)的成本非常低,因此稍不注意就可能创建出大量的协程,一方面会造成资源的浪费,另一方面不容易控制这些协程的状态。
不过,“能力越大,越需要克制”。网络上已经存在一些讲控制 Goroutine 数目的文章,本文通过图示的方式再简单总结一下其基本理念,以便于记忆。
控制 Goroutine 的数量
先看 Goroutine 数量不受控制的代码(例一)
package main import ( "fmt" "runtime" "sync" "time" ) func main() { jobsCount := 10 group := sync.WaitGroup{} for i := 0; i < jobsCount; i++ { group.Add(1) go func(i int) { fmt.Printf("hello %d!\n", i) time.Sleep(time.Second) // 刻意睡 1 秒钟,模拟耗时 group.Done() }(i) fmt.Printf("index: %d,goroutine Num: %d \n", i, runtime.NumGoroutine()) } group.Wait() fmt.Println("done!") }
上面的代码假设有 jobsCount
个任务,通过 for-range
给每个任务创建了一个 Goroutine。为了让主协程等待所有的子协程执行完毕后再退出,使用 sync.WaitGroup
监控所有协程的状态,从而保证主协程结束时所有的子协程已经退出。为了说明问题,上面的代码还输出了 runtime.NumGoroutine()
的值用以表征协程的数量。
运行上面的代码,可以得到类似下面的输出。从下面的输出中我们可以得到两点信息:① 协程的执行顺序是随机的(比如 hello 3
在 hello 4
后面出现);② 协程的数量递增,最后竟达到了 11 个之多。
hello 0! index: 0,goroutine Num: 2 index: 1,goroutine Num: 3 hello 1! index: 2,goroutine Num: 4 hello 2! index: 3,goroutine Num: 5 index: 4,goroutine Num: 6 index: 5,goroutine Num: 7 index: 6,goroutine Num: 8 hello 4! hello 5! hello 3! hello 7! index: 7,goroutine Num: 9 index: 8,goroutine Num: 10 index: 9,goroutine Num: 11 hello 8! hello 9! hello 6! done!
Goroutine 数量不受控制的图示
我们应该怎么理解 例一 的代码呢?
假如 CPU 只有 两个 核,下图展示了为每个 job 创建一个 goroutine 的情况(换句话说,goroutine 的数量是不受控制的)。此种情况虽然生成了很多的 goroutine,但是 每个 CPU 核上同一时间只能执行一个 goroutine ;当 job 很多且生成了相应数目的 goroutine 后,会出现很多等待执行的 goroutine,从而造成资源上的浪费。
Goroutine 数量受到限制的图示
给每个 job 生成一个 goroutine 的方式显得粗暴了很多,那么可以通过什么样的方式控制 goroutine 的数目呢?其实“ 例一 ”小节的代码通过一个 for-range
循环完成了两件事情:①为每个 job 创建 goroutine;②把任务相关的标识传给相应的 goroutine 执行。为了控制 goroutine 的数目,完全可以把上面的两个过程拆分开:a)先通过一个 for-range
循环创建指定数目的 goroutine,b)然后通过 channel
/ buffered channel
给每个 goroutine 传递任务相关的信息(这里的 channel
是否缓冲无所谓,主要用到的是 channel
的线程安全特性)。如下图所示。
控制 Goroutine 数量的代码(例二)
代码实现上也很简单:一个 for-range
创建指定数目的 goroutine,另一个 for-range
把 job 依次推送到 channel
供 goroutine 消费。
package main import ( "fmt" "runtime" "sync" "time" ) func main() { jobsCount := 10 group := sync.WaitGroup{} var jobsChan = make(chan int, 3) // a) 生成指定数目的 goroutine,每个 goroutine 消费 jobsChan 中的数据 poolCount := 3 for i := 0; i < poolCount; i++ { go func() { for j := range jobsChan { fmt.Printf("hello %d\n", j) time.Sleep(time.Second) group.Done() } }() } // b) 把 job 依次推送到 jobsChan 供 goroutine 消费 for i := 0; i < jobsCount; i++ { jobsChan <- i group.Add(1) fmt.Printf("index: %d,goroutine Num: %d\n", i, runtime.NumGoroutine()) } group.Wait() fmt.Println("done!") }
运行上面的代码可以得到下面类似的输出( 可以看到 goroutine 的数量控制在了 4 个 )。
index: 0,goroutine Num: 4 index: 1,goroutine Num: 4 hello 1 index: 2,goroutine Num: 4 index: 3,goroutine Num: 4 index: 4,goroutine Num: 4 hello 2 index: 5,goroutine Num: 4 hello 0 hello 3 hello 4 hello 5 index: 6,goroutine Num: 4 index: 7,goroutine Num: 4 index: 8,goroutine Num: 4 hello 6 hello 7 index: 9,goroutine Num: 4 hello 8 hello 9 done!
小结
本文通过图示的方式总结了控制 goroutine 数目的一种简单方式,简单来讲就是:①通过一个 for-range
创建指定数目的 goroutine,② 通过另一个 for-range
把 job 依次推送到 channel
供第一步生成的 goroutine 消费。
为了说明问题,代码示例中输出了 runtime.NumGoroutine()
(即 gouroutine 的数目)的变化,便于大家更直观地观察效果。
参考
- 1.6 来,控制一下 goroutine 的并发数量 · 跟煎鱼学 Go
- Golang 并发问题(五)goroutine 的调度及抢占 – 敬维 之前总结的一篇 goroutine 调度与抢占的文章
- GitHub – chalvern/gochan: pool of goroutine with buffer channel, for concurrent execution but events of individual object running sequentially 基于同样的原理实现的一个小工具,不过每个 goroutine 监听自己的 channel,并通过一个分发器(dispatcher)把任务分发到特定的 channel 中(因为考虑到有些任务前后可能是有关联的)。