什么是协程
Go的并发编程是通过协程(goroutine)来实现的,所以了解协程是掌握Go并发编程的第一步。协程是更加轻量级的线程,当我们需要异步执行一段代码的时候,直接创建一个协程来运行接口,Go的协程是通过运行时(runtime)来管理的,所以开发人员不需要关心协程的调度以及销毁等过程。
创建协程方法:go + 匿名函数
package async
import "testing"
func TestGoroutine(t *testing.T) {
//创建一个协程异步执行任务
go func() {
println("do something")
}()
println("end")
}
协程和线程的区别
- 占用空间:协程比线程占用更小的空间,线程和协程占用空间主要是栈占用的空间大小,创建一个线程占用内存一般是在1M到数M之间,而一个协程默认的站大小是2K,这是因为协程的栈是可以进行扩展的,初始化的时候不需要分配太多,这样更加节省资源。所以在项目中创建的线程数往往不能太多,但是却可以创建成千上万的协程。
- 调度方式:这个是协程和线程最大的区别,也是我认为Go做的很好的一个设计。我们知道传统的线程是对应这操作系统的线程,然后是由操作系统来进行调度的,进行CPU时间片的抢占。但是Go的协程是由用户态的调度器来实现调度,不直接和操作系统线程关联,因为是用户态,所以在调度的时候线程切换的开销更小,不存在状态的切换。
- 并发度和性能:其实从1和2两个区别也能看出来,Go协程的并发性能比传统的线程要高很多,因为Go协程的创建和调度开销小,可以轻松创建成千上万个协程。而传统线程的并发度受限于系统的线程数目,创建大量线程可能会导致系统资源耗尽。当然这个更多的是得益于Go的协程调度模型,这个在下面会讲到。
总的来说,Go协程提供了一种更加高效、易用且安全的并发编程方式。通过协程,开发者可以编写出高并发、高性能的程序,而不用担心传统多线程编程中可能出现的各种问题。
G-P-M调度模型
什么是GPM
Go协程的并发高性能主要还是得益于高效的调度策略,也就是我们这里要讲到的GPM模型,了解了这个模型的工作原理,我们也就掌握了Go中协程并发运行的原理。
- G:代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。
- P:代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;G的执行依赖P,只有被分配到P中才能被执行。
- M:M 代表着真正的执行计算资源。在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
依赖条件:
- G必须依赖于P才能被M执行,程序创建的goroutine协程必须放入调度器P才能被执行。
- M必须关联到一个P才能执行对应队列中的G,一个P可以被多个M关联,但是同一时刻只可能被一个M关联,所以这里可以看出,Go的并发度是取决于P的数量的,而不是M。
工作流程
创建一个协程到运行的简单过程:
GPM整体工作流程:
调度策略
队列轮转
从上面的GPM流程中我们可以看到每个P是维护了一个自己的协程队列,调度器会按照队列的出队顺序把G给到M去执行,每个G都会被分到一个时间片,执行一段时间后如何还未完成就记录上下文和寄存器信息,放入队列尾部等到下次重新被调度。这种是比较常规的调用方式。
抢占式调度
Go的协程调度是支持抢占式调度的,即一个Goroutine在执行过程中可能被强制暂停,切换到其他Goroutine执行。这种机制防止了某个Goroutine长时间占用处理器,确保其他Goroutines也有机会执行。
系统调用
当一个Goroutine进入系统调用后会进入阻塞状态,这个时候其对应的M也会随之进入空闲状态,和P进行解绑。调度器会把P调度给其他的M,保证P中其他的G可以被执行。当系统调用完成之后,这个Goroutine会尝试获取空闲的P,如果获取到就继续执行,如果没有就进入全局队列,等待被执行。
工作量窃取
每个P维护的G队列可能不是均衡的,有的多有的少,为了提高并发度,这里提供了工作窃取的调度方式,跟线程的fork-join有点类似。当某个P执行中的G被执行完成之后,会先去全局队列中查询,如果全局队列中也没有就会从其他的P中”窃取“一部分G(一半儿)来执行。