8

进程、线程、协程与goruntine

 3 years ago
source link: https://zhuanlan.zhihu.com/p/27245377
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

进程、线程、协程与goruntine

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

不过协程一般不会被问到,但在golang开发的过程中相信大家最经常接触的就是go协程,但对于什么才是协程,什么才是go协程,很多有经验的开发很可能会说go出去的就是协程....仅仅停留在这个层面认识,不仅会给我们项目带来持续的问题和宕机,对我们自身也是一种时间和精力损耗,作为开发猿,我们的愿望无非是代码稳一点,跑的快一点,bug少一点这点愿望。在此我从操作系统的角度来对进程,线程,协程进行介绍,并试着说明协程和goruntine到底是不是一回事。

协程的概念其实比线程还要早,不过是这几年才被大家熟知,线程在实现上可以说是一个特化的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

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中的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。

说的不对还请狂喷!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK