在golang开发的过程中相信大家最经常接触的就是go协程,但对于什么是协程以及什么是go协程,可能还停留在go出去的就是协程这个表面认知,这不仅会给我们项目带来隐藏的问题。对此,结合一些资料,从操作系统的角度来对进程,线程,协程进行介绍,并试着说明协程和goruntine是什么。
一、概念理解
1、进程
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
2、线程
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,而拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程也由操作系统调度(标准线程是这样的)。只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
3、协程
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
一个应用程序一般对应一个进程,一个进程一般有一个主线程,还有若干个辅助线程,线程之间是平行运行的,在线程里面可以开启协程,让程序在特定的时间内运行。
二、理解区分
需要区分进程、线程(内核级线程)、协程(用户级线程)三个概念。
(1)进程、线程 和 协程 之间概念的区别
对于 进程、线程,都是有内核进行调度,有 CPU 时间片的概念,进行 抢占式调度(有多种调度算法)
对于 协程(用户级线程),这是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的 CPU 控制权切换到其他进程/线程,通常只能进行 协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。对于线程,每个线程都有一个id,这个在线程创建时就会返回,所以可以很方便的通过id操作某个线程。但是在goroutine内没有这个概念,这个是go语言设计之初考虑的,防止被滥用,不能在一个协程中杀死另外一个协程,编码时需要考虑到协程什么时候创建,什么时候释放。
(2)goroutine 和协程区别
本质上,goroutine 就是协程。 不同的是,Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU (P) 转让出去,让其他 goroutine 能被调度并执行,也就是 Golang 从语言层面支持了协程。Golang 的一大特色就是从语言层面原生支持协程,在函数或者方法前面加 go关键字就可创建一个协程。
(3)其他方面的比较
1. 内存消耗方面
每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少。
goroutine:2KB
线程:8MB
2. 线程和 goroutine 切换调度开销方面
线程/goroutine 切换开销方面,goroutine 远比线程小
线程:涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等。
goroutine:只有三个寄存器的值修改 - PC / SP / DX.
三、goroutine协程
1. goroutine协程实现原理
线程是操作系统的内核对象,多线程编程时,如果线程数过多,就会导致频繁的上下文切换,这些 cpu 时间是一个额外的耗费。所以在一些高并发的网络服务器编程中,使用一个线程服务一个 socket 连接是很不明智的。于是操作系统提供了基于事件模式的异步编程模型。用少量的线程来服务大量的网络连接和I/O操作。但是采用异步和基于事件的编程模型,复杂化了程序代码的编写,非常容易出错。因为线程穿插,也提高排查错误的难度。
协程,是在应用层模拟的线程,他避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。举个例子,一个高并发的网络服务器,每一个socket连接进来,服务器用一个协程来对他进行服务。代码非常清晰。而且兼顾了性能。
协程和线程的原理是一样的,当 a线程 切换到 b线程 的时候,需要将 a线程 的相关执行进度压入栈,然后将 b线程 的执行进度出栈,进入 b线程 的执行序列。协程只不过是在 应用层 实现这一点。但是,协程并不是由操作系统调度的,而且应用程序也没有能力和权限执行 cpu 调度。怎么解决这个问题?
协程是基于线程的。内部实现上,维护了一组数据结构和 n 个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这 n 个线程从队列中拉出来执行。这就解决了协程的执行问题。那么协程是怎么切换的呢?答案是:golang 对各种 io函数 进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步 io函数,当这些异步函数返回 busy 或 bloking 时,golang 利用这个时机将现有的执行序列压栈,让线程去拉另外一个协程的代码来执行,基本原理就是这样,利用并封装了操作系统的异步函数。包括 linux 的 epoll、select 和 windows 的 iocp、event 等。
由于golang是从编译器和语言基础库多个层面对协程做了实现,所以,golang的协程是目前各类有协程概念的语言中实现的最完整和成熟的。十万个协程同时运行也毫无压力。关键我们不会这么写代码。但是总体而言,程序员可以在编写 golang 代码的时候,可以更多的关注业务逻辑的实现,更少的在这些关键的基础构件上耗费太多精力。
2. goroutine调度
线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这代价就小了;另外,协程的切换时间点是由调度器决定的,而不是系统内核决定的,尽管他们切换点都是时间片超过一定阈值,或者进入I/O或睡眠等状态;再次,还有垃圾回收的考虑,因为go实现了垃圾回收,而垃圾回收的必要条件时内存位于一致状态,这就需要暂停所有的线程,如果交给系统去做,那么会暂停所有的线程使其一致,而在go里面调度器知道什么时候内存位于一致状态,那么就没有必要暂停所有运行的协程。
对线程来说,有三种映射(用户线程与内核线程的因素)模型:
go调度里面有三个角色:三角形M代表内核线程,正方形P代表上下文,圆形G代表协程:
下面图我们看到他们之间的对应规则:一个M对应一个P,一个P下面挂多个G,但一个时候只有一个G在跑,其余都是放入等待队列,等待下一次切换时使用。
那么假如一个运行的协程G调用syscall进入阻塞怎么办?如下图左边,G0进入阻塞,那么P会转移到另外一个内核线程M1(此时还是1对1)。当syscall返回后,需要抢占一个P继续执行,如果抢占不到,G0挂入全局就绪队列runqueue,等待下次调度,理论上会被挂入到一个具体P下面的就绪队列runqueu(区别于全局runqueue)。
假如一个P0下面的所有G都跑完了,怎么办?这时候会从别的P1下面就绪队列抢占G进行运行,个数为P1就绪队列的一半。
四、个人总结
总体上,协程与线程主要区别是它将不再被内核调度,而是交给了程序自己而线程是将自己交给内核调度,所以也不难理解golang中调度器的存在。协程避免了无意义的调度,由此可以提高性能,但也因此,程序员必须自己承担调度的责任,同时,协程也失去了标准线程使用多CPU的能力。协程的概念并不是与线程对应的,应该说和函数调用 call/return对应(不难理解为什么会把golang中的goruntine当作一个以函数为单位的执行单元)。它们的区别在于协程允许一个函数有多个入口、出口(逻辑上的),并且在切换到另一个函数执行时,允许使用一个新的context(包括调用栈)。正是有了这个机制基础,再加上CPU支持了保护模式,操作系统就可以接着实现进程、线程了。
明白了协程原理,进程和线程就比较好理解了。个人觉得进程与线程其实最核心的是隔离与并行。进程可看作为分配资源的基本单位,比如new出了一块内存,就是操作系统将一块物理内存映射到你的进程地址空间上(进程创建必须分配一个完整的独立地址空间),这块内存就属于这个进程,进程内的所有线程都可以访问这块内存,其他进程就访问不了,其他类型的资源也是同理。所以进程是分配资源的基本单位,也是我们说的隔离。线程作为独立运行和独立调度的基本单位,进而我们可以认为线程是进程的一个执行流,独立执行它自己的程序代码。线程上下文一般只包含CPU上下文及其他的线程管理信息,线程创建的开销主要取决于为线程堆栈的建立而分配内存的开销,这些开销并不大。线程还分为系统级别和用户级线程,用户级别线程对引起阻塞的系统调用的调用会立即阻塞该线程所属的整个进程,而内核实现线程则会导致线程上下文切换的开销跟进程一样大,所以经常的折衷的方法是轻量级进程(Lightweight)。在 Linux 中,一个线程组基本上就是实现了多线程应用的一组轻量级进程。线程的作用就在于充分使用硬件CPU,也就是我们说的并行。
从应用角度来说,我们一般将协程理解为用户态轻量级线程,是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户的程序自己调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的CPU控制权切换到其他进程/线程,通常只能进行协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。但我们以上说的协程和golang中的协程是不一样的。就像开头说的很多人将go的协程理解为我们常说的协程,但深究它们的名称不难看出,一个是goruntine,另一个是Coroutine,是不一样的。golang语言作者Rob Pike也说,“Goroutine是一个与其他goroutines 并发运行在同一地址空间的Go函数或方法。一个运行的程序由一个或更多个goroutine组成。它与线程、协程、进程等不同。它是一个goroutine“。 Go 协程意味着并行,协程一般来说不是这样的;Go 协程通过通道来通信而协程通过让出和恢复操作来通信;而且Go 协程比协程更强大。因为Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,也就是Golang 有自己的调度器,工作方式基本上是协作式,而不是抢占式,但也不是完全的协作式调度,例如在系统调用的函数入口处会有抢占。当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU (P) 转让出去,让其他 goroutine 能被调度并执行,也就是我们为什么说 Golang 从语言层面支持了协程。简单的说就是golang自己实现了协程并叫做goruntine。
在golang中进程和线程概念基本和我们常说的一致,大多调用系统的API实现,例如os 包及其子包 os/exec 提供了创建进程的方法,在 Unix 中,创建一个进程,通过系统调用 fork 实现(及其一些变种,如 vfork、clone),在windows中通过系统调用CreateProcess等。相信熟悉golang的都用过GOMAXPROCS,很多人都简单地理解为这个是限制进程数量,这样理解显然不仅是望文生义还有就是对进程和线程理解不够,官方解释就很准确: GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously。很清楚,就是限制cpu数,限制cpu数,本质上是什么,就是限制并行数,并行数即同时执行数量,执行单元即线程,即限制最大并行线程数量。
goruntine的优势在于并行和非常低的资源使用,体现在内存消耗方面和切换(调度)开销方面,每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少,只有2KB,而线程则需要8MB;线程切换涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等;而goroutine 只有三个寄存器的值修改 - PC / SP / DX。