golang goroutine的调度

singhamxiao · · 838 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

golang goroutine的调度

1、什么是协程?

    协程是一种用户态的轻量级线程。

2、进程、线程、协程的关系和区别:

    * 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
    
    * 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)。
    
    * 协程和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。
    
    * 协程和线程的区别是:协程避免了无意义的调度,由此可以提高性能。
    
    * 执行协程只需要极少的栈内存(大概是4~5KB),默认情况下,线程栈的大小为1MB。
        
        goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个goroutine,但它们并不是被操作系统所调度执行。
        
        runtime。GOMAXPROCS(runtime。NumCPU()) // go version>=1.5的时候,GOMAXPROCS的默认值就是go程序启动时可见的操作系统认为的CPU个数。
        
        注意:在go程序中使用的操作系统线程数量包括:正服务于cgo calls的线程,阻塞于操作系统calls的线程,所以go程序中使用的操作系统线程数量可能大于GOMAXPROCS的值。
        
3、调度

    要理解协程的实现,首先需要了解go中的三个非常重要的概念,它们分别是G(goroutine)、M(machine)和P(process):
    
    G (goroutine)
    
        G是goroutine的头文字,goroutine可以解释为受管理的轻量线程,goroutine使用go关键词创建。
        
        举例来说,func main() { go other() },这段代码创建了两个goroutine,一个是main,另一个是other,注意main本身也是一个goroutine。
        
        goroutine的新建,休眠,恢复,停止都受到go运行时的管理。
        
        goroutine执行异步操作时会进入休眠状态,待操作完成后再恢复,无需占用系统线程。
        
        goroutine新建或恢复时会添加到运行队列,等待M取出并运行。
   
    M (machine)
    
        M是machine的头文字,在当前版本的golang中等同于系统线程。
        
        M可以运行两种代码:
        
            * go代码,即goroutine,M运行go代码需要一个P。
            
            * 原生代码,例如阻塞的syscall,M运行原生代码不需要P。
        
        M会从运行队列中取出G,然后运行G,如果G运行完毕或者进入休眠状态,则从运行队列中取出下一个G运行,周而复始。
        
        有时候G需要调用一些无法避免阻塞的原生代码,这时M会释放持有的P并进入阻塞状态,其他M会取得这个P并继续运行队列中的G。
        
        go需要保证有足够的M可以运行G,不让CPU闲着,也需要保证M的数量不能过多。
    
    P (process)
    
        P是process的头文字,代表M运行G所需要的资源。
                   
        虽然P的数量默认等于cpu核心数,但可以通过环境变量GOMAXPROC修改,在实际运行时P跟cpu核心并无任何关联。       
        
        P也可以理解为控制go代码的并行度的机制,
        
            * 如果P的数量等于1,代表当前最多只能有一个线程(M)执行go代码;
            
            * 如果P的数量等于2,代表当前最多只能有两个线程(M)执行go代码。
            
        执行原生代码的线程数量不受P控制。
        
        因为同一时间只有一个线程(M)可以拥有P,P中的数据都是锁自由(lock free)的,读写这些数据的效率会非常的高。
        
    备注:每个P会维护一个本地的运行队列,除了每个P拥有一个本地的运行队列外,还存在一个全局的运行队列。
    
    
    为什么需要创造一个用户空间调度器?
    
        POSIX线程API是对现有Unix进程模型的一个非常大的逻辑扩展,而且线程获得了非常多的跟进程相同的控制。
        
        比如,线程有它自己的信号掩码,线程能够被赋予CPU affinity功能(就是指定线程只能在某个CPU上运行),
        
        线程能被添加到[cgroups]中,线程所用到的资源也可以被查询到。
        
        所有的这些控制增大了Go程序使用goroutines时根本不需要的特性的开销,当你的程序有100,000个线程的时候,这些开销会急剧增长。
        
        另外一个问题是,基于Go模型,操作系统不能给出特别好的决策。
        
        比如,当运行一次垃圾收集的时候,Go的垃圾收集器要求所有线程都被停止而且要求内存要处于一致状态。
        
        这个涉及到要等待全部运行时线程到达一个点,系统事先知道在这个点内存是一致的。
        
        当很多被调度的线程分散在随机的点上的时候,结果就是你不得不等待他们中的大多数到达一致状态。
        
        Go调度器能够作出这样的决策,就是只在内存保持一致的点上进行调度。
        
        这就意味着,当程序为垃圾收集而停止的时候,程序只须等待在一个CPU核上处于活跃运行状态的线程即可。
        
    
    目前有三个常见的线程模型:
    
        一个是N:1的,即多个用户空间线程运行在一个OS线程上。这个模型可以很快的进行上下文切换,但是不能利用多核系统(multi-core systems)的优势。
        
        另一个模型是1:1的,即可执行程序的一个线程匹配一个OS线程。这个模型能够利用机器上的所有核心的优势,但是上下文切换非常慢,因为它不得不陷入OS(trap through the OS)。
        
        Go试图通过M:N的调度器去获取这两个世界的全部优势。它在任意数目的OS线程上调用任意数目的goroutines。你可以快速进行上下文切换,并且还能利用你系统上所有的核心的优势。这个模型主要的缺点是它增加了调度器的复杂性。
    
    
    原理:
    
        P的数量在初始化由GOMAXPROCS决定;
        
        程序要做的就是添加G;
        
        G的数量超出了M的处理能力,且还有空余P的话,runtime就会自动创建新的M;
        
        M拿到P后才能干活,取G的顺序:本地运行队列 > 全局运行队列 > 其他P的运行队列,如果所有运行队列都没有可用的G,M会归还P并进入休眠。
        
        一个G如果发生阻塞等事件会进行阻塞,G发生上下文切换条件:
                                          
            * 系统调用;
            
            * 读写channel;
            
            * gosched主动放弃,会将G扔进全局队列;
        
        一个G发生阻塞时,M0让出P,由M1接管其任务队列;当M0执行的阻塞调用返回后,再将G0扔到全局队列,自己则进入睡眠(因为没有P,无法干活)。
        
        例子:
            
            当G0调用一个系统调用的时候。因为一个线程不能既执行代码同时又阻塞到一个系统调用上,则需要移交对应于这个线程的P以让这个P可以被调度。
            
            M0放弃了它的P以保证其它的M1可以运行它。调度器确保有足够的线程来运行所有的P。
            
            M1可能仅仅是系统为了处理G0的系统调用而被创建出来,或者它可能来自一个线程池。
            
            这个处于系统调用中的线程M0将会保持在这个导致系统调用的G0上,因为从技术上来说,它仍然在执行,虽然阻塞在OS里了。
            
            当这个系统调用返回的时候,这个线程必须尝试获取一个P1来运行这个返回的G0,操作的正常模式是从其它所有线程M中的其中一个线程Mn中“盗取”一个Pn。
            
            如果“盗取”不成功,它就会把它的G0放到一个全局运行队列中,然后把自己放到线程池中或者转入睡眠状态。
            
            这个全局运行队列是各个P在运行完自己的本地运行队列后用来获取新G的地方。
            
            各个P也会周期性的检查这个全局运行队列上的G,否则,全局运行队列上的G可能因得不到执行而被饿死。
            
            备注:Go程序要在多线程上运行的原因就是因为要处理系统调用,哪怕GOMAXPROCS等于1。运行时(runtime)使用调用系统调用的goroutines,而不是线程。
            
        “盗取”:
            
            当一个P运行完要被调度的所有G的时候。如果各个P的本地运行队列里的G的数目不均衡,改变就会发生了,
            
            否则会导致一个P1在执行完它的本地运行队列里的G后就会结束,尽管系统中仍然有许多G要执行。
            
            所以为了保持运行Go代码,一个P能够从全局运行队列中获取G,但是如果全局运行队列中也没有G了,那么P就不得不从其它Pn的运行队列获取G了。
            
            当一个P完成自己的任务后,它就会尝试“盗取”另一个P运行队列中G的一半。这将确保每个P总是有活干,然后反过来确保所有线程M尽可能处于最大负荷。
            
        备注:goroutine是按照抢占式调度的,一个goroutine最多执行10ms就会换作下一个。
        
            这个和目前主流系统的的cpu调度类似(按照时间分片)
            
            windows:20ms
            
            linux:5ms-800ms

有疑问加站长微信联系(非本文作者)

本文来自:博客园

感谢作者:singhamxiao

查看原文:golang goroutine的调度

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

838 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传