Go并发基础
并行:程序在任意时刻内都是同时运行的
并发:程序在单位时间内都是同时运行的
扇入:多条通道聚合到一条通道中(select聚合,加密解密服务)
扇出:一条通道发散到多条通道中(goroutine实现并发,Web服务器并发处理用户请求)
在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换。
Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。
(1)goroutine可以在用户空间调度,避免内核态和用户态的切换。
(2)goroutine是语言原生支持的,屏蔽了大部分复杂底层实现。
(3)goroutine更小的栈空间允许用户创建更多的实例。
Go语言并行调度原理
GPM是Go语言运行时(runtime)层面实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
-
G就是goroutine,它不是一个运行实体,而是用于存放并发执行的代码入口地址,上下文,运行环境(关联的P和M),运行栈等元信息。为了减少对象的分配和回收,G对象可以被复用(执行完的goroutine可以重新初始化)。
-
P(Processor)管理着一组goroutine队列,P是存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界)的一个数据结构而非控制实体,P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
-
M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟,是操作系统层面调度和运行的实体。 M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的,M仅负责执行,资源来自于P和G;P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,绑定相应的P,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。
-
M拥有自己的栈g0,但只有当拿到P才可以执行,M会在堆栈g0上恢复G的上下文,完成后切换到G的栈开始执行
Go启动初始化过程:
- 分配和检查栈空间。
- 初始化参数和环境变量。
- 当前运行线程标记为m0,即程序主线程。
- 调用运行时初始化函数runtime.schedinit 进行初始化。(内存空间分配,GC,生成空闲P列表)
- 在m0上调度第一个G,这个G负责运行runtime.main函数。runtime.main会拉起运行时的监控线程(对应一个M,专门监控、控制程序的内存和调度信息),然后调用main包的init()初始化函数,最后执行main函数。
总结:
- 一个操作系统线程对应用户态多个goroutine。
- go程序可以同时使用多个操作系统线程。
- goroutine和OS线程是多对多的关系,即m:n。
命令 | 作用 |
---|---|
runtime.GOMAXPROCS(x) | x==0时为查询可以并发执行的goroutine数目 ;x>0时为设置GOMAXPROCS值 |
runtime.Goexit | 结束当前goroutine运行(会调用其defer但是不会产生panic) |
runtime.Gosched | 放弃当前调度执行的机会,将当前goroutine放到Processor的队尾 |
runtime.NumGoroutine | 返回当前程序的goroutine个数 |
Go语言为什么并行更快?
Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。