什么是 Goroutine?
在Go语言中 是通过 ‘协程’ 来实现并发, Goroutine 是 Go 语言特有的名词, 区别于进程 Process, 线程Thread, 协程 Coroutine, 因为 Go语言的作者们觉得是有所区别的,所有专门创造做 Goroutine.
Goroutine 是与其他函数或方法同时运行的函数或方法。 Goroutine 可以被认为是 轻量级的线程,于线程相比 创建 Goroutine 的成本很小,他就是一段代码,一个函数入口 以及在堆上分配的一个堆栈(初始大小为 4k, 会随着程序的执行 自动增长删除)。 因此它非常廉价。GO应用程序可以并发运行着数千个 Goroutine.
Goruotine在线程上的优势:
- 与线程相比,Goroutine非常便宜,它只是堆栈大小的几个KB, 堆栈可以根据应用程序的需要增长或删除。而在线程的情况下。堆栈大小必须指定 并且固定。
- Goroutine 被多路复用 到较少的OS线程。在一个程序中可能只有一个线程和数百个Goroutines。如果线程中的任何 Goroutine 都表示等待用户输入,则会创建另一个OS线程,剩下的Goroutines 被转移到新的 OS线程。所有这些都是运行时进行处理的。我们作为程序编写者从这些复杂的 细节中抽象出来, 并得到一个只与并发工作相关的 干净API
- 当使用Goroutine 访问共享内存时,通过设计好的通道 可以防止竞态条件的发生,通道可以被认为是 Goroutine 的通信管道
主Goroutine:
封装main函数的Goroutine 称为 主Goroutine,
主Goroutine 所做的事情并不是执行main函数这么简单,它首先要做的是:假设每一个Goroutine 所能申请的栈控件的最大尺寸,在32位计算机系统中 次尺寸为 256MB,而64位计算机中的尺寸为1GB, 如果有某个 goroutine 的栈空间尺寸大于这个限制,那么运行时系统就会引发一个 栈溢出(stack overflow)的运行时错误,随后 这个Go 程序也会终止。
此后 主goroutine 会进行一系列的初始化工作, 涉及的工作内容大致如下:
- 创建一个特殊的 defer语句,用于在主 goroutine 退出时要做的必要处理。应为主 goroutine 也可能非正常的结束
- 创建专用于在后台清扫垃圾内存的 gorontine ,并设置 GC 可用的标识
- 执行main 包中的 init 函数
- 执行main 函数,执行main 函数之后,它还会检查主 goroutine 是否引发了 运行时错误,并进行必要的处理。最后主 gorontine 才会结束自己以及当前运行的进程
如何使用 goruntine:
在函数 或者方法调用前 加上关键字go, 你将会同时运行一个新的 goroutine.
package mianimport("fmt" )func hello(){fmt.Println("hello world goroutine") }func main(){go hello()fmt.Println("hello main func") }
运行结果: 可能会输出:hello main func
Goroutine 执行的规则:
- 当新的 goroutine 开始时, goroutine 调用调用立即返回。 与函数不同 go 关键字 不等待 gorontine执行结束。 当goroutine 调用时 goroutine 的人哈返回值都会被忽略。 公会立即执行到下一行代码。
- main 中的 goroutine 应该为其他 goroutine 执行。如果main的 goroutine 终止了,程序也将被终止。 而其他 Goroutine 将不会被运行。
Go 语言的并发模型(原理)
Go 语言相比 java c# 语言的一个很大优势 就是可以很方便的编写 并发程序,Go语言内置了 Goroutine 机制,使用 goroutine 可以快速编写出优秀的并发程序。 更好的利用处理器资源,接下来我们来了解 Go语言的并发原理。
线程模型:
在现在操作系统中,线程是处理调度分配计算机资源的基本单位。进程则作为资源拥有基本单位。每个进程是由虚拟的私有地址空间 代码 数据 和其他各种系统资源组成。 线程是进程内部的一个执行单元。每一个进程至少有一个主执行线程,它无需由用户主动去创建,是由系统自动创建。 用户根据需要在应用程序中创建其他线程。多个线程并发的运行同一个进程中。
我们先从线程讲起, 无论语言层面何种并发模型。到了操作系统层面 一定是以线程的形态曾在。而操作系统根据资源的访问权限不同。体系架构可以分为 用户空间 和 内核空间; 内核空间主要操作访问 CPU 资源, I/O资源,内存资源等硬件资源, 为上层应用程序提供最基本的基础资源。 用户空间 就是上层应用程序固定活动的空间, 用户空间不可以直接访问资源。必须通过 系统调用, 函数库, 或者 shell 脚本 来调用内核空间提供的资源
我们现在计算机语言 可以狭义的认为是一种 软件,他们中所谓的 线程 往往是 用户态的线程, 和 操作系统本身内核态的线程(简称KES)还是有很大区别的
Go 并发编程模型在 底层是由操作系统提供的线程库支撑的,因此还是得用线程实现模型说起:
线程可以视为进程中的控制流:一个进程至少会包含一个线程,因此其中至少有一个控制流 持续运行,因而 一个进程的 第一个线程会随着这个进程启动而创建。这个线程称为改进程的主线程。当然一个进程也可以包含多个线程。这些线程都是当前进程已经存在的线程创建的。创建的方法就是调用系统调用,更确切的说就是调用 pthread create函数,拥有多个线程的进程可以并发执行多任务,并且计时某个 或 某些任务被阻塞,也不会影响其他线程的执行,这可以大大改善程序的响应时间和吞吐量 另一方面线程不可以独立于进程的存在。他的生命周期不可能逾越其所在的进程生命周期
线程的实现模型主要有三个,分别是:用户级线程模型,内核级线程模型,两级线程模型。他们之间最大的差异就在于 线程与内核调度实体(Kerner Scheduling Entity 简称KSE)之间的对应关系上。顾名思义 内核调度实体就是可以内内核调用调度的对象。很多文献和书中被称为 内核级线程,也就是操作系统上最小调度单元
内核级线程模型:
用户线程与KSE是1对1关系(1:1)。大部分编程语言的线程库(如 linux的 pthread, java 的 java.lang.Thread ,C++11 的 std::Thread 等等)都是对操作系统线程(内核线程)的一层封装,创建出来的每一个线程与一个不同的 KSE静态关联,因此其调度完全有OS 调度器来操作。这种方式实现简单 直接借助OS提供的线程能力,并且不同用户线程之间一般也不会相互影响。但其创建 销毁 以及多个线程之间的上下文切换等操作 都是由OS 层面来做得。 在需要使用大量线程的情况下对 OS 的性能影响会比较大。 每个线程都由内核调度器独立的调度,所有如果一个线程阻塞 则不影响其他的线程。
优点:
在多核处理器的硬件支持下,OS内核空间线程模型支持了真正并行,当一个线程被阻塞后,容许另一个线程继续执行,所有并发能力较强
缺点:
每创建一个用户级线程都需要创建一个内核级别线程与之对应,这样线程开销会比较大,会影响到应用程序的性能
用户级线程模型:
用户线程模型 与 KSE 是多对1 关系(M:1), 这种线程的创建,销毁以及多个线程之间的协调等操作都需要用户自己实现的线程库来负责,对于内核透明 一个进程中的所有创建的线程都与 KSE 在运行时动态关联。现在有许多语言实现的 协程 基本上都属于这种方式。这种实现方式相比内核级别线程可以做到很轻量级, 对系统资源的消耗会小很多。因此可以创建的数量与上下文切花所 花费的资料也会小很多,但是这个模型有一个致命的缺点,如果我们的用户线程上调用了阻塞调用(read 网络 IO操作等等),那么一旦 KSE 因阻塞被内核调度出 CPU 的话,剩下的所有对用户的线程都会变为阻塞状态。(整个进程挂起)。
所以这些语言的协程库 会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在自己阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的线程在改SKE上运行,从而避免内核调度器由于 SKE 阻塞而做上下文切换,这样整个进程也不会被阻塞
![]()
优点:
这种模型的好处是 线程上下文切换都发生在用户空间,避免模态切换(mode switch),从而对于性能有积极的影响。
缺点:
所有的线程基于一个内核调度器实体,这意味着只有一个处理器可以被利用,在多处理器环境下 这种是不能接受的。本质上 用户线程只能解决并发的问题,但是没有解决并行问题。如果线程应为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都会被阻塞,用户空间也可以使用非阻塞 I/O 但是不能避免性能以及复杂问题
两级线程模型:
用户线程与 KSE 是多对多关系 (M:N), 这种实现综合了前两种模型的优点,为一个进程中创建多个KSE,并且线程可以与不同的KSE在运行时进行动态关联,当某个KSE由于其上工作的线程的阻塞操作被内核调度出CPU 时,当前与其关联的其余用户线程可以重新与其KSE建立关系。当然这种动态关联的实现很复杂,也需要用户自己去实现。这算一种缺点吧,Go 语言中的并发就是使用这种方式实现。Go为了实现该模型自己实现了一个运行时调度器来负责Go中的 “线程” 与KSE的动态关联。此模型有时也被成为 混合型线程模型,即用户调度器实现用户线程 到 KSE的 “调度”,内核调度器实现KSE到 CPU上的调度。
Go并发调度器:G-P-M模型
在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。 goroutine 机制实现了 M:N 的 线程模型, gorouttine 机制协程 coroutine 的一种实现,golang 内置调度器,可以让多核CPU 中的每一个 CPU 执行一个协程。
调度器是如何工作的
有了上面的知识,我们可以开始真正的介绍Go的并发机制,先用一段代码展示一下再 Go 语言中新建一个 “线程”(Go 语言中称为 goroutine)的样子
// 用 go 关键字加上一个函数 (这里是匿名函数) // 调用就做到了在 一个新的 “线程” 并发执行任务go func(){// do something in on new goroutine }{}
功能等价于 Java8 的代码:
new java.lang.Thread(()->{//do something in one new thread }).start();
理解 goroutine 机制的原理, 关键是理解 Go 语言 scheduler 的实现。
Go语言中支撑整个 scheduler 实现的主要有四个重要结构,分辨是M ,G , P , Sched, 前三个定义在 runtime.h 中, Sched 定义在 proc.c 中。
- Sched 结构就是 调度器,它维护有存储 M 和 G的列队以及调度器的一些状态信息等。
- M 结构就是 Machine, 系统线程,它由操作系统管理, goroutine 就是跑在M之上的,M是一个很大的结构,里面维护小对象 cache (mcache), 当前执行的 goroutine, 随机数发生器等等 非常多的信息
- P 结构是 Processor, 处理器 它的 主要用途就是用来执行 goroutine的, 它维护了一个 goroutine 列队,即 runqueue, Processor 是让我们冲 N:1 调度到 M:N 的重要部分
- G 是 goroutine 实现的核心结构,它包含了栈,指令指针,以及其他对调度goroutine重要信息,例如阻塞的 channel
Processor 的数量实在启动时被设置为环境变量的 GOMAXPROCS 的值,或者通过运行时调用函数 GOMAXPROCS() 进行设置。Processor 数量固定意味着任意时刻只有GOMAXPROCS 个线程在运行 go 代码。
我们分布使用三角形, 矩形, 圆形 表示 Machine Processor 和 goroutine
- 在单核处理器场景下:所有的 goroutine 运行在同一个M系统线程中,每一个M系统线程维护一个 processor, 任何时刻 一个 processor 中只有一个 goroutine. 其他 grountine 在 runqueque 中等待。 一个 goroutine 运行完自己的时间片之后, 让出上下文。 回到 runqueue中,
- 多核处理器场景下:为了运行goroutine 每个M系统线程都会持有一个 processor
正常情况下,Scheduler 会按照上面的流程进行调度,但是线程会发生阻塞等情况,看一下 goroutine 对线程阻塞的处理:
线程阻塞
当正在运行的 goroutine 发送阻塞时:例如进行系统调用时,会创建一个(或者直接使用空闲的)系统线程 M1, 当前的 M0 线程放弃了它的 processor, P 转到新的线程M1中去运行。 M0 继续执行阻塞任务 g0,这样就不会 阻塞后面的 goroutine。 这样 所有的 goroutine 都会得到执行。
runqueue执行完成
当其中一个 processor 的 runqueue 的列队执行完毕,列队为 空,没有 goroutine 可以调度,他会从另一个上下文字获取一半的 goroutine。
![]()
图中的G, P, 和 M 都是 Go 语言运行时系统, 其中包括内存分配 并发调度器 垃圾收集等组件, 可以想象为 Java中的 VM, 抽象出来概念和 数据结构对象:
- G : goroutine 的简称,上面用 go 关键字加函数调用代码 就是创建一个 G 对象,是对一个重要并发的任务封装, 也可以称为用户态线程。 属于用户级资源, 对 OS 透明,具备轻量级, 可以大量创建 上下文切换成本低等优点