这一部分有三篇文章,主要是讲解go调度器的一些内容html
三篇文章分别是:git
当我在解决一个问题尤为是新问题的时候,我开始不会去考虑并发(concurrency)是否合适。我首先会去找一系列的解决方式而后确保它有效。而后在可读性和技术方案评估以后,我会开始去考虑并发是否实际合理。有些时候并发的好处是显而易见的,可是有时候并非很明显。github
第一篇文章,我解释了OS调度器的相关内容,我以为这部分对于你写多线程代码很重要。第二篇里,我讲解了一些Go调度器的一些内容,这部分对于你理解和写go的并发代码颇有帮助。在这篇文章里,我会在OS和Go调度器层面让你去深层次的理解并发究竟是什么。golang
这部份内容的目标是:算法
并发的含义就是无序的执行。给你一系列的指令,去找到一个方式能够无序执行并且和有序执行产生一样的结果。这个问题在你面前,显而易见的是无序执行会增长一些足够的性能增益在计算了复杂性成本以后,可是你可能会以为无序执行是不可能的甚至是没有意义的。bash
你也要清楚一点,并发和并行是不同的。并行是在相同时间内同时执行两个或两个以上的指令,这和并发的概念不同。网络
图3.1里,你看到主机上有两个逻辑处理器。每一个都有他们单独的OS线程(M)依附于一个独立的硬件线程(Core)。你能够看到2个Goroutine(G1和G2) 正在并行在各自的OS/硬件线程上面同时执行它们的指令。在每一个逻辑处理器里,有3个Goroutines以轮转的方式共享OS线程。这些Goroutines正在以无序的方式并发地执行它们的指令,而且在OS线程上共享时间片。多线程
这里有一个问题。有些时候利用并发而不采用并行实际上会下降你的吞吐量,有趣的是,有时候利用并发同时加上并行处理也不会为你带来你理想中的性能增益。并发
你是如何知道无序执行(并发)是可行的呢?了解你所处理问题的工做负载(workload)是一个起点。有两种类型的工做负载在并发的时候要考虑到。ide
cpu-bound的工做负载,你须要并行去使用并发。一个单独的OS/硬件线程处理多个Goroutines效率很低,由于Goroutines在这个工做负载里不会主动进入或者是离开等待状态。Goroutines数多于OS/硬件线程数的时候会下降工做负载的执行速度,由于从OS线程换上或者是换下Goroutines会有延迟(切换的时间)。上下文切换会在workload里建立出“一切都中止”事件,由于在切换的时候你的全部workload都不会执行。
在IO-Bound的workloads里,你不须要并行去使用并发。一个单独OS/硬件线程能够有效率地处理多个Goroutines,由于Goroutines做为它本身workload的一部分能够自动进入或者离开等待状态(waiting)。Goroutines数量多于OS/硬件线程数能够加速workload的执行,由于Goroutines在OS线程上切换不会建立“一切都中止”事件。你的workload会天然中止而且这会让一个不一样的Goroutine去有效率地使用相同的OS/硬件线程,而不是让OS/硬件线程空闲下来。
你如何知道每一个硬件线程设置多少个Goroutines会有最好的吞吐量呢?太少的Goroutines你会有更多空闲时间。太多Goroutines你会有更多上下文切换延迟。这件事情你须要考虑,可是这超出了 本篇文章讲述的范围。
如今,咱们须要看一些代码来巩固你去判断何时workload能够利用并发,以及何时须要利用并行何时不须要并行。
不须要太复杂的代码,就看一下下面的add
函数。它计算了一堆整数的和。
36 func add(numbers []int) int {
37 var v int
38 for _, n := range numbers {
39 v += n
40 }
41 return v
42 }
复制代码
在L1的36行,声明了add方法,他接受一个int型的slice,而后返回它们的和。37定义了一个变量v去作数字累加。38行函数遍历这些整数,39行把当前数加上去。最后41行返回它们的和。
Question: add
是否适合无序执行?我相信答案确定是yes。整数集能够被分解成更小的lists,而且这些lists能够并行去处理。一旦全部lists都各自加完,这一系列lists的和能够加到一块儿,获得上面代码里同样的结果。
可是,另外一个问题来了。咱们应该分多少个lists去分别单独处理才能获得最好的吞吐量呢?为了回答这个问题,你须要知道add
方法运行究竟是哪一种workload。add
方法处理的是CPU-Bound类型的workload由于这是一个纯数学计算的方法,它不会致使goroutines进入自动等待状态。这意味着每一个OS/硬件线程一个Goroutine便可得到理想的吞吐量。
下面的L2是add
方法的并发版本。
44 func addConcurrent(goroutines int, numbers []int) int {
45 var v int64
46 totalNumbers := len(numbers)
47 lastGoroutine := goroutines - 1
48 stride := totalNumbers / goroutines
49
50 var wg sync.WaitGroup
51 wg.Add(goroutines)
52
53 for g := 0; g < goroutines; g++ {
54 go func(g int) {
55 start := g * stride
56 end := start + stride
57 if g == lastGoroutine {
58 end = totalNumbers
59 }
60
61 var lv int
62 for _, n := range numbers[start:end] {
63 lv += n
64 }
65
66 atomic.AddInt64(&v, int64(lv))
67 wg.Done()
68 }(g)
69 }
70
71 wg.Wait()
72
73 return int(v)
74 }
复制代码
在L2里面,addConcurrent
方法是add
方法的并发版本。这里有不少代码所以我只讲解重要的代码行
并发版本比有序版本更复杂,这种复杂度是否值得呢?回答这个问题最好的方式就是写一个benchmark。这里我用了一个一千万个数大小整数集,而且关掉了垃圾回收。这里对add
和addConcurrent
进行了对比。
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
add(numbers)
}
}
func BenchmarkConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
addConcurrent(runtime.NumCPU(), numbers)
}
}
复制代码
L3展现了benchmark函数。下面是当Goroutines只有一个单独的OS/硬件线程能用的状况。有序版本使用1个Goroutine而后并发版本使用runtime.NumCPU
数,个人机器上是8。这个例子下面,并发版本没有使用并行去作并发。
10 Million Numbers using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential 1000 5720764 ns/op : ~10% Faster
BenchmarkConcurrent 1000 6387344 ns/op
BenchmarkSequentialAgain 1000 5614666 ns/op : ~13% Faster
BenchmarkConcurrentAgain 1000 6482612 ns/op
复制代码
L4给出的benchmark代表,在仅有一个单独OS/硬件线程时候有序版本比并发版本大约要快%10--%13。这在咱们的意料之中,由于并发版本须要在一个单独的OS线程上频繁进行上下文切换(context switches)以及处理Goroutines。
下面是每一个Goroutines有一个单独的OS/硬件线程的状况下的结果。有序版本用一个Goroutine而后并发版本使用runtime.NumCPU
,在我本机上是8个。这种状况下利用了并行去处理并发。
10 Million Numbers using 8 goroutines with 8 cores
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 8 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential-8 1000 5910799 ns/op
BenchmarkConcurrent-8 2000 3362643 ns/op : ~43% Faster
BenchmarkSequentialAgain-8 1000 5933444 ns/op
BenchmarkConcurrentAgain-8 2000 3477253 ns/op : ~41% Faster
复制代码
L5中的benchmark代表了,每一个Goroutines使用一个OS/硬件线程的时候并发版本比有序版本要快大约41%--43%。这是咱们指望中的事情,由于全部的Goroutines如今都在并行执行,8个Goroutines如今都在同一时间并发执行。
须要明白,不是全部的CPU-bound的workloads都适合并发处理。当把工做拆解或者是把结果合并须要花费很大代价的时候这种说法是正确的。这种状况咱们能够看一个算法的例子:冒泡排序。看一下下Go实现的冒泡排序。
01 package main
02
03 import "fmt"
04
05 func bubbleSort(numbers []int) {
06 n := len(numbers)
07 for i := 0; i < n; i++ {
08 if !sweep(numbers, i) {
09 return
10 }
11 }
12 }
13
14 func sweep(numbers []int, currentPass int) bool {
15 var idx int
16 idxNext := idx + 1
17 n := len(numbers)
18 var swap bool
19
20 for idxNext < (n - currentPass) {
21 a := numbers[idx]
22 b := numbers[idxNext]
23 if a > b {
24 numbers[idx] = b
25 numbers[idxNext] = a
26 swap = true
27 }
28 idx++
29 idxNext = idx + 1
30 }
31 return swap
32 }
33
34 func main() {
35 org := []int{1, 3, 2, 4, 8, 6, 7, 2, 3, 0}
36 fmt.Println(org)
37
38 bubbleSort(org)
39 fmt.Println(org)
40 }
复制代码
在L6里,给出了Go版本的冒泡排序。排序算法遍历每一个值并在整数集上进行数据交替。根据初始顺序不一样,排序可能须要屡次的遍历。
Question: bubbleSort
的workload适合无序执行吗?答案确定是no。整数集能够分解成更小的lists而且这些lists能够并发地排序。可是全部并发工做完成以后,并无一个有效的方式再去把这些小的lists排序到一块儿。这里是一个并发版本的冒泡排序。
01 func bubbleSortConcurrent(goroutines int, numbers []int) {
02 totalNumbers := len(numbers)
03 lastGoroutine := goroutines - 1
04 stride := totalNumbers / goroutines
05
06 var wg sync.WaitGroup
07 wg.Add(goroutines)
08
09 for g := 0; g < goroutines; g++ {
10 go func(g int) {
11 start := g * stride
12 end := start + stride
13 if g == lastGoroutine {
14 end = totalNumbers
15 }
16
17 bubbleSort(numbers[start:end])
18 wg.Done()
19 }(g)
20 }
21
22 wg.Wait()
23
24 // Ugh, we have to sort the entire list again.
25 bubbleSort(numbers)
26 }
复制代码
L8中,bubbleSortConcurrent
方法是bubbleSort
的并发版本。它使用多个Goroutines去并发地排序整个整数集的一部分。结果你获得的是各自的排序的list。结果你最终在25行仍是要整个list作一次排序。
由于冒泡排序的本质就是遍历整个list。25行调用bubbleSort
直接否认了任何并发的潜在收益。冒泡排序里,使用并发并无性能上的增益。
咱们给出了2个CPU-Bound类型的workloads,那么IO-Bound类型的workload状况是什么样的?当Goroutines自动进入或者是离开waiting状态,状况会有什么不一样么?看一个IO-bound类型的workload,它的工做内容是读取文件并查找文本。
第一个版本是一个有序版本的find
方法
42 func find(topic string, docs []string) int {
43 var found int
44 for _, doc := range docs {
45 items, err := read(doc)
46 if err != nil {
47 continue
48 }
49 for _, item := range items {
50 if strings.Contains(item.Description, topic) {
51 found++
52 }
53 }
54 }
55 return found
56 }
复制代码
在L10里面,你看到一个有序版本的find
函数。line 43定义了一个found
变量去存topic
在文档里的出现次数。line 44,对全部文档进行遍历,而且在45行上使用read
方法对每一个doc进行读取。最后从49--53行,使用strings
包的Contains
方法去检查topic是否在读取到的items里面。若是发现,found
变量就对应加一。
这里是find
调用的read
方法的实现。
33 func read(doc string) ([]item, error) {
34 time.Sleep(time.Millisecond) // Simulate blocking disk read.
35 var d document
36 if err := xml.Unmarshal([]byte(file), &d); err != nil {
37 return nil, err
38 }
39 return d.Channel.Items, nil
40 }
复制代码
read
方法以一个time.Sleep
方法开始。这个里模拟了真实从硬盘读取文档的系统调用所产生的延迟。设置这个延迟对咱们精确地测试有序版本和并发版本find
方法的性能差别十分重要。而后在35--39行,测试的xml文档存储在fine
的全局变量里,它被反序列化成一个要去处理的struct。最后返回了一个items的集合。
下面是一个并发版本代码。
58 func findConcurrent(goroutines int, topic string, docs []string) int {
59 var found int64
60
61 ch := make(chan string, len(docs))
62 for _, doc := range docs {
63 ch <- doc
64 }
65 close(ch)
66
67 var wg sync.WaitGroup
68 wg.Add(goroutines)
69
70 for g := 0; g < goroutines; g++ {
71 go func() {
72 var lFound int64
73 for doc := range ch {
74 items, err := read(doc)
75 if err != nil {
76 continue
77 }
78 for _, item := range items {
79 if strings.Contains(item.Description, topic) {
80 lFound++
81 }
82 }
83 }
84 atomic.AddInt64(&found, lFound)
85 wg.Done()
86 }()
87 }
88
89 wg.Wait()
90
91 return int(found)
92 }
复制代码
L12是find
方法的并发版本。并发版本有30行代码,而非并发版本代码只有13行。个人目标是处理未知数量的documents时候控制Goroutines的数量。这里我选择在池化模式里使用一个channel去给池子里的goroutines喂数据。
这部分代码比较多,我只讲解重要部分
Line 61-64: 建立一个channel去处理全部的documents。 Line 65 关闭这个channel,来让池子里的goroutines在全部documents处理完成后能自动中止。
Line 70:建立一个goroutines线程池
Line 73--83:每个池子里的goroutine从channel接受一个document,读取到内存而后检查内容是否有topic。匹配的话,lfound就加一个。
Line 84:把每一个单独goroutines跑出来的数加到一块儿。
并发版本确实比有序版本代码更加复杂,这个复杂性是否值得?验证的最好方式就是再次写一个benchmark。我用了1000个documents的集合,而且关闭了垃圾回收。一个是顺序版本find
,一个是并发版本findConcurrent
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
find("test", docs)
}
}
func BenchmarkConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
findConcurrent(runtime.NumCPU(), "test", docs)
}
}
复制代码
L13给出了benchmark。下面是当全部goroutines只有一个OS/硬件线程的时候。顺序代码使用1个goroutines,而并发版本是runtime.NumCPU
的数,在我本机上是8。这种状况下,咱们没用并行去作并发。
10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential 3 1483458120 ns/op
BenchmarkConcurrent 20 188941855 ns/op : ~87% Faster
BenchmarkSequentialAgain 2 1502682536 ns/op
BenchmarkConcurrentAgain 20 184037843 ns/op : ~88% Faster
复制代码
L14里面代表了,在只有一个单独OS/硬件线程的时候,并发版本大概要比顺序版本代码快87%--%88。这是咱们预料到的由于每一个Goroutines都能有效的共享这一个OS/硬件线程。在read
调用的时候每一个goroutines可以自动进行上下文切换,这样OS/硬件线程会一直有事情作。
下面是使用并行去作并发处理。
10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential-8 3 1490947198 ns/op
BenchmarkConcurrent-8 20 187382200 ns/op : ~88% Faster
BenchmarkSequentialAgain-8 3 1416126029 ns/op
BenchmarkConcurrentAgain-8 20 185965460 ns/op : ~87% Faster
复制代码
L15的benchmark结果说明,额外的OS/硬件线程并无提供更好的性能。
这篇文章的目的就是让你知道何时你的workload适合使用并发。考虑到不一样的场景,我给出了不一样的例子。
你能够清楚的看到IO-Bound类型的workload并不须要使用并行处理去得到性能的大幅增长,这正好跟CPU-Bound类型的工做截然相反。像相似冒泡算法这种,使用并发其实会增长代码复杂度,并且不会有任何性能增益。因此,必定要肯定你的workload是否适合使用并发场景,这是很重要的事情。