Golang并发机制

线程模型

在操作系统提供的内核线程之上,go搭建了一个两级线程模型。毫无疑问,其中一级必然是内核线程,另一级便是goroutine。你可以将goroutine看作是应用程序线程。

goroutine一词是由go的开发者们专门创建的,它所表达的含义是:

不要用共享内存的方式来通信,而应该以通信作为手段来共享内存。

三大核心

go的线程模型由三个核心元素支撑:

  • M(machine):一个M代表一个内核线程。

  • P(processor):一个P代表执行一个go代码片段所必须的资源(上下文环境)。

  • G(goroutine):一个G代表一个go代码片段,goroutine是对go代码片段的一种封装。

不要惊讶于这里出现的三大核心元素。它们并不是新概念,我们之前已经见过它们了。在golang并发初探一文中,我们提到了操作系统线程,逻辑处理器和goroutine三个词,它们和这里的M、P、G是一一对应的。所谓的逻辑处理器就是这里的P,也就是上下文环境,所以我说前文是很不负责的。

有关M、P、G的声明都在runtime包的runtime2.go文件中。

  1. 一个G的执行需要P和M的支持;

  2. 一个M和一个P关联后就形成了一个有效的G运行环境(内核线程+上下文环境);

  3. 每个P都会包含一个可运行的G的队列;

  4. 队列中的G会被依次传递给与本地P关联的M执行。

M、P、G、KSE(内核调度实体)之间的关系

  1. M与KSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。

  2. M与P之间也总是一对一的,但是M与P的关联可能在调度过程中改变。

  3. P与G之间是一对多的关系,因为每个P都包含一个可运行的G队列。同样,它们之间的关联也会在调度中改变。

  4. M与G之间也会建立关联,因为一个G终归由一个M来运行。它们之间的关联通过P来牵线。

M

 type m struct {             g0        *g        //一个特殊的goroutine,由go运行时系统在启动之初创建,用于执行运行时任务。            mstartfn  func()    //在新的M上启动某个特殊任务的函数,可能系统监控,GC辅助或M自旋。            curg      *g        //当前M正在运行的那个G(goroutine)的指针。            p         puintptr  //与当前M关联的P(上下文环境)。            nextp     puintptr  //暂存与当前M有潜在关联的P,将P赋给M的nextp字段称为M和P的预联。             spinning  bool      //当前M是否正在寻找可运行的G。寻找过程中,M处于自旋状态。             lockedg   *g        //与当前M锁定的G。一旦锁定,这个M只能运行这个G,这个G也只能由该M运行。            thread    uintptr   //线程句柄。真正用来执行go代码的系统线程。
}

字段p的作用是当前M与一个P关联,字段nextp的作用是将当前M与一个P预联。我们知道,一个M和一个P关联以后才是一个G的运行环境。那么预联有什么用呢?运行时系统有时会把刚重新启动的M和已与它预联的P关联在一起,也就是将nextp字段的值赋给p字段,这就是nextp字段的主要作用。

除了lockedg字段可以将一个M和一个G锁定之外,runtime包中的函数LockOSThresdUnlockOSThread也提供了锁定和解锁的方法。

以上列出的只是结构体m中的一部分字段

M的创建

M创建之初会做如下3件事:

  1. 将创建的M加入全局M列表(runtime.allm)中。

  2. 设置它的起始函数(mstartfn字段)和预联的P(nextp字段)。其中,起始函数仅当运行时系统要用此M执行系统监控或垃圾回收等任务时才会被设置。

  3. 运行时系统创建一个新的内核线程并与之关联,也就是赋给thread字段。

全局M列表除了可以通过它获取到所有M的信息以及防止M被当作垃圾回收掉之外,并没有特殊意义。

M的初始化

新的M创建之后,go运行时系统会对它进行初始化。包括自身所持栈空间以及信号处理方面的初始化。

初始化完成后,如果有起始函数,那么该M的起始函数就会执行。如果这个起始函数代表的是系统监控任务,那么该M会一直执行它,而不会继续后面的流程。

起始函数执行完毕以后,当前M会与那个预联的P完成关联,也就是将nextp字段的值赋给p字段。

然后M会依次在多处寻找可运行的G并运行之。这也是调度的一部分。

M的停止

运行时系统管辖的M(runtime.allm中的M)有时也会停止。比如在运行时系统执行垃圾回收任务的过程中。

运行时系统停止M后会将它放入调度器的空闲M列表(runtime.sched.midle)中。这很重要,因为当需要一个未被使用的M时,运行时系统会首先尝试从该列表中获取。M是否空闲,仅以它是否存在于调度器的空M列表中为依据。

M的限制

go程序运行前会先启动一个引导程序,用来为程序运行建立必要的环境。引导程序在初始化调度器时,会对M的最大数量进行初始化,初始值是1000。也就是说最多能创建一万个M为当前的go程序服务。

同时runtime/debug包的SetMaxThreads函数可以用来修改这一限制。该函数会返回旧的M数量的最大值。需要注意的是,如果你给定的新值比当时已有的M的数量小,运行时系统会立即引发一个运行时恐慌。所以调用这个函数一定要慎重,而且如果真的有必要,那么越早调用越好。因为调整的过程中会损耗部分性能。

P

P是goroutine的上下文环境,也是G能在M中运行的关键。go运行时系统会适时的让P与不同的M建立或断开连接,以使P中那些可运行的G能即使获得运行时机。

在go中,P也是一个结构体。

 type p struct {             lock      mutex             //互斥锁             status    uint32            //P的状态            m         muintptr          //和当前P绑定的M             runqhead  uint32            //可运行G队列的队头            runqtail  uint32            //可运行G队列的队尾            runq     [256]guintptr     //可运行G队列,固定长度为256             runnext   guintptr          //下一个要运行的G             gfree     *g                //自由G队列             gfreecnt  int32             //自由G队列长度
}

上面只是p的部分字段,后面你会看到他们都有什么用。

设置P的最大数量

go提供了两种方法来设置程序拥有的P的最大数量:

  1. 调用runtime.GOMAXPROCS函数并将新值作为参数传入。该函数会返回旧值。

  2. 在go程序运行前,设置环境变量GOMAXPROCS的值。

还记得前面的引导程序吗?它在初始化调度器时,也会设置P的最大数量。默认与CPU核心数相同。但是如果设置了GOMAXPROCS环境变量,引导程序就会在检查该环境变量的有效性后将P的最大数量设置为该值。有效的最大P数量必须大于0,小于等于256。256是硬性的上限值,因为go目前无法保证在多于256个P同时存在的情况下,go程序还能保持高效。这个值不是永久的,也许将来会提高。P的最大值的作用是限制并发G的规模。

一个G在被启用后,会先被追加到某个P的可运行G队列中,以等待运行时机。当M运行的G进入系统调用而阻塞时,运行时系统会将该M和与之关联的P分离开。如果这个P的可运行G队列中还有未被运行的G,那么运行时系统会找一个空闲的M或创建一个新的M,并与该P关联,以运行这些G。因此M的数量常常多于P,从他们的最大值的限制上也能看出这一点。

runtime.GOMAXPROCS函数的调用会会暂时让所有P都脱离运行状态,并试图阻止任何用户G的运行。新的P最大数量设置完毕后,运行时系统才开始陆续恢复它们。这对程序性能是非常大的损耗。因此最好不要去调用它,万不得已的时候也也该尽量在main函数的最前面去调用。

然后运行时系统根据新的P的最大值调整全局P列表(runtime.allp)。与全局M列表类似,该列表包含了当前运行时系统创建的所有P。运行时系统会把这些P中的可运行G全部移入调度器的可运行G队列中。这是调整全局P列表的重要前提。被转移的G会在之后的调度中再次放入某个P的可运行队列中。

当一个P不再与任何M关联并且该P的可运行G列表为空时,运行时系统就会把它放入调度器的空闲P列表(runtime.sched.pidle)。当运行时系统需要一个空闲M的时候也会首先从此列表中获取。

P的状态:

  • Pidle:空闲状态,表明当前P未与任何M关联。

  • Prunning:表明当前P正在与某个M关联。

  • Psyscall:表明当前P中运行的那个G正在进行系统调用。

  • Pgcstop:表明运行时系统需要停止调度。例如,运行时系统在开始垃圾回收的某些步骤之前,就会试图把全局P列表中的所有P都置于此状态。

  • Pdead:表明当前P已经不会再被使用。如果go程序运行过程中,调用runtime.GOMAXPROCS函数减少了P最大数量,那么多余的P就会被运行时系统置于此状态。此状态的P将被销毁。

P在创建之初的状态是Pgcstop,但这并不意味着运行时系统要进行垃圾回收。P出于这一状态的时间会非常短暂,在紧接着的初始化后,运行时系统会将其状态设置为Pidle并放入调度器的空闲P列表。

Pdead状态的P在运行时系统停止调度时都会被置于Pgcstop状态。重启调度时(如垃圾回收结束后),所有P都会被置于Pidle状态,而不是他们原来的状态。

Pgcstop状态的P都会因最大P数量的减小而被认为是多于的,并被置于Pdead状态。当P进入Pdead状态之前,该P的可运行G队列会被转移到调度器的可运行G队列,它的自由G列表会被转移到调度器的自由G列表。

每个P中有一个可运行的G队列,以及一个自由G列表。自由G列表中包含了已运行完成的G。随着已运行完成的G越来越多,该列表会不断增长。如果它增长到一定程度,运行时系统会把其中部分G转移到调度器的自由G列表。同样,当调度器发现其中的自由G太少时,会预先尝试从调度器的自由G列表中转移一些G过来。

当使用go语句启用一个G时,运行时系统会先从相应P的自由G列表中获取一个G来封装这个go语句的函数。仅当获取不到的时候,也就是调度器的自由G列表也空了,才会创建一个新的G。

G

一个G代表一个goroutine,也与go函数相对应。如你所想,G也是一个结构体,其中封装了一个go函数。

go编译器会把go语句变成对内部函数newProc的调用,并把go函数及其参数作为参数传递给newProc函数。它的作用就是创建一个G。该函数的调用会触发下面这一系列事情的发生:

  1. 检查go函数及其参数的合法性;

  2. 从本地P的自由G列表和调度器的自由G列表获取可用的G,如果没有就新建一个G;

  3. 新建的G会在第一时间加入到运行时系统的全局G列表(runtime.allgs),该列表用于存放当前运行时系统中所有G的指针;

  4. 无论用于封装当前go函数的G是否是新的,运行时系统都会对它进行一次初始化,包括关联go函数以及设置G的状态和ID等;

  5. 初始化完成后,这个G会立即被存储到本地P的runnext字段中。该字段用于存放新鲜出炉的G,以求尽早运行它;

  6. 如果runnext字段中已有一个G,那么这个已有的G会被移到该P的可运行G队列的末尾。如果该队列已满,就移到调度器的可运行G队列。

G的状态:

  • Gidle:当前G刚被分配,但还未初始化。

  • Grunnable:当前G正在可运行队列中等待运行。

  • Grunning:当前G正在运行。

  • Gsyscall:当前G正在执行系统调用。

  • Gwaiting:当前G正在阻塞。

  • Gdead:当前G正在闲置。

  • Gcopystack:当前G的栈正在被移动,原因可能是栈的扩展或收缩。

  • Gidle

  • Gscanrunnable:当前G正等待运行,同时它的栈正被扫描。扫描原因一般是GC(垃圾回收)任务的执行。

  • Gscanrunning:当前G正在运行,同时它的栈正被扫描。

  • Gscansyscall:当前G正在执行系统调用,同时它的栈正被扫描。

  • Gscanwaiting:当前G正在阻塞,同时它的栈正被扫描。

  1. 一个G在创建之初是Gidle状态。只有被初始化之后,其状态才变成Grunnable。一个G真正开始被使用是在其状态设置为Grunnabel之后。

  2. 一个G在运行过程中是否会等待某个事件以及等待什么事件,完全由其封装的go函数决定。涉及通道操作,网络I/O以及操纵定时器和调用time.sleep函数会使G进入Gwaiting状态。

  3. 事件到来之后,等待的G会被唤醒,并置于Grunnable状态,等待运行。

  4. G在退出系统调用时,运行时系统会首先尝试直接运行这个G。仅当无法直接运行时,才会把它转换为Grunnable状态并放入调度器的可运行G队列。那么为什么不是放入本地P的可运行G队列呢?因为在G进入系统调用之后,本地P就与当前M分离开了。当G退出系统调用时,本地P已经不在了,也就是说这个G没有本地P,所以只能让调度器去接纳它了。

  5. 进入死亡状态(Gdead)的G会被放入本地P或调度器的自由G列表,可以在需要的时候重新初始化并使用。相比之下,P在进入死亡状态(Pdead)之后,只能面临销毁的结局。同样是“死”,但一个去往轮回,一个魂飞魄散。

M、P、G的容器

名称 源码 作用域 说明
全局M列表 runtime.allm 运行时系统 存放所有M的一个单向链表
全局P列表 runtime.allp 运行时系统 存放所有P的一个数组
全局G列表 runtime.allgs 运行时系统 存放所有G的一个切片
调度器的空闲M列表 runtime.sched.midle 调度器 存放空闲M的一个单向链表
调度器的空闲P列表 runtime.sched.pidle 调度器 存放空闲P的一个单向链表
调度器的可运行G队列 runtime.sched.runqhead 调度器 存放可运行G的一个队列
  runtime.sched.runqtail 调度器  
调度器的自由G列表 runtime.sched.gfreeStack 调度器 存放自由G的一个单向链表
  runtime.sched.gNoStack 调度器 存放自由G的一个单向链表
P的可运行G队列 runtime.p.runq 本地P 存放当前P中可运行G的一个队列,长度为256
P的自由G列表 runtime.p.gfree 本地P 存放当前P中自由G的一个单向链表

调度器和P都有可运行的G队列,它们拥有几乎平等的运行机会。

可运行G的去向:

  1. 任何G都会存在于全局G列表中。

  2. Gsyscall状态转出的G都会被放入调度器的可运行G队列。

  3. 刚被运行时系统初始化的G都会被放入本地P的可运行G队列。

  4. Gwaiting状态转出的G,有的会被放入本地P的可运行G队列,有的会被放入调度器的可运行G队列,还有的会直接运行(刚进行完网络I/O的G)。

  5. 调用runtime.GOMAXPROCS函数后调度器会把多于的P的可运行G队列中的G全部转移到调度器的可运行G队列。

  6. 本地P的可运行G队列已满时,其中一半的G都会被转移到调度器的可运行G队列中。注意P的可运行G队列长度固定为256。

调度器的可运行G队列有两个变量,runqhead代表队列头,runqtail代表队列尾。已入队的G总是从头部取走,一般情况下,新的G会追加到队列尾,但有时也会插入到队列头部,例如runtime.GOMAXPROCS函数的调用就间接执行了此操作。

自由G的去向:

  1. 一个G转入Gdead状态后,首先会被放入本地P的自由G列表。

  2. 运行时系统在需要空闲的G来封装go函数时,会先尝试从本地P的自由G列表中获取。如果本地P的自由G列表为空,运行时系统会先从调度器的自由G列表中转移一些过来。

  3. 本地P的自由G列表已满时,运行时系统也会转移一些到调度器的自由G列表。

  4. 调度器有连个自由G列表,区别其中存放的G是否有栈。在把G放入自由G列表之前,运行时系统会检查该G的栈空间是否为初始大小。如果不是,就释放掉,让该G变成无栈的,以节约资源。而从自由G列表取出G后,运行时系统会检查它是否拥有栈,如果没有就初始化一个新的栈给它。

  5. 自由G列表的数据结构本质是栈,所以所有的自由G列表都是先进后出的。

与自由G列表对应的是调度器的空闲M列表和空闲P列表。没错,它们的数据结构也是栈,所以调度器的空闲M列表和空闲P列表也是先进后出的。它们用来存放暂时不用的M和P,等到需要时会先从这里获取。

本集的主角是M、P、G,但是“运行时系统”和“调度器”这两个概念又贯穿全篇,却也没有丝毫对他们的解释。其实它们是同一个东西。当同一个东西有了两幅面具,往往让人迷惑。你可以这样理解:完成调度需要数据和函数,它们分别从不同的角度描述了同一个功能。

地鼠宝宝的轶事奇闻之线程模型相关推荐

  1. 地鼠宝宝的轶事奇闻之并发初探

    Golang并发初探 并行与并发 并行与并发是两个不同的概念,区别在于: 并行:一起执行 并发:一起发生 并发的多个任务并不会同时执行,它们只是同时发生,然后分时执行.比如现在给你两个任务:洗碗和拖地 ...

  2. 地鼠宝宝的秩事异闻之调度器

    Golang并发机制 Golang并发机制 调度器 基本结构 一轮调度 一轮调度的时机 关于锁定 总结 全力查找可运行的G 第一阶段: 第二阶段 自旋状态 启用或停止M 系统监测任务 基本变量 系统监 ...

  3. mongodb线程池_常用高并发网络线程模型设计及MongoDB线程模型优化实践

    服务端通常需要支持高并发业务访问,如何设计优秀的服务端网络IO工作线程/进程模型对业务的高并发访问需求起着至关重要的核心作用. 本文总结了了不同场景下的多种网络IO线程/进程模型,并给出了各种模型的优 ...

  4. Redis线程模型的前世今生

    作者:vivo互联网服务器团队-Wang Shaodong 一.概述 众所周知,Redis是一个高性能的数据存储框架,在高并发的系统设计中,Redis也是一个比较关键的组件,是我们提升系统性能的一大利 ...

  5. 阻塞io阻塞io_Redis:RESP协议,阻塞IO 与非阻塞IO,Redis的线程模型

    1.Redis 阻塞IO 与非阻塞IO Java在JDK1.4 中引入了NIO ,但是也有很多人在使用阻塞IO,这两种IO有什么区别? 在阻塞模式下,如果你从数据流读取不到指定大小的数据量,IO就会阻 ...

  6. COM线程模型的行为

    原文:https://msdn.microsoft.com/library/ms809971.aspx Behavior of the COM Threading Models COM线程模型的行为 ...

  7. Android系统Surface机制的SurfaceFlinger服务的线程模型分析

    在前面两篇文章中,我们分析了SurfaceFlinger服务的启动过程以及SurfaceFlinger服务初始化硬件帧缓冲区的过程.从这两个过程可以知道,SurfaceFlinger服务在启动的过程中 ...

  8. C#高性能大容量SOCKET并发(十):SocketAsyncEventArgs线程模型

    原文:C#高性能大容量SOCKET并发(十):SocketAsyncEventArgs线程模型 线程模型 SocketAsyncEventArgs编程模式不支持设置同时工作线程个数,使用的NET的IO ...

  9. Netty实战七之EventLoop和线程模型

    简单地说,线程模型指定了操作系统.编程语言.框架或者应用程序的上下文中的线程管理的关键方面.Netty的线程模型强大但又易用,并且和Netty的一贯宗旨一样,旨在简化你的应用程序代码,同时最大限度地提 ...

最新文章

  1. aws 认证_引入#AWSCertified挑战:您的第一个AWS认证之路
  2. centOS 7 安装man中文版手册
  3. 使用jQuery的9个误区
  4. Linux 用户和用户组配置说明
  5. matlab中有哪些有趣的命令?好玩的matlab彩蛋
  6. 武科大计算机网络课程设计,【川大】计算机网络课程设计9013,奥鹏2017
  7. 13 个超炫的 Conky 配置
  8. 安装mysql后在安装目录下只有my-default.ini没有my.ini文件 解决-The MySQL server is running with the --secure-file-priv
  9. matlab hadamard(哈达玛变换)变换
  10. slf4j+logback使用
  11. 广州.NET微软技术俱乐部微信群各位技术大牛的blog
  12. 解决Linux环境下idea、webstorm等编辑器中文无效
  13. java自动发送qq消息
  14. blockUI弹出层
  15. 4.1 android 头像,微商抠图软件换头像app
  16. 触摸屏与TSC2005触摸屏控制器
  17. 华为设备如何将接口配置为中继模式_华为无线路由器怎么设置中继
  18. Mac Spotlight 聚焦搜索
  19. HTTP协议和web服务技术---Apche配置
  20. ++a与a++、--a与a--

热门文章

  1. Linux固件开发 | 几分钟看透GPT分区
  2. 模块:导入和使用标准模块,第三方模块
  3. 单页面应用——SPA
  4. PHP中curl请求无响应
  5. glob.glob() in Python
  6. mysql的锁机制,你真的了解吗?进来吧!用图表告诉你
  7. 西门子1200plc485轮询读写28个测试仪表,包括plc程序和触摸屏程序
  8. 计算机测试 原理是什么,rtk的测量原理和工作步骤是什么?
  9. React.js 学习
  10. JAVA基础经典50题