并发模型

并发模型

前言

golang最大的特性goroutine,非常的方便开发者做并发编程,是为云而生的语言,也是2019年后台开发者最想学习的语言之一。作为后台开发者,必然会面对大并发的业务场景,并发编程面临的场景和常见的解决方案是后台开发者技能树中最重要的一部分。在学并发编程之前我们应该先理清楚常见的并发模型有哪些,有什么差异。

背景知识介绍

进程

进程是系统进行资源分配的一个独立单位,操作系统内核通过进程控制块(PCB,process control block)来感知进程

进程申请系统资源包含如下
  • 已打开的文件
  • 已申请到的I/O设备
  • 用户的地址空间
  • 进程维护的地址映射表
  • 实现进程(线程)间同步和通信的机制

线程

是操作系统能够进行运算调度的最小单位

线程本身不拥有系统资源,大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并行多个线程,每条线程并行执行不同的任务

线程按照实现方式分为

  • 内核支持线程KST
    内核支持线程是驻留在内核空间中的内核对象。用户进程或者系统进程中的线程的创建、撤消、切换都是在内核空间中实现的,并由内核为其分配线程控制块进行管理。每个用户线程会被映射或者绑定到一个内核线程,形成一对一的线程对应关系,如下图所示,内核可以感知线程的存在,其调度是以线程为基本单位。
  • 用户级线程ULT
    内核支持线程是驻留在内核空间中的内核对象。用户进程或者系统进程中的线程的创建、撤消、切换都是在内核空间中实现的,并由内核为其分配线程控制块进行管理。每个用户线程会被映射或者绑定到一个内核线程,形成一对一的线程对应关系,如下图所示,内核可以感知线程的存在,其调度是以线程为基本单位。
  • 组合方式
    用户级线程仅存在于用户空间中,对于这种线程的创建、撤消、线程之间的同步与通信等,无须利用系统调用来实现,也同样无须内核的运行,而是通过中间系统(线程库)在用户空间中来完成。由于不需要用户/内核态切换,线程切换速度比较快。但由于内核无法感知用户级线程的存在,其调度仍是以进程为单位进行的。当内核调度一个进程运行时,用户级线程库调度该进程的一个线程执行,如果时间片允许,进程的其他线程也可能被执行,该进程的多个线程共享该进程的运行时间片。如果一个线程需要进行i/o读写,该线程调用系统调用进入内核,在启动I/O设备后内核会把该进程值为阻塞态,并把CPU分配给其他进程。即使该进程的其他线程可以运行,内核也不会发现这一情况,在该进程的状态变为就绪之前内核不会调度该进程运行,因而属于该进程的线程都不可能运行,因而用户级线程的并行性会受到一定的限制。用户级线程是一种"多对一"的线程映射。



协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程间切换只需要保存任务的上下文,没有内核的开销。

协程是编译器级别的,现在很多编程语言都支持协程,如 Erlang、Lua、Python、Golang。准确来说,协程只是一种用户态的轻量线程。

协程的优势

  • 内存占用少:线程栈空间通常是 2M,Goroutine 栈空间最小 2K;Golang 程序中可以轻松支持10w 级别的 Goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2G。
  • 上下文切换代价小:Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;
  • Go 协程使用信道(Channel)来进行通信。信道用于防止多个协程访问共享内存时发生竞态条件(Race Condition)。信道可以看作是 Go 协程之间通信的管道。我们会在下一教程详细讨论信道。

线程的出现,是为了分离进程的两个功能:资源分配和系统调度。让更细粒度、更轻量的线程来承担调度,减轻调度带来的开销。但线程还是不够轻量,因为调度是在内核空间进行的,每次线程切换都需要陷入内核,这个开销还是不可忽视的。协程则是把调度逻辑在用户空间里实现,通过自己(编译器运行时系统/程序员)模拟控制权的交接,来达到更加细粒度的控制。

进程调度方式

  • 非剥夺调度方式,又称非抢占方式。是指当一个进程正在处理机上执行时 ,即使有某个更为重要或紧迫的进程进入就绪队列,仍然让正在执行的进程继续执行,直到该进程完成或发生某种事件而进入阻塞状态时,才把处理机分配给更为重要或紧迫的进程。
  • 剥夺调度方式,又称抢占方式。是指当一个进程正在处理机上执行时,若有某个更为重要或紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给这个更为重要或紧迫的进程。

并发模型比较

一、多进程模型

主进程监听和管理连接,当有客户请求的时候,fork 一个子进程来处理连接,父进程继续等待其他客户的请求。但是进程占用服务器资源是比较多的,服务器负载会很高。
Apache的web容器实现就是这种模型

二、多线程与锁

几乎每种编程语言都以某种形式提供了支持,我们应该了解底层的原理,但是,多数时候,应该使用更上层的类库,更高效,更不易出错。这种方式无外乎几种经典的模式,互斥锁(临界区),生产者-消费者,同步等等

优点:

  • 好理解 基础语言库都有支持,容易实现
  • 其他并发模型的基础

缺点:

  • 难以测试,很容易隐藏某些难以发现的问题
  • 会频繁地创建、销毁线程。这对系统也是个不小的开销

三、单线程回调和事件轮询模型

Nginx
采用的是多进程(单线程) & 多路IO复用模型:
Nginx 在启动后,会有一个 master 进程和多个相互独立的 worker 进程。
接收来自外界的信号,向各 worker 进程发送信号,每个进程都有可能来处理这个连接
master 进程能监控 worker 进程的运行状态,当 worker 进程退出后(异常情况下),会自动启动新的 worker 进程。
主进程(master 进程)首先通过 socket() 来创建一个 sock 文件描述符用来监听,然后fork生成子进程(workers 进程),子进程将继承父进程的 sockfd(socket 文件描述符),之后子进程 accept() 后将创建已连接描述符(connected descriptor)),然后通过已连接描述符来与客户端通信。
缺点
存在惊群现象:当连接进来时,所有子进程都将收到通知并“争着”与它建立连接。
Nginx 在 accept 上加一把互斥锁来应对惊群现象。在每个 worker 进程里,Nginx 调用内核 epoll()函数来实现 I/O 的多路复用。

Node.js
Node.js 也是单线程模型。Node.js中所有的逻辑都是事件的回调函数,所以 Node.js始终在事件循环中,程序入口就是事件循环第一个事件的回调函数。

缺点
会造成大量回调函数的嵌套,代码可读性不佳。因为没有多线程,在多核的机器上,也没办法实现并行执行。

四、协程模型

协程基于用户空间的调度器,具体的调度算法由具体的编译器和开发者实现,相比多线程和事件回调的方式,更加灵活可控。不同语言协程的调度方式也不一样,python是在代码里显式地yield进行切换,golang 则是用go语法来开启 goroutine,具体的调度由语言层面提供的运行时执行。

在使用 goroutine 的时候,可以把它当作轻量级的线程来用,和多进程、多线程方式一样,主 goroutine 监听,开启多个工作 goroutine 处理连接。比起多线程的方式,优势在于能开更多的 goroutine,来处理连接

G-P-M 模型理解

Go 调度器模型我们通常叫做G-P-M 模型,他包括 4 个重要结构,分别是G、P、M、Sched:

G:Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。

P: Processor,表示逻辑处理器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P 才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。

M: Machine,OS 内核线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取。

Sched:Go 调度器,它维护有存储 M 和 G 的队列以及调度器的一些状态信息等。

形象的总结
地鼠(Gopher)的工作任务是:工地上有若干砖头,地鼠借助小车把砖头运送到火种上去烧制。M 就可以看作图中的地鼠,P 就是小车,G 就是小车里装的砖。



Actor 和 CSP 模型

“Don’t communicate by sharing memory, share memory by communicating”(不要通过共享内存来通信,而应该通过通信来共享内存)的思想.Actor 和 CSP 就是两种基于这种思想的并发编程模型

Actor模型

在Actor模型中,主角是Actor,类似一种worker,Actor彼此之间直接发送消息,不需要经过什么中介,消息是异步发送和处理的:


Actor模型描述了一组为了避免并发编程的常见问题的公理:

  • 1.所有Actor状态是Actor本地的,外部无法访问。
  • 2.Actor必须只有通过消息传递进行通信。
  • 3.一个Actor可以响应消息:推出新Actor,改变其内部状态,或将消息发送到一个或多个其他参与者。
  • 4.Actor可能会堵塞自己,但Actor不应该堵塞它运行的线程

CSP模型

Go语言的CSP模型是由协程Goroutine与通道Channel实现:

Channel模型中,worker之间不直接彼此联系,而是通过不同channel进行消息发布和侦听。消息的发送者和接收者之间通过Channel松耦合,发送者不知道自己消息被哪个接收者消费了,接收者也不知道是哪个发送者发送的消息。



Go协程goroutine: 是一种轻量线程,它不是操作系统的线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。是一种绿色线程,微线程,它与Coroutine协程也有区别,能够在发现堵塞后启动新的微线程。
通道channel: 类似Unix的Pipe,用于协程之间通讯和同步。协程之间虽然解耦,但是它们和Channel有着耦合。

Actor模型和CSP区别
Actor模型和CSP区别图如下:


Actor之间直接通讯,而CSP是通过Channel通讯,在耦合度上两者是有区别的,后者更加松耦合。

同时,它们都是描述独立的流程通过消息传递进行通信。主要的区别在于:在CSP消息交换是同步的(即两个流程的执行"接触点"的,在此他们交换消息),而Actor模型是完全解耦的,可以在任意的时间将消息发送给任何未经证实的接受者。由于Actor享有更大的相互独立,因为他可以根据自己的状态选择处理哪个传入消息。自主性更大些。

在Go语言中为了不堵塞流程,程序员必须检查不同的传入消息,以便预见确保正确的顺序。CSP好处是Channel不需要缓冲消息,而Actor理论上需要一个无限大小的邮箱作为消息缓冲。

参考文献

https://mp.weixin.qq.com/s/ihJFa5Wir4ohhZUXVSBvMQ
studygolang.com/article
cloud.tencent.com/devel
jdon.com/concurrent/act

发布于 2020-04-29 22:48