经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 程序设计 » Go语言 » 查看文章
go调度: 第三部分-并发
来源:cnblogs  作者:月落无影  时间:2021/1/18 16:29:06  对本文有异议

前言

这个是用来讲述go调度器机制和特性的第三部分. 这个主要关注并发.

博客三部分的顺序:

1) go调度: 第一部分-操作系统调度

2) go调度: 第二部分-go调度器

3) go调度: 第三部分-并发

 

介绍

当我在解决一个问题, 尤其是一个新问题的时候, 开始阶段, 我不会考虑并发是不是有用. 我寻找一个顺序化解决方案, 并且确保这个方案有效. 然后, 我进行评估, 来看看并发是否合适. 有些情况下, 并发是很合适的, 而有些情况下则未必.

在第一部分,我讲了操作系统的调度器的特性, 如果你想写多线程应用, 这个是很有必要的. 在第二部分, 我讲了go的调度器特性, 我认为, 这个对使用go语言写多线程是很有意义的. 在这篇中, 我将结合操作系统调度和go程调度, 来提供一个对于使用go语言写多线程的更加深入的理解.

这篇博客的目的是:

(1) 辨别你代码应用的场景是否适合使用并发.

(2) 展示如何根据应用场景改变并发的使用.

 

什么是并发

并发意味着乱序执行. 一系列指令, 可以被顺序执行, 也可以在满足限制的情况下乱序执行, 但是还是可以产生相同的结果. 对于你眼前的这个问题, 乱序执行必须可以展示出明显的价值, 也就是说, 并发可以明显的提高性能, 同时, 没有代码复杂度的增加可以容忍. 根据你的问题, 乱序执行有时候是不可行的.

有一个重要的点需要注意, 并发不等于并行. 并行意味着同一时间执行多条指令. 这个与并发的概念并不相同. 只有在有至少2个操作系统线程(运行在两个硬件线程之上), 并且有至少2go程的情况下, 每个操作系统/硬件线程运行一组指令的情况下, 并行才会发生.

图1

在图1, 你看到两个逻辑处理器(P)运行在两个不同的操作系统线程(M), 这两个M对应着不同的硬件线程. 这种情况下, 两个go(G1G2)处于并行状态. 在每个逻辑处理器上, 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)

我们不需要复杂的代码来理解这种语境. 看下面这个将多个数字加在一起的函数.

 

  1. 36 func add(numbers []int) int {
  2. 37 var v int
  3. 38 for _, n := range numbers {
  4. 39 v += n
  5. 40 }
  6. 41 return v
  7. 42 }

问题: add函数是一个适合并发的负载吗? 我相信是的. 这些整数可以拆分程更小的几组整数, 然后每组整数并发计算. 当这些组整数都相加完成后, 然后将这些整数相加的结果进行相加, 就可以得到最终的结果.

然而, 现在有另外一个问题, 我们需要拆成多少个小的组, 然后让他们并发执行, 从而提供最好的性能? 为了回答这个问题, 我们需要知道add的负载类型. add函数是CPU密集型的负载, 因为这个函数只进行数学运算, 而不会使go程进入等待状态. 这种情况下, 每个go程对应一个操作系统/硬件线程是合理的.

Listing 2是我的add函数的并发版本.

Listing 2

  1. 44 func addConcurrent(goroutines int, numbers []int) int {
  2. 45 var v int64
  3. 46 totalNumbers := len(numbers)
  4. 47 lastGoroutine := goroutines - 1
  5. 48 stride := totalNumbers / goroutines
  6. 49
  7. 50 var wg sync.WaitGroup
  8. 51 wg.Add(goroutines)
  9. 52
  10. 53 for g := 0; g < goroutines; g++ {
  11. 54 go func(g int) {
  12. 55 start := g * stride
  13. 56 end := start + stride
  14. 57 if g == lastGoroutine {
  15. 58 end = totalNumbers
  16. 59 }
  17. 60
  18. 61 var lv int
  19. 62 for _, n := range numbers[start:end] {
  20. 63 lv += n
  21. 64 }
  22. 65
  23. 66 atomic.AddInt64(&v, int64(lv))
  24. 67 wg.Done()
  25. 68 }(g)
  26. 69 }
  27. 70
  28. 71 wg.Wait()
  29. 72
  30. 73 return int(v)
  31. 74 }

并发版本明显比顺序运行版本复杂, 那么增加的这个复杂性值得吗? 最好地回答这个问题的方法是通过基准测试(benchmark). 对于这些基准测试, 我将垃圾收集器关闭, 然后将一千万个数字相加. 下面测试分别使用了顺序版本的add函数, 和并发版本的addConcurrent函数.

Listing 3

  1. func BenchmarkSequential(b *testing.B) {
  2. for i := 0; i < b.N; i++ {
  3. add(numbers)
  4. }
  5. }
  6.  
  7. func BenchmarkConcurrent(b *testing.B) {
  8. for i := 0; i < b.N; i++ {
  9. addConcurrent(runtime.NumCPU(), numbers)
  10. }
  11. }

 Listing 4

  1. 10 Million Numbers using 8 goroutines with 1 core
  2. 2.9 GHz Intel 4 Core i7
  3. Concurrency WITHOUT Parallelism
  4. -----------------------------------------------------------------------------
  5. $ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
  6. goos: darwin
  7. goarch: amd64
  8. pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
  9. BenchmarkSequential 1000 5720764 ns/op : ~10% Faster
  10. BenchmarkConcurrent 1000 6387344 ns/op
  11. BenchmarkSequentialAgain 1000 5614666 ns/op : ~13% Faster
  12. BenchmarkConcurrentAgain 1000 6482612 ns/op

注意: 在本机运行基准测试不是一件简单的事. 有很多会导致基准测试不准确的因素, 因此, 你需要确保你的机器尽可能的空闲, 并且多运行几次测试.

listing 4的基准测试显示, 当只有一个硬件线程时, 顺序版本比并发版本快大约10%13%. 因为并发版本有在同一个操作系统线程上的go程的上下文切换, 这种情况是可以预料到的.

Listing 5

  1. 10 Million Numbers using 8 goroutines with 8 cores
  2. 2.9 GHz Intel 4 Core i7
  3. Concurrency WITH Parallelism
  4. -----------------------------------------------------------------------------
  5. $ GOGC=off go test -cpu 8 -run none -bench . -benchtime 3s
  6. goos: darwin
  7. goarch: amd64
  8. pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
  9. BenchmarkSequential-8 1000 5910799 ns/op
  10. BenchmarkConcurrent-8 2000 3362643 ns/op : ~43% Faster
  11. BenchmarkSequentialAgain-8 1000 5933444 ns/op
  12. BenchmarkConcurrentAgain-8 2000 3477253 ns/op : ~41% Faster

在上面的基准测试中, 并发版本快了大约41%43%, 因为每个go程可以运行在不同的操作系统/硬件线程.

 

排序

理解不是所有的CPU密集型负载都适合并发是很重要的. 尤其是在将任务拆解, 以及任务聚合都很复杂的时候. 排序算法中的冒泡排序就是其中的一个例子. 我们来看看go语言中实现的冒泡排序.

Listing 6

  1. 01 package main
  2. 02
  3. 03 import "fmt"
  4. 04
  5. 05 func bubbleSort(numbers []int) {
  6. 06 n := len(numbers)
  7. 07 for i := 0; i < n; i++ {
  8. 08 if !sweep(numbers, i) {
  9. 09 return
  10. 10 }
  11. 11 }
  12. 12 }
  13. 13
  14. 14 func sweep(numbers []int, currentPass int) bool {
  15. 15 var idx int
  16. 16 idxNext := idx + 1
  17. 17 n := len(numbers)
  18. 18 var swap bool
  19. 19
  20. 20 for idxNext < (n - currentPass) {
  21. 21 a := numbers[idx]
  22. 22 b := numbers[idxNext]
  23. 23 if a > b {
  24. 24 numbers[idx] = b
  25. 25 numbers[idxNext] = a
  26. 26 swap = true
  27. 27 }
  28. 28 idx++
  29. 29 idxNext = idx + 1
  30. 30 }
  31. 31 return swap
  32. 32 }
  33. 33
  34. 34 func main() {
  35. 35 org := []int{1, 3, 2, 4, 8, 6, 7, 2, 3, 0}
  36. 36 fmt.Println(org)
  37. 37
  38. 38 bubbleSort(org)
  39. 39 fmt.Println(org)
  40. 40 }

问题: bubbleSort函数适合改成并发执行吗? 我相信不合适. 这些整数可以拆分成小的队列, 然后这些队列并发排序. 然而, 这些小的已排序的队列, 没有好的办法, 将它们排序在一起. 下面是冒泡排序的并发版本.

Listing 8

  1. 01 func bubbleSortConcurrent(goroutines int, numbers []int) {
  2. 02 totalNumbers := len(numbers)
  3. 03 lastGoroutine := goroutines - 1
  4. 04 stride := totalNumbers / goroutines
  5. 05
  6. 06 var wg sync.WaitGroup
  7. 07 wg.Add(goroutines)
  8. 08
  9. 09 for g := 0; g < goroutines; g++ {
  10. 10 go func(g int) {
  11. 11 start := g * stride
  12. 12 end := start + stride
  13. 13 if g == lastGoroutine {
  14. 14 end = totalNumbers
  15. 15 }
  16. 16
  17. 17 bubbleSort(numbers[start:end])
  18. 18 wg.Done()
  19. 19 }(g)
  20. 20 }
  21. 21
  22. 22 wg.Wait()
  23. 23
  24. 24 // Ugh, we have to sort the entire list again.
  25. 25 bubbleSort(numbers)
  26. 26 }

Listing 8, bubbleSortConcurrent函数作为bubbleSort函数的并发版本.

Listing 9

  1. Before:
  2. 25 51 15 57 87 10 10 85 90 32 98 53
  3. 91 82 84 97 67 37 71 94 26 2 81 79
  4. 66 70 93 86 19 81 52 75 85 10 87 49
  5.  
  6. After:
  7. 10 10 15 25 32 51 53 57 85 87 90 98
  8. 2 26 37 67 71 79 81 82 84 91 94 97
  9. 10 19 49 52 66 70 75 81 85 86 87 93

bubbleSortConcurrent25行调用了bubbleSort, 这里抵消了并发可能实现的提升. 对于冒泡排序, 并发不能实现性能提升.

 

读文件

 

举了两个CPU密集型负载的例子, 下面, 我们看一下IO密集型负载的例子. 我们看一下读取文件, 然后进行文本搜索的例子.

 

顺序操作版本的函数名叫做find.

 

Listing 10

 

  1. 42 func find(topic string, docs []string) int {
  2. 43 var found int
  3. 44 for _, doc := range docs {
  4. 45 items, err := read(doc)
  5. 46 if err != nil {
  6. 47 continue
  7. 48 }
  8. 49 for _, item := range items {
  9. 50 if strings.Contains(item.Description, topic) {
  10. 51 found++
  11. 52 }
  12. 53 }
  13. 54 }
  14. 55 return found
  15. 56 }

下面是find函数中调用的read函数的实现:

Listing 11

  1. 33 func read(doc string) ([]item, error) {
  2. 34 time.Sleep(time.Millisecond) // Simulate blocking disk read.
  3. 35 var d document
  4. 36 if err := xml.Unmarshal([]byte(file), &d); err != nil {
  5. 37 return nil, err
  6. 38 }
  7. 39 return d.Channel.Items, nil
  8. 40 }

Listing 11中的read函数, 以一个time.Sleep函数开始, 这个调用用来模拟实际系统调用从硬盘中读取文件的延迟. 相同的延迟对于精确测试find函数与它的并发版本的性能很重要.

下面我们看看并发版本:

Listing 12

  1. 58 func findConcurrent(goroutines int, topic string, docs []string) int {
  2. 59 var found int64
  3. 60
  4. 61 ch := make(chan string, len(docs))
  5. 62 for _, doc := range docs {
  6. 63 ch <- doc
  7. 64 }
  8. 65 close(ch)
  9. 66
  10. 67 var wg sync.WaitGroup
  11. 68 wg.Add(goroutines)
  12. 69
  13. 70 for g := 0; g < goroutines; g++ {
  14. 71 go func() {
  15. 72 var lFound int64
  16. 73 for doc := range ch {
  17. 74 items, err := read(doc)
  18. 75 if err != nil {
  19. 76 continue
  20. 77 }
  21. 78 for _, item := range items {
  22. 79 if strings.Contains(item.Description, topic) {
  23. 80 lFound++
  24. 81 }
  25. 82 }
  26. 83 }
  27. 84 atomic.AddInt64(&found, lFound)
  28. 85 wg.Done()
  29. 86 }()
  30. 87 }
  31. 88
  32. 89 wg.Wait()
  33. 90
  34. 91 return int(found)
  35. 92 }

并发版本明显比顺序执行版本复杂, 那么这样做值得吗? 最好的方法还是通过基准测试. 在测试中, 我们同样将垃圾回收关闭.

Listing 13

  1. func BenchmarkSequential(b *testing.B) {
  2. for i := 0; i < b.N; i++ {
  3. find("test", docs)
  4. }
  5. }
  6.  
  7. func BenchmarkConcurrent(b *testing.B) {
  8. for i := 0; i < b.N; i++ {
  9. findConcurrent(runtime.NumCPU(), "test", docs)
  10. }
  11. }

Listing 14

  1. 10 Thousand Documents using 8 goroutines with 1 core
  2. 2.9 GHz Intel 4 Core i7
  3. Concurrency WITHOUT Parallelism
  4. -----------------------------------------------------------------------------
  5. $ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
  6. goos: darwin
  7. goarch: amd64
  8. pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
  9. BenchmarkSequential 3 1483458120 ns/op
  10. BenchmarkConcurrent 20 188941855 ns/op : ~87% Faster
  11. BenchmarkSequentialAgain 2 1502682536 ns/op
  12. BenchmarkConcurrentAgain 20 184037843 ns/op : ~88% Faster

listing 14的基准测试显示, 当所有go程共用一个操作系统/硬件线程时, 并发版本大约快了87%88%. 这种情况是可以预料到的, 因为所有的go程可以很好的共享一个操作系统/硬件线程.

下面测试使用并行性.

Listing 15

  1. 10 Thousand Documents using 8 goroutines with 1 core
  2. 2.9 GHz Intel 4 Core i7
  3. Concurrency WITH Parallelism
  4. -----------------------------------------------------------------------------
  5. $ GOGC=off go test -run none -bench . -benchtime 3s
  6. goos: darwin
  7. goarch: amd64
  8. pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
  9. BenchmarkSequential-8 3 1490947198 ns/op
  10. BenchmarkConcurrent-8 20 187382200 ns/op : ~88% Faster
  11. BenchmarkSequentialAgain-8 3 1416126029 ns/op
  12. BenchmarkConcurrentAgain-8 20 185965460 ns/op : ~87% Faster

listing 15中的基准测试显示, 额外的操作系统/硬件线程并不能提升性能.

 

结论

这篇博客的目的是告诉你如何决定负载是否适合使用并发. 其中IO密集型一般适合使用并发, CPU密集型需要使用并行. 有些任务类型(算法), 可能使用并发和并行都不能提高性能.

 

 

原文参考: https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part3.html

 

原文链接:http://www.cnblogs.com/albizzia/p/11427574.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号