Go语言进阶主题:并发,GMP与GC | 青训营

214 阅读9分钟

Channel

先从Channel开始,逐步深入Go的内存管理、GMP等进阶主题。

为什么需要channel? 可以从go的并发模型理念说起,不同于java和go中sync包这种通过共享内存实现通信,go channel提倡通过通信共享内存,这就是典型的CSP思想的实践。

channel可以理解成不同goroutine之间的通道,它是安全的,goroutine可以通过这个通道交互数据,也可以此功能完成不同goroutine的阻塞和唤醒。

  • channel的声明
// 只读 
var C <-chan Type  

// 只写 
var C chan<- Type

// 可读可写
var C chan Type
操作nil的channel正常channel已关闭的channel
读 <-ch阻塞成功或阻塞读到零值
写 ch<-阻塞成功或阻塞panic
关闭 close(ch)panic成功panic

针对于如果channel关闭导致的panic问题,可以提前判断下channel的关闭状态

v, ok := <-ch
    • ok为true,读到数据,channel没有关闭
    • false表示channel已关闭
  • channel的分类

程序在给channel分配内存时,可以指定channel的容量大小。可以根据是否具有容量将channel分为无缓冲和缓冲两类。

channel的创建默认是无缓冲区的 ,也称为同步模式,这时发送和接受者可以一一对应,如果接收方没有准备好,发送就会阻塞。有缓冲区的也被称为异步模式,缓冲区未满的情况下,不会被阻塞,可以继续发送,如果缓冲区空了,接收的协程也会被阻塞。

// 同步
c1 := make(chan int)
// 异步
c2 := make(chan int,2)
  • 和select搭配使用
func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}

func main() {
	c := make(chan int)
	quit := make(chan int)
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Println(<-c)
		}
		quit <- 0
	}()
	fibonacci(c, quit)
}

fibonacci函数,一旦c有事件,就会执行case c <- x,main函数会在go协程中触发10次这样的事件。然后最后go协程给quit一个信号,fibonacci退出死循环。

  1. 和select搭配时,如果遇到多个channel同时响应,那么会随机执行一种情况。
  2. channel可以理解成维护一个数组的对象,支持交替的读写,所以会有两个指针分别维护。

池化

Goroutine是很轻量级的协程,但是频繁的创建协程也需要很大的开销,主要表现在创建(内存),调度(调度器),删除(GC)。

虽然Goroutine是用户态上的操作,但是最终都需要交给系统线程,而系统线程也有承载压力,所以我们需要协程池来降低这部分的压力。

协程池通过维护一组协程(也就是Goroutine),来处理并发任务。协程池可以有效地控制协程的数量,避免过多的协程导致系统资源的浪费,从而提高程序的性能和稳定性。

实现协程池一般需要以下几个步骤:

  • 创建协程池对象。协程池对象中需要包含协程池大小、任务队列、信号量等信息。
  • 初始化协程池。在初始化协程池时,需要创建一定数量的协程(Goroutine)并加入到协程池中。
  • 向协程池中提交任务。提交任务时,将任务加入到任务队列中,并通过信号量激活一个空闲的协程来执行任务。
  • 执行任务。协程从任务队列中取出任务并执行,当任务队列为空时,协程将进入等待状态。
  • 停止协程池。停止协程池时,需要将所有任务执行完毕,并关闭所有协程。

下面是一个简单的协程池实现:

type Job func()
type Pool struct {
    jobChan chan Job
    wg      sync.WaitGroup
}
func NewPool(size int) *Pool {
    p := &Pool{
        jobChan: make(chan Job),
    }
    p.wg.Add(size)
    for i := 0; i < size; i++ {
        go func() {
            defer p.wg.Done()
            for job := range p.jobChan {
                job()
            }
        }()
    }
    return p
}
func (p *Pool) Submit(job Job) {
    p.jobChan <- job
}
func (p *Pool) Shutdown() {
    close(p.jobChan)
    p.wg.Wait()
}

同时,Golang中也有很多三方库可以用,如tunny,ants等。

GMP

Goroutine的并发编程模型基于GMP模型,简要解释一下GMP的含义:

G: 表示goroutine,每个goroutine都有自己的栈空间,定时器,初始化的栈空间在2k左右,空间会随着需求增长。

M: 抽象化代表内核线程,记录内核线程栈信息,当goroutine调度到线程时,使用该goroutine自己的栈信息。(物理Processor)

P: 代表调度器,负责调度goroutine,维护一个本地goroutine队列,M从P上获得goroutine并执行,同时还负责部分内存的管理。(逻辑Processor)

G 如果想运行起来必须依赖 P,因为 P 是它的逻辑处理单元,但是 P 要想真正的运行,他也需要与 M 绑定,这样才能真正的运行起来,P 和 M 的这种关系就和 Linux 系统中的用户层面的线程和内核的线程是一样的

image.png

M代表一个工作线程,在M上有一个P和G,P是绑定到M上的,G是通过P的调度获取的,在某一时刻,一个M上只有一个G(g0除外)。在P上拥有一个G队列,里面是已经就绪的G,是可以被调度到线程栈上执行的协程,称为运行队列。

接下来看一下程序中GMP的分布。

image.png

每个进程都有一个全局的G队列,也拥有P的本地执行队列,同时也有不在运行队列中的G。如正处于channel的阻塞状态的G,还有脱离P绑定在M的(系统调用)G,还有执行结束后进入P的gFree列表中的G等等。

调度场景

Channel阻塞:当goroutine读写channel发生阻塞时候,会调用gopark函数,该G会脱离当前的M与P,调度器会执行schedule函数调度新的G到当前M。

系统调用:当某个G由于系统调用陷入内核态时,该P就会脱离当前的M,此时P会更新自己的状态为Psyscall,M与G互相绑定,进行系统调用。结束以后若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。

系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。

GC与内存管理

golang GC 采用基于标记-清除的三色标记法,下图为 golang 一轮完整的 GC 的过程:

image.png

一轮完整的 GC,总是从 Off,如果不是 Off 状态,则代表上一轮GC还未完成,如果这时修改指针的值,是直接修改的。

Stack scan: 收集根对象(全局变量和 goroutine 栈上的变量),该阶段会开启写屏障(Write Barrier)。

Mark: 标记对象,直到标记完所有根对象和根对象可达对象。此时写屏障会记录所有指针的更改(通过 mutator)。

Mark Termination: 重新扫描部分全局变量和发生更改的栈变量,完成标记,该阶段会STW(Stop The World),也是 gc 时造成 go 程序停顿的主要阶段。

Sweep: 并发的清除未标记的对象。

三色标记

以上 Mark 阶段,采用的是三色标记法,是传统标记-清除算法的一种优化,主要思想是增加了一种中间状态,即灰色对象,以减少 STW 时间。
三色标记将对象分为黑色、白色、灰色三种:

  • 黑色:已标记的对象,表示对象是根对象可达的。
  • 白色:未标记对象,gc开始时所有对象为白色,当gc结束时,如果仍为白色,说明对象不可达,在 sweep 阶段会被清除。
  • 灰色:被黑色对象引用到的对象,但其引用的自对象还未被扫描,灰色为标记过程的中间状态,当灰色对象全部被标记完成代表本次标记阶段结束。

三色标记的主要过程即:

  1. 开始时所有对象为白色
  2. 将所有根对象标记为灰色,放入队列
  3. 遍历灰色对象,将其标记为黑色,并将他们引用的对象标记为灰色,放入队列
  4. 重复步骤 3 持续遍历灰色对象,直至队列为空
  5. 此时只剩下黑色对象和白色对象,白色对象即为下一步需要清除的对象

STW

传统的标记-清除算法,为了防止在标记过程中,对象引用发生变化,导致清除仍在使用的对象,需要 STW(Stop The World),这会造成程序的停顿。在三色标记的过程中,由于引入了灰色对象这一中间状态,标记过程和用户的 golang 代码中可以并发执行,不需要 STW,这极大的减少了应用的停顿时间。
三色标记具体如何避免在标记过程中对象应用的改变呢,这里用到了写屏障(Write Barrier)。

写屏障

在 GC 的流程中,Stack scan 这一步骤,启用了写屏障。写屏障的主要思想,是在标记的过程中,通过写屏障记录发生变化的指针,然后在 Mark termination 的 rescan 过程中,重新进行扫描,因为在这一步骤会 STW,所以在这一步骤完成后的白色对象,不会再被引用,可以直接清除。

GC触发

golang 程序的执行过程中,如下几种情况下会触发 GC:

  • 主动触发,用户代码中调用 runtime.GC 会主动触发 GC
  • 默认每 2min 未产生 GC 时,golang 的守护协程 sysmon 会强制触发 GC
  • 当 go 程序分配的内存增长超过阈值时,会触发 GC

内存分配

golang 内存分配分为堆内存和栈内存。
栈:一般函数内部执行中声明的变量,函数返回直接释放,不会引起垃圾回收,对性能无影响。
堆:有引用到的内存空间,靠 GC 回收,会影响程序进程。

内存逃逸

逃逸分析是指由编译器决定内存分配的位置,不需要程序员指定。即由编译器决定新申请的对象会分配到堆上还是栈上。
逃逸分析场景:

  1. 指针逃逸
    go 将函数内定义的变量返回到函数外,会将本应分配到栈上的内存分配到堆上。
  2. 栈空间不足逃逸
    当栈空间不足或无法判断当前切片长度时会将对象分配到堆上。
  3. 动态类型逃逸
    当函数参数为 interface 类型,编译期间无法确定参数的具体类型,也可能会产生逃逸。