并发
并行和并发有什么区别?
并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务
并不会同时运行。并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务
一定是同时运行。goroutine 可能发生并行执行;
但 coroutine(协程) 始终顺序执行。
并发的三大特性:
有序性 ,原子性,可见性
有序性:即程序执行的顺序按照代码的先后顺序执行。原子性:一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。可见性:当一个线程修改了共享变量的值,其他线程能够看到修改的值。
并发控制手段
Go是一门天生支持并发编程的语言,提供了丰富的原生并发控制手段,以下是其中的几个常用的并发控制手段:
- goroutine:goroutine是Go中的轻量级线程,可以通过关键字
go快速启动一个goroutine,使其在后台执行任务。在一个程序中可以同时运行数千甚至数百万个goroutine,有效地提高了程序的并发性和性能。 - channel:channel是Go语言中的一种通信机制,用于在goroutine之间传递数据。通过使用channel,可以在不使用锁的情况下实现不同goroutine之间的同步和协调。
- sync.Mutex:互斥锁是最常用的同步原语之一,用于保护临界区。在Go语言中,通过
sync包提供的Mutex类型可以实现互斥锁。 - sync.WaitGroup:
sync包还提供了WaitGroup类型,用于等待一组goroutine的完成。通过在WaitGroup中添加计数器,可以在主goroutine中等待所有goroutine完成后再继续执行。 - sync.Cond:条件变量是一种高级的同步原语,用于在不同的goroutine之间传递信号。通过使用
sync.Cond,可以实现复杂的同步和协调操作。 - atomic:
atomic包提供了原子操作,用于在不加锁的情况下进行并发访问。例如,通过原子操作可以实现无锁的计数器。 - context:
context包提供了一种机制,用于在goroutine之间传递上下文信息。通过使用context,可以实现在不同goroutine之间安全地取消操作、超时控制等。
这些并发控制手段在不同的场景下有着不同的应用,程序员需要根据实际情况选择合适的手段来实现并发控制,以提高程序的性能和可靠性。
如何确保高并发场景下一些事情只执行一次
加载文件,关闭管道等
使用sync.Once,或双检锁
如:单例模式
1 | type Singleton struct {} |
once.Do 源码
1 | func (o *Once) Do(f func()) {//判断是否执行过该方法,如果执行过则不执行 |
容器的并发安全性
数组,slice,struct允许并发修改(可能会脏写),并发修改map有时会发生panic
如果需要并发修改map,使用sync.Map
n++ 可能会出现脏写,n = a, a = a + 1, n = a
解决:把n++封装成原子操作,解除资源竞争,避免脏写
1 | func atomic.AddInt32(addr *int32, delta int32) (new int32) |
读写锁
1 | var lock sync.RWMutex // 声明读写锁,无需初始化 |
任意时刻只能加一把写锁,且不能加读锁
每加写锁时,可以同时加多把读锁,读锁加上后不能加写锁
临界区
如果一部分程序会被并发访问或修改,那么,为了避免并发访问导致的意向不到的结果,这部分程序需要被保护起来,这部分程序就是临界区。
如果多个线程同时访问或操作临界区,会造成访问错误,可以使用互斥锁,限定临界区同一时间只能有1个线程持有。
- 当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。
- 直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。
Goroutine
goroutine介绍
Goroutine,经 Golang 优化后的特殊协程,协程是一种更细粒度的调度,可以满足多个不同处理逻辑的协程共享一个线程资源,它的创建销毁和调度的成本都非常的小。它与线程存在映射关系,为 M:N,可以利用多个线程实现并行,并且go实现了GMP调度模型,可以通过调度器的调度来实现线程间的动态绑定和灵活调度。
GMP:M 是操作系统线程的抽象,P 则是用于管理 G的调度器。P 结构负责管理 G的调度,包括创建、销毁、挂起和唤醒等,而 M 结构则负责将 G 绑定到操作系统线程上并执行它们。
调度流程:M如果想要运行G就需要与P绑定,调度时执行p.runnext先找p的本地队列,再找全局队列,最后找准备就绪的网络协程,这样的好处是取本地队列时可以接近于无锁化减少锁的竞争。其中每61次调度会直接从全局队列中取G执行,并且把一个G放入本地队列,避免全局队列的G被饿死。如果都没有的话就会触发work stealing机制,会尝试从其他P的本地队列中偷取一半的G来执行。当M执行G时遇到了系统调用或其他阻塞行为,M会阻塞,此时runtime会通过handoff机制将M与P解绑,P与空闲的M或新建一个M绑定执行后续的G。当M结束阻塞后,G会尝试获取一个空闲的P,如果获取不到则这个M会变成休眠状态
(1)M 是线程的抽象;G 是 goroutine;P 是承上启下的调度器;
(2)M调度G前,需要和P绑定;
(3)全局有多个M和多个P,但同时并行的G的最大数量等于P的数量;
(4)G的存放队列有三类:P的本地队列;全局队列;和wait队列;
(5)M调度G时,优先取P本地队列,其次取全局队列,最后取wait队列;这样的好处是,取本地队列时,可以接近于无锁化,减少全局锁竞争;
(6)为防止不同P的闲忙差异过大,设立work-stealing机制,本地队列为空的P可以尝试从其他P本地队列偷取一半的G补充到自身队列.
goroutine特点:
(1)与线程存在映射关系,为 M:N;
(2)创建、销毁、调度在用户态完成,对内核透明,足够轻便;
(3)可利用多个线程,实现并行;
(4)通过调度器的斡旋,实现和线程间的动态绑定和灵活调度;
(5)栈空间大小可动态扩缩,因地制宜.
调度假设当前正在执行G1,G1阻塞(如系统调用),此时P与G1,M1解绑,P被挂载到M2上继续执行G队列中其他任务。G1解除阻塞后,如果有空闲的P就加入到P队列中,如果没有就放到全局可运行队列runqueue中。P会周期性扫描全局(61次)可运行队列,执行里面的G;如果全局runqueue为空,就会从其他的P的执行队列中取一半G来执行。
在 GPM 模型,有一个全局队列(Global Queue):存放等待运行的 G,还有一个 P 的本地队列:也是存放等待运行的 G,但数量有限,不超过 256 个。
GPM 的调度流程从 go func()开始创建一个 goroutine,新建的 goroutine 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了,则会保存到全局队列中。
M 会从 P 的队列中取一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会从其他的 MP 组合偷取一个可执行的 G 来执行,
当 M 执行某一个 G 时候发生系统调用或者阻塞,M 阻塞,
如果这个时候 G 在执行,runtime 会把这个线程 M 从 P 中摘除,然后创建一个新的操作系统线程来服务于这个 P,当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P 来执行,并放入到这个 P 的本地队列,如果这个线程 M 变成休眠状态,加入到空闲线程中,然后整个 G 就会被放入到全局队列中。
work stealing(工作量窃取) 机制:会优先从全局队列里进行窃取,之后会从其它的P队列里窃取一半的G,放入到本地P队列里。
hand off (移交)机制:当前线程的G进行阻塞调用时,例如睡眠,则当前线程就会释放P,然后把P转交给其它空闲的线程执行,如果没有闲置的线程,则创建新的线程。
线程VS协程:
创建销毁:
- 协程goroutine由Go runtime负责管理,创建和销毁的销毁都非常小,是用户级
- 线程创建和销毁开销巨大,因为是内核级的,通常的解决方法是线程池
创建数量:
- 协程:轻松创建上百万个
- 线程:通常最多不超过1w个
内存占用:
协程:2kb,初始分配4k堆栈,随着程序的执行自动增长删除
线程:1M,创建线程是必须指定堆栈且固定,通常M为单位
切换成本:
协程:协程切换只需保存3个寄存器,耗时约200纳秒
线程:线程切换需要保存几十个寄存器,耗时约1000纳秒
调度方式:
协程:非抢占式,由Go runtime主动交出控制权
线程:在时间片用完后,由CPU中断任务强行将其调度走,此时需要保存很多信息
gorountine的优势
- Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU,最大限度的使用cpu的性能
- 开启一个goroutine消耗是非常小的(大概是2kb),所以可以轻松的创建数以百计的goroutine。
- 速度快,还可以用channel进行通信
协程什么时候会发生切换?
协程可以主动让渡自己的执行权利(用户调用强制让渡),
也可以在发生锁或者通道堵塞时被动让渡自己的执行权利。
除此之外,为了让每个协程都有执行的机会,并且最大化利用CPU 资源,在Go 语言初始化时会启动一个特殊的线程来执行系统监控服务。系统监控会判断协程是否需要执行垃圾回收或者当前协程是否运行时间过长或处于系统调用阶段,在这些情况下,调度器将借助操作系统信号机制或者抢占逻辑处理器实现抢占调度。
GMP
组成及原理
GMP:M 是操作系统线程的抽象,P 则是用于管理 G的调度器。
P 结构负责管理 G的调度,包括创建、销毁、挂起和唤醒等,而 M 结构则负责将 G 绑定到操作系统线程上并执行它们。
调度流程:M如果想要运行G就需要与P绑定,调度时调用p.runnext先找p的本地队列,再找全局队列,最后找网络轮询器 Net Poller 上有一个陷入异步网络调用的准备就绪的G,这样的好处是取本地队列时可以接近于无锁化减少锁的竞争。其中每61次调度会直接从全局队列中取1个G执行,避免全局队列的G被饿死。如果都没有的话就会触发work stealing机制,会尝试从其他P的本地队列中偷取一半的G来执行。
当M执行G时遇到了系统调用或其他阻塞行为,M会阻塞,此时runtime会通过handoff(移交)机制将M与P解绑,P与空闲的M或新建一个M绑定执行后续的G。当M结束阻塞后,G会尝试优先绑定oldP,失败后从全局P队列中获取一个P,如果获取不到则这个M会变成休眠状态G放入全局队列。
抢占如果有G一直占用资源,go在运行时有sysmon进行监控,如果G独占P超过10ms就会被抢占。
为什么使用p?
- 每个P有本地队列可以近乎无锁化执行,减少全局队列的锁竞争
- 当本地队列为空时,通过workstealing机制减少空转,提高资源利用率。
- 当M阻塞时会绑定到其他M上执行,提高并发性能,如果将队列实现在M上会使得阻塞时队列等待。
G0负责调度与创建g,分配defer,stw,扫描栈,栈分配
GMP 组成及数量关系
G(Goroutine),表示一个 goroutine,即我需要分担出去的任务;
M(Machine),对应一个内核线程,用于将一个 G 搬到线程上执行;
P(Processor),一个装满 G 的队列,用于维护一些任务;
G:Groutine协程,拥有运行函数的指针、栈、上下文(指的是sp、bp、pc等寄存器上下文以及垃圾回收的标记上下文),在整个程序运行过程中可以有无数个,代表一个用户级代码执行流(用户轻量级线程);
P:Processor,调度逻辑处理器,同样也是Go中代表资源的分配主体(内存资源、协程队列等),默认为机器核数,可以通过GOMAXPROCS环境变量调整
M:Machine,代表实际工作的执行者,对应到操作系统级别的线程;M的数量会比P多,但不会太多,最大为1w个。
G: 表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。P: Processor,表示逻辑处理器, 对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。M: Machine,OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。
在 Golang 中,为了提高并发性能,一个 M 可以绑定多个 P,并且一个 P 也可以被多个 M 共享。这种设计可以在多核 CPU 上更好地利用硬件资源,从而提高并发性能。
特殊的M0和G0
在 Go 中创建的所有 Goroutine 都会被一个内部的调度器所管理。Go 调度器尝试为所有的 Goroutine 分配运行时间,并且在当前的 Goroutine 阻塞或者终止的时候,Go 调度器会通过运行 Goroutine 的方式使所有 CPU 保持忙碌状态。这个调度器实际上是作为一个特殊的 Goroutine 运行的。
M0
M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0==负责执行初始化操作和启动第一个G==, 在之后M0就和其他的M一样了。
G0
G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。
职责:
- Goroutine 创建与调度
- defer 函数分配。
- 垃圾收集操作,比如 STW( stopping the world ),扫描 Goroutine 的栈,以及一些标记清理操作。
- 栈增长。当需要的时候,Go 会增加 Goroutine 的大小。这个操作是由
g0的 prolog 函数完成的。
m 通过 p 调度执行的 goroutine 永远在普通 g 和 g0 之间进行切换,当 g0 找到可执行的 g 时,会调用 gogo 方法,调度 g 执行用户定义的任务;当 g 需要主动让渡或被动调度时,会触发 mcall 方法,将执行权重新交还给 g0.
goroutine什么时候会发生阻塞?
- 由于原子、互斥量或通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine;
- 由于网络请求和 IO 操作导致 Goroutine 阻塞。
- 当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,
- 如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了
在GPM调度模型,goroutine 有哪几种状态?
g有9种状态
- _Gidle:刚刚被分配并且还没有被初始化
- _Grunnable:没有执行代码,没有栈的所有权,存储在运行队列中
- _Grunning:可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
- _Gsyscall:正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
- _Gwaiting:由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
- _Gdead:没有被使用,没有执行代码,可能有分配的栈
- _Gcopystack:栈正在被拷贝,没有执行代码,不在运行队列上
- _Gpreempted:由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
- _Gscan:GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
如果goroutine一直占用资源怎么办,PMG模型怎么解决这个问题
如果有一个goroutine一直占用资源的话,GMP模型会从正常模式转为饥饿模式,通过信号协作强制处理在最前的 goroutine 去分配使用
基于信号的抢占式调度
在1.14中加入了基于信号的协程调度抢占。原理是这样的,首先注册绑定 SIGURG 信号及处理方法runtime.doSigPreempt,sysmon会间隔性检测超时的p,然后发送信号,m收到信号后休眠执行的goroutine并且进行重新调度。sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果某协程独占P超过10ms,那么就会被抢占!
如果若干个线程中有一个发生OOM会发生什么?如果goroutine发生呢?
当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行
go中的内存泄漏一般都是goroutine泄露,就是goroutine没有被关闭,或者没有添加超时控制,让goroutine一只处于阻塞状态,不能被GC。
当一个线程发生OOM(内存溢出)时,通常会导致整个进程崩溃,因为线程共享进程的地址空间。然而,当一个Goroutine发生OOM时,情况可能会有所不同。由于Goroutine的堆栈是动态扩展的,当一个Goroutine的堆栈无法扩展时,Go运行时会尝试回收其他Goroutine的内存,以便为当前Goroutine分配更多的内存。如果回收失败,Go运行时会抛出一个运行时错误(如runtime: out of memory),但不会导致整个进程崩溃。
什么是协程泄露
协程泄露指的是在 Go 语言程序中,由于某种原因而导致协程无法正常结束,从而造成内存泄露的情况。这种情况通常发生在使用协程时处理异步任务时,如果没有正确地处理协程的终止条件,它们将一直保持活动状态,不断占用内存,最终导致内存泄露。为了避免协程泄露,应该确保在程序结束时关闭所有协程,并释放其占用的内存。
- 可以通过
leaktest库来检测 - 使用 golang 自带的
pprof监控工具,可以发现内存上涨情况
常见的协程泄漏:
channel缺少消费者,导致发送阻塞;没有生产者,读取阻塞
死锁
同一个goroutine中,使用同一个chnnel读写;
2个 以上的goroutine中, 使用同一个 channel 通信。 读写channel 先于 go程创建;
channel 和 读写锁、互斥锁混用;
select 所有的case都阻塞
无限死循环
主协程如何等待所有协程都完成
sync.WaitGroup- Add:WaitGroup 类型有一个计数器,默认值是 0,通常通过个方法来标记需要等待的子协程数量
- Done:当某个子协程执行完毕后,可以通过 Done 方法标记已完成,常用 defer 语句来调用
- Wait 阻塞当前协程,直到对应 WaitGroup 类型实例的计数器值归零
- 使用channel
- 声明一个和子协程数量一致的通道数组,然后为每个子协程分配一个通道元素,在子协程执行完毕时向对应的通道发送数据;然后在主协程中,依次读取这些通道接收子协程发送的数据,只有所有通道都接收到数据才会退出主协程。
- 使用context
- 使用
context.WithCancel在子协程退出:
- 使用
起多个协程,怎么控制他们的退出
channel通知,select监控,ctx中Done退出,waitGroup等待
- 调用
runtime.Goexit()来手动终止协程 - 使用select监控:监控chan或ctx.Done
- waitGroup等待
避免多线程竞争的方法
Go语言提供了传统的同步 goroutine 的机制,就是对共享资源加锁。atomic 和 sync 包里的一些函数就可以对共享的资源进行加锁操作。
原子函数
原子函数能够以很底层的加锁机制来同步访问整型变量和指针。
n++ 可能会出现脏写,n = a, a = a + 1, n = a
解决:把n++封装成原子操作,解除资源竞争,避免脏写
1 | func atomic.AddInt32(addr *int32, delta int32) (new int32) |
1 | var isInit uint32 |
互斥锁
另一种同步访问共享资源的方式是使用互斥锁,互斥锁这个名字来自互斥的概念。
互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界代码。