Java并发编程(1)——并发成本和并发模型

1 并发编程的成本

1.1 结构复杂

一般而言多线程程序结构更为复杂,线程之间的交互极为复杂,不正确的线程交互非常难以发现,并且重现以修复。

1.2 上下文切换

即使是单核CPU也能够执行多线程程序,CPU通过为线程分配时间片来支持多线程。所谓时间片,即是分配给每个线程的CPU运行时间。时间片非常短,因此在切换的过程中用户无法察觉,CPU通过时间片分配算法来循环执行任务,当前任务执行完一个时间片之后就会将CPU交给下一个线程。但在线程切换之前,需要保存上一个任务的状态,以便下次切换回这个任务时可以加载这个任务的状态,这个操作称之为上下文切换
上下文的切换会消耗一定的资源,因此并非所有的场景都适合多线程,多线程之间任务的切换将会浪费大量的时间,我们应当尽量避免上下文切换。

1.3 资源消耗

除了CPU资源以外,线程还需要一些内存来维持它本地的堆栈,它还需要占用一些操作系统的资源来实现对线程的管理。因此,就算是创建什么都不做的“空”线程,它们也会占用相当的资源。

2 并发编程模型

2.1 并发模型与分布式系统的相似性

并发模型与分布式系统之间具有一定的相似性,例如,在并发模型中,线程之间可以进行通信,在分布式系统中进程之间也可以进行通信,它们之间通常可以相互借鉴思想,为工作者(线程)分配作业的模型一般与分布式系统中的负载均衡系统就较为相似。同样,它们在日志记录、失效转移、幂等性等错误处理技术上也颇为相似。

2.2 并发编程模型

2.2.1 并行工作者

并行工作者的图片描述
这里写图片描述
委派者(Delegator)将传入的作业分派给不同的工作者,每个工作者完成整个任务,工作者们并行运行在不同的线程上,甚至不同的CPU上。在Java应用系统中,并行工作者是最常见的并发模型,java.util.current 中很多实用并发工具都是设计用于这个模型的。
优点:易于理解,只要增加新的工作者就可以提升系统的并行度。
缺点

共享状态可能会很复杂

并行的工作者可能会访问一些共享的数据,包括内存中的或者数据库中的。
这里写图片描述
有些共享状态是在像作业队列这样的机制下,但也有一些是业务数据、数据缓存、数据库连接池等。
一旦共享状态潜入到并行工作者模型中,情况就将变得复杂起来,线程需要以某种方式存取共享数据,确保某个线程的修改对其他线程可见(数据修改需要同步到主存中,不仅仅将数据保存在执行这个线程的CPU缓存中)。线程需要避免竟态、死锁以及其它共享状态的并发性问题。
此外,在等待访问共享数据结构时,线程之间的互相等待将会丢失部分并行性。许多并发数据结构时阻塞的,这意味着同一时刻只有一个或者很少线程能够访问共享数据。这会导致在这些共享数据上出现竞争状态,在执行需要访问共享数据结构部分的代码时,高竞争基本会使得执行时出现一定程度的串行化。
非阻塞算法当然可以降低竞争提升性能,但实现更为困难。
可持久化的数据结构是另一种选择,在进行修改操作时,可持久化数据总是能够保证它的上一个版本不受影响。因此,如果多个线程指向一个可持久化数据结构,并且其中一个线程对其进行修改,进行修改的线程会获得一个指向新结构的引用,其他线程则保持对旧结构的引用,旧结构不会被修改并因此保持了一致性。
我们需要注意:这里的持久化数据结构并非是持久化存储,而是指一种数据结构,比如Java中的StringCopyOnWriteArrayList。虽然可持久化的数据结构在解决共享数据结构的并发修改时显得很优雅,但是可持久化的数据结构的表现往往不尽人意。

无状态的工作者

共享状态能够被系统中得其他线程修改。所以工作者在每次需要的时候必须重读状态,以确保每次都能访问到最新的副本,不管共享状态是保存在内存中的还是在外部数据库中。工作者无法在内部保存这个状态(但是每次需要的时候可以重读)称为无状态的。
每次都重读需要的数据,将会导致速度变慢,特别是状态保存在外部数据库中的时候。

任务顺数是不确定的

并行工作者模式的这种非确定性的特性,使得很难在任何特定的时间点推断系统的状态。这也使得它也更难(如果不是不可能的话)保证一个作业在其他作业之前被执行

2.2.2 流水线模式

这是相对于并行工作者的名称。
这里写图片描述
每个工作者仅负责作业中的一部分,当这一部分完成后就将作业转发给下一个工作者,每个工作者在自己的线程上运行,并不会和其它线程有共享状态,因此也称为无共享并行模型
通常使用非阻塞的IO来设计使用流水线并发模型的系统。非阻塞IO意味着,一旦某个工作者开始IO操作(比如读取文件或者从网络连接中读取数据),这个工作者不会一直等待IO操作的结束,IO操作很慢,等待它的结束就是对CPU时间的浪费。此时CPU可以做一些其他的事情,当IO操作完成时,其结果(比如读取的数据或者数据写完的状态)被传递给下一个工作者。
有了非阻塞IO,就可以使用IO操作确定工作者之间的边界,工作者会尽可能地多运行直到遇到并开启某个IO操作,然后交出作业的控制权。当IO操作完成时,在流水线上的下一个工作者继续进行操作,直到它也遇到并启动一个IO操作。
这里写图片描述
实际操作中,作业可能不会沿着单一流水线进行。由于大多数系统可以进行多个作业,作业从一个工作者流向另一个工作者取决于作业需要做的工作,在实际中可能会有多个虚拟流水线同时运行,以下是现实中作业可能的移动情况:
这里写图片描述
作业甚至也有可能被转发到超过一个工作者上并发处理。。比如说,作业有可能被同时转发到作业执行器和作业日志器。下图说明了三条流水线是如何通过将作业转发给同一个工作者(中间流水线的最后一个工作者)来完成作业:
这里写图片描述
流水线模式有时会比这个更复杂。

反应器,事件驱动系统

采用流水线并发模型的系统有时也称作反应器或事件驱动系统。系统内的工作者对系统内的事件作出反应,这些事件也有可能来自外部系统或者发自其它工作者,事件可以是某个HTTP请求,也可以是某个文件被成功加载到内存中等等。现在已经有许多的反应器/事件驱动平台可以使用了,例如:VERT.x,AKKa,Node.js(JavaScript)等等。

Actors和Channels

Actors和Channels是两种比较类似的流水线模型。
在Actors模型中,每一个工作者都被称为Actor,它们之间可以异步地发送和处理消息。Actor可以用来实现像一个或多个像前文中那样的作业流水线。
这里写图片描述
而在Channels模型中,工作者之间不进行直接通信,相反,它们在不同的通道中发布自己的消息(事件),其它工作者可以在这些通道中监听消息,发送者不需要知道谁在监听。下图是Channels模型:
这里写图片描述

流水线模型的优点:

  • 无需共享状态,这意味着实现的时候无需考虑因访问共享对象而导致的并发性问题,使得工作者的实现变得更为容易;
  • 有状态的工作者,指的是,当工作者的数据不会被其他线程修改时,它可以是有状态的,意味着它可以将自己操作的数据保存在内存中,只在最终才刷新到持久存储中;
  • 较好的硬件整合(Hardware Conformity);
  • 合理的作业顺序:基于流水线模型的并发,某种程度上可以保证作业的执行顺序,这就使得它更容易推断出某个时刻系统所处的状态。

流水线模型的缺点:

最大的缺陷在于,将作业的执行分布到了多个工作者上,进而分不到一个项目的多个类上,导致追踪某个作业到底是被什么代码执行时变得困难;同样,这也加大了代码编写的难度,有时工作者会被写成回调处理的形式,但代码中出现过多的回调处理可能会引发回调地狱(callback hell)现象:追踪代码在回调过程中到底做了什么,以及确保每个回调只访问它需要的数据的时候,变得非常困难。

2.2.3 函数式并行(Functional Parallelism)

这是我们要介绍的第三种并发模型,也是最近讨论的较热的,它的基本思想是通过函数调用实现程序,函数可以看做“代理人”(Agents)或者“Actor”,函数之间像流水线模型互相发送消息。某个函数调用另一个的过程类似于消息发送。
函数都是通过拷贝来传递参数,所以除了接收函数之外没有实体可以操作数据,这对于避免共享数据的竞态来说是很有必要的,同样也使得函数的执行类似原子操作,每个函数的调用执行都是独立于其它函数的。
Java7 中的 java.util.concurrent 包里包含的 ForkAndJoinPool 能够帮助我们实现类似于函数式并行的一些东西。而 Java8 中并行 streams 能够用来帮助我们并行的迭代大型集合。记住有些开发者对 ForkAndJoinPool进行了批判(你可以在我的 ForkAndJoinPool 教程里面看到批评的链接)。
函数式并行里面最难的是确定需要并行的那个函数调用。跨 CPU 协调函数调用需要一定的开销。某个函数完成的工作单元需要达到某个大小以弥补这个开销。如果函数调用作用非常小,将它并行化可能比单线程、单 CPU执行还慢。

猜你喜欢

转载自blog.csdn.net/ascend2015/article/details/80370139