并行&并发

并行(parallel):在同一时刻,有多条操作指令在多个处理器上同时执行。
Go并发——Goroutine - 图2
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,通过cpu时间片轮转使多个进程快速交替的执行。
Go并发——Goroutine - 图3
大师曾以咖啡机的例子来解释并行和并发的区别。
Go并发——Goroutine - 图4

  • 并行是两个队列同时使用两台咖啡机 (真正的多任务)
  • 并发是两个队列交替使用一台咖啡机 ( 假 的多任务)

下面这张图很好的表示出了串行,并行,并发
Go并发——Goroutine - 图5
总结:
并发:同一时间段内执行多个操作
并行:同一时刻执行多个操作

进程&线程

线程是操作系统在做调度时的最基本单元,线程和进程的实现在不同操作系统上也有所不同,但是在大多数的实现中线程都是进程的一个组件。
Go并发——Goroutine - 图6
多个线程可以存在于同一个进程中并共享了同一片内存空间,由于不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之前的通信也正是基于共享的内存进行的,相比于重量级的进程,线程显得比较轻量,所以我们可以在一个进程中创建出多个线程。

GroutineGo并发——Goroutine - 图7

虽然线程相对进程比较轻量,但是线程仍然会占用较多的资源并且调度时也会造成比较大的额外开销,每个线程会都占用 1M 以上的内存空间,在对线程进行切换时不止会消耗较多的内存空间,对寄存器中的内容进行恢复还需要向操作系统申请或者销毁对应的资源,每一次线程上下文的切换都需要消耗 ~1ms 左右的时间,
Go 调度器对 Goroutine 的上下文切换 ~0.2ms,减少了 80% 的额外开销。除了减少上下文切换带来的开销。

创建Groutine

Go 语言里创建一个协程非常简单,使用 go 关键词加上一个函数调用就可以了。Go 语言会启动一个新的协程,函数调用将成为这个协程的入口。

  1. package main
  2. import "fmt"
  3. func helloGo(){
  4. fmt.Println("hello golang")
  5. }
  6. func main(){
  7. helloGo()
  8. fmt.Println("hello world")
  9. }

上面示例中helloGo函数和下面的打印语句是串行的,执行的结果如下

  1. hello golang
  2. hello world

按照创建在goroutine的方式,在调用helloGo函数前面加上关键字go,启动一个goroutine去执行helloGo这个函数

  1. package main
  2. import "fmt"
  3. func helloGo(){
  4. fmt.Println("hello golang")
  5. }
  6. func main(){
  7. go helloGo()
  8. fmt.Println("hello world")
  9. }

上面程序执行结果如下

  1. hello world

并没有打印hello golang。在程序启动时,Go程序就会为main()函数创建一个默认的goroutine。
当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束,造成helloGo函数的goroutine没有执行
可以简单粗暴的方式加入时间延时,来延缓main()的goroutine结束时间

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func helloGo(){
  7. fmt.Println("hello golang")
  8. }
  9. func main(){
  10. go helloGo()
  11. time.Sleep(time.Second)
  12. fmt.Println("hello world")
  13. }

当然也可以通过runtime.Gosched()出让main函数的goroutine,来让helloGo的goroutine执行

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. )
  6. func helloGo(){
  7. fmt.Println("hello golang")
  8. }
  9. func main(){
  10. go helloGo()
  11. runtime.Gosched()
  12. fmt.Println("hello world")
  13. }

GMP模型

GMP是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。GPM具体包含如下3个部分

  • G — 表示 Goroutine,每一个 Goroutine 都包含堆栈、指令指针和其他用于调度的重要信息;
  • M — 表示操作系统的线程,它是被操作系统管理的线程,与 POSIX 中的标准线程非常类似;
  • P — 表示调度的上下文,它可以被看做一个运行于线程 M 上的本地调度器;

image.png

G(Goroutine)

G就是Go语言中并发的执行单元Goroutine,与操作系统中的线程相比占用了更小的内存空间,并降低了 Goroutine 切换的开销。
Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态为我们提供的『线程』,如果一个 Goroutine 由于 IO 操作而陷入阻塞,操作系统并不会对上下文进行切换,但是 Go 语言的调度器会将陷入阻塞 Goroutine 『切换』下去等待系统调用结束并让出计算资源,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

Goroutine 在 Go 语言运行时使用一个名为 g 的私有结构体表示,这个私有结构体非常复杂,总共有 40 多个用于表示各种状态的成员变量,我们在这里也不能介绍全部的属性,而是会挑选其中的一部分重点进行介绍,我们可以在 runtime2.go#L387-L450 文件中查看 g 结构体的全部属性:

  1. type g struct {
  2. m *m // current m; offset known to arm liblink
  3. sched gobuf
  4. syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
  5. syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
  6. param unsafe.Pointer // passed parameter on wakeup
  7. atomicstatus uint32
  8. goid int64
  9. schedlink guintptr
  10. waitsince int64 // approx time when the g become blocked
  11. waitreason waitReason // if status==Gwaiting
  12. preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
  13. lockedm muintptr
  14. writebuf []byte
  15. sigcode0 uintptr
  16. sigcode1 uintptr
  17. sigpc uintptr
  18. gopc uintptr // pc of go statement that created this goroutine
  19. startpc uintptr // pc of goroutine function
  20. waiting *sudog // sudog structures this g is waiting on (that have a valid elem ptr); in lock order
  21. }

为了减少无关的干扰项,我们在这里删除了跟堆栈以及追踪相关的字段,剩下的都是 g 结构体中比较重要的字段。

结构体 g 的字段 atomicstatus 就存储了当前 Goroutine 的状态,runtime2.go 文件中定义了 Goroutine 全部可能存在的状态,除了几个已经不被使用的以及与 GC 相关的状态之外,全部常见的状态都展示在这里:

状态 描述
_Gidle 刚刚被分配并且还没有被初始化
_Grunnable 没有执行代码、没有栈的所有权、存储在运行队列中
_Grunning 可以执行代码、拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall 正在执行系统调用、拥有栈的所有权、没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead 没有被使用,没有执行代码,可能有分配的栈
_Gcopystack 栈正在被拷贝、没有执行代码、不在运行队列上

上述状态中比较常见是 _Grunnable_Grunning_Gsyscall_Gwaiting 四个状态,我们在这里也会重点介绍这几个状态,Goroutine 中所有状态的迁移是一个非常复杂的过程,会触发 Goroutine 状态迁移的方法也非常多,在这里我们也没有办法介绍全部的迁移线路,我们会从其中选择一些进行介绍。
Go并发——Goroutine - 图9
虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成最终的三种:等待中、可运行、运行中,在运行期间我们会在这三种不同的状态来回切换:

  • 等待中:表示当前 Goroutine 等待某些条件满足后才会继续执行,例如当前 Goroutine 正在执行系统调用或者同步操作;
  • 可运行:表示当前 Goroutine 等待在某个 M 执行 Goroutine 的指令,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间;
  • 运行中:表示当前 Goroutine 正在某个 M 上执行指令;

在此总结一下引起goroutine可能的切换点有如下情况
I/O,slect
channel
等待锁
函数调用
runtime.Gosched()

M(操作系统线程)

M 其实表示的是操作系统线程, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的,在默认情况下调度器能够允许创建 10000 个线程,但是其中绝大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个线程 M 能够正常运行。
P的个数是通过runtime.GOMAXPROCS设定,Go1.5版本之后默认为物理线程数。因此在默认情况下,一个四核机器上会创建四个操作系统线程,每一个线程其实都是一个 m 结构体,我们也可以通过 runtime.GOMAXPROCS 改变最大可运行线程的数量,我们可以使用 runtime.GOMAXPROCS(3) 将 Go 程序中的线程数改变成 3 个。Go并发——Goroutine - 图10
在大多数情况下,我们都会使用 Go 的默认设置,也就是 #thread == #CPU,在这种情况下不会触发操作系统级别的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。

操作系统线程在 Go 语言中就会使用私有结构体 m 来表示,这个结构体中也包含了几十个私有的字段,我们这里还是对其进行了简单的删减,感兴趣的读者可以查看 runtime2.go#L452-L521 了解更多的内容:

  1. type m struct {
  2. g0 *g // goroutine with scheduling stack
  3. curg *g // current running goroutine
  4. ...
  5. }

其中 g0 是持有调度堆栈的 Goroutine,curg 是在当前线程上运行的 Goroutine,这也是作为操作系统线程唯一关心的两个 Goroutine 了。

P(上下文环境)

处理器 P其实就是线程需要的上下文环境,也是用于处理代码逻辑的处理器,通过处理器 P 的调度,每一个内核线程 M 都能够执行多个 G,这样就能在 G 进行一些 IO 操作时及时对它们进行切换,提高 CPU 的利用率。
每一个 Go 语言程序中所以处理器的数量一定会等于 GOMAXPROCS,这是因为调度器在启动时就会创建 GOMAXPROCS 个处理器 P,这些处理器会绑定到不同的线程 M 上并为它们调度 Goroutine。

处理器在 Go 语言运行时中同样使用私有结构体 p 表示,作为调度器的内部实现,它包含的字段也非常多,我们在这里就简单展示一下结构体中的大致内容,感兴趣的读者可以查看 runtime2.go#L523-L602)

  1. type p struct {
  2. id int32
  3. status uint32 // one of pidle/prunning/...
  4. link puintptr
  5. schedtick uint32 // incremented on every scheduler call
  6. syscalltick uint32 // incremented on every system call
  7. sysmontick sysmontick // last tick observed by sysmon
  8. m muintptr // back-link to associated m (nil if idle)
  9. mcache *mcache
  10. runqhead uint32
  11. runqtail uint32
  12. runq [256]guintptr
  13. runnext guintptr
  14. sudogcache []*sudog
  15. sudogbuf [128]*sudog
  16. ...
  17. }

我们将结构体中 GC 以及用于追踪调试的字段全部删除以简化这里需要展示的属性,在上述字段中,status 表示了当前处理器的状态,runheadrunqtailrunq 以及 runnext 等字段表示处理器持有的运行队列,运行队列中就包含待执行的 Goroutine 列表。

p 结构体中的状态 status 其实就会是以下五种状态其中的一种,我们能在 runtime2.go#L99-L147 文件中找到处理器 P 的全部状态:

状态 描述
_Pidle 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning 被线程 M 持有,并且正在执行用户代码或者调度器
_Psyscall 没有执行用户代码,当前线程陷入系统调用
_Pgcstop 被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead 当前处理器已经不被使用

通过分析处理器 P 的这些状态,我们其实能够对处理器的工作过程有一些简单的理解,例如处理器在执行用户代码时会处于 _Prunning 状态,在当前线程执行 IO 操作时会陷入 _Psyscall 状态。

异常处理

协程的异常退出会将异常传播到主协程,直接会导致主协程也跟着挂掉,然后整个程序就崩溃

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. go func() {
  8. fmt.Println("run new goroutine")
  9. panic("crash")
  10. }()
  11. time.Sleep(time.Second)
  12. fmt.Println("main goroutine will quit")
  13. }

执行结果如下,主协程最后一句打印语句没能运行就挂掉了

  1. panic: crash
  2. run new goroutine
  3. goroutine 5 [running]:
  4. main.main.func1()

在协程的入口函数开头增加 recover() 语句来恢复协程内部发生的异常,阻断它传播到主协程导致程序崩溃。recover 语句必须写在 defer 语句里面。

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. go func() {
  8. defer func() {
  9. if err := recover(); err != nil {
  10. fmt.Println("err:",err)
  11. }
  12. }()
  13. fmt.Println("run new goroutine")
  14. panic("crash")
  15. }()
  16. time.Sleep(time.Second)
  17. fmt.Println("main goroutine will quit")
  18. }

参考

https://draveness.me/golang/concurrency/golang-goroutine.html
https://www.cnblogs.com/sunsky303/p/9705727.html
https://segmentfault.com/a/1190000016038785
https://blog.csdn.net/luslin1711/article/details/88319040