轻量级进程模型:
用同步IO的方法写程序的逻辑,第二点是用尽可能多的并发进程来提升IO并发的能力。
核心思想,第一:让每个轻量级进程的资源占用更小,创建进程个数的唯一限制便是内存大小。每个进程资源占用越小的内存就能产生越高的并发性,内存资源是宝贵的,反而也是非常廉价的。第二:更轻量级的切换成本,把进程做到用户态,这样切换成本和函数的调用基本在同一个数量级,切换成本非常的低,如果是操作系统切换进程则需要从用户态到核心态再到用户态的切换。
轻量级进程的实现原理:
进程,进程本质上就是一个栈加上寄存器的状态。进程切换,保存当前的寄存器,让后把寄存器修改为另外一个寄存器状态,这样就是等同于切换了栈,栈的位置其实也是寄存器维持的(esp/ebp)。
轻量级进程的底层还是线程池加上异步IO,你可以把这个线程池中的每个线程想像成虚拟的CPU(vCPU)。逻辑的轻量级进程routine的个数通常远大于物理线程的,每个物理线程的同一时刻只有一个routine在跑,更多的routine是在等待当中。但是这个等待中的routine有两种形式,一种是IO等待,还有一种是IO已完成的等待,或者是本身没有任何前置条件,可以随时参加调度。如果某一个物理的线程vcpu他的routine主动的或者是以为IO触发了一个调度,把线程vcpu让出来,这个时候就可以让一个新的routine跑在上面,也就是从等待当中并且可以满足调度的routine参与调度,按某种优先级算法选择一个routine。所以轻量级进程调度原理是这样的:
他是用户态的线程,让后有一个非抢占式的调度机制,调度时机主要是由IO操作触发的。发生IO操作的时候,IO操作的函数是这样的:先发起一个异步的IO请求,发起后把这个routine状态设置为等待IO完成,让后让出CPU,这是时候触发调度器,调度器查看是否有任务等待调度,有就切换过去。然后再IO事件完成的时候,IO完成后会有个回掉函数作为IO完成的时间通知,这个会被调度器接管,回到函数把这个IO操作所属的routine设置为Ready,可以参与就可以调度了。还有一个种是routine主动的让出CPU,在这种情况下,routine的状态在切换的时候还是Ready的,任何时候都可以切换到它。非抢占操作的基础的调度触发条件:IO操作,IO完成时间,主动让出CPU。用户态的线程也可以实现抢占式的调度,调度器起一个定时器,定时器发出一个定时任务,或者任务检查每个正在执行当中的routine状态,发现CPU占用时间过长就让他主动过的让出CPU,这就是可以实现抢占式的调度。
Go runtime的调度器:在了解Go的运行时的scheduler之前,需要先了解为什么需要它,因为我们可能会想,OS内核不是已经有一个线程scheduler了嘛?
熟悉POSIX API的人都知道,POSIX的方案在很大程度上是对Unix process进场模型的一个逻辑描述和扩展,两者有很多相似的地方。 Thread有自己的信号掩码,CPU affinity等。但是很多特征对于Go程序来说都是累赘。 尤其是context上下文切换的耗时。另一个原因是Go的垃圾回收需要所有的goroutine停止,使得内存在一个一致的状态。垃圾回收的时间点是不确定的,如果依靠OS自身的scheduler来调度,那么会有大量的线程需要停止工作。
单独的开发一个GO得调度器,可以是其知道在什么时候内存状态是一致的,也就是说,当开始垃圾回收时,运行时只需要为当时正在CPU核上运行的那个线程等待即可,而不是等待所有的线程。
用户空间线程和内核空间线程之间的映射关系有:N:1,1:1和M:N
N:1是说,多个(N)用户线程始终在一个内核线程上跑,context上下文切换确实很快,但是无法真正的利用多核。
1:1是说,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。
M:N是说, 多个goroutine在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。
Go的调度器内部有三个重要的结构:M,P,S
M:代表真正的内核OS线程,和POSIX里的thread差不多,真正干活的人
G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
P:代表调度的上下文,可以把它看做一个局部的调度器,使go代码在一个线程上跑,它是实现从N:1到N:M映射的关键。
图中看,有2个物理线程M,每一个M都拥有一个context(P),每一个也都有一个正在运行的goroutine。
P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。
图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。
为何要维护多个上下文P?因为当一个OS线程被阻塞时,P可以转而投奔另一个OS线程!图中看到,当一个OS线程M0陷入阻塞时,P转而在OS线程M1上运行。调度器保证有足够的线程来运行所以的context P。
图中的M1可能是被创建,或者从线程缓存中取出。当MO返回时,它必须尝试取得一个context P来运行goroutine,一般情况下,它会从其他的OS线程那里steal偷一个context过来,如果没有偷到的话,它就把goroutine放在一个global runqueue里,然后自己就去睡大觉了(放入线程缓存里)。Contexts们也会周期性的检查global runqueue,否则global runqueue上的goroutine永远无法执行。
另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了一个上下文P闲着没事儿干而系统却任然忙碌。但是如果global runqueue没有任务G了,那么P就不得不从其他的上下文P那里拿一些G来执行。一般来说,如果上下文P从其他的上下文P那里要偷一个任务的话,一般就‘偷’run queue的一半,这就确保了每个OS线程都能充分的使用。
ps:更多请参考 golang的goroutine是如何实现的?
Erlang和golang的区别:
第一对锁的态度不同,第二对异步IO的态度不同,第三消息机制不同。Erlang对锁非常反感,认为变量不可变可以很大程度避免锁。
Golang的观点是锁确实有很大的负担,但是锁基本上是无法避免的,一旦有人共享状态并且互相抢占去改变他,这时候锁是必须存在的。
Erlang服务器是单进程的,是逻辑上就没有并发的东西,一个Process就是一个执行体,所以Erlang的服务器和golang的服务器不一样,golang的服务器是多进程的(goroutine)一起构成的一个服务器。每个请求建立一个独立的进程(goroutine)。但是Erlang不同,一个服务器就是一个单进程的,所有的并发请求都进入到了进程邮箱,然后这个服务器从进程邮箱里取邮件(请求的内容)处理,Erlang的服务器并没有并发的请求,所以不需要所锁。Erlang的高并发实现,第一:每个Erlang的物理进会有很多的服务器,每个服务器是互相无干扰的,他们可以并发。第二是单服务器高并发使用的是异步IO。
go认为何时都不应该有异步IO的代码,Erlang则是在异步IO的基础上加上轻量级进程模型的混杂。
Golang对并发的支持,第一:价值回归,golang最重要的事情是让执行成本降低,golang的栈最小可以到4K。第二:把执行体作为语言内建的标准设施(golang的代码风格只有标准化得一种)。go得并发模型是最古老的并发模型,该并发模型包括,routine,原子操作,互斥体,同步,消息,同步IO。
Ps:附带讲解ppt