Golang并发

1. golang锁状态

  • mutexLocked 互斥锁的锁定状态
  • mutexWoken 从正常模式被唤醒
  • mutexStarving 当前的互斥锁进入饥饿状态
  • waitersCount 当前互斥锁上等待的协程个数

2. 正常模式和饥饿模式

  • 正常模式
    所有goroutine安装FIFO顺序等待,唤醒的goroutine不会立即拥有锁,而是和新请求goroutine竞争锁,新的请求的goroutine更容易抢占,因为它运行在cpu上,所以刚唤醒的goroutine很有可能在锁竞争中失败,此时被唤醒的goroutine加入等待队列的前面
  • 饥饿模式
    解决了等待goroutine队列的长尾问题
    该模式下直接由unlick把锁交给等待队列中的队头,新进来的goroutine也不会参与抢锁也不会进入自旋状态,直接进入等待队列的队尾,解决了老的goroutine抢不到锁的场景
    饥饿模式触发条件:一个goroutine等待时间超过1ms,或当前队列只剩一个goroutine,mutex切换到饥饿模式
  • 区别
    正常模式下性能最好,一个goroutine可以多次获得锁,饥饿模式解决了取锁的公平问题,但性能下降

3. Mutex允许自旋的条件

  • 锁被占用,且不处于饥饿模式
  • 积累的自旋次数小于最大自旋次数(action_spin=4)
  • CPU核数大于1
  • 有空闲的P
  • 当前Goroutine所挂载的P下,本地运行队列为空

4. RWMutex读写锁的实现

通过readerCount读锁的数量实现,当有一个写锁的时候,将读锁数量设置为(-1*1<<30),目的是让新进入的读锁等待之前的写锁释放,然后再通知读锁。当有写锁抢占时,也会等待之前的读锁释放完毕再通知写锁,两者互相限制。

  • RWMutex是单写多读锁,可以加多个读锁一个写锁
  • 读锁占用会阻止读,不会阻止写
  • 写锁会阻止其它Goroutine进来(无论读写),整个锁由Goroutine独占
  • 适用于多读少写的场景
  • RWMutex类型遍历的零值为一个未锁定状态的互斥锁
  • RWMutex首次使用后不能被拷贝
  • RWMutex的读/写锁再为锁定状态会触发panic
  • RWMutex的一个写锁去锁定临界区的共享资源,如果该资源已经被锁,则该操作会被阻塞直到解锁
  • RWMutex不要用户递归调用,容易产生死锁
  • RWMutex的锁定状态与特定的Goroutine无关,一个Goroutine可以RLock,另一个Goroutine可以RUnLock,即锁定的是资源。
  • 写锁被解锁后,所有因读操作而锁定读锁被阻塞的Goroutine将被唤醒,并都可以成功锁定读锁
  • 读锁被解锁后,在没有被其他所锁定的前提下,所有因写操作而锁定写锁而阻塞的Goroutine,其中等待时间最长的一个Goroutine会被唤醒

4.Cond

用于多个Goroutine等待,一个Goroutine通知(事件发生)的场景
sys.Cond是基于互斥锁/读写锁实现的条件变量,协调想访问共享资源的Goroutine,共享资源状态发生变化时,sys.Cond可以通知等到条件发生而阻塞的Goroutine,每个Cond都会关联一个Lock,修改条件或调用Wait方法时们必须加锁保护Condition。互斥锁是一个通知一个等待

//broadcast 唤醒所有等待c的goroutine
func (c *Cond)Boardcast()
//只唤醒一个等待c的goroutine
func (c *Cond)Signal()
//wait会自动释放c.L锁,并挂起调用者的goroutine,之后恢复执行,并在返回时对c.L加锁(被signal/Boradcast唤醒会返回)
func (c *Cond)Wait()

5. WaitGroup

WaitGroup是sync用来做任务编排的并发原语,解决了并发-等待的问题。
若一个Goroutine等待一组Goroutine全部完成,此时该Goroutine将会阻塞,直到该组Goroutine全部执行完成。采用传统方法,轮询查看Goroutine组内所有线程是否完成,会导致性能低下和多了很多无谓的轮询,此时WaitGroup解决了该问题,阻塞等待的Goroutine,直到所有的Goroutine
类似于linux中的barrier,C++中的barrier,Java中的CyclicBarrier,CountDowmLatch

//用来设置WaitGroup的计数值;
func (wg *WaitGroup) Add(delta int)
//用来将WaitGroup的计数值减1,其实就是调用了Add(-1);
func (wg *WaitGroup) Done()
//调用这个方法的goroutine会一直阻塞,直到WaitGroup的计数值变为0。
func (wg *WaitGroup) Wait()

实现:

type WaitGroup struct {
   // noCopy noCopy
   state1 [3]uint32
}
  • noCopy的辅助字段,主要就是辅助vet工具检查是否通过copy赋值这个WaitGroup实例。
  • state1,一个具有复合意义的字段,包含WaitGroup的计数、阻塞再检查点的waiter数和信号量。
    WaitGroup维护两个计数器,一个是请求计数器v,一个是等待计数器w,二者组成一个64bit的值,请求计数器占高32bit,等待计数器占低32bit

6. sys.Once

sys.Once保证仅执行一次该操作,常用于单例对象的初始化,或者并发访问只需要初始化一次的共享资源,sys.Once仅暴露了一个Do方法,可以多次调用该方法,但只有第一次调用时,f参数(无参无返回值的函数)才会执行。
sync.Once.Do(f func())能保证f只执行一次,无论你是否更换once.Do(f)这里的方法,这个sync.Once块只会执行一次。

7. sys.Pool

高并发下,为了减少优化GC,使得对象重用,用对象池存储待回收对象,等待下次使用。保存的对象可能随时释放掉,不是存放socket长连接或数据库连接对象

type Pool struct {
	// New optionally specifies a function to generate
	// a value when Get would otherwise return nil.
	// It may not be changed concurrently with calls to Get.
	New func() interface{}
	// contains filtered or unexported fields
}

// Get 从 Pool 中获取元素。当 Pool 中没有元素时,会调用 New 生成元素,新元素不会放入 Pool 中。若 New 未定义,则返回 nil。
func (p *Pool) Get() interface{}

// Put 往 Pool 中添加元素 x。
func (p *Pool) Put(x interface{})

8. Goroutine

golang在语言级别支持协程,golang标准库的系统调用操作时,都会出让CPU给Goroutine,使得Goroutine切换不依赖于系统的进程和线程,而是Golang Runtime统一管理

GMP模型
  • G:Goroutine协程,
  • M:thread线程(运行Goroutine的实体),
  • P:processor处理器,(把可运行的Goroutine分配到工作线程里)
    image
    全局队列:存放等待运行的Goroutine
    P的等待队列,存放等待运行的Goroutine,数量不超过256。新建G时,G优先加入P的本地队列,如果队列满了,会把本地队列的一半的G移入全局队列
    P列表:所有的P都在程序启动时创建,保存在数组中,最多有GOMAXPROCS个
    M:线程想运行任务得获取P,从P得本地队列获取G,P队列为空时,M尝试从全局队列拿一些G放到P的本地队列,或从其它P的本地队列拿一半放到自己的P的本地队列,M运行G,G执行后,M会尝试获取下一个G,一直循环。
    P: $GOMAXPROCS,程序执行时最多有该值个Goroutine同时执行,在确定了该值后,程序运行时系统会创建N个P
    M:Golang程序启动设置M的最大数量,默认10000,但内核很难支持这么多线程数。没有足够的M关联P并运行Goroutine,比如某时M全被阻塞,但还有些Goroutine未执行,则会先寻找空闲的M,若无则会创建新的M

调度机制

  • work stealing
    当线程没有可运行的Goroutine时,会尝试从其它线程绑定的P中的本地队列中偷取G,而不是销毁线程
  • hand off
    当本线程因为G中进行系统调用阻塞时,线程释放绑定的P,并把P交给其它空闲线程执行
  • 并行
    GOMAXPROCS为最大并行数,最多该数个线程分布在多个CPU上执行
  • 抢占
    未防止goroutine锁死,每个Goroutine最多占用CPU 10ms,而在协程(coroutine)中要等待一个协程主动让出CPU才行
    • 1.14版本-协作式的抢占调度,存在Goroutine长时间占用线程造成其它Goroutine饥饿的问题,并且垃圾回收需要暂停整个程序,导致程序无法工作
    • 基于信号的抢占式调度
      通过sysmon监控实现,标动的周期性检查,作用:
      • 释放限制超过5分钟的span物理内存
      • 强制回收超过2分钟的线程
      • 长时间未处理的netpoll添加到全局队列
      • 向长时间运行的G发出抢占调度
      • 收回因系统调用长时间阻塞的P
  • 全局G队列
    如果M执行work stealing偷取不到G,则在全局队列中执行work stealing

9. Golang GC

写屏障
Golang在进行垃圾回收的时候并没有STW,当使用三色标记法时,标记了对象A的所有引用,此时另一个Goroutine将对象B修改为A的引用,会导致B扫描不到,此时被认为是白色对象。而写屏障就解决了该问题。引入写屏障之后,即使B被A抛弃。在下一轮的GC中才会被回收。

  • 插入写屏障
    插入写屏障将白色指针插入黑色对象的操作,标记其对应的对象为灰色状态,这样就不存在黑色对象引用白色对象的情况,满足了强三色不变式
  • 删除写屏障
    一个对象即使被删除了最后一个指向它的指针也依旧可以存活过这一轮,在下一轮GC中被清除。
    删除屏障也是拦截写操作,因为它是写入一个空对象。具体的操作是,被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。满足了弱三色不变式原则,保护灰色对象到白色对象的可达路径不会断
  • 混合写屏障
    • 混合写屏障继承了插入写屏障的有点,起始无需STW打快照,并发扫描垃圾
    • 继承了删除写屏障的优点,赋值器是黑色赋值器,GC 期间,任何在栈上创建的新对象,均为黑色。扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;
    • 混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的是 GC 过程全程无 STW;
    • 混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是要停止这个 goroutine 赋值器的工作(针对一个 goroutine 栈来说,是暂停扫的,要么全灰,要么全黑,原子状态切换)。
      GC触发时机
  • 主动触发 调用 runtime.GC
  • 被动触发
    • 使用系统监控,触发条件由runtime.forcegcperiod变量控制,超过两分钟没有产生任何GC时强制触发GC
    • 使用步调算法控制北村的增长比例,下一次 GC 结束时的堆大小和上一次 GC 存活堆大小成比例.
      GC流程
  • GCMark 标记准备阶段,为并发标记做准备工作,启动写屏障
  • STWGCMark 扫描标记阶段,与赋值器并发执行,写屏障开启并发
  • GCMarkTermination 标记种植阶段,保证一个周期内标记任务完成,停止写屏障
  • GCoff 内存清扫阶段,将需要回收的内存还到堆中,写屏障关闭
  • GCoff 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭
    GC调优
    go tool pprof go tool trace
  • 控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU的利用率
  • 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝
  • 需要时,增大 GOGC 的值,降低 GC 的运行频率。
posted @ 2022-05-02 17:13  流光之中  阅读(148)  评论(0编辑  收藏  举报