Goroutine(一)Goroutine的基本引入和介绍,并发并行的理解

对于go的学习到了goroutine了,但是发现gorutine如果需要理解,是需要很多基础知识的,需要构建一个基础框架。

并发编程

首先,我们需要理解什么是并发,什么是并行,对于一个学计算机的人来说,这个概念应该并不陌生,我记得有一次面试,被老师问到这个问题,并发和并行的区别是什么,多线程多进程的区别是什么?怎么样回答的短而精辟呢,现在我似乎知道了答案。Go语言之父Rob Pike说过这样一句话:
并发不是并行,并发关乎结构,并行关乎执行
如何理解并发关乎结构,并行关乎执行呢,有一个比较经典的例子:模拟机场安检

排队旅客(passenger):代表应用的外部请求。
机场工作人员:代表计算资源。
安检程序:代表应用,必须在获取机场工作人员后才能工作。模拟安检例子中,安检程序内部流程包括登机身份检查(idCheck)、人身检查(bodyCheck)和X光机对随身物品的检查(xRayCheck)。
安检通道(channel):每个通道对应一个应用程序的实例。

顺序设计:
在这里插入图片描述
在这里插入图片描述
并行设计:在这里插入图片描述
并发方案:由并行方案我们知道,限制检验速度的已经是安检程序了,因为对于某一个通道来说,假设安检工作人员在检查id(即使计算资源足够多,也就是安检人员足够多),这个通道的其他资源也是空闲的,这个时候需要调整应用结构了(并发在于结构)。
在这里插入图片描述
在上图,模拟开启了三个通道,每个通道创建了三个工作人员,分别负责处理id,body,xray,其实这已经和现实生活的地铁安检十分接近了(我觉得计组的流水线也是这个思想,都是一样的思想),此时可以看到,每个单元都可以充分的利用,每个时间单位可以出来一个人。如果计算资源不足,最差也会回退到顺序设计的水平。
由上述例子可以看到,对于并行,我们看的是程序的执行阶段,而对于并发,我们看的是程序的设计和实施阶段,也就是程序的结构

Go语言的先天并发优势

go语言的设计哲学:原生并发,轻量高效
我们了解到并发是一种能力,他让你的程序可以由若干个代码片段组合而成(类似于每一步的工作人员),并且每个片段都是独立运行的,Go语言原生支持这种并发能力,而goroutine恰是go原生支持并发的具体实现,传统编程语言并非面向并发设计。而Go和其他传统语言的区别在这里就体现出来了:
对于传统编程语言来说,多以操作系统现场作为承载分解后的代码片段(模块的执行单元),由操作系统调度(操作系统线程的创建销毁线程间的上下文切换代价都很大),
而go则是实现了gorutine这一由go运行时负责调度的用户层轻量级线程为并发程序提供原生支持。

有以下优点:

  • 资源占用小
  • go运行时而不是操作系统调度上下文切换代价较小
  • 语言原生支持
  • 语言内置channel作为通信原语

Gorutine基本原理

首先,Gorutine是什么呢?它是由go运行时管理的轻量级线程(我的理解是go语言的协程),对于协程的概念,这里作个补充:
协程在行为逻辑上和线程、进程类似,都是实现不同逻辑流的切换和调度。但要明确的是,协程(Coroutine)编译器级的,进程(Process)和线程(Thread)操作系统级的,也可以理解为轻量级的线程,它其实就是用户态的线程起的一个名字。
在这里插入图片描述

Goroutine调度器

由于一个goroutine占用资源很少(几KB),线程几MB,进程可能几GB,一个go程序可以创建成千上万个并发的goroutine,而将这些goroutine按照一定算法放到cpu上执行的程序就叫goroutine调度器,一个go程序对于操作系统只是一个用户层程序(进程),操作系统眼中只有线程,goroutine的调度靠Go自己完成.

GM模型

最开始的简单goroutine调度器就是GM模型,其中的G为一个goroutine,而被视为物理cpu的操作系统线程则被抽象为M,这个模型比较简单且可以正常工作。M(内核线程)从加锁的Goroutine队列中获取G(协程)执行,如果G在运行过程中创建了新的G,那么新的G也会被放入全局队列中。
在这里插入图片描述
有以下缺点:

  • 单一全局互斥锁和集中状态存储的存在导致goroutine的相关操作都要上锁(创建销毁G都需要每个M获取锁)
  • goroutine传递问题,经常在M之间传递会导致调度延时大的开销(时间片到了,G需要移出M)
  • 系统调用(cpu在M之间的转换导致了线程阻塞和解除阻塞的开销)

GPM模型

发现了GM模型的不足后,便改进了调度器,在GO1.11版本中实现了GPM模型
有人曾经说过,计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决
同样,这里也采用的该思想
在这里插入图片描述
P是一个逻辑处理器,一个G想要运行,先需要分配一个P,即进入P的本地运行队列,对于G来说,P就是他的”cpu“。G的眼里只有p,但从调度器的视角来看,真正的cpu是M,只要绑定P和M才能让P的本地运行队列G运行起来,这样P与M的关系就类似于Linux操作系统用户线程与内核线程对于的关系,多对多

G、P、M介绍

关于G、P、M的定义,在/src/run-time/runtime2.go这个源文件中

G

Goroutine 是 Go 语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。
Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。
Goroutine 在 Go 语言运行时使用私有结构体 runtime.g 表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量。我们重点看下面这几个字段
G代表goroutine,存储了goroutine的执行栈信息,goroutine状态以及任务函数,G对象是可重用的,协程的信息都在G结构中

type g struct {
    stack       stack // stack 字段描述了当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)
    stackguard0 uintptr // 可以用于调度器抢占式调度, 检查栈空间是否足够的值, 低于这个值会扩张栈, 0是go代码使用的
  
	stackguard1 uintptr //   // 检查栈空间是否足够的值, 低于这个值会扩张栈, 1是原生代码(c语言)使用的
    preempt       bool // 抢占信号
    preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
    preemptShrink bool // 在同步安全点收缩栈
    _panic       *_panic // 最内侧的 panic 结构体
    _defer       *_defer // 最内侧的延迟函数结构体
    m              *m // 当前 Goroutine 占用的线程,可能为空
    sched          gobuf //  存储 Goroutine 的调度相关的数据;
    atomicstatus   uint32 //  Goroutine 的状态
    goid           int64 //  Goroutine 的 ID,该字段对开发者不可见
}

在这里插入图片描述
G实际分为四类:

  • 主协程,用来执行用户main函数的协程
  • 主协程创建的协程,也是P调度的主要成员
  • G0,每个M都有一个G0协程,他是runtime的一部分,G0是跟M绑定的,主要用来执行调度逻辑的代码,所以不能被抢占也不会被调度(普通G也可以执行runtime_procPin禁止抢占),G0的栈是系统分配的,比普通的G栈(2KB)要大,不能扩容也不能缩容
    G0介绍
    g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。
  • sysmon协程,sysmon协程也是runtime的一部分,sysmon协程直接运行在M不需要P,主要做一些检查工作如:检查死锁、检查计时器获取下一个要被触发的计时任务、检查是否有ready的网络调用以恢复用户G的工作、检查一个G是否运行时间太长进行抢占式调度。
    sysmon介绍
    Go Runtime 在启动程序的时候,会创建一个独立的 M 作为监控线程,称为 sysmon,它是一个系统级的 daemon 线程。这个sysmon 独立于 GPM 之外,也就是说不需要P就可以运行,因此官方工具 go tool trace 是无法追踪分析到此线程( 源码),在程序执行期间 sysmon 每隔 20us~10ms 轮询执行一次( 源码),监控那些长时间运行的 G 任务, 然后设置其可以被强占的标识符,这样别的 Goroutine 就可以抢先进来执行。
    它的任务有:
  • 释放闲置超过5分钟的span物理内存;
  • 如果超过2分钟没有垃圾回收,强制执行;
  • 将长时间未处理的netpoll结果添加到任务队列;
  • 向长时间运行的G任务发出抢占调度;
  • 收回因syscall长时间阻塞的P。
P

P代表逻辑processor,p的数量决定了系统内最大可并行的G的数量(前提,系统物理cpu>=p的数量),p中最有用的是拥有的各种G对象队列、链表、一些缓存
P是用来调度G的执行,所以每个P都有自己的一个G的队列,当G队列都执行完毕后,会从global队列中获取一批G放到自己的本地队列中,如果全局队列也没有待运行的G,则P会再从其他P中窃取一部分G放到自己的队列中。而调度的时机一般有三种:

  • 主动调度,协程通过调用runtime.Goshed方法主动让渡自己的执行权利,之后这个协程会被放到全局队列中,等待后续被执行
  • 被动调度,协程在休眠、channel通道阻塞、网络I/O堵塞、执行垃圾回收时被暂停,被动式让渡自己的执行权利。大部分场景都是被动调度,这是Go高性能的一个原因,让M永远不停歇,不处于等待的协程让出CPU资源执行其他任务。
  • 抢占式调度,这个主要是sysmon协程上的调度,当发现G处于系统调用(如调用网络io)超过20微秒或者G运行时间过长(超过10ms),会抢占G的执行CPU资源,让渡给其他协程;防止其他协程没有执行的机会;(系统调用会进入内核态,由内核线程完成,可以把当前CPU资源让渡给其他用户协程)
M

M:Go 语言并发模型中的 M 是操作系统线程。代表真正的执行计算机资源,调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。在绑定有效的P后,进入一个调度循环;而调度循环的机制大致是从各种队列、P的本地运行队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M。如此反复。M并不保留G状态,这是G可以跨M调度的基础。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以在程序中使用 runtime.GOMAXPROCS 来改变最大的活跃线程数
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。Go 语言的运行时会通过 runtime.startm 启动线程来执行处理器 P,如果我们在该函数中没能从闲置列表中获取到线程 M 就会调用 runtime.newm 创建新的线程.
Go 语言会使用私有结构体 runtime.m 表示操作系统线程,这个结构体也包含了几十个字段,这里先来了解几个与 Goroutine 相关的字段:

type m struct {
        g0   *g
        curg *g
        ...
}

其中 g0 是持有调度栈的 Goroutine,curg 是在当前线程上运行的用户 Goroutine,这也是操作系统线程唯一关心的两个 Goroutine。
在这里插入图片描述
M大致分为三类:

  • 普通M,用来与P绑定执行G中任务
  • m0:Go程序是一个进程,进程都有一个主线程,m0就是Go程序的主线程,通过一个与其绑定的G0来执行runtime启动加载代码;一个Go程序只有一个m0
  • 运行sysmon的M,主要用来运行sysmon协程。

具体GPM调度见第二章:Gorutine(二)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值