• 并发与调度亲和性


    pic-0

    写在前面

    微信公众号:[double12gzh]

    关注容器技术、关注Kubernetes。问题或建议,请公众号留言。

    将一个goroutine从一个操作系统线程切换到另一个线程是有代价的,如果发生得太频繁,会拖慢应用程序的速度。然而,通过一段时间的努力,Go调度器已经解决了这个问题。它现在提供了goroutine和线程并发工作时的亲和力。让我们回到几年前来了解一下这个改进吧。

    在Go的早期,Go 1.0 & 1.1期间,当运行并发代码时,如果OS线程较多,即GOMAXPROCS的值较高,语言就会面临性能下降的问题。让我们从文档中使用的一个计算质数的例子开始说起,如下:

    代码出处

    // A concurrent prime sieve
    
    package main
    
    // Send the sequence 2, 3, 4, ... to channel 'ch'.
    func Generate(ch chan<- int) {
    	for i := 2; ; i++ {
    		ch <- i // Send 'i' to channel 'ch'.
    	}
    }
    
    // Copy the values from channel 'in' to channel 'out',
    // removing those divisible by 'prime'.
    func Filter(in <-chan int, out chan<- int, prime int) {
    	for {
    		i := <-in // Receive value from 'in'.
    		if i%prime != 0 {
    			out <- i // Send 'i' to 'out'.
    		}
    	}
    }
    
    // The prime sieve: Daisy-chain Filter processes.
    func main() {
    	ch := make(chan int) // Create a new channel.
    	go Generate(ch)      // Launch Generate goroutine.
    	for i := 0; i < 10; i++ {
    		prime := <-ch
    		print(prime, "
    ")
    		ch1 := make(chan int)
    		go Filter(ch, ch1, prime)
    		ch = ch1
    	}
    }
    

    下面是在多个不同的GOMAXPROCS数值时,用Go 1.0.3计算前十万个质数时的基准测试的结果:

    name     time/op
    Sieve    19.2s ± 0%
    Sieve-2  19.3s ± 0%
    Sieve-4  20.4s ± 0%
    Sieve-8  20.4s ± 0%
    

    为了理解这些结果,我们需要了解当时的调度器是如何设计的。在Go的第一个版本中,调度器只有一个全局队列,所有线程都能在其中推送和获取goroutine。下面是一个应用程序运行的例子,在下面的模式下,通过将GOMAXPROCS设置为2可以定义最多运行两个操作系统线程(M)。

    pic-1

    只有一个队列并不能保证goroutine会在同一个线程上继续运行。第一个准备好的线程会接收到一个等待的goroutine,并将其运行。因此,它涉及到goroutine从一个线程到另一个线程,这在性能上是有代价的。下面是一个有阻塞通道的例子,
    G7这个goroutine在通道上阻塞,并等待一个消息。一旦收到消息,goroutine就会被推送到全局队列中。

    pic-2

    然后,通道推送消息,goroutine #X将在一个可用线程上运行,而goroutine G8则在通道上阻塞。

    pic-3

    下面G7将会调度到可用的线程上被执行:

    pic-4

    goroutines现在运行在不同的线程上。有了一个全局队列,也迫使调度器有一个全局mutex来管理所有goroutines的调度操作。下面是由pprof创建的CPU profile文件:

    Total: 8679 samples
    3700  42.6%  42.6%     3700  42.6% runtime.procyield
    1055  12.2%  54.8%     1055  12.2% runtime.xchg
    753   8.7%  63.5%     1590   18.3% runtime.chanrecv
    677   7.8%  71.3%      677    7.8% dequeue
    438   5.0%  76.3%      438    5.0% runtime.futex
    367   4.2%  80.5%     5924   68.3% main.filter
    234   2.7%  83.2%     5005   57.7% runtime.lock
    230   2.7%  85.9%     3933   45.3% runtime.chansend
    214   2.5%  88.4%      214    2.5% runtime.osyield
    150   1.7%  90.1%      150    1.7% runtime.cas
    

    procyieldxchgfutexlock都与Go调度器的全局mutex有关。我们可以清楚地看到,应用程序的大部分时间都是处于locked状态。由于这些问题的存在,导致Go不能发挥处理器的优势,因此在Go 1.1中已经用新的调度器解决了。

    并发调度与亲和性

    Go 1.1带来了一个新的调度器的实现和本地goroutine队列的创建。这一改进避免了在有本地goroutine的情况下锁定整个调度器,并允许它们在同一个操作系统线程上工作。
    由于线程可以在系统调用上阻塞,而且阻塞的线程数量不受限制,Go引入了处理器的概念。一个处理器P代表一个正在运行的OS线程,并将管理本地goroutines队列。

    下图展示了调度器的新模型:

    pic-5

    在这个新模型下,我们在Go 1.1.2中做了benckmark测试,得到如下结果:

    name     time/op
    Sieve    18.7s ± 0%
    Sieve-2  8.26s ± 0%
    Sieve-4  3.30s ± 0%
    Sieve-8  2.64s ± 0%
    

    新模型下,Go可以比较充分的利用所有可用的CPU了,此时再通过pprof得到的CPU profile如下:

    Total: 630 samples
    163  25.9%  25.9%      163  25.9% runtime.xchg
    113  17.9%  43.8%      610  96.8% main.filter
    93  14.8%  58.6%      265   42.1% runtime.chanrecv
    87  13.8%  72.4%      206   32.7% runtime.chansend
    72  11.4%  83.8%       72   11.4% dequeue
    19   3.0%  86.8%       19    3.0% runtime.memcopy64
    17   2.7%  89.5%      225   35.7% runtime.chansend1
    16   2.5%  92.1%      280   44.4% runtime.chanrecv2
    12   1.9%  94.0%      141   22.4% runtime.lock
    9   1.4%  95.4%       98    15.6% runqput
    

    大部分与锁相关的操作都被删除了,标记为chanXXXX的操作只与通道有关。但是,如果调度器提高了goroutine和线程之间的亲和力,在某些情况下,这种亲和力是可以降低的。

    亲和性的限制

    为了理解亲和力的限制,我们必须了解什么会进入本地和全局队列。本地队列将用于所有期望系统调用的操作,例如对通道和选择的阻塞操作,对定时器和锁的等待。然而,有两个特性会限制goroutine和线程之间的亲和力。

    • 工作窃取(work-stealing)。当一个处理器P的本地队列中没有足够的工作时,如果全局队列和网络轮询器是空的,它就会从其他P中窃取goroutine。完成工作窃取后,goroutine就会在另一个线程上运行。

    • 系统调用(system call)。当发生系统调用时(如文件操作、http调用、数据库操作等),Go会将正在运行的操作系统线程移动到阻塞模式,让新的线程处理当前P上的本地队列。

    然而,通过更好地管理本地队列的优先级,可以避免这两个限制。Go 1.5的目的是给在通道上来回通信的goroutine更多的优先权,从而优化与分配线程的亲和力。

    亲和性增强

    一个goroutine在一个通道上来回通信,会导致频繁的阻塞,也就是频繁的在本地队列中重新排队。但是,由于本地队列有FIFO的实现,如果有其他goroutine占用了线程,处于unblock状态的goroutine就不能保证尽快运行。下面这个例子中,有一个之前在通道上是被阻塞的但现在可以运行的goroutine。

    pic-6

    G9程序在通道上被封锁后恢复。然而,它必须等待G2、G5和G4之后才能运行。在这个例子中,G5的goroutine会占用它的线程,延迟G9的goroutine,使它有可能被其他处理器窃取。从Go 1.5开始,从阻塞通道返回的goroutine现在将被优先运行(这要归功于它的一个特殊属性P)。

    goroutine G9现在被标记为下一个可运行的程序。这个新的优先级允许goroutine在再次被通道阻塞之前快速运行。然后,其他goroutine现在将有运行时间,这个变化对Go标准库有一个整体的积极影响,提高了一些包的性能。

  • 相关阅读:
    【DL】如何使用MMSegmentation训练数据集
    【python基础】Python错误:AttributeError: module 'json' has no attribute 'loads'解决办法
    【python基础】如何理解Python装饰器?
    【DL】如何生成用于训练的数据集
    【pytorch基础】基于训练的pytorch模型转换为onnx模型并测试
    【python基础】JupyterNotebook配置远程登录
    【工具使用】标注工具Labelme的安装以及使用
    【leetcode_easy_math】892. Surface Area of 3D Shapes
    【leetcode_easy】1636. Sort Array by Increasing Frequency
    【leetcode_easy】1640. Check Array Formation Through Concatenation
  • 原文地址:https://www.cnblogs.com/double12gzh/p/13678510.html
Copyright © 2020-2023  润新知