大家都知道,通常情况下一个简单的串行执行的程序在计算机内部被执行的时候是按照指令的顺序一步一步执行,这种情况下CPU在每一条指令必须执行结束才会有下一条指令被执行,整个过程是严格有序的。那么如果遇到某些场景:
a. 比如CPU指令执行速度远远大于结果返回的速度时(比如IO操作)时,我们如果还是顺序执行,那么大把的时间就会被浪费,这样在IO操作过程中CPU实际上是处于空闲,这个时候就需要多个操作同时执行。
b. 有时我们确实需要有时候同时处理多个并行任务的时候,那么一个CPU可能根本没有办法执行,这个时候如果有多个核心能同时处理任务的话,我们的整个性能就会大幅度的提升。
实际上上面提到的两种都是典型的并发场景,正因为有了这些场景,才会出现所谓的并发问题。在讨论并发之前,我们必须明白,CPU一个核心在执行程序的时候没有真正物理设备上的并行处理 (Simultaneously Processing), 一个CPU的一个核心永远只会在一个时间段处理一个任务或者一条指令。那么我们提到的两个场景分别是利用单核CPU时间片 (Time slice) 来进行抢占式调度 (Preemption) 和真正的多核多线程并行执行。
什么是协程 (Coroutine) 和线程 (Thread)
解释完什么是并发了,我们看一下到底Golang的并发指的是什么。
大家都知道Golang最大的亮点之一就是在语言层面实现了Goroutine,Go把它叫做Go协程,然而协程(Coroutine)这个概念并不是什么新东西,早在上世纪60年代初协程便首次出现在了公开发表的刊物上,比后来的多线程和多核早了很多年。他是一种非抢占式的多任务处理机制,和我们刚才提到的抢占式有根本的区别。
抢占式的多任务机制最大的代表就是线程,它有几个特点,抢占式,内核态,操作系统控制。而协程则恰恰相反:非抢占式,用户态,调用方控制。
在通常的coroutine实现中,每一个coroutine都会有一个函数yield,专门处理当资源可以被别的coroutine使用时将CPU交出去的操作,这是一种主动的环境切换。而在thread的处理过程中,操作系统会充当这个指挥者,我们每一个thread自己并不知道什么时候会被操作系统挂起,什么时候CPU又会回来执行自己的任务。而在操作系统层面,还有几个非常大的不同:
a. 多线程/多进程
传统的thread在处理的时候和多进程(multiprocess)最大的区别就是前者互相share一个memory区域,而后者各是各的。因此,在上下文切换过程中,多线程最大的优势就是操作系统在做schedule的时候由于堆内存是在受保护的一个进程内,因此各种切换会比多进程要高效很多。但是即使在这种情况下,大量的CPU临时状态保存与读取也是非常昂贵的资源消耗。
b. 协程
大家都知道,用户态的操作在大部分情况下都比内核态要cheap很多,因此coroutine在生命周期内几乎不受任何操作系统的内核调度影响,这是非常轻量的操作,而且yield的时候自己已经将自己的状态暂存于自己的的memory区域内,因此当CPU被别人使用完yield回来时它能快速重新回到运行态。
c. Goroutine
那么goroutine在这里是怎么做的呢?按照go官方的blog描述,goroutine的生命周期管理默认是由go的runtime完成的。包括调度,yield,状态保存,上下文切换。我们可以看出goroutine实际上给了一个非常折中的方案:
用runtime而不是像thread一样由操作系统来做切换的操作,避免内核态的频繁syscall导致的系统开销;
同时不用goroutine自己去管理状态暂存和上下文切换,极大降低了编码难度。另外,按照go官方的介绍,每个goroutine实际上都复用在操作系统thread上,这意味着,单核CPU也可以随随便便开启成千上万个goroutine也毫不费力。
我们看到了在多任务调度中goroutine所凸显出来的设计上的优化,在内存分配上goroutine有什么值得称赞的地方呢?
先看一下操作系统中通常情况下是怎么处理进程memory的:
上图就是操作系统中通常处理进程memory的模型,一个进程的memory被分为三个部分,stack,Heap和Gaurd Page, stack的地址从高位到低位增长,heap的地址从低位到高位,这样涨下去总会有重合的时候,所以操作系统划分了一个不可访问的区域叫guard page用来提前告知stack和heap不让他们出现地址分配的冲突。
在thread模型中,唯一与进程区别的就是stack和guard page区域被每一个thread划分成各自的区域,多个stack和多个guard page。这样就会带来一个很严重的问题,每个thread在创建时候操作系统必须要提前预置足够的stack空间保证thread的调用期间不会出现stack overflow导致提前hit它的guard page区。于是线程的内存开销是非常大的。
那么Goroutine是怎么做的呢?
在Go的runtime每次创建goroutine的时候,由于不是直接操作系统的分配方式,因此runtime开创性的从整个进程的heap中分配goroutine的stack,如下图:
因此,Go的runtime根本无需提前预置太大空间,只需要满足每个goroutine最基本的stack开销即可,因为从heap上划分空间非常cheap,因此在创建后go的runtime回去检查创建后的goroutine的stack是否够用,不够用就把原stack复制一份再划大一点儿重创一次就可以了。那么这个最基本的stack开销到底是多大呢?go1.5以后每个是2KB
这样从内存模型上Go的做法也保证了goroutine的足够轻量和简单。
通常在thread模型下,我们同步线程的的方式都是加锁同步,锁的开销是巨大的尤其是在一些并发模型下多个线程交互非常频繁的时候lock和unlock会直接降低整个系统的性能。
而在Go的channel下,无锁的goroutine同步方式更好的解决了多任务同步的问题,尤其是带有buffer的channel通过non-blocking的消息同步可以把整个锁操作带来的开销降低的同时,让并发代码更好理解,更容易维护。
那么,我们现在回答一下最开始的问题,Golang的并发指的是什么?很显然,我们通常用多线程,多进程实现的并发模型在别的语言下和goroutine在Golang下相比,从概念上来说没有根本上的区别,都是利用CPU的时间片或者多核优势同时处理多个子任务。然而goroutine在底层的多种优化机制让开发者从繁杂的非业务处理中解放,带来的程序上的性能提升是次要,而对工程上的简便,轻量和可维护才是很多人为之着迷的主要原因。
欢迎关注课程《Go语言实战流媒体视频网站》
共同学习,写下你的评论
评论加载中...
作者其他优质文章