前言html
这个是用来说述go调度器机制和特性的第三部分. 这个主要关注并发. git
博客三部分的顺序:github
1) go调度: 第一部分-操做系统调度算法
2) go调度: 第二部分-go调度器网络
3) go调度: 第三部分-并发多线程
介绍并发
当我在解决一个问题, 尤为是一个新问题的时候, 开始阶段, 我不会考虑并发是否是有用. 我寻找一个顺序化解决方案, 而且确保这个方案有效. 而后, 我进行评估, 来看看并发是否合适. 有些状况下, 并发是很合适的, 而有些状况下则未必.ide
在第一部分,我讲了操做系统的调度器的特性, 若是你想写多线程应用, 这个是颇有必要的. 在第二部分, 我讲了go的调度器特性, 我认为, 这个对使用go语言写多线程是颇有意义的. 在这篇中, 我将结合操做系统调度和go程调度, 来提供一个对于使用go语言写多线程的更加深刻的理解.函数
这篇博客的目的是:性能
(1) 辨别你代码应用的场景是否适合使用并发.
(2) 展现如何根据应用场景改变并发的使用.
什么是并发
并发意味着乱序执行. 一系列指令, 能够被顺序执行, 也能够在知足限制的状况下乱序执行, 可是仍是能够产生相同的结果. 对于你眼前的这个问题, 乱序执行必须能够展现出明显的价值, 也就是说, 并发能够明显的提升性能, 同时, 没有代码复杂度的增长能够容忍. 根据你的问题, 乱序执行有时候是不可行的.
有一个重要的点须要注意, 并发不等于并行. 并行意味着同一时间执行多条指令. 这个与并发的概念并不相同. 只有在有至少2个操做系统线程(运行在两个硬件线程之上), 而且有至少2个go程的状况下, 每一个操做系统/硬件线程运行一组指令的状况下, 并行才会发生.
图1
在图1中, 你看到两个逻辑处理器(P)运行在两个不一样的操做系统线程(M)上, 这两个M对应着不一样的硬件线程. 这种状况下, 两个go程(G1和G2)处于并行状态. 在每一个逻辑处理器上, go程轮流分享操做系统线程. 全部的这些go程都并发地执行, 这些go程分享操做系统的运行时间, 以一种不肯定的顺序运行.
有一点须要注意的是, 有时候没有并行执行的并发会下降程序的性能. 另外, 有时候程序并行执行, 可是并不会明显提高你的程序的性能.
负载
咱们如何知道并发是否是有意义呢? 理解你的问题的负载类型, 是一个好的入手点. 在考虑使用并发时, 你须要区分以下两类负载:
(1) CPU密集型: 这类问题, 主要用来作计算, 不会让go程常常在等待和运行状态之间切换. 计算Pi的第Nth小数属于这类负载.
(2) IO密集型. 这类负载须要go程常常在等待和运行状态之间切换. 这类工做包括网络请求资源, 操做系统调用, 等待事件发生. 读取文件, 使用同步事件(mutexes, atomic)属于这类负载.
对于CPU密集型负载, 你须要使用并行来提升性能. 一个单一的操做系统/硬件线程处理多个go程, 比处理单个go程性能更差, 由于要进行等待和运行状态的切换. 因此, 在这种状况下, go程数超过操做系统/硬件线程数, 会下降性能, 而不是提升性能.
对于IO密集型负载, 你能够经过并发(能够不适用并行)来提升性能. 一个操做系统/硬件线程能够高效地处理不少个go程, 由于go调度器很擅长处理等待和运行状态的切换. go程数超过操做系统/硬件线程数, 能够加快负载的执行, 由于这种状况下, 能够减小操做系统/硬件线程的空载时间.
咱们如何知道每一个硬件线程对应多少个go程比较合适? go程不多意味着更多的空载时间. 线程太多, 用于上下文切换的时间就会花费不少.
下面, 咱们看一些代码, 学习在什么状况下, 能够利用并发, 何时能够利用并行.
加法运算(Adding Numbers)
咱们不须要复杂的代码来理解这种语境. 看下面这个将多个数字加在一块儿的函数.
36 func add(numbers []int) int { 37 var v int 38 for _, n := range numbers { 39 v += n 40 } 41 return v 42 }
问题: add函数是一个适合并发的负载吗? 我相信是的. 这些整数能够拆分程更小的几组整数, 而后每组整数并发计算. 当这些组整数都相加完成后, 而后将这些整数相加的结果进行相加, 就能够获得最终的结果.
然而, 如今有另一个问题, 咱们须要拆成多少个小的组, 而后让他们并发执行, 从而提供最好的性能? 为了回答这个问题, 咱们须要知道add的负载类型. add函数是CPU密集型的负载, 由于这个函数只进行数学运算, 而不会使go程进入等待状态. 这种状况下, 每一个go程对应一个操做系统/硬件线程是合理的.
Listing 2是个人add函数的并发版本.
Listing 2
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 }
并发版本明显比顺序运行版本复杂, 那么增长的这个复杂性值得吗? 最好地回答这个问题的方法是经过基准测试(benchmark). 对于这些基准测试, 我将垃圾收集器关闭, 而后将一千万个数字相加. 下面测试分别使用了顺序版本的add函数, 和并发版本的addConcurrent函数.
Listing 3
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) } }
Listing 4
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
注意: 在本机运行基准测试不是一件简单的事. 有不少会致使基准测试不许确的因素, 所以, 你须要确保你的机器尽量的空闲, 而且多运行几回测试.
listing 4的基准测试显示, 当只有一个硬件线程时, 顺序版本比并发版本快大约10%到13%. 由于并发版本有在同一个操做系统线程上的go程的上下文切换, 这种状况是能够预料到的.
Listing 5
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
在上面的基准测试中, 并发版本快了大约41%到43%, 由于每一个go程能够运行在不一样的操做系统/硬件线程.
排序
理解不是全部的CPU密集型负载都适合并发是很重要的. 尤为是在将任务拆解, 以及任务聚合都很复杂的时候. 排序算法中的冒泡排序就是其中的一个例子. 咱们来看看go语言中实现的冒泡排序.
Listing 6
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 }
问题: bubbleSort函数适合改为并发执行吗? 我相信不合适. 这些整数能够拆分红小的队列, 而后这些队列并发排序. 然而, 这些小的已排序的队列, 没有好的办法, 将它们排序在一块儿. 下面是冒泡排序的并发版本.
Listing 8
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 }
在Listing 8中, bubbleSortConcurrent函数做为bubbleSort函数的并发版本.
Listing 9
Before: 25 51 15 57 87 10 10 85 90 32 98 53 91 82 84 97 67 37 71 94 26 2 81 79 66 70 93 86 19 81 52 75 85 10 87 49 After: 10 10 15 25 32 51 53 57 85 87 90 98 2 26 37 67 71 79 81 82 84 91 94 97 10 19 49 52 66 70 75 81 85 86 87 93
bubbleSortConcurrent的25行调用了bubbleSort, 这里抵消了并发可能实现的提高. 对于冒泡排序, 并发不能实现性能提高.
读文件
举了两个CPU密集型负载的例子, 下面, 咱们看一下IO密集型负载的例子. 咱们看一下读取文件, 而后进行文本搜索的例子.
顺序操做版本的函数名叫作find.
Listing 10
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 }
下面是find函数中调用的read函数的实现:
Listing 11
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 }
Listing 11中的read函数, 以一个time.Sleep函数开始, 这个调用用来模拟实际系统调用从硬盘中读取文件的延迟. 相同的延迟对于精确测试find函数与它的并发版本的性能很重要.
下面咱们看看并发版本:
Listing 12
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 }
并发版本明显比顺序执行版本复杂, 那么这样作值得吗? 最好的方法仍是经过基准测试. 在测试中, 咱们一样将垃圾回收关闭.
Listing 13
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) } }
Listing 14
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
listing 14的基准测试显示, 当全部go程共用一个操做系统/硬件线程时, 并发版本大约快了87%到88%. 这种状况是能够预料到的, 由于全部的go程能够很好的共享一个操做系统/硬件线程.
下面测试使用并行性.
Listing 15
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
listing 15中的基准测试显示, 额外的操做系统/硬件线程并不能提高性能.
结论
这篇博客的目的是告诉你如何决定负载是否适合使用并发. 其中IO密集型通常适合使用并发, 而CPU密集型须要使用并行. 有些任务类型(算法), 可能使用并发和并行都不能提升性能.
原文参考: https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part3.html