1.进程和线程
关系
- 线程是调度的基本单位,而进程则是资源拥有的基本单位
- 每个进程都有一个完整的虚拟地址空间,每个线程会共享同一进程的虚拟地址空间.
进程上下文切花需要:
- 切换cpu寄存器和程序计数器
- 保存之前进程的虚拟内存和用户栈,还要刷新新进程的虚拟内存和用户栈
- tlb也要更新,tlb是报错虚拟内存到实际物理内存地址的映射,每个进程的虚拟内存都不一样,因此进程变化了,tlb的一些数据就是脏页了.
发生进程上下文切换的几种情况:
- 进程执行结束
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。
线程上下文切换:
因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间.
2.协程
进程和线程已经极大提高了系统的并发能力,但是每个任务都创建一个线程不太现实,每个线程都要使用M级别的内存,线程多了,线程的调度也会很消耗cpu.
因此协程出现了,cpu并不知道协程的存在,用户态就可以执行协程的切换.协程是k级别内存消耗
我们看下golang的调度器的演进过程
2.1 M:G调度器
有一个全局的协程队列,系统线程(M)会从G队列中存取协程.因为有多个线程进行操作,因此需要加锁.
当时一些阻塞情况怎么办?
- Sending and Receiving on Channel. //channel
- Network I/O. //网络io
- Blocking System Call. //阻塞系统调用
- Timers. //定时器
- Mutexes. //互斥锁
2.1.1 Channel
Each Channel has a recvq (waitq) that is used to store the blocked goroutines which are trying to read data from the channel.
每个channel都会保存一个队列(waitq),这个队列里都是在阻塞等着通知的协程.
Unblocked goroutine after channel operation is put into Run queue by the channel itself.
channel自身就会将不再阻塞的goroutine放入到runQueue.
2.2.2 系统调用
阻塞系统调用(blocking system call)
由于要执行系统调用,M2线程肯定执行不了其他协程任务了.
我们会唤醒另一个线程,调度协程.
当系统调用结束后,我们会把这个协程放回runq.避免超额的线程任务.
The initial setting is 10,000 threads, the program will crash if it exceeds.
非阻塞系统调用
例如在java中使用多路复用机制时,需要先创建selector,然后将感兴趣的事件注册到selector上,当可读/可写/accept事件到来的时候,会对相应的事件进行处理.但是go中,runtime为我们隐藏了很多细节,让我们貌似同步的就能处理这些异步的读写.那么runtime怎么做的呢?大概逻辑就是,当不可读时,会把当前读操作的协程挂起.并由一个sysmon的线程(golang runtime启动时创建的),定时去轮训网络(fd),看哪个fd就绪,如果有就绪的,就把对应的goroutine放到runq中. netpoll机制源码,会在之后进行分析.
但是,这里还会有些问题:
- 缓存一致性保证的开销。
- 在创建,销毁和调度Goroutine G时进行激烈的锁竞争
既然有锁,那我们在每个线程上搞一个队列不行吗?
就是在每个线程都有一个队列.
now,我们有几个队列.
- Local Run queue //线程本地q
- Global Run queue //全局q
- Network Poller //网络轮训q
那么,当我们调度一个协程时,需要以这么一个顺序去拿G
- Local Run queue
- Global Run queue
- Network Poller
- Work Stealing
这个work stealing,就是去别的协程队列拿,这种思想还是很重要的,还记得sync.pool源码吗?
到目前为止,我们的模型已经可以解决如下问题:
- It can handle Parallel Execution (Multiple threads). //并行问题(多线程)
- Handles Blocking System call and network I/O. //阻塞系统调用和网络io
- Handles Blocking User level (on channel) calls. //channel
- Scalable. //扩展.通过本地队列
but,还不是最高效,因为还存在一些问题.
在系统调用时,我们会把当前线程阻塞,并开启一个新线程,一个事实就是线程可能比cpu核数多很多.那么就会产生固定的开销.
1.窃取工作时,需要扫描所有的线程的goroutine队列.甭管这些线程底下有没有goroutine.
2.同样的,在gc,内存分配时,也要扫描大量的没有用的线程.
因此,我们引入M:P:G三层模型,来解决效率问题.
2.2M:P:G调度器
GPM:
- G: 表示goroutine,存储了goroutine的执行stack信息、goroutine状态以及goroutine的任务函数等;另外G对象是可以重用的。
- P: 表示逻辑processor,P的数量决定了系统内最大可并行的G的数量(前提:系统的物理cpu核数>=P的数量);P的最大作用还是其拥有的各种G对象队列、链表、一些cache和状态。
- M: M代表着真正的执行计算资源。在绑定有效的p后,进入schedule循环;而schedule循环的机制大致是从各种队列、p的本地队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到m,如此反复。M并不保留G状态,这是G可以跨M调度的基础。
Go运行时将首先根据计算机的逻辑CPU数量(或根据请求)创建固定数量的逻辑处理器P。这样.窃取工作时,就可以只扫描固定的P就行了.!!!! 内存分配或者垃圾回收也一样
1.刚开始学习时候的迷惑,到底是谁在调度.
源码: proc.go
1.schedinit(),初始化最大线程和P个数
2.newproc(),创建一个协程,这是主协程(proc.go--main).那么这个main是干啥的呢?
- 启动监控线程(sysmon),这个线程的任务就是承担着用户协程的全局调度任务.注意这个线程无需绑定M
- 启动垃圾回收
- 执行main函数,注意这个main函数才是我们自己写的那个main函数。
那么核心看看sysmon
sysmon每20us~10ms启动一次
- 释放闲置超过5分钟的span物理内存(还记得tcmalloc吗);
- 如果超过2分钟没有垃圾回收,强制执行;
- 将长时间未处理的netpoll结果添加到任务队列;
- 向长时间运行的G任务发出抢占调度;
- 收回因syscall长时间阻塞的P;
解释:
1.调用netpoll读取当前ready的socket所对应的goroutine,然后把他们加入到全局的等待执行的goroutine队列中,并且如果当前有空闲的P,会调用startm()直接唤醒sleep的M或者新建M来执行这些goroutine
2.抢占式调度
如果有些协程一直执行,那么需要抢占他的P,让别的协程运行一会.调用的retake函数
golang在编译时,会给每个方法前面插入一些代码.主要逻辑就是插入一些标志位.如果一个G任务运行10ms,sysmon就会认为其运行时间太久而发出抢占式调度的请求。一旦G的抢占标志位被设为true,那么待这个G下一次调用函数或方法时,runtime便可以将G抢占,并移出运行状态,放入P的local runq中,等待下一次被调度.那么当那个协程再调用函数或者调用一些系统调用.检查到标志,就会停下来,放到P的队列中,那么自然再重新分配一个G给M运行.(基于协作的抢占)
但是,如果你的协程不去调用外部函数或者系统调用,那么将无法被抢占,因为你根本不给机会去check,这个问题在1.14基于信号的抢占式调度解决.(基于信号的抢占)
一些流程可以参考下面这个博客.很清晰,不多说了 图解调度原理https://segmentfault.com/a/1190000018775901
3.channel或者io阻塞
如果G被阻塞在某个channel操作或network I/O操作上时,G会被放置到某个wait队列中,而M会尝试运行下一个runnable的G;如果此时没有runnable的G供m运行,那么m将解绑P,并进入sleep状态。当I/O available或channel操作完成,在wait队列中的G会被唤醒,标记为runnable,放入到某P的队列中,绑定一个M继续执行。
4.system call阻塞情况下的调度
M-G sleep; P找另一个M(idle或者new)
如果G被阻塞在某个system call操作上,那么不光G会阻塞,执行该G的M也会解绑P,与G一起进入sleep状态。
如果此时有idle的M,则P与其绑定继续执行其他G;如果没有idle M,但仍然有其他G要去执行,那么就会创建一个新M。
当阻塞在syscall上的G完成syscall调用后,G会去尝试获取一个可用的P,如果没有可用的P,那么G会被标记为runnable,之前的那个sleep的M将再次进入sleep。
5.
//todo 源码分析.最近在找工作,等找完再补上
参考: