【Golang】golang并发模型

 

Golang调度器

  先看看golang调度的由来。

  一. 单进程时代不需要调度器

     在早期操作系统是单进程的,一个进程拥有整个系统的所有资源,所以也不需要调度器。

  

 但是单进程的操作系统也有明显的缺点:

   1. 采用单一的执行流程,计算机只能一个任务一个任务处理。

   2. 进程阻塞所造成CPU资源的浪费。

那么如何充分利用资源,可以让多个进程同时并发的去执行呢?
  所以后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。
 
 二. 多进程/线程操作系统
   在支持多进程和多线程的操作系统中,可以同时运行多个进程或者多个线程,CPU采用一定的策略,比如轮训去执行多个进程或者线程,执行第一个进程然后在执行下一个进程,当一个进程阻塞CPU可以立刻切换到其他进程中去执行,而且调度CPU的策略可以保证在每个运行的进程/线程都可以被分配到CPU的运行时间片,这个时间片就是规定每个进程当前所运行的时间,当一个进程的时间片结束后,CPU就将当前进程上下文保存再去执行下一个进程,该进程下一次运行只能等到再次被分配到时间片在接着运行,因为每个时间片时间很短,这样从宏观的角度来看,似乎多个进程/线程是在同时被运行。
  
  进程/线程切换的缺点
  但是有新的问题,就是当某个执行进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU虽然利用起来了,但如果进程过多,CPU有很大的一部分都被用来进行进程调度了,比如进程的上下文切换,就要保存当前进程的信息,这就需要系统调用,以及拷贝/复制等这些操作,所以进程/线程数量越多,切换的成本就越大(尤其是进程),CPU要处理切换这些事情,造成对CPU的利用率就越低,所以对性能就越有影响。
 
   

  

  进程比较重,占用资源比较大,比较占用内存空间,CPU切换必然对性能有很大的影响。

  相对于进程,线程虽然比进程轻量,也被称为轻量级进程(Lightweight Process,LWP),但是实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等,开发也变得非常复杂。

  

 那么怎么才能提高CPU的利用率呢?

  三. 使用协程来提高CPU利用率
  协程(coroutine)被称为是一种用户态的轻量级线程,协程也是一种线程,而一个线程分为"内核态"的线程和"用户态"的线程,一个“用户态线程”必须要绑定一个“内核态线程”,但是CPU并不知道有“用户态线程”的存在,CPU只知道它运行的是一个“内核态线程”(Linux的PCB进程控制块),如下图所示。
  

   在Go语言中,协程(coroutine)是Go语言中的轻量级线程实现,那么我们可以内核线程依然叫 “线程 (thread)”,而用户线程叫 “协程 (co-routine)”,如下。

  

 上图里一个协程 (co-routine) 可以绑定一个线程 (thread),这种是一个协程对应一个线程,所以这种是1:1模型。协程的创建,删除,切换也都是需要CPU去完成的。

 当然多个协程 (co-routine) 也可以绑定一个或者多个线程 (thread),这样就是N个协程对应一个线程 。

 

 上图三个协程在用户态,对应内核空间的一个线程,用户态重的协程在工作时候也是可以进行切换的,但是这种切换是在用户态,协程之间的切换是由图中协程调度器去完成的,这样带来的好处就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速

  但是N:1这种模型也是有缺点的

  N:1模型的缺点:

  • 某个程序用不了硬件的多核加速能力。
  • 一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

 

进一步改良M:N模型,就是M个协程对应N个线程,如下图所示。

 

 上图内核态有两个线程,当有两个CPU的时候,就可以利用多核,每个CPU可以绑定一个线程,不过这种处理模式就更加的复杂了,线程和协程之间都是通过协程调度器去协作和管理,所以调度器的性能就显得非常重要。

 

四. Go语言中的协程goroutine

   在Go语言中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,这个比线程轻量一个数量级,因为占用内存小,所以调度更灵活 (runtime 调度),切换也可以很频繁。无论协程还是线程都需要调度器去完成调度,我们需要了解最关键的调度协程的调度器的实现原理。

  来看看早期Go语言的goroutine调度器如何实现的。

  

  在早期goroutine调度器中,每创建一个协程goroutine就会被添加到一个全局go协程队列中,当线程M0想要获取一个goroutine时候,就去从全局go协程队列中获取,全局go协程队列会有一个锁来保护,当线程获取锁之后就从全局队列中拿到一个goroutine并去执行,执行后就去将锁换回去,并将goroutine放回,这就是一个完整的过程。

  这种调度器比较简单,但是也是有缺点的

  缺点:

    1. 创建,销毁,调度goroutine需要每个线程都先获得锁,这样就容易形成锁竞争。

    2. 线程转移goroutine会造成延迟和额外的系统负载。比如线程(M1)当执行中的goroutine(G1)中包含创建新协程的时候,线程(M1)创建新的一个goroutine(G2),为了继续执行这个新的goroutine(G2),需要把这个新的goroutine(G2)交给另一个线程(M2)执行,也造成了很差的局部性,因为 (G2)和(G1)是相关的,最好放在(M1)上执行,而不是其他(M2)。

    3. 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

 

 

 

CSP模型介绍

  CSP模型的全称是Communicating Sequential Process,翻译是通信顺序进程,是一种并发编程模型,还有一种并发模型是Actor模式,著名的并发编程语言Erlang就是用的Actor模式csp模型在上个世纪70年代就提出来了,是用于描述两个独立的并发实体通过共享的通讯channel(也就是管道)来进行通信的并发模型。相对于Actor模型来说,CSP中的channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时候所使用的channel,CSP模型是一种很强大的并发通讯模型,也成为面向并发编程语言的理论源头,也就诞生出后来的golang等语言。

   对于golang来说,其实只用到了CSP模型的很小一部分,即Process/Channel,对应golang语言就是goroutine/channel,这两个并发关键字之间没有从属关系,Process可以是一个进程,线程,甚至可以是一个代码块,Process可以订阅任意个Channel,Channel也可不关心是从那个Process在利用它通信,Process围绕Channel进行读写。

 

  goroutine 是通过 GMP 调度模型实现的。
  
  G: 表示一个 goroutine,它有自己的栈。

       M: 表示内核级线程,一个 M 就是一个线程,goroutine 跑在 M 之上的。
  P: 全称是 Processor,处理器。它主要用来执行 goroutine 的,同时它也维护了一个 goroutine 队列。

 

 

 

参考来源

  https://www.bilibili.com/read/cv5098443

  https://morsmachine.dk/go-scheduler

posted @ 2018-07-03 21:00  songguojun  阅读(545)  评论(0编辑  收藏  举报