21 面试必问!Goroutine的调度原理

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

正常的执行顺序就是线性的,谁在前面,谁就先执行,但是并发能力,会让你的程序,可以由若干个代码片段组合而成,并且每个片段都是独立运行的。Go语言天生支持这种并发能力,而Goroutine就是Go原生支持并发的具体实现。无论是Go的运行时还是用户写的代码都是运行在Goroutine中。

Goroutine是由Go运行时管理的轻量级线程。和操作系统线程相比,goroutine的资源占用和使用代价非常小。我们可以创建几百到十几万个goroutine,甚至更多。Go运行时负责对goroutine进行管理和调度。

不要让—“管理和调度”这种专业术语吓住,你可以简单理解,什么时候、哪个goroutine将获得资源开始执行,哪个goroutine应该停止执行让出资源,哪个goroutine应该被唤醒回复执行等。这样的理解和现实生活中是一模一样的,例如哨兵站岗。goroutine的调度模型和原理,对于编写出优雅而高质量的代码是大有益处的。因此,在面试中可以说是每次必问。

一 goroutine调度器

调度:操作系统中对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理CPU上去运行。如C、C++等并发实现多是基于线程模型的,就是应用程序负责创建线程(libpthread等库函数去实现),操作系统负责调度线程。这种模式就是有一些不足: 

复杂

1)写过C、C++的人肯定都知道,这里有多么的复杂,利用libpthread库中的API创建一个线程的时候,虽然要传入的参数很多,但是还可以接受。一旦涉及到线程的退出,那就要考虑主线程与新线程的很多资源相关的问题。

2) 并发执行单元互相通信困难,容易出错:多个线程间的通信有很多机制,但用起来也是很复杂的;一旦用到共享内存,那就是各种锁机制,导致死锁,更是很轻松就做到的。

难度

1) 我们使用线程的代价要比进程小很多,但是依然不能大量创建线程,除了每个线程占用的资源不小之外,操作系统调度切换线程的代价也很大。

2) 很多服务端程序,由于不能大量创建线程,只能选择在少量线程里做网络多路复用的方案(epoll/kqueue/IoCompletionPort这种机制),或者你会说可以用libevent和libev啊,这样的写法存在大量的钩子回调,给开发人员带来不小的负担。

看到上面的痛点,Go采用了Goroutine来解决这些痛点。Goroutine占用资源非常小,每个Gorouine栈的大小默认是2k字节。goroutine调度的切换也不用在操作系统内核中完成,代价很低。所以一个Go程序可以创建成千上万个并发的goroutine,而把这些goroutine按照一定算法放到cpu上执行的程序,我们就成为goroutine调度器(Scheduler)。

一个Go程序运行起来,在操作系统看来就是一个用户程序,操作系统的概念,只有线程,它甚至不知道有Goroutine的存在。Goroutine的调度完全靠GO自己完成。实现GO程序内Goroutine之间的公平竞争CPU的资源,这个任务就靠GO运行时(runtime)了,一个Go程序中,除了用户层代码,其他就是go运行时了。

二 Go调度器模型与演化过程

第一版 G-M 模型
2012.3.28日,Go1.0正式发布,Go团队实现了一个简单的goroutine调度器。在这个调度其中,每个goroutine对应于运行时中的一个抽象结构G(Goroutine),另外一个结构体是M(Machine),它被看成是“物理CPU”的操作系统线程。这个模型实现起来比较简单,且工作正常,但是也有一些问题,最重要的是限制了GO并发程序的伸缩性,如下几个方面:

单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作。如创建、重新调度都要加锁。

goroutine传递问题:M经常在M之间传递“可运行”的goroutine,这导致调度延迟增大及额外的性能损耗;

每个M都做内存缓存,导致内存占用过高,数据局部性交差。

由于系统调用而形成的频繁的工作线程阻塞和解阻塞,导致额外性能损耗。

第二版 G-P-M 模型
基于第一版的问题,在Go1.1中实现了G-P-M模型和work stealing算法,这个模型一直沿用。
在这里插入图片描述

我们看到在G-M中增加了一个P,这个P是何方神圣呢? P是一个“逻辑Processor”,每个G要想真正运行起来,都需要被分配到一个P,即进入到P的本地运行队列中,先暂时忽略全局队列。对于G来说,P就是运行它的“CPU”,在G看来只有P。但从调度器的角度看,真正的“CPU”是M,只有将P和M绑定才能让P中的G真正的运行起来。这样的P与M的关系,类似Linux操作系统中用户线程和内核线程的对应关系(N*M)

3 抢占式调度

有了G-P-M模型,是很大的进步,但是仍有一个问题,它不支持抢占式调度,一旦某个G中出现死循环的代码逻辑,那么G将永久占用分配给他的P和M,而在同一个P中的其他G永远不能被调度,出现其他G被“饿死”的情况。在Go1.2中实现了“抢占式”调度。

抢占式的原理是在每个函数或方法的入口,加一段额外的代码,让运行时有机会检查是否需要执行抢占调度。这种解决方案只能局部解决“饿死”问题。对于没有函数调用而是存算法计算的G,仍然无法实现抢占。

4 NUMA调度模型
从Go1.2后,Go将重点放在对GC的低延迟的优化上,只是一些小的改动。

5 其他优化
Go运行时已经实现了netpoller(https://morsmachine.dk/netpol...,也不会导致M被阻塞(仅阻塞G),从而不会导致大量(M)被创建出来。但是对于常规I/O操作一旦阻塞,那么线程(M)将进入挂起状态,等待I/O返回后被唤醒。这时,P将与挂起的M分离,再选择一个处于空闲的M.如果此时没有空闲的M,则新建一个M,这就是为何大量I/O操作会导致大量线程被创建的原因。

三 对Go调度器深入了解

1. G、P、M 
    G、P、M的定义,在 $GOROOT/src/runtime/runtime2.go 源文件中。 

G、P、M这三个结构体定义都是很繁重的,每个结构体定义都包含十几甚至二、三十个字段。像调度器这样的核心代码都是非常复杂的,考虑的因素也很多。简单说明一下:

G: 它是Goroutine,存储了Goroutine的执行栈信息、Goroutine状态以及Goroutine的任务函数等(G是可以重用的)。

P: 它是逻辑Processor,P的数量决定了系统内最大可并行的G的数据(物理CPU核数>=P的数量);P最大的作用是它有各种G对象队列、链表、缓存和状态。

M: 它是真正执行计算的资源。在绑定有效的P后,一个调度循环开始;而调度循环的机制是从各种队列、P的本地运行队列中获取G,切换到G的执行栈上并行执行G的函数,调用goexit做清理工作,然后回到M。这样反复。M并不保存G的状态,这是G可以跨M调度的基础。

G被抢占调用调度
操作系统是按时间片调度线程的,Go并没有时间片的概念。如果某个G没有进行系统调用、没有I/O操作、没有阻塞在一个channel上,那么M是怎么让G停下来并调度下一个可运行的G?
这就要说抢占调度了。

上面说了,除非是无限死循环,否则只要G调用函数,Go运行时就有了抢占G的机会。GO程序启动的时候,运行时会启动一个名为sysmon的M(你可以简单理解为监控器或监控协程),该M特殊之处就是其无需绑定P即可运行(以g0的形式),该M在整个Go程序的运行过程中非常重要。

$GOROOT/src/runtime/proc.go

//The main goroutine.
func main() {

 ... ... 
systemstack(func() { 
    newm(sysmon, nil) 
}) 
.... ... 

}

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {

// If a heap span goes unused for 5 minutes after a garbage collection, 
// we hand it back to the operating system. 
scavengelimit := int64(5 * 60 * 1e9) 
... ... 

if  .... { 
    ... ... 
    // retake P's blocked in syscalls 
    // and preempt long running G's 
    if retake(now) != 0 { 
        idle = 0 
    } else { 
        idle++ 
    } 
   ... ... 
} 

}

从上面源代码可以看到symon每20us—10ms启动一次,sysmon主要工作:

释放闲置超过5分钟的span物理内存; 
如果超过2分钟没有垃圾回收,强制执行; 
将长时间未处理的netpoll结果添加到任务队列; 
向长时间运行的G任务发出抢占调度; 
收回因syscall长时间阻塞的P; 

3 channel阻塞或网络I/O下的调度

如果G被阻塞在某个channel操作或者网络I/O操作上的时候,G会被放入到某个等待队列中,而M会尝试运行P的下一个可运行的G;如果此时P没有可运行的G给M运行,那么M将解绑P,并进入挂起状态。当I/O或者channel操作完成,在等待队列中的G会被唤醒,标记为可运行,并被放入到某个P队列中,绑定一个M后继续运行。

4 系统调用阻塞情况下,如何调度

如果G被阻塞在某个系统调用上,那么不仅仅G会阻塞,执行G的M也会解绑P,与G一起进入挂起状态。如果此时有空闲的M,则P和与其绑定并继续执行其他的G;如果没有空闲的M,但还是有其他G去执行,那么会创建一个新M。当系统调用返回后,阻塞在该系统调用上的G会尝试获取一个可用的P,如果没有可用的P,那么这个G会被标记为runnable,之前的那个挂起的M将再次进入挂起状态。

image


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

本文来自:Segmentfault

感谢作者:.container .card .information strong

查看原文:21 面试必问!Goroutine的调度原理

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

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