目录
前言
一、进程、线程、协程
1.进程
2.线程
3.协程
4.协程的优势
5.进程、线程、协程的对比
二、协程
1.协程数据结构
2.协程执行过程
3.GMP调度模型
4.调度策略
1.队列轮转
2.系统调用
3.工作量窃取
4.抢占式调度
总结
前言
随着Go语言市场份额越来越大,很多人都开启了学习Go语言,本文就介绍了Go语言中协程的基础内容以及协程的调度模型。
一、进程、线程、协程
1.进程
进程是应用程序的启动实例,是系统进行资源分配和调度的基本单位,每个进程都有独立的内存空间,不同进程通过进程间的通信方式来通信。
2.线程
线程从属于进程,是进程中的一个实体,线程是CPU调度的基本单位,一个线程由线程ID、当前指令指针、寄存器集合和堆栈组成。
线程不拥有自己的系统资源,它与同属于同一进程的其他线程共享进程所拥有的全部资源,多个线程之间通过共享内存等线程间的通信方式来通信,线程拥有自己独立的栈和共享的堆。
3.协程
协程可以理解为轻量级线程,一个线程可以拥有多个协程,与线程相比,协程不受操作系统调度,协程调度器按照调度策略把协程调度到线程中执行,协程调度器由应用程序的runtime包提供,用户使用go关键字即可创建协程,这也就是GO在语言层面直接支持协程的含义。
4.协程的优势
由于协程运行在用户态,能够大大减少上下文切换带来的开销,并且协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,从而有效的避免了线程的频繁切换,达到了使用少量的线程实现高并发的效果,但是对于一个线程来说每一时刻只能运行一个协程。
5.进程、线程、协程的对比
- 协程既不是进程也不是线程,一个进程可以包含多个线程,一个线程可以包含多个协程,进程、线程、协程不是同一个维度的。
- 进程、线程的切换者都是操作系统,切换时机由操作系统的切换策略决定,而协程的切换者是用户,切换时机由用户自己的程序来决定。
- 进程的切换内容包含页全局目录、内核栈、硬件上下文,切换内容保存在内存中;
- 线程的切换内容包含内核栈、硬件上下文,切换内容保存在内核栈中;
- 协程切换内容是硬件上下文,切换内容保存在用户栈或堆中,由此可以看出协程切换的开销是要小于线程切换的开销的。
- 协程的切换过程只存在于用户态,因此切换效率高。
二、协程
1.协程数据结构
1.协程数据结构
在runtime包下的runtime2.go可以找到协程的结构体:
type g struct {
stack stack // 协程栈
...
sched gobuf
...
param unsafe.Pointer
atomicstatus uint32 // 协程的状态
...
goid int64 // 协程的id
...
}
type stack struct {
lo uintptr // 协程栈的低地址
hi uintptr // 协程栈的高地址
}
type gobuf struct {
sp uintptr // 栈指针,现在运行到哪个方法
pc uintptr // 程序计数器,现在运行到哪个方法的哪一行
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
2.协程执行过程
每一个go里面的线程会从schedule()开始运行,并且schedule()方法开始时是在g0栈上执行,g0栈就是给g0协程在栈空间中分配的内存地址,用来记录函数调用、跳转的信息。
G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。
为什么不用一个普通协程的栈记录函数调用、跳转的信息?
- 普通协程的栈只能记录业务方法函数调用、跳转的信息。
- 当线程还没有拿到协程去运行的时候,是没有普通协程栈的。
在runtime包下的proc.go文件中可以找到schedule():
func schedule() {
...
var gp *g // 即将要运行的协程
var inheritTime bool
...
execute(gp, inheritTime)
}
3.GMP调度模型
Go协程调度包含三个关键实体:
- G:Go协程,每个go关键字会创建一个协程。
- M:工作线程,由操作系统调度。
- P:处理器,这是Go定义的概念,并不是CPU,包含运行Go代码的必要资源,也有调度协程的能力。
工作线程必须持有处理器才可以执行代码,另外工作线程也会被系统调用阻塞。处理器的个数在程序启动时决定,默认情况下等同于CPU的核数。
https://www.topgoer.com/">图片来源->https://www.topgoer.com/
全局队列由多个处理器共享,在早期的调度器中只包含全局队列,多个处理器通过互斥锁来调度队列中的协程,在多核情况下,多个处理器需要经常争抢锁来调度全局队列中的协程,严重影响了并发执行效率,后来就引入了本地队列,这样每个处理器访问自己的本地队列不需要加锁。
处理器中的协程额外再创建的协程会加入本地队列中,如果本地队列已满或者阻塞协程被唤醒,都会被放入到全局队列中去,全局队列中的协程大多来自从系统调用中恢复的协程。
4.调度策略
1.队列轮转
每个处理器P维护着一个自己的本地协程队列,处理器P会依次将协程G调度到工作线程M中执行。同时处理器会周期性的查看全局队列中是否有待运行的协程并将其调度到工作线程中执行,如果不周期性查看全局队列,那么可能会导致全局队列中的协程得不到调度而出现饥饿。
在runtime包下proc.go文件的schedule():
func schedule() {
...
if gp == nil {
// Check the global runnable queue once in a while to ensure fairness.
// Otherwise two goroutines can completely occupy the local runqueue
// by constantly respawning each other.
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
...
}
通过注释和代码可以知道处理器每隔一段时间检查一次全局可运行队列,以确保公平性,每执行61次循环并且全局队列中有协程的时候,就会获取锁,去全局队列中获取协程,以此避免饥饿问题。
2.系统调用
如果一个协程发起系统调用,那么对应的工作线程会被阻塞,该协程所在的本地队列中的其他协程也会被阻塞。例如上图中的M0工作线程的协程发出系统调用,而此时M3工作线程是空闲的,那么M0工作线程就会释放处理器P,然后M3获取到处理器P继续执行处理器本地队列中剩余的协程,只要处理器P不空闲就可以保证充分利用CPU。
->(G0协程发出系统调用,M0释放处理器P )
当M0结束系统调用之后,如果能获取到空闲的处理器P,那么就继续执行刚才发出系统调用的协程G0。如果获取不到空闲的处理器P,那么会将G0放入全局队列。
3.工作量窃取
处理器P中的协程G有可能会一直派生新的协程,可能会导致有的处理器的本地队列有很多协程,有的处理器本地队列一个协程也没有。
因此就出现了工作量窃取的调度策略,当某个处理器的本地队列没有需要调度的协程的时候,就会从其他处理器的本地队列中偷取协程。
4.抢占式调度
调度器会监控每个协程执行时间,如果执行时间过长并且队列中还有其他协程等待执行,那么会把当前正在执行的协程暂停,转而执行队列中的其他协程,避免了协程饥饿问题。
总结
以上就是今天要讲的内容,本文仅仅简单介绍了Go语言协程的概念和调度模型。



