Go 入坑 之 协程(goroutine)

本文参考:
《Go语言四十二章经》
《深度探索Go语言》- 封幼林
原文链接:
https://github.com/ffhelicopter/Go42

无情的缝合机器,内容非原创,只作为学习笔记用


前置知识:
并发: 指的是程序的逻辑结构。如果程序代码结构中的某些函数逻辑上可以同时运行,但物理上未必会同时运行。
并行: 并行是指程序的运行状态。并行则指的就是在物理层面也就是使用了不同CPU在执行不同或者相同的任务。

Go 的并发

goroutine是Go语言提供的一种用户态线程,一般称为协程。
所谓协程,可以理解为轻量的线程。

补充小知识 ( 进程 & 线程 & 协程) :

进程:是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
线程:又叫做轻量级进程,是进程的一个实体,是处理器任务调度和执行的基本单位位。它是比进程更小的能独立运行的基本单位。线程只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。


实际例子:
对于操作系统来说,一个任务就是一个进程(Process)。比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。
有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,进程内的这些“子任务”称为线程(Thread)。


协程: 又称微线程,是一种用户态的轻量级线程,协程的调度完全由用户控制(也就是在用户态执行)。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到线程的堆区,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。


协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和线程切换相比,线程数量越多,协程的性能优势就越明显。不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。此外,一个线程的内存在MB级别,而协程只需要KB级别。


三者比较图:
Go 入坑 之 协程(goroutine)_第1张图片

注: 本段内容引用以下文章,若侵权请联系我删除
什么是进程、线程和协程?
一文快速了解进程、线程与协程

说到 Go 语言的调度系统,GMP 调度模型一定不陌生。那 GMP 分别代表的是什么?

  • G:指的是 goroutine
  • M:指的是工作线程(Machine 的缩写)
  • P:指的是处理器 Processor,代表一组资源,M 要执行 G 的代码,必须持有一个 P 才可以

简单点说,GMP 就是 Task、Worker、Resource 的关系。G 和 P 都是 Go 语言实现的抽象度更高的组件,而对于工作线程而言,Machine 一词表明了它与具体的操作系统、平台密切相关,对于具体平台的适配、特殊处理等大多数都是在这一层实现。

其实早期版本的 Go 实现中,只有 G 和 M,为啥后续又引进了 P 呢?
Go 入坑 之 协程(goroutine)_第2张图片

因为 GM 模型有以下几个问题:

  • 用一个全局的 mutex 保护着一个全局的 runq (就绪队列),所有 goroutine 的创建、结束,以及调度等操作都要先获得锁,造成对锁的争用异常严重
  • G 的每次执行都会被分发到随机的 M 上,造成不同 M 之间频繁切换,破坏了程序的局部性,主要原因也是因为只有一个全局的 runq。例如:在一个 chan 上互相唤醒的两个 goroutine; 或者是 新创建的 G被创建它的 M 放入 全局runq 中,但是被另外的 M 调度执行,此时会造成不必要的开销。
  • 每个 M 都会关联一个内存分配缓存 mcache,造成了大量的内存开销,进一步使数据的局部性变差(不同的 M 上进行的内存分配可能在不同的内存块上,导致不同的 M 之间的访问存在较大的跨度,降低了数据的局部性)。
  • 在存在系统调用的情况下,工作线程经常被阻塞解除阻塞,从而增加了很多开销。

为了解决上面的问题,于是引入了 GMP 模型。
Go 入坑 之 协程(goroutine)_第3张图片
总体的优化思路就是:将处理器 P 的概念引入 runtime,并在 P 之上实现工作窃取调度程序M 仍旧是工作线程P 表示执行 Go 代码所需的资源

当一个 M 在执行 Go 代码时,它需要有一个关联的 P,当 M 执行系统调用或者空闲时,则不需要 P。

GMP 主要的数据结构

(本小节基于 go1.14版本以后的源码)
主要的数据结构有:runtime.g, runtime.m, runtime.p, schedt

runtime.g 也就是 G,数据结构如下(指摘选部分字段):

type g struct {
	stack 				stack  		// 描述了 goroutine 的栈空间
	stackguard0 		uintptr 	// 被正常的 goroutine 使用,编译器安插在函数头部的栈增长代码。用它来和SP比较,按需进行栈增长
	stackguard1 		uintptr  	// 原理和 stackguard0 差不过,只不过是被 g0 和 gsignal 中的 C 代码使用
	m 					*m   		// 关联到正在执行当前G的工作线程M
	sched 				gobuf  		// 被调度器用来保存 goroutine 的执行上下文
	atomicstatus 		uint32  	// 用来表示当前 G 的状态
	goid 				int64		// 当前 goroutine 的全局唯一ID
	schedlink 			guintprt	// 被调度器用于实现内部链表、队列,对应的 guintptr 类型从逻辑上讲等价于 *g,二底层类型却是个 uintptr,这样是为了避免写障碍
	preempt				bool		// 为 true 时,调度器会在合适的时机触发一次抢占
	lockedm 			muintptr	// 关联到与当前 G 绑定的 M
	waiting 			*sudog		// 主要用于实现 channel 中的等待队列
	timer 				*timer		// runtime 内部实现的计时器类型,用来支持 time.Sleep
}

schedt 的结构定义 (go 1.16)

type schedt struct {
   goidgen 		uint64		// 全局的 goid 分配器,保证 goid 的唯一性
   lastpoll 	uint64		// 记录上次执行 netpoll 的时间,若等于0,则表示某个线程正在阻塞式地执行 netpoll
   pollUntil 	uint64		// 表示阻塞式的 netpoll 将在何时被唤醒
   lock 		mutex		// 全局范围的调度器锁,访问 sched 中的很多字段需要提前获得该锁
   midle 		muintptr	// 	空闲 M 链表的链表头, nmidle 记录的是空闲 M 的数量,即链表的长度
   nmidle 		int32		
   nmidlelocked int32		// 	统计的是与 G 绑定且处于空闲状态的 M,绑定的 G 没有在运行,相应的 M 不能用来运行其他的 G,只能挂起,以便进入空闲状态
   mnext 		int64		// 记录了共创建了多少个M,同时也被用作下一个M的ID
   maxmcount 	int32       // 	限制了最多允许的 M 的个数,出去那些已经释放的
   nmsys 		int32		// 	统计的是系统 M 的个数(不在检查死锁的范围内)
   nmfreed  	int64		// 统计的是累计已经释放了多少M
   ngsys 		uint32		// 	记录的是系统 goroutine 的数量,会被原子性地更新
   pidle		puintptr	// 空闲 P 链表的表头, npidle 记录了空闲 P 的个数,也就是链表长度
   npidle 		uint32
   nmspinning 	uint32		// 记录的是处于自旋状态的 M 的数量
   runq 		gQueue		// 全局就绪队列
   runqsize 	int32		// 记录的是全局就绪队列的长度
   disable 		struct {	// 	用来禁止调度用户 goroutine,其中的 user 变量置为 true 后,调度器将不再调度执行用户 goroutine,系统 goroutine 不受影响。期间就绪的用户 goroutine 会被临时存放到 disable.runnable 队列中, 变量 n 记录了队列的长度
   					user bool
   					runnable gQueue
   					n int32
   				}
   gFree 		struct {	// 用来缓存已退出运行的 G, lock 是本结构单独的锁,避免争用 sched.lock。 stack 和 nostack 这两个列表分别用来存储有栈和没有栈的 G,因为在 G 结束运行被回收的时候,如果栈大小超过了标准大小,就会被释放,所以有一部分 G 是没有栈的。变量 n 是两个列表长度之和,也就是总共缓存了多少个 G
   					lock mutex
   					stack gList
   					noStack gList
   					n int32
   				}
   
   // 1 和 2 构成了 sudog 结构的中央缓存,供各个 P 存取
   sudoglock 	mutex		// 1.
   sudogcache 	*sudog		// 2. 

   // 3 和 4 构成了 _defer 结构的中央缓存
   deferlock 	mutex		// 3. 
   deferpool 	[5]*_defer	// 4.
   
   freem  		*m			// 	一组已经结束运行的 M 构成的链表的表头,通过 m.freelink

   gcwaiting 	uint32		// 表示 GC 正在等待运行,和 stopwait、stopnote 一同被用于实现 STW。 stopwait 记录了 STW 需要停止的 P 的数量,发起 STW 的线程会先把 GOMAXPROCS 赋值给 stopwait,也就是需要停止所有的 P。再把 gvwaiting 置为 1,然后在 stopnote 上睡眠等待被唤醒。其他正在运行的 M 检测到 gcwaiting 后会释放关联 P 的所有权,并把 P 的状态置为 _Pgcstop,再把 stopwait 的值减 1,然后 M 把自己挂起。M 在自我挂起之前如果检测到 stopwait = 0,也就是所有 P 都已经停止了,就会通过 stopnote 唤醒发起 STW 的线程
   stopwait 	int32		
   stopnote 	note	
   sysmonwait 	uint32		// 不为 0 是表示监控线程 sysmon 正在 sysmononote 上睡眠,其他的 M 会在适当的时机将 sysmonwait 置为 0,并通过 sysmonnote 唤醒监控线程
   sysmonnote 	note

   sysmonStarting uint32 	// 表示主线程已经创建了监控线程 sysmon,但是后者尚未开始运行,某些操作需要等到 sysmon 启动后才能进行

   safePointFn 	func(*p)	// 是个 Function Value, safePointWait 和 safePointNote 的作用有些类似于 stopwait 和 stopnote,被runtime.forEachP 用来确保每个 P 都在下一个 GC 安全点执行了 safePointFn
   safePointWait int32
   safePointNote note

   profilehz 	int32		// 用来设置性能分析的采样频率

   // 5 和 6 统计了改变 GOMAXPROCS 所花费的时间
   procesizetime int64		// 5.
   totaltime 	 int64		// 6.
   sysmonlock 	 mutex		// 监控线程 sysmon 访问 runtime 数据时会加上的锁,其他线程可以通过它和监控线程进行同步
}

其他的结构自行查阅 go 源码 或者书籍。(手敲确实累)

下一篇笔记内容是:调度器初始化过程

你可能感兴趣的:(Go,golang,开发语言)