并发编程概念
并行:多个处理器或多核处理器同时处理多个任务。
并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。

线程通信
显式通信 wait 、notify、notifyAll(一定要在线程同步中使用,并且是同一个锁的资源)
lock

Concurrency unit 并发调度单位

并发调度单位讲究 轻!快!
unit占用轻!unit切换快!

进程 线程
进程是系统资源分配的基本单位 线程是任务调度和执行的基本单位

进程作为Unit

原始时代, 程序在各自的进程(Process)中运行, 相互分离, 相互独立执行, 由操作系统来分配资源, 比如内存、文件句柄、安全证书。
进程通信: Socket、信号处理(signal handlers)、 共享内存(shared memory)、信号量(semaphores)和文件

进程拥有独占的内存和指令流,是一个应用的包装,但是进程作为并发基本单元有如下问题:

  1. 资源占用过大
    1. 每个进程占用的内存太大了,进程携带了自己的虚拟内存页表,文件描述符等
  2. 不能发挥多核的性能
    1. 进程不能很好的发挥多核机器的性能,常常出现一个核跑,多个核看的现象
    2. image.png
  3. 进程切换消耗过大
    1. 进程切换需要进行系统调用,涉及到内存从用户态拷贝至内核态
    2. 保存当前进程的现场,并且恢复下一个进程

image.png

线程作为Unit

轻量级进程
线程较轻量级,一个进程可以包含多个线程,则每个最小并发粒度的资源占用要小很多,且同一个进程内线程间切换只需要对指令流进行切换即可。
线程共享进程范围内的资源, 比如内存和文件句柄句, 但是每一个线程都有自己的程序计数器、栈和本地变量
同一程序内多个线程可以在多CPU的情况下同时调度
image.png

上下文切换(Context switches) ——调度器临时挂起当前运行的线程, 另一个线程开始运行, CPU会花费大量时间保存和恢复线程的上下文
切换仍需要进入内核进行,仍然存在大量的并发切换消耗

协程作为Unit

协程,也叫做用户态线程,它规避了最后一个问题,切换消耗过大的问题,无需通过系统调用进入内核进行切换,协程所有的生命周期均发生在用户态。
image.png
因为协程的优点,协程类编程也开始越来越火了。比较有代表性的有Go的goroutine、Erlang的Erlang进程、Scala的actor、windows下的fibre(纤程)等,一些动态语言像Python、Ruby、Lua也慢慢支持协程。
但是 语言引入协程作为并发调度单位,需要实现自己的协程调度器、并提供协程间通信的方式等一系列支持模块,相较于传统的基于进程线程的并发方式,需要实现很多额外的功能组件。实现较复杂。

Concurrency model 并发模型

总体来看,目前能找到的最轻量的调度单元就是协程了,虽然实现起来有些麻烦,但是现代语言也越来越多的引入协程了。
那么解决了并发单元的问题后,我们再研究下并发模型,为什么需要并发模型呢,因为并发就意味着竞争:对内存的竞争,对算力的竞争等,那么如何降低竞争带来的性能损耗,就需要并发模型的设计了。简单来说,并发模型就是指导并发单元以何种方式处理竞争,尽量减少竞争带来的性能损耗。简单来说,就是定义了并发单元间的通信方式

共享内存+锁

image.png
最经典的模型,通过锁来保护资源,多个并发单元访问资源前首先争夺锁,然后再去访问资源。没抢到锁的unit则阻塞等待。
这个应该是目前最常用的了,也最符合直觉,但是可以明显看到,在竞争时会产生阻塞耗时
这就是常说的使用共享内存来进行通信

函数式编程

既然基于共享内存通信会产生大量的竞争,那么函数式编程的通信思想是,在并发单元执行过程中不进行通信,只在最后大家都执行完后统一对结果做收集和汇总
并发编程理论 - 图6
函数式编程的特性:

  1. 不可变数据,默认是变量是不可变的,如果你要改变变量,你需要把变量copy出去
  2. 函数对于Input A一定会返回Output B,即函数内部没有状态,不会对全局变量进行修改,运行时涉及的变量都是局部变量
  3. 这么一来,每个函数对输入负责,只会访问局部变量,全局不存在资源竞争

基于函数式编程模型作为并发模型的话,性能会很高,但是会产生额外的大量的局部变量
代表语言:clojure

举个例子:

s3e在设计之初,提供了一套SDK,目的是帮助业务建模和模型可视化,大体是这样的,将一个业务功能节点抽象为了workflow,workflow中的每个task state对应一个函数,为了降低使用成本,各个函数的签名都是一致的
func Action(ctx context.Context, db *Databus) (*Databus, error)
每个函数都会对Databus做一些自己的修改,这个修改是全局Action可见的(因为Databus传的是指针类型),因此如果存在并发的节点,会存在对全局变量的锁竞争。
但是在接入业务需求时,这套设计还好,不会产生太大的问题,但是在接入拦截系统时,由于拦截系统的比较大的诉求是希望引入并发带来对耗时的优化,如果仍然采用这种粗粒度锁的方式,竞争会比较大,可预见的性能优化不会太明显,如图
image.png
type Data struct { input *Input collector *ChekerCollector } type CheckerCollector struct { lock sync.Lock data map[string]*CheckRes } func (cc *CheckerCollector) Report(key string, res *CheckRes) { cc.lock.Lock() map[key] = res cc.lock.Unlock() }
那么,采用函数式编程的思路,我们把databus尽量减少写操作,将需要写的字段分配到每个并发节点的运行时局部变量中,然后再对每个并发节点的结果做统一的收集,可以很好的减少并发竞争
image.png

Actor

那么,我们回归到通信本身,有没有更好的通信方式呢?
Actor的主要思路是,每个并发单元抽象为actor,每个actor都拥有一个邮箱,所有actor间的通信都会异步的发送到对方的邮箱中,这样解耦了actor之间的关系,各个actor都能按照自己的步调进行执行,但是他们会按照邮箱中消息的发送顺序来依次处理消息,当且仅当前一个消息处理完成后,才会开始下一个消息处理,即保障了消息的时序性
image.png
这样的话,在并发单元执行过程中,也不会存在锁资源的竞争,但是由于发送过程是异步的,即只将消息放入目标actor的邮箱中即完成了发送操作,但是消息什么时候会被目标actor处理,则是不可预测的。
image.png
代表语言:erlang,scala的akka库

CSP

csp的降低竞争的思想大体和actor保持一致,但是在消息发送上则采用了同步的方式,且与actor不同的是,csp关注的不是发送接受者,而是发送的媒介。
image.png
并发单元间通过一个FIFO队列(channel)来进行通信,而不是直接和目标单元进行通信。

actor和csp的区别

image.png

  1. actor的并发单元之间直接通信,csp则通过channel通信,后者显然耦合更松散
  2. csp的消息交换是同步的,actor则是完全异步,可以在任意时间点发送消息到任何并发单元(甚至不存在的),并且actor可以自由选择处理哪些消息
  3. 相应的,csp的channel空间可以使有限的,而actor的邮箱理论上是需要无限大的
  4. actor关注的是并发单元,而csp关注的则是channel

    现实

    现实上,几乎所有并发编程语言都支持锁+共享内存的形式进行并发单元通信,而对于支持函数式编程,actor和csp等概念的大部分语言,并没有严格完全按照模型定义实现,都在使用上或多或少的做了一些折中。



原文地址: https://nber1994.com/posts/channel-select/
Nginx为什么多进程:https://blog.csdn.net/m0_38110132/article/details/75126316