• [go]goroutine协程调度


    参考

    goroutine 和 thread

    • thread
    linux线程本质是轻量级进程,
        线程的特点是资源共享,意思就是线程之间共享进程的内存空间,所以一个线程可以访问另一个线程的局部变量, 
        
        线程也有自己独立的堆栈,用户的代码逻辑可以在这些堆栈上执行。
        
        当用户启动一个线程时,需要指定一个
            让该线程执行的起始函数和
            该函数的参数, 
        
        那么线程要做的事情就是去执行用户指定的函数。而且在线程里可以创建另一个线程。
    
    
    • 内存占用
    创建一个 goroutine 的栈内存消耗为 2 KB,
    实际运行过程中,如果栈空间不够用,会自动进行扩容。
    
    创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。
    
    对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果
    用一个使用线程作为并发原语的语言构建的服务,
        例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMermoryError)。
    
    • 创建和销毀
    Thread 创建和销毀都会有巨大的消耗,
        因为要和操作系统打交道,是内核级的,
        通常解决的办法就是线程池。而 
    
    goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。
    
    
    • 切换
    当 threads 切换时,需要保存各种寄存器,以便将来恢复:
    
    16 general purpose registers, 
        PC (Program Counter), 
        SP (Stack Pointer), 
        segment registers, 
        16 XMM registers, 
        FP coprocessor state, 
        16 AVX registers, 
        all MSRs etc.
    
    而 goroutines 切换只需保存三个寄存器:
        Program Counter, 
        Stack Pointer and 
        BP。
    
    一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。
    Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。
    因此,goroutines 切换成本比 threads 要小得多。
    

    m:n模型

    Go runtime 会负责 goroutine 的生老病死,从创建到销毁整个生命周期,都一手包办。

    Runtime 会在程序启动的时候,创建 M 个线程(CPU 执行调度的单位),
    
    之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行。这就是 M:N 模型:
    
    

    在同一时刻,一个线程上只能跑一个 goroutine。

    当 goroutine 发生阻塞(例如上篇文章提到的向一个 channel 发送数据,被阻塞)时,runtime 会把当前 goroutine 调度走,让其他 goroutine 来执行。

    目的就是不让一个线程闲着,榨干 CPU 的每一滴油水。

    什么是 scheduler

    
    Go 程序的执行由两层组成:
        Go Program
        Runtime,即用户程序和运行时。
        
        它们之间通过函数调用来实现
            内存管理、
            channel 通信、
            goroutines 创建等功能。
        
        用户程序进行的系统调用都会被 Runtime 拦截,以此来帮助它进行调度以及垃圾回收相关的工作。
    

    为什么要 scheduler

    Go scheduler 可以说是 Go 运行时的一个最重要的部分了。

    Runtime 维护所有的 goroutines,并通过 scheduler 来进行调度。
    
    Goroutines 和 threads 是独立的,
    但是 goroutines 要依赖 threads 才能执行。
    

    Go 程序执行的高效和 scheduler 的调度是分不开的。

    scheduler 底层原理

    实际上在操作系统看来,所有的程序都是在执行多线程。
    将 goroutines 调度到线程上执行,仅仅是 runtime 层面的一个概念,在操作系统之上的层面。

    scheduler有三个基础的结构体来实现 goroutines 的调度。g,m,p。

    gpm说明

    G(goroutine)
        调度系统的最基本单位goroutine,存储了
            goroutine的执行stack信息、
            goroutine状态以及
            goroutine的任务函数等。
        在G的眼中只有P,P就是运行G的“CPU”。P保存了需要执行的那些goroutine;
        相当于两级线程
    
    
    // 每个g的实例都有任务函数,如下代码, 任务函数是userFunc
    
    	userFunc := func() {
    		fmt.Println("hi boya!")
    	}
    
    	go userFunc()
    
    P(processor)
        P表示逻辑processor,代表线程M的执行的上下文。
        P的最大作用是其拥有的各种G对象队列、链表、cache和状态。
        P的数量也代表了golang的执行并发度,即有多少goroutine可以同时运行
    
    
        这里的p虽然表示逻辑处理器,但P并不执行任何代码,
            对G来说,P相当于CPU核,G只有绑定到P才能被调度。 
            对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等
    
    M(machine)
        M代表着真正的执行计算资源,可以认为它就是os thread(系统线程)。
        M是真正调度系统的执行者,每个M就像一个勤劳的工作者,总是从各种队列中找到可运行的G,而且这样M的可以同时存在多个。
        M在绑定有效的P后,进入调度循环,而且M并不保留G状态,这是G可以跨M调度的基础。
    
        在整个go调度的层面,对外的是M,P会找到一个M,让它去与外面的线程交互,从而去真正执行程序。
    

    图解goroutine调度

    • GPM的关系示意图
    通过连线可以看出来,
        M和P有关联,
        G和P有关联,
        
        但M和G没有关联, 这是为何G可以在不同的M上执行的本质原因。g眼中之后p, p选一个m执行, 对外的是M
    

    当然还有一个核心的结构体:sched,它总览全局。

    Runtime 起始时会启动一些 G:
        垃圾回收的 G,
        执行调度的 G,
        运行用户代码的 G;
        
        并且会创建一个 M 用来开始 G 的运行。
        
        随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。
    
    • src/runtime/runtime2.go
    type g struct {...
    type m struct {...
    type p struct {...
    type schedt struct {...
    

    Go scheduler 会启动一个后台线程 sysmon,用来检测长时间(超过 10 ms)运行的 goroutine,将其调度到 global runqueues。这是一个全局的 runqueue,优先级比较低,以示惩罚。

    总览

    通常讲到 Go scheduler 都会提到 GPM 模型,我们来一个个地看。

    下图是我使用的 mac 的硬件信息,只有 2 个核。

    func main() {
    	// NumCPU 返回当前进程可以用到的逻辑核心数
    	fmt.Println(runtime.NumCPU())  //因为 NumCPU 返回的是逻辑核心数,而非物理核心数,所以最终结果是 4。
    }
    

    当我在本地启动一个 Go 程序时,会得到 4 个系统线程去执行任务,每个线程会搭配一个 P。

    G、P、M 都说完了,还有两个比较重要的组件没有提到: 全局可运行队列(GRQ)和本地可运行队列(LRQ)。 LRQ 存储本地(也就是具体的 P)的可运行 goroutine,GRQ 存储全局的可运行 goroutine,这些 goroutine 还没有分配到具体的 P。


    Go scheduler 是 Go runtime 的一部分,它内嵌在 Go 程序里,和 Go 程序一起运行。因此它运行在用户空间,在 kernel 的上一层。和 Os scheduler 抢占式调度(preemptive)不一样,Go scheduler 采用协作式调度(cooperating)。

    work stealing

    Go scheduler 的职责就是将所有处于 runnable 的 goroutines 均匀分布到在 P 上运行的 M。
    当一个 P 发现自己的 LRQ 已经没有 G 时,会从其他 P “偷” 一些 G 来运行。看看这是什么精神!自己的工作做完了,为了全局的利益,主动为别人分担。这被称为 Work-stealing,Go 从 1.1 开始实现。
    Go scheduler 使用 M:N 模型,在任一时刻,M 个 goroutines(G) 要分配到 N 个内核线程(M),这些 M 跑在个数最多为 GOMAXPROCS 的逻辑处理器(P)上。每个 M 必须依附于一个 P,每个 P 在同一时刻只能运行一个 M。如果 P 上的 M 阻塞了,那它就需要其他的 M 来运行 P 的 LRQ 里的 goroutines。


    个人感觉,上面这张图比常见的那些用三角形表示 M,圆形表示 G,矩形表示 P 的那些图更生动形象。

    找到一个可执行的 goroutine 后,就会一直执行下去,直到被阻塞。
    当 P2 上的一个 G 执行结束,它就会去 LRQ 获取下一个 G 来执行。如果 LRQ 已经空了,就是说本地可运行队列已经没有 G 需要执行,并且这时 GRQ 也没有 G 了。这时,P2 会随机选择一个 P(称为 P1),P2 会从 P1 的 LRQ “偷”过来一半的 G。


    这样做的好处是,有更多的 P 可以一起工作,加速执行完所有的 G。

    调度中存在的问题

    问题1 G不均

    我们知道,现实情况有的goroutine运行的快,有的慢,那么势必肯定会带来的问题就是,忙的忙死,闲的闲死,go肯定不允许摸鱼的P存在,势必要榨干所有劳动力。
    如果你没有任务,那么,我们看到模型中还有一个全局G队列的存在,如本地队列已满,会一次性转移半数到全局队列。其他闲的小伙子就会从全局队列中拿;(顺便说一下,优先级是先拿下一个需要执行的,然后去本地队列中拿,再去全局队列中拿,先把自己的做完再做别人的嘛)同时如果全局都没有了,就会去抢别人的做。

    问题2 任务卡主了

    万一有个程序员启动一个goroutine去执行一个任务,然后这个任务一直睡觉(sleep)就是循环睡觉,那咋办嘛!你作为执行人,你总不能说,让它一直占用着一整个线程的资源,然后后面的goroutine都卡主了,那如果只有一个核心P,不就完蛋了?聪明的go才不会那么傻,它采用了抢占式调度来解决这个问题。只要你这个任务执行超过一定的时间(10ms),那么这个任务就会被标识为可抢占的,那么别的goroutine就可以抢先进来执行。只要下次这个goroutine进行函数调用,那么就会被强占,同时也会保护现场,然后重新放入P的本地队列里面等待下次执行。
    谁来做的呢?
    sysmon,就是这个背后默默付出的人,它是一个后台运行的监控线程,它来监控那些长时间运行的G任务然后设置可以强占的标识符。(同时顺便提一下,它还会做的一些事情,例如,释放闲置的span内存,2分钟的默认gc等)

    问题3 阻塞可怎么办?

    我们经常使用goroutine还有一个场景就是网络请求和IO操作,这种阻塞的情况下,我们的G和M又会怎么做呢?
    这个时候有个叫做netpoller的东西出现了,当每次有一个网络请求阻塞的时候,如果按照原来的方法这个时候这个请求会阻塞线程,而有了netpoller这个东西,可以将请求阻塞到goroutine。
    意思是说,当阻塞出现的时候,当前goroutine会被阻塞,等待阻塞notify,而放出M去做别的g,而当阻塞恢复的时候,netpoller就会通知对应的m可以做原来的g了。
    同时还要顺便提一句,当P发现没有任务的时候,除了会找本地和全局,也会去netpoll中找。

    问题4 系统方法调用阻塞?

    还有一个问题,我们自己想可能比较难想到,就是当调用一些系统方法的时候,如果系统方法调用的时候发生阻塞就比较麻烦了。下面引用一段话:
    当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于block on syscall状态,此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。

  • 相关阅读:
    windows10(64位)Anaconda3+Python3.6搭建Tensorflow(cpu版本)及keras
    Windows10下安装pytorch并导入pycharm
    应用程序无法正常启动0xc000007b解决
    Clion安装配置
    Android Studio安装&&安装bug
    VMWARE虚拟机安装64位系统此主机支持IntelVTx 但IntelVTx处于禁用状态
    A. Text Volume
    1001 数组中和等于K的数对
    11100
    Seek the Name, Seek the Fame
  • 原文地址:https://www.cnblogs.com/iiiiiher/p/12172251.html
Copyright © 2020-2023  润新知