Go的诞生
go语言自诞生(2007年设计、2009年发布Go1)以来,凭借其高并发的特性开始逐渐进入大家的视野,接着在云原生领域大行其道,其中最著名的代表作便是大名鼎鼎的k8s和docker。伴随着其在开源社区的巨大成功,Go成为了时下编程界最耀眼的明星。当然Go的成功绝非偶然,曾经听过这么一个笑话,Go是在C++的编译过程中诞生的,其意思是吐槽C++繁琐而漫长的编译过程。Overall Simplicity 全面的简单。这是Go自诞生以来一贯秉承的价值观。Go语言价值观形成是与Go的初期设计者不无关系的,可以说Go最初设计者主导了Go语言价值观的形成!这就好比一个企业的最初创始人缔造企业价值观和文化一样
图中是Go的三位最初设计者,从左到右分别是罗伯特·格瑞史莫、罗伯·派克和肯·汤普逊。Go初期的所有features adoption是需要三巨头达成一致才行。三位设计者有一个共同特征,那就是深受Unix文化熏陶。罗伯特·格瑞史莫参与设计了Java的HotSpot虚拟机和Chrome浏览器的JavaScript V8引擎,罗博·派克在大名鼎鼎的bell lab侵淫多年,参与了Plan9操作系统、C编译器以及多种语言编译器的设计和实现,肯·汤普逊更是图灵奖得主、Unix之父。关于Unix设计哲学阐述最好的一本书莫过于埃瑞克.理曼德(Eric S. Raymond)的《UNIX编程艺术》了,该书中列举了很多unix的哲学条目,比如:简单、模块化、正交、组合、pipe、功能短小且聚焦等。三位设计者将Unix设计哲学应用到了Go语言的设计当中,因此你或多或少都能在Go的设计和应用中找到这些哲学的影子。
进程
我们都知道计算机CPU的处理速度远高于其它的IO设备(磁盘,网卡等),假设我们一台计算机上面只有一个程序在执行,如果这个程序是计算密集型的程序(假设一个算法要执行一个月后才能得出结果),那么这台计算机没有操作系统也能很好地利用CPU资源,但现实是这种类型的应用很少,大部分的应用只需要少量的计算资源和大量的IO操作,那么这种情况下我们再让计算机只跑一个程序,就会造成计算资源的严重浪费。为了解决这种浪费,人们就引入进程,为了管理进程,人们又设计了操作系统。
要认识进程我们要先从大家写的程序开始,我们编写代码,然后进行编译链接打包,成为一个程序,程序是存在计算机的磁盘中的一个文件而已。当程序被执行起来,它就会从磁盘上被加载进内存中,计算机为了运行这段程序,就会在CPU设置各种寄存器的值,申请堆栈等,像这样一个程序运行起来后执行环境的总和,就是一个进程。为了提高CPU的利用率,我们会启动很多的进程,操作系统就是用来管理这些进程的。前面说到很多进程都要等待IO设备,那么这样的进程就会被操作系统挂起,进程从运行状态到被挂起,然后换成另一个进程获得CPU的执行权,这样的一个过程就叫作进程的上下文切换,这里的进程上下文实际上就是指运行进程所需的CPU寄存器状态和内存状态。进行上下文切换本身也是要消耗一定的CPU资源的。
用户空间和内核空间
一般来说,操作系统的内存会被划分为两块:内核空间和用户空间。为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。当进程运行在内核空间时就处于内核态,当进程运行在用户空间时就处于用户态。
进程用户态和内核态的切换也是要消耗一定资源的,但相对进程上下文切换来说,这部分资源的消耗要小得多。进程一般通过系统调用,或者中断来进入内核空间。
分时系统
前面提到为了提高CPU资源的利用率,我们需要进行进程切换。linux是一个基于时间片的分时操作系统,所谓时间片就是操作系统给每个进程分配一定的时间配额,只要进程获得的cpu资源超过了该时间配额的值,该进程就会被换出去,让其它进程有执行的机会,这样做的好处是让系统中的每个进程都有机会执行而不至于被“饿死”。但是本身“判断某个进程时间片是否用完”这个过程也是一段代码,它也是要消耗CPU资源的,换一种说法它也是要被执行起来才能起到调度的作用,而且这个过程还要每隔一段时间就被执行一下,我们将这个过程称作“检查-调度”。一般要实现这个功能有两种做法,一种是主动式的,一种是被动式的。
主动式
主动式就是将你的进程代码和“检查-调度”写到一块去,比如我们可以每运行n个指令后去检查一下时间片,如果还没到时间,我们再运行n个指令,然后再去检查一下。
被动式
被动式的就是我们将“检查-调度”单独放到一个地方,然后通过某种机制通知CPU去运行这部分代码。这种方式也是linux操作系统使用的方式(时钟中断)。时钟中断打断CPU,中断处理程序调用“检查-调度”代码。
抢占
了解上面的时间片概念以后,抢占就是当一个进程的时间片还没运行完,这时候有一个更高优先级的进程需要立即被执行,我们强行进行进程切换,使得高优先级的进程得以运行,这个过程就叫作抢占。抢占还分为内核抢占和用户抢占,当进程在内核态被抢占就称为内核抢占。
线程模型
线程的实现模型主要有3种:内核级线程模型、用户级线程模型和混合型线程模型。它们之间最大的区别在于线程与内核调度实体KSE(Kernel Scheduling Entity)之间的对应关系上。所谓的内核调度实体KSE 就是指可以被操作系统内核调度器调度的对象实体,有些地方也称其为内核级线程,是操作系统内核的最小调度单元。在linux中,这样一个KSE就是一个轻量级的进程(通过clone系统调用创建出来的)。
内核级线程模型
用户线程与KSE是1对1关系(1:1)。大部分编程语言的线程库(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个不同的KSE静态关联,因此其调度完全由OS调度器来做。这种方式实现简单,直接借助OS提供的线程能力,并且不同用户线程之间一般也不会相互影响。但其创建,销毁以及多个线程之间的上下文切换等操作都是直接由OS层面亲自来做,在需要使用大量线程的场景下对OS的性能影响会很大。
用户级线程模型
用户线程与KSE是多对1关系(M:1),这种线程的创建,销毁以及多个线程之间的协调等操作都是由用户自己实现的线程库来负责,对OS内核透明,一个进程中所有创建的线程都与同一个KSE在运行时动态关联。现在有许多语言实现的 协程 基本上都属于这种方式。这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的数量与上下文切换所花费的代价也会小得多。但该模型有个致命的缺点,如果我们在某个用户线程上调用阻塞式系统调用(如用阻塞方式read网络IO),那么一旦KSE因阻塞被内核调度出CPU的话,剩下的所有对应的用户线程全都会变为阻塞状态(整个进程挂起)。
所以这些语言的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。
混合型线程模型
用户线程与KSE是多对多关系(M:N), 这种实现综合了前两种模型的优点,为一个进程中创建多个KSE,并且线程可以与不同的KSE在运行时进行动态关联,当某个KSE由于其上工作的线程的阻塞操作被内核调度出CPU时,当前与其关联的其余用户线程可以重新与其他KSE建立关联关系。当然这种动态关联机制的实现很复杂,也需要用户自己去实现,这算是它的一个缺点吧。Go语言中的并发就是使用的这种实现方式,Go为了实现该模型自己实现了一个运行时调度器来负责Go中的”线程”与KSE的动态关联。此模型有时也被称为 两级线程模型,即用户调度器实现用户线程到KSE的“调度”,内核调度器实现KSE到CPU上的调度。
GO Routine模型
- G:Goroutine的简称,上面用go关键字加函数调用的代码就是创建了一个G对象,是对一个要并发执行的任务的封装,也可以称作用户态线程。属于用户级资源,对OS透明,具备轻量级,可以大量创建,上下文切换成本低等特点。
- M:Machine的简称,在linux平台上是用clone系统调用创建的,其与用linux pthread库创建出来的线程本质上是一样的,都是利用系统调用创建出来的OS线程实体。M的作用就是执行G中包装的并发任务。Go运行时系统中的调度器的主要职责就是将G公平合理的安排到多个M上去执行。其属于OS资源,可创建的数量上也受限了OS,通常情况下G的数量都多于活跃的M的。
- P:Processor的简称,逻辑处理器,主要作用是管理G对象(每个P都有一个G队列),并为G在M上的运行提供本地化资源。
用户态调度
1 | func main() { |
以上代码将P设置为1个,然后启动10个goroutine,每个goroutine对数组里相应的索引位置进行递增操作。没写过go的同学一般会认为这段程序运行不会有什么问题,但实际上这段程序的main函数运行到time.Sleep之后,就再也没机会运行了。这也是goroutine在用户态调度所存在的问题:用户态调度器只能“主动式”的运行“检查-调度”代码,这是因为用户态没有类似中断这种可以打断CPU的能力,因而它没办法采用“被动式的”方法。Go运行时,将“检查-调度”代码放在了每次函数调用之前,我们观察这段代码,每个goroutine里面都是一个无限循环的内存操作而已,不存在函数调用,因而,“检查-调度”代码永远都没有机会被执行,所以其它的goroutine都被活活“饿死了”。要解开这个问题,我们需要“主动的”调用“检查-调度”代码,在go里面也就是runtime.Gosched(),在for循环里面加上这行代码就可以解决这个问题。
总结
虽然goroutine很强大,但凡事都是有利有弊,我们只有深入了解其背后的机理,才能趋利避害,既能充分利用其高效的特性,又能避开其埋下的一些坑。