并发总结

来源:互联网 发布:手机淘宝一元秒杀 编辑:程序博客网 时间:2024/06/05 08:08

1、并发是什么?

先说明一下什么是并发、什么是并行。
- 并发(concurrency) 并发的关注点在于任务切分。举例来说,你是一个创业公司的CEO,开始只有你一个人,你一人分饰多角,一会做产品规划,一会写代码,一会见客户,虽然你不能见客户的同时写代码,但由于你切分了任务,分配了时间片,表现出来好像是多个任务一起在执行。
- 并行(parallelism) 并行的关注点在于同时执行。还是上面的例子,你发现你自己太忙了,时间分配不过来,于是请了工程师,产品经理,市场总监,各司一职,这时候多个任务可以同时执行了。

这个世界本来就充斥的大量的并发&并行模型,所以并发&并行只是真实世界的客观抽象和反应。
在计算机世界里,更强调的是CPU的工作模式。并发并不要求必须并行,可以用时间片切分的方式模拟,比如单核cpu上的多任务系统,并发的要求是任务能切分成独立执行的片段。而并行关注的是同时执行,必须是多(核)cpu,要能并行的程序必须是支持并发的。但是大多数情况下不会严格区分这两个概念,默认并发就是指并行机制下的并发。

2、为什么会存在并发?

理论上一个CPU只干一个任务,不来回切换,效率会更高;但是为什么不这样呢,个人理解是CPU工作快(可以切换支持多个任务),CPU贵(安装太多无浪费),任务多(不可能达到一个CPU一个任务)。所以就存在一个CPU切换做多个任务的情况。同时,CPU相较于内存、磁盘、网络等运行速度快,因为与内存等交互,经常存在等待内存、等待网络、等待磁盘等情况;所以出现了在等待的同时,切换到别的等待计算任务工作的场景。通过压榨CPU(提高资源利用率)来支持并发任务。
举个列子:web用户A在登录、用户B在访问数据、用户C在修改数据,后台的web服务中就通过CPU切换并发支持三个用户(QPS是衡量服务性能的重要指标),否则会出现A用户操作,B、C用户的等待的情况。
并发的目的是及时响应、高效(分布式)、容错(比串行处理粒度细)、简单(比串行方案简单)。但是个人理解,根本是为了支持客观业务需求(也引导业务发展?)。

3、并发是怎么实现的?

先来看一句话:之所以写正确的并发、容错、可扩展的程序如此之难,是因为我们用了错误的工具和错误的抽象(Akka官方文档开篇)。我们抽取一点,并发是困难的,至于AKKA是否的正确的工具咱不讨论。并发系统是经典的困难,因为没有顺序能够保证内存突变(程序员无法通过代码指定线程执行顺序,因为线程只听命于CPU调度),告诉哪些线程可能会执行一个给定的一段代码不可避免地变得很难。由于并发马虎出现的错误是出了名的难以解决,这些都是由于线程调度的不可预测性。那么并发是怎么实现的呢?

3.1 线程与锁模型

是一种经典的解决方案。
线程的使用比较简单,如果你觉得这块代码需要并发,就把它放在单独的线程里执行,由系统负责调度,具体什么时候使用线程,要用多少个线程,由调用方决定。但是现实世界是复杂的,总有一些资源需要在不同的线程中共享。比如前面的例子,开发人员和市场人员同时需要和CEO商量一个方案,这时候CEO就成了竞态条件。如果要得到正确的使用,则引入了锁。

  • 线程(Thread)是操作系统能够进行运算调度的最小单位。是更轻量的进程,由系统内核进行调度,同一进程的多个线程可共享资源。
  • 竞态条件(race conditions),从多进程间通信的角度来讲,是指两个或多个进程对共享的数据进行读或写的操作时,最终的结果取决于这些进程的执行顺序。

首先确定认为锁的问题是不可完美解决的,因为竞态条件的客观存在;其次任何锁的解决方式都属于根据不同使用场景的优化,不管是时间还是空间维度上,无出其右。从时间片上切分保证先后使用,从空间粒度(表锁行锁)上切分保证锁的范围更小;多锁控制不当会导致死锁。
有以下几种实现思路:
1、互斥同步(悲观锁),在多少个线程并发访问共享数据,保证共享数据在同一时刻只能被一个线程使用。互斥在这个时候是实现同步的一种手段,临界区(critical section),互斥量(Mutex),信号量(semaphore)都是主要的互斥实现方式。
Java中最基本的互斥同步手段就是synchronized关键字,锁块、锁对象、锁方法。通过字节码monitorenter和monitorexit控制锁计数器实现。因为java线程是映射到操作系统的原生线程之上的,java线程的阻塞和唤醒等状态切换需要从用户态转换到核心态(虚拟机有锁优化),耗费处理器时间可能比执行线程本身都要长,所以synchronized是重量级的操作,尽量避免。另外是JUC包中的重入锁ReentrantLock,原理类似,但是加入了等待可中断(等待线程可以选择放弃等待改为处理其他事情)、公平/非公平锁(按照申请锁的顺序获得锁),以及所可以绑定多个条件;在1.6之后,synchronized的性能高于Lock。

2、非阻塞同步(乐观锁),上述互斥同步的思想是不管资源实际使用是否有竞争,先进行锁定、用户态核心态转换、维护锁计数器、检查线程是否阻塞,悲观认为当前必定有锁竞争,所以是悲观锁思想;但是随着硬件指令集发展,我们可以使用乐观锁,即先进行共享数据操作,如果没有竞争则操作成功,如果有竞争(产生非预期的冲突) 则操作失败,失败后可以采取措施(常见措施是不断重试直到成功),这种策略减少了很多不必要的线程状态的切换挂起,所以也成为非阻塞同步。
常用的非阻塞同步实现依赖硬件指令集的比较并交换(compare and swap, CAS)指令,原子操作指令。java常用的有AtomicInteger,AtomicBoolean等。但是CAS操作会导致ABA问题的出现,但是大多数ABA问题不会影响并发正确性,视实际情况而定。有人认为CAS属于也无锁情况,不予争论。
volatile Java专门引入了volatile关键词,实现线程间共享,读写不互斥锁定,它并不能解决 Counter 的问题,可以降低只读情况下的锁的使用。
MVCC多版本并发控制、CopyOnWriteList,思想是写数据时候先复制出新版本写入、然后在回写覆盖(乐观写),读数据使用原来版本,这样来实现非阻塞同步,提高效率。

3、无锁,这种情况有点扯,原因是根本不涉及到的竞态条件问题。但还提及一下,比如本地方法内的私有变量(StringBuilder)操作,或者不可变的对象, 或者使用ThreadLocal 共享对象线程本地化(线程独享)。

最后人们在其上锁原理基础上进行各种优化,如:

  • 自旋锁(或自适应自旋锁):线程大多数浪费在挂起和恢复线程的操作过程中(内核态才能完成),这是要后面的线程“等一会儿”,就避免了和前面线程的同步关系,避免了挂起恢复的操作,但是还是需要占用了CPU的时间。“等一会儿”的过程用来忙循环(自旋),这项技术叫自旋锁。自旋锁有一个时间的问题,不能等太长(浪费资源),不能等太短(起不到作用),所以java1.6之后引入了自适应自旋锁,通过自学习技术等实现锁状况预测。
  • 锁消除:复制出来一份新的竞争资源,避免竞争。public String concat(String s1,String s2){return s1+s2;} –>> public String concat(String s1,String s2){StringBuffer sb = new StringBuffer(); sb.append(s1).append(s2); return sb.toString();}
  • 锁粗化:原则是同步块(空间维度)尽量做到最小,但是如果频繁的锁定和解锁的话开销也很大,所以可以适当把同步块放大,减少锁开销。

这些优化技术都是建立一定情景基础之上的,使用得当才会提升性能,使用不当反而会事倍功半。

这种解决方案比较经典,属于共享内存模型,符合常理易于理解,但是型难于调试,有时候甚至让你觉得无助。

3.2 事件驱动模型

事件驱动模型可能表述不准确,可能是因为个人想把Callback、CSP、Actor模型总结为一类导致。
换个角度想,如果线程是一直处于运行状态,我们只需设置和CPU核数相等的线程数即可,这样就可以最大化的利用CPU,并且降低切换成本以及内存使用。但如何做到这一点呢?陈力就列,不能者止!
这句话是说,能干活的代码片段就放在线程里,如果干不了活(需要等待,被阻塞等),就摘下来。通俗的说就是不要占着茅坑不拉屎,如果拉不出来,需要酝酿下,先把茅坑让出来,因为茅坑是稀缺资源。

3.2.1 Callback模型

异步回调,典型如NodeJS,遇到阻塞的情况,比如网络调用,则注册一个回调方法(其实还包括了一些上下文数据对象)给IO调度器(linux下是libev,调度器在另外的线程里),当前线程就被释放了,去干别的事情了。等数据准备好,调度器会将结果传递给回调方法然后执行,执行其实不在原来发起请求的线程里了,但对用户来说无感知。但这种方式的问题就是很容易遇到callback hell,因为所有的阻塞操作都必须异步,否则系统就卡死了。还有就是异步的方式有点违反人类思维习惯,人类还是习惯同步的方式。
//TODO

3.2.2 CSP模型

CSP(Communicating Sequential Processes),即顺序通信进程模型,描述两个独立的并发实体通过共享的通讯channel进行通信的并发模型。这是一个很早被提出的理论(1978),但是因为八九十年代的机器大多还都是单核,所以基本没什么人关注。话说Erlang也是如此,不得不赞叹这些CSP/Actor这些理论作者们的前瞻性。go语言并没有完全实现了CSP模型的所有理论,仅仅是借用了process和channel这两个概念:

  • channel(通道):channel用于消息的存储,并且可以在process之间传递来共享数据,其实是一个阻塞的消息队列。
  • process(协程):process是轻量级的线程,实际并发执行的实体,每个实体之间是通过channel通讯来实现数据共享。

在过程式编程中,当调用一个过程的时候,需要等待其执行完才返回。而调用一个协程的时候,不需要等待其执行完,会立即返回。协程十分轻量,Go语言可以在一个进程中执行有数以十万计的协程,依旧保持高性能。而对于普通的平台,一个进程有数千个线程,其CPU会忙于上下文切换,性能急剧下降。随意创建线程可不是一个好主意,但是我们可以大量使用的协程。
通道是协程之间的数据传输通道。通道可以在众多的协程之间传递数据,具体可以值也可以是个引用。通道有两种使用方式:
- 协程可以试图向通道放入数据,如果通道满了,会挂起协程,直到通道可以为他放入数据为止。
- 协程可以试图向通道索取数据,如果通道没有数据,会挂起协程,直到通道返回数据为止。

如此,通道就可以在传递数据的同时,控制协程的运行。有点像事件驱动,也有点像阻塞队列。go的这种实现完全不同于线程与锁模型,经典总结:不要通过共享内存来通信,而应该通过通信来共享内存。

在go中Goroutine 是实际并发执行的实体,它底层是使用协程(goroutine)实现并发,goroutine是一种运行在用户态的用户线程,类似于 greenthread,go底层选择使用goroutine的出发点是因为,它具有以下特点:

  • 用户空间,避免了内核态和用户态的切换导致的成本
  • 可以由语言和框架层进行调度
  • 更小的栈空间允许创建大量的实例(百万级别)

可以看到第二条用户空间线程的调度不是由操作系统来完成的,像在java 1.3中使用的greenthread的是由JVM统一调度的(后java已经改为内核线程),还有在ruby中的fiber(半协程) 是需要在重新中自己进行调度的,而goroutine是在golang层面提供了调度器,并且对网络IO库进行了封装,屏蔽了复杂的细节,对外提供统一的语法关键字支持,简化了并发程序编写的成本。

调度器一些特点:
1、轻量级进程和系统线程的M:N关系,当启用的系统线程数量为1时,不管上面创建了多少个轻量级进程,同一时间在执行的只有1个,即并发而非并行。这其实类似于系统进程/线程和机器核数的关系,在单核机器上不管启了多少个线程,同时都能只有一个在执行。
2、由于系统线程数为1,这里只有当一个goroutine执行完后其他goroutine才能获取这个线程的使用权,如果有一个goroutine里出现了死循环,那么其他goroutine就永远不会被执行了。
3、当一个goroutine中出现阻塞操作(不是死循环),会释放当前的线程,直到阻塞操作完成才会重新分配线程调度。在Linux系统中,goroutine阻塞操作的底层通过epoll实现,性能是很好的。不管Go的阻塞重新调度还是Erlang VM的公平调度都解决了饿死的问题,在这一点上Akka差的太多,我觉得jvm给Akka带来了平台,也给Akka装上了枷锁。

有兴趣可以自己搜索goroutine调度器获得更多原理知识。

Goroutine很大程度上降低了并发的开发成本,是不是我们所有需要并发的地方直接go func就搞定了呢?
Go通过Goroutine的调度解决了CPU利用率的问题。但遇到其他的瓶颈资源如何处理?比如带锁的共享资源,比如数据库连接等。互联网在线应用场景下,如果每个请求都扔到一个Goroutine里,当资源出现瓶颈的时候,会导致大量的Goroutine阻塞,最后用户请求超时。这时候就需要用Goroutine池来进行控流,同时问题又来了:池子里设置多少个Goroutine合适?

3.2.3 Actor模型

Actors模型(Actor model)首先是由Carl Hewitt在1973定义, 由Erlang OTP (Open Telecom Platform) 推广,其消息传递更加符合面向对象的原始意图。
Actor 设计思想中:Actor模型希望能以一种更自然的方式模拟人类社会分工协作解决一类复杂系统问题的工作模式,因此在 Actor 的设计哲学中,认为万物都是Actor,这与面向对象编程的“一切皆是对象”类似。两者的根本区别在于面向对象编程通常是顺序执行的,而Actor模型是并发执行的。
Actor 模型通过消息来进行Actor之间的交互,消息的发送和处理是异步非阻塞,也就说发送完消息并不需要等待响应,消息是存放在接收方的邮箱里;而面向对象对象之间是通过方法调用的,是同步阻塞的。因为 Actor 之间只能发送消息,而不会共享任何数据或者内存空间,因此不存在锁的问题。Actor 模型通过隔离控制和计算实体,实现封装和模块化。
从某种意义上来说,actor是面向对象的最严格的形式,但是最后把它们看成一些人:在使用actor来对解决方案建模时,把actor想象成一群人,把子任务分配给他们,将他们的功能整理成一个有组织的结构,考虑如何将失败逐级上传。这样的结果就可以在脑中形成进行软件实现的框架。
我们可以这么理解: Actor=状态+行为+消息, 状态=actor status,行为=process,消息=channel。有没有很像CSP?

Actor的目标:

  • Actor可独立更新,实现热升级。因为Actor互相之间没有直接的耦合,是相对独立的实体,可能实现热升级。
  • 无缝弥合本地和远程调用 因为Actor使用基于消息的通讯机制,无论是和本地的Actor,还是远程Actor交互,都是通过消息,这样就弥合了本地和远程的差异。
  • 容错 Actor之间的通信是异步的,发送方只管发送,不关心超时以及错误,这些都由框架层和独立的错误处理机制接管。
  • 易扩展,天然分布式 因为Actor的通信机制弥合了本地和远程调用,本地Actor处理不过来的时候,可以在远程节点上启动Actor然后转发消息过去。

Actor的实现:
- Erlang/OTP Actor模型的标杆,其他的实现基本上都一定程度参照了Erlang的模式。实现了热升级以及分布式。
- Akka(Scala,Java)基于线程和异步回调模式实现。由于Java中没有Fiber,所以是基于线程的。为了避免线程被阻塞,Akka中所有的阻塞操作都需要异步化。要么是Akka提供的异步框架,要么通过Future-callback机制,转换成回调模式。实现了分布式,但还不支持热升级。
- Quasar (Java) 为了解决Akka的阻塞回调问题,Quasar通过字节码增强的方式,在Java中实现了Coroutine/Fiber。同时通过ClassLoader的机制实现了热升级。缺点是系统启动的时候要通过javaagent机制进行字节码增强。

3.4 函数式模型
//TODO
rust

参考:
《七周七并发》
《深入理解java虚拟机》
并发之痛 http://jolestar.com/parallel-programming-model-thread-goroutine-actor/
读书笔记:对线程模型的批评 http://coolshell.cn/articles/4626.html
synchronized、锁、多线程同步的原理是咋样的 http://www.jianshu.com/p/5dbb07c8d5d5
有关线程同步方式的总结 http://blog.2baxb.me/archives/407
Callback、Actor、CSP等并发模型 http://zora.ghost.io/jian-yi-callback-actor-csp/
Golang CSP并发模型 http://www.jianshu.com/p/36e246c6153d
Actor 模型 http://www.yanjiankang.cn/actor-model-research/