进程、线程、协程与goroutine

简介

相信作为服务端开发尤其是高性能服务开发的猿们,曾经面试都曾经被问到进程,线程之类的问题,作为操作系统最核心的概念,这些X程就像我们的一个个工具,是我们在开发过程中经常接触的概念
对于这些概念的不清晰我们便发现写的代码功能是对的,代码是渣的,将直接体现在我们代码的低效率,高bug率并附带问题出现都不知到问题出在哪里
作为新时代的猿我们原不需要那么多时间去解bug,我们需要更多时间陪女票,不是吗?

声明

本文参考自
Go runtime的调度器
进程、线程、协程与goroutine
goroutine
进程、线程、轻量级进程、协程和go中的Goroutine 那些事儿
由本文作者整理,想看原文请自行跳转

正文

基本概念

进程:独立的栈空间,独立的堆空间,进程之间调度由os完成。

线程:独立的栈空间,共享堆空间,内核线程之间调度由os完成。

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

同步调用:就是由调用者主动等待这个调用的结果。发出一个调用,在没有得到结果之前,该调用就不返回。一旦调用返回,就得到返回值了。

异步调用:调用在发出之后,这个调用结果就直接返回了。当一个异步调用过程在发出之后,调用者不会立即得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或者通过函数回调来处理这个调用。

阻塞与非阻塞:关注的是:程序在等待调用结果(消息 返回值)时的状态

阻塞调用:是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。

非阻塞调用:是指,在不能立刻得到结果之前,该调用不会阻塞当前线程,当前线程还会继续执行下去。

协程

协程的概念其实比线程还要早,不过是这几年才被大家熟知,线程在实现上可以说是一个特化的1:N协程
协程的核心机制是什么?
学过汇编的童鞋应该记得实模式编程下,理论上操作系统只能加载一个进程,那个时候进程要使用系统服务的方法非常简单,就是手工产生一个中断,然后我们就知道了会触发CPU的中断处理机制,会保护好发起中断的现场,然后会将当前执行地址设置为对应的中断处理函数的地址,处理完以后回到刚刚保存的现场。
其实这个过程,本质上就是协程的核心流程了。是不是觉得很熟悉?这不就是调用函数的call/return嘛,但这是一种和call/return不同的逻辑路径跳转方式
区别是基于call/return方式系统进入处理函数,被调用函数会继续使用调用函数的context就是栈,返回的时候就会释放栈资源
而基于中断的方式,发起方和处理方可以使用自己的context,系统通过中断的方法来达到提供系统服务的目的
一个很重要的原因就是可以保障在很多情况下,都能让系统处理函数至少能有一个可用的context(属于系统的资源),这样当用户进程的context资源耗尽的情况下,也能调用一些系统服务
假设调用 go func(1,2,3) ,func函数会在一个新的go线程中运行,显然新的goroutine不能和当前go线程用同一个栈,否则会相互覆盖。所以对go关键字的调用协议与普通函数调用是不同的。不像常规的C语言调用是push参数后直接call func,上面代码汇编之后会是:

  • 参数
  • push func
  • push 12
  • call runtime.newproc
  • pop
  • pop

12是参数占用的大小。在runtime.newproc中,会新建一个栈空间,将栈参数的12个字节拷贝到新栈空间并让栈指针指向参数。
这时的线程状态有点像当被调度器剥夺CPU后一样,pc,sp会被存到类型于类似于进程控制块的一个结构体struct G内。
func被存放在了struct G的entry域,后面进行调度时调度器会让goroutine从func开始执行。
defer关键字调用过程类似于go,不同的是call的是runtime.deferproc,函数返回时,如果其中包含了defer语句,不是调用add xx SP, return,而是call runtime.deferreturn,add 48 sp,return
可以说,协程与线程主要区别是它将不再被内核调度,而是交给了程序自己,而线程是将自己交给内核调度
所以也不难理解golang中调度器的存在。
所以我们可以看出,协程的概念并不是与线程对应的,应该说和函数调用 call/return对应(也不难理解为什么会把golang中的goroutine当作一个以函数为单位的执行单元)。
它们的区别在于协程允许一个函数有多个入口、出口(逻辑上的),并且在切换到另一个函数执行时,允许使用一个新的context(包括调用栈)。正是有了这个机制基础,再加上CPU支持了保护模式,操作系统就可以接着实现进程、线程了。

进程和线程

协程明白了原理,进程和线程就更好理解了。
我觉得进程与线程其实最核心的是隔离与并行
进程可看作为分配资源的基本单位,比如你new出了一块内存,就是操作系统将一块物理内存映射到你的进程地址空间上(进程创建必须分配一个完整的独立地址空间),这块内存就属于这个进程,进程内的所有线程都可以访问这块内存,其他进程就访问不了,其他类型的资源也是同理。
所以进程是分配资源的基本单位,也是我们说的隔离。
线程作为独立运行和独立调度的基本单位,进而我们可以认为线程是进程的一个执行流,独立执行它自己的程序代码。
线程上下文一般只包含CPU上下文及其他的线程管理信息,线程创建的开销主要取决于为线程堆栈的建立而分配内存的开销,这些开销并不大。
线程还分为系统级别和用户级线程,用户级别线程对引起阻塞的系统调用的调用会立即阻塞该线程所属的整个进程,而内核实现线程则会导致线程上下文切换的开销跟进程一样大,所以经常的折中的方法是轻量级进程(Lightweight)。
在 Linux 中,一个线程组基本上就是实现了多线程应用的一组轻量级进程。
线程的作用就在于充分使用硬件CPU,也就是我们说的并行。

goroutine

从我们应用角度来说,我们一般将协程理解为用户态轻量级线程,是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户的程序自己调度的。
因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的CPU控制权切换到其他进程/线程,通常只能进行协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。
但我们以上说的协程和golang中的协程是不一样的。
就像开头说的很多人将go的协程理解为我们常说的协程,但深究它们的名称不难看出,一个是goroutine,另一个是Coroutine,是不一样的。
golang语言作者Rob Pike也说,

Goroutine是一个与其他goroutines 并发运行在同一地址空间的Go函数或方法。
一个运行的程序由一个或更多个goroutine组成。它与线程、协程、进程等不同。它是一个goroutine。

Go 协程意味着并行,协程一般来说不是这样的
Go 协程通过通道来通信,而协程通过让出和恢复操作来通信
而且Go 协程比协程更强大。因为Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理
也就是Golang 有自己的调度器,工作方式基本上是协作式,而不是抢占式,但也不是完全的协作式调度 例如在系统调用的函数入口处会有抢占。当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU (P) 转让出去,让其他 goroutine 能被调度并执行,也就是我们为什么说 Golang 从语言层面支持了协程。
简单的说就是golang自己实现了协程并叫做goroutine。

Go中的进程和线程

在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数,本质上是什么,就是限制并行数,并行数即同时执行数量,执行单元即线程,即限制最大并行线程数量。

goroutine优势

goroutine的优势在于并行和非常低的资源使用
体现在内存消耗方面和切换(调度)开销方面
每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少,只有2KB,而线程则需要8MB
线程切换涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP…等寄存器的刷新等
而goroutine 只有三个寄存器的值修改 - PC / SP / DX。

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,G。
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线程都能充分的使用。

总结

进程:分配资源的基本单位,独立的栈空间,独立的堆空间,进程之间调度由os完成
线程:独立运行和独立调度的基本单位,独立的栈空间,共享堆空间,内核线程之间调度由os完成
协程:用户级线程,独立的栈空间,共享堆空间,调度由用户自己控制,协作式调度,主动转让控制权
goroutine:Golang自己实现的协程,不完全协作式调度,由go自己实现的调度器调度。

您的支持将鼓励我继续创作!