Go语言并发编程3 - 并发模型

1 Go的并发机制

1.1 CSP简介

《Communicating Sequential Processes》是计算机科学领域的“大牛”托尼.霍克于1978年发表的一篇论文,后期不断优化最终发展为一个代数理论,用来描述并发系统消息通信模型并验证其正确性。其最基本的思想是:将并发关系抽象为 Channel 和 Process 两部分,Channel 用来传递消息,Process 用于执行,Channel 和 Process 之间相互独立,没有从属关系,消息的发送和接收有严格的时序限制。Go语言主要借鉴了 Channel 和 Process 的概念,在Go中 Channel 就是通道,Process 就是 goroutine。

 1.2 Go并发简介

在操作系统提供的内核线程的基础上,Go搭建了一个特有的两级线程模型。goroutine这个特有名词是Go语言独创的,它代表着可以并发执行的Go代码片段。那么goroutine的真正含义是什么?Go语言打出的标语是这样的:

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

更确切地讲,把数据放在共享内存中以供多个线程访问,这一方式虽然在基本思想上非常简单,却使并发访问控制变得异常复杂。只有做好了各种约束和限制,才有可能让这种看似简单的方法得以正确地实施。但是,正确性往往不是我们唯一想要的,软件系统的可伸缩性也是高优先级的指标。可伸缩性越好,就越能获得计算机系统的红利,比如多核CPU。然而,一些同步方法的使用,让这种红利的获得变得困难了许多。

Go不推荐使用共享内存的方式传递数据,而推荐使用 channel(通道)。channel 主要用来在多个 goroutine 之间传递数据,并且还会保证整个过程的并发安全性。不过,作为可选方法,Go依然保留了一些传统的同步方法(比如互斥锁、条件变量等)。

Go的并发机制指的是用于支撑 goroutine 和 channel 的底层原理。

2 线程实现模型 — GMP模型

Go语言虽然使用一个Go关键字即可实现并发编程,但goroutine被调度到后端之后,具体的实现比较复杂。先看看调度器有哪几部分组成。

说起Go的线程实现模型,有3个必知的核心元素:GMP模型,它们支撑起了这个模型的主框架,简要说明如下:

1、G

G 是 goroutine 的缩写,相对于操作系统中的进程控制块,在这里是 goroutine的控制结构,是对goroutine 的抽象。其中包括执行的函数指令及参数;G保存的任务对象;线程上下文切换,现场保护和现场

恢复需要的寄存器(SP、IP)等信息。一个G 代表一个 Go代码片段。前者是对后者的一种封装。

2、M

M 是 Machine 的缩写。一个 M 代表一个内核线程,或称“工作线程”,是操作系统层面调度和执行的实体。M仅负责执行,M不停地被唤醒或创建,然后执行。M启动时进入的是运行时的管理代码,由这段

代码获取G 和 P 资源,然后执行调度。另外,Go语言运行时会单独创建一个监控线程,负责对程序的内存、调度等信息进行监控和控制。

线程想运行G任务就得获取P,然后从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

3、P

P 是 Process 的缩写。P代表M运行G所需要的资源,是对资源的一种抽象和管理,P不是一段代码实体,而是一个管理的数据结构,P主要是降低M管理调度G的复杂性,增加一个间接的控制层数据结构。

把P看做资源,而不是处理器,P 控制Go代码的并行度,它不是运行实体。P 持有 G 的队列,P可以隔离调度,解除 P 和 M的绑定就解除了 M 对一串 G 的调用。P在运行模型中只是一个数据模型,而不是程序

控制模型,理解这一点很重要。

P决定了同时可以并发执行任务的数量,可通过GOMAXPROCS限制同时执行用户级任务的操作系统线程数。可以通过runtime.GOMAXPROCS进行指定。在Go1.5之后GOMAXPROCS被默认设置可用的

CPU核心数,而之前则默认为1。

GMP三者的关系

简单来说,一个G的执行需要 P 和 M 的支持。一个M在与一个P关联之后,就形成了一个有效的G运行环境(内核线程+上下文环境)。每个P都有一个本地的可调度的 G 队列,队列里的 G 会被与P关联的

M 依次调度执行,如果本地队列空了,则会去全局队列偷取一部分 G,如果全局队列也是空的,则去其他的 P 中偷取一部分G,这就是 Working Stealing 算法的基本原理。

需要注意的是,G并不是执行体,而是用于存放并发执行体的元信息,包括并发执行的函数的入口地址、堆栈、上下文等信息。G由于保存的是元信息,为了减少对象的分配和回收,G 对象是可以复用的,

只需将相关元信息初始化为新值即可。M仅负责运行,M启动时进入运行时的管理代码,这段管理代码必须拿到可用的 P 后,才能调度执行。P的数目默认是CPU核心的数量,可以通过runtime.GOMAXPROCS 

函数设置或查询,M 和 P 的数目差不多,但运行时会根据当前的状态动态地创建M,M有一个最大值上限,目前是10 000。G 与 P 是一种 M:N 的关系,M可以成千上万,远远大于N。

 在Go语言中,线程才是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上。如下图所示:

P队列

P 有两种队列:本地队列和全局队列。

  • 本地队列: 当前P的队列,本地队列是Lock-Free,没有数据竞争问题,无需加锁处理,可以提升处理速度。本地队列存放G的数量有限,不超过256个。新建G时,G优先加入到P的本地队列中,如果队列已满,则会把本地队列中一半的G移到全局队列中。
  • 全局队列:全局队列为了保证多个P之间任务的平衡。所有M共享P全局队列,为保证数据竞争问题,需要加锁处理。

P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS个,它是可配置的。

说明》goroutine 调度器 和 OS 调度器是通过 M 结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。

3 Go语言调度器

操作系统的进程和线程

当运行一个应用程序(如一个 IDE 或者编辑器)的时候,操作系统会为这个应用程序启动一个进程。可以将这个进程看作一个包含了应用程序在运行中需要用到和维护的各种资源的容器。

下图展示了一个包含所有可能分配的常用资源的进程。这些资源包括但不限于内存地址空间、文件和设备的句柄以及线程。一个线程是一个执行空间,这个空间会被操作系统调度来运行函数中所写的代

码。每个进程至少包含一个线程,每个进程的初始线程被称作主线程。因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。操作系统将线程调度到某个处理器上行,

这个处理器并不一定是进程所在的处理器。不同操作系统使用的线程调度算法一般都不一样,但是这种不同会被操作系统屏蔽,并不会展示给程序员。


                                                           图1  一个运行的应用程序的进程和线程的简要描绘

Go调度器调度过程

操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度goroutine来运行。每个逻辑处理器都分别绑定到单个操作系统线程。在 1.5 版本及之上版本中,Go语言的运行时默

认会为每个可用的物理处理器分配一个逻辑处理器。在 1.5 版本之前的版本中, 默认给整个应用程序只分配一个逻辑处理器。这些逻辑处理器会用于执行所有被创建的goroutine。即便只有一个逻辑处理器,

Go也可以以神奇的效率和性能,并发调度无数个goroutine。

在下图的图2中,可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一个 goroutine 并准备运行,这个 goroutine 就会被放到调度器的全局运行队列中。之后,调度器就将这些队列

中的 goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的goroutine会一直等待直到自己被分配到逻辑处理器上执行。


                                                                         图2-Go调度器如何管理goroutine

有时, 正在运行的 goroutine 需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和 goroutine 会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻

辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个 goroutine 来运行。一旦被阻塞的系统调用执行完成并返回,

对应的 goroutine 会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。

如果一个 goroutine 需要做一个网络 I/O 调用,流程上会有些不一样。在这种情况下, goroutine会和逻辑处理器分离,并移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或者写操作已经就

绪,对应的 goroutine 就会重新分配到逻辑处理器上来完成操作。Go调度器对可以创建的逻辑处理器的数量没有限制,但Go语言运行时默认限制每个程序最多创建 10 000 个线程。这个限制值可以通过调用

runtime/debug 包的 SetMaxThreads()方法来更改。如果程序试图使用更多的线程,就会崩溃。

几个概念的理解

上下文切换

简单地理解就是当前的运行环境即可。环境包括当前程序的运行状态以及变量状态。例如,线程切换的时候在内核会发生上下文切换,这里的上下文就包括了当前寄存器的值,把寄存器的值保存起来,等下次该线程又得到CPU时间片的时候再恢复寄存器的值,这样线程才能继续正确运行。

对于代码中的某个变量值来说,上下文是指这个值所在的局部(全局)作用域对象。相对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等。

线程清理

Goroutine被调度执行必须保证P 和 M进行绑定,所以线程清理只需要将P释放就可以实现线程的清理。什么时候P会释放,保证其它G可以被执行。P被释放主要有两种情况。

  • 主动释放:最典型的例子是,当执行G任务时有系统调用,当发生系统调用时M会处于Block状态。调度器会设置一个超时时间,当超时时会将P释放。

  • 被动释放如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的P/M组合。当超过系统程序设置的超时时间,会自动将P资源抢走。去执行队列的其它G任务。

并发(concurrent)不是并行(Parallelism)

并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。在很多情况下,并发的效果

比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情” 的哲学,也是指导 Go 语言设计的哲学。

如果希望让 goroutine 并行,必须使用多于一个的逻辑处理器。 当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。不过要想真的实现并

行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go 语言运行时使用多个线程, goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

下图的图3展示了在一个逻辑处理器上并发运行goroutine和在两个逻辑处理器上并行运行两个并发的 goroutine 之间的区别。调度器包含一些聪明的算法,这些算法会随着 Go 语言的发布被更新和改进,所

以不推荐盲目修改语言运行时对逻辑处理器的默认设置。如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数进行细微调整。

                                                                                       图3-并发和并行的区别

P 和 M 的个数问题

1、P的数量

由启动时环境变量$GOMAXPROCS或者是由runtime.GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。

 2、M的数量

go语言本身的限制:go程序启动时,会设置M的最大数量,默认10 000。但是内核很难支持这么多的线程数,所以这个限制可以忽略。也可以在runtime/debug中的SetMaxThreads()函数中设置M的最大数

量。一个M阻塞了,会创建新的M。M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换到另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。

什么时候创建M、P、G

在程序启动的过程中会初始化空闲P列表,P是在这个时候被创建的,同时第一个G也是在初始化过程中被创建的。后续再有go并发调用的地方都有可能创建G,由于G只是一个数据结构,并不是执行实体,所以G是可以被复用的。在需要G结构时,首先要去P的空闲G列表里面寻找已经运行结束的goroutine,其G会被缓存起来。

每个并发调用都会初始化一个新的G任务,然后唤醒M去执行任务。这个唤醒不是特定唤醒某个线程去工作,而是先尝试当前线程M,如果无法获取,则从全局调度的空闲M队列中获取可用的M,如果没有可用的,则新建M,然后绑定P和G进行运行。所以M和P不是一一对应的,M 是按需分配的,但是运行时会设置一个上限值(默认是10 000),超出最大值将导致程序崩溃。

<注意> 创建新的M 有一个自己的栈g0,在没有执行并发程序的过程中,M 一直都是在g0栈中工作的。M 一定要拿到P才能执行,G、M 和 P 维护着绑定关系,M 在自己的堆栈g0上运行恢复 G 上下文的逻辑。完成初始化后,M 从g0栈切换到 G 的栈,并跳转到并发程序代码点开始执行。

M 线程里有管理调度和切换堆栈的逻辑,但是M 必须拿到P后才能执行,可以看到M是自驱动的,但是需要P 的配合。这是一个巧妙的设计。

4 调度器的设计策略

 复用线程:避免频繁地创建、销毁线程,而是对线程的复用。

1)work stealing 机制

当本线程无可运行的G时,M会尝试从其他线程绑定的P偷取G,而不是销毁线程。

2)hand off 机制

当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

利用并行

GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核心进行并行。

抢占式调度

1、抢占调度的原因

(1)不让某个G长久地被系统调用阻塞,阻碍其他G的运行。

(2)不让某个G一直占用某个M 不释放。

(3)避免全局队列里的G得不到执行。

2、抢占调度的策略

(1)在进入系统调用(syscall)前后,各封装一层代码检测 G 的状态,当检测到当前 G 已经被监控线程抢占调度,则 M 停止执行当前G,进行调度切换。

(2)监控线程经过一段时间检测感知到P运行超过一定时间,取消 P 和 M 的关联,这也是一种更高层次的调度。

(3)监控线程经过一段时间检测感知到 G 一直运行,超过了一定时间,设置 G 标记,G执行栈扩展逻辑检测到抢占标记,根据相关条件决定是否抢占调度。

5 go func() 调度流程

从上图的流程图可以分析出几个结论:

1、我们通过 go func()来创建一个goroutine。

​2、有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了,则会保存在全局的G队列中。

3、G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会向其他的 M/P 组合偷取一个可执行的G来执行。

4、一个M调度G执行的过程是一个循环机制。

5、当M执行某一个G时候如果发生了系统调用(syscall)或者其它阻塞操作,M就会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的OS线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P。

6、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

6 调度器的生命周期

特殊的M和G

1、M0

Go语言中还有特殊的M 和 G,它们是 m0 和 G0。m0是启动程序后编号为0的主线程,这个m对应的信息会存放在全局变量runtime.m0中,不需要在堆中分配。m0负责初始化操作和启动第一个G,之后m0就和其他的M一样了。

2、G0

每个M都会有一个自己的管理堆栈g0,g0不指向任何可执行的函数,g0仅在M执行管理和调度逻辑时使用。在调度或系统调用时会切换到g0的栈空间,全局变量的g0是m0的g0。

Go启动初始化过程

(1)分配和检查栈空间。

(2)初始化参数和环境变量。

(3)当前运行线程标记为m0,m0是程序启动的主线程。

(4)调用运行时初始化函数 runtime.schedinit 进行初始化。主要是初始化内存空间分配器、GC、生成空闲P列表。

(5)在m0上调度第一个G,这个G运行 runtime.main 函数。runtime.main 会拉起运行时的监控线程,然后调用 main 包的 init() 初始化函数,最后执行main()函数。

参考

[典藏版]Golang调度器GMP原理与调度全分析

《Go并发编程实战(第2版)》

《Go程序设计语言》

《Go语言核心编程》

《Go语言实战》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值