一、术语问题
术语“并发”,“并行”,“多任务”,“多处理”,“多线程”,”分布式系统“(可能还有其他)在很多编程文献中都以多种相互冲突的方式使用,并且经常被混为一谈。 Brian Goetz 在他 2016 年《从并发到并行》的演讲中指出了这一点,之后提出了合理的二分法:
- 并发是关于正确有效地控制对共享资源的访问
- 并行是使用额外的资源来更快的产生结果
“并发”通常表示:不止一个任务正在执行。而“并行”几乎总是代表:不止一个任务同时执行。所以问题就是:“并行”也有不止一个任务正在执行的语义在里面。区别就在于细节:究竟是怎么“执行”的。此外,还有一些场景重叠:为并行编写的程序有时在单处理器上运行,而一些并发编程系统可以利用多处理器。
另一种定义的方式是,在影响到程序的执行性能时:
- 并发: 同时完成多任务。无需等待当前任务完成即可执行其他任务。“并发”解决了程序因外部控制而无法进一步执行的阻塞问题。最常见的例子就是 I/O 操作,任务必须等待数据输入(在一些例子中也称阻塞)。这个问题常见于 I/O 密集型任务。
- 并行: 同时在多个位置完成多任务。这解决了所谓的 CPU 密集型问题:将程序分为多部分,在多个处理器上同时处理不同部分来加快程序执行效率。
上面的定义说明了这两个术语令人困惑的原因:两者的核心都是“同时完成多个任务”,不过并行增加了跨多个处理器的分布。更重要的是,它们可以解决不同类型的问题:并行可能对解决 I / O 密集型问题没有任何好处,因为问题不在于程序的整体执行速度,而在于 I/O 阻塞。而尝试在单个处理器上使用并发来解决计算密集型问题也可能是浪费时间。两种方法都试图在更短的时间内完成更多工作,但是它们实现加速的方式有所不同,这取决于问题施加的约束。
这两个概念混合在一起的一个主要原因是包括 Java 在内的许多编程语言使用相同的机制 - 线程来实现并发和并行
- Java中的Thread类定义了多线程,通过多线程可以实现并发或并行。
- 在CPU比较繁忙,资源不足的时候(开启了很多进程),操作系统只为一个含有多线程的进程分配仅有的CPU资源,这些线程就会为自己尽量多抢时间片,这就是通过多线程实现并发,线程之间会竞争CPU资源争取执行机会。
- 在CPU资源比较充足的时候,一个进程内的多线程,可以被分配到不同的CPU资源,这就是通过多线程实现并行。
- 至于多线程实现的是并发还是并行?上面说的,多线程可能被分配到一个CPU内核中执行,也可能被分配到不同CPU执行,分配过程是操作系统所为,不可人为控制。所以,我们所写的多线程有可能是并发的,也有可能是并行的。
- 不管并发还是并行,都提高了程序对CPU资源的利用率,最大限度的利用CPU资源。
我们甚至可以尝试以更细的粒度去进行定义(然而这并不是标准化的术语):
- 纯并发: 仍然在单个 CPU 上运行任务。纯并发系统比顺序系统更快地产生结果,但是它的运行速度不会因为处理器的增加而变得更快。
- 并发-并行: 使用并发技术,结果程序可以利用更多处理器更快地产生结果。
- 并行-并发: 使用并行编程技术编写,如果只有一个处理器,结果程序仍然可以运行(Java 8 Streams 就是一个很好的例子)。
- 纯并行: 除非有多个处理器,否则不会运行。
纯并行和纯并发的图解:
纯并发:指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在围观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
纯并行: 指在同一时刻,有多条指令在多个处理器上同时执行
二、并发的新定义
书中给出的并发定义如下:
并发性是一系列性能技术,专注于减少等待
这实际上是一个相当复杂的表述,可以将其分解:
- 集合:并发性包含许多不同的方法来解决这个问题。这是使定义并发性如此具有挑战性的问题之一,因为技术差异很大。
- 性能技术:并发的关键点在于让你的程序运行得更快。在 Java 中,并发是非常棘手和困难的,所以要谨慎使用并发。
- 减少等待:这个概念很重要而且微妙。无论你运行多少个处理器,你只能在等待发生时使用并发才会产生效益。如果你发起 I/O 请求并立即获得结果,没有延迟,因此无需改进。如果你在多个处理器上运行多个任务,并且每个处理器都以满容量运行,并且没有任务需要等待其他任务,那么尝试提高吞吐量是没有意义的。并发的唯一机会程序的某些部分被迫等待。等待可以以多种形式出现(这解释了为什么存在如此不同的并发方法)。
三、并发能够加快程序运行
3.1 顺序执行与并发执行-为什么并发可以加快程序运行
3.1.1 前趋图
前趋图(DAG,Directed Acyclic Graph)是一个有向无环图,用于描述进程之间执行的先后顺序。图中的每个结点可用来表示一个进程或程序段,乃至一条语句,节点间的有向边则表示两个节点之间存在的偏序关系或前趋关系。前趋关系可用“ → \to →”来表示,如果进程 P i P_i Pi和 P j P_j Pj存在前趋关系,可以写成 P i → P j P_i\to P_j Pi→Pj,表示 P j P_j Pj开始执行前 P i P_i Pi必须完成。
在下图中,存在如下的前趋关系:
P
1
→
P
2
,
P
1
→
P
3
,
P
1
→
P
4
,
P
2
→
P
5
,
P
3
→
P
5
,
P
4
→
P
6
,
P
4
→
P
7
,
P
5
→
P
8
,
P
6
→
P
8
,
P
7
→
P
9
,
P
8
→
P
9
P_1\to P_2,P_1\to P_3,P_1\to P_4,P_2\to P_5,P_3\to P_5,P_4\to P_6,P_4\to P_7,P_5\to P_8,P_6\to P_8,P_7\to P_9,P_8\to P_9
P1→P2,P1→P3,P1→P4,P2→P5,P3→P5,P4→P6,P4→P7,P5→P8,P6→P8,P7→P9,P8→P9
注意前趋图中不允许有循环,否则必然会产生不可能实现的前趋关系。如下所示:
3.1.2 顺序执行
3.1.2.1 程序的顺序执行
通常,一个应用程序由若干个程序段组成,每一个程序段完成特定的功能,它们在执行时,都需要按照某种先后次序顺序执行,仅当前一程序段执行完后,才运行后一程序段。例如,在进行计算时,应先运行输入程序,用于输入用户的程序和数据;然后运行计算程序,对所输入的数据进行计算;最后才是运行打印程序,打印计算结果。用节点表示上述过程,I代表输入操作,C代表计算操作,P代表打印操作,用箭头表示前趋关系。这样,上述的三个程序段就可以用前趋图来表示:
3.1.2.2 程序顺序执行时的特征
- 顺序性:指处理机严格地按照程序所规定的顺序执行,即每一操作必须在下一个操作开始前结束
- 封闭性:指程序在封闭的环境下运行,即程序运行时独占全机资源,资源的状态(除初始的状态外)只有本程序才能改变它,程序一旦开始执行,其执行结果不受外界因素影响
- 可再现性:指只要程序执行时的环境和初始条件相同,当程序重复执行时,不论它是从头到尾不停顿地执行,还是“停停走走”地执行,都可以获得相同的结果。
3.1.3 程序的并发执行
程序顺序执行时,虽然可以给程序员带来方便,但系统资源的利用率却很低。为此,在系统中引入了多道程序技术,使程序或程序段间能并发执行。然而,并非所有的程序都能并发执行。事实上,只有不存在前趋关系的程序间才可能并发执行,否则无法并发执行。
3.1.3.1 程序的并发执行
通过一个常见的例子来说明程序的并发执行,还是以上面的计算程序为例。存在着
I
i
→
C
i
→
P
i
I_i\to C_i\to P_i
Ii→Ci→Pi这样的前趋关系,对每一个作业的输入、计算和打印程序,都必须顺序执行。但若是对一批作业进行处理时,每道作业的输入、计算和打印程序段的执行情况则如下所示:
输入程序
(
I
1
)
(I_1)
(I1)在输入第一次数据后,由计算程序
(
C
1
)
(C_1)
(C1)对该数据计算的同时,输入程序
(
I
2
)
(I_2)
(I2)可以再输入第二次数据,从而使第一个计算程序
(
C
1
)
(C_1)
(C1)可以与第二个输入程序
(
I
2
)
(I_2)
(I2)并发执行。事实上,正是由于
C
1
C_1
C1和
(
I
2
)
(I_2)
(I2)之间并不存在前趋关系,因此它们之间可以并发执行。一般来说,输入程序
(
I
i
+
1
)
(I_{i+1})
(Ii+1)再输入第i+1次数据时,计算程序
(
C
i
)
(C_i)
(Ci)可能正在对程序
(
I
i
)
(I_i)
(Ii)的第i次输入的数据进行计算,而打印程序
(
P
i
−
1
)
(P_{i-1})
(Pi−1)正在打印程序
(
C
i
−
1
)
(C_{i-1})
(Ci−1) 的计算结果。
从上图可以看出,存在前趋关系:
I
i
→
C
i
,
I
i
→
I
i
+
1
,
C
i
→
P
i
,
C
i
→
C
i
+
1
,
P
i
→
P
i
+
1
I_i\to C_i, I_i\to I_{i+1},C_i\to P_i,C_i\to C_{i+1},P_i\to P_{i+1}
Ii→Ci,Ii→Ii+1,Ci→Pi,Ci→Ci+1,Pi→Pi+1
而
I
i
+
1
I_{i+1}
Ii+1和
(
C
i
)
(C_i)
(Ci)以及
(
P
i
−
1
)
(P_{i-1})
(Pi−1)使重叠的,即在
P
i
−
1
P_{i-1}
Pi−1和
C
i
C_i
Ci以及
I
i
+
1
I_{i+1}
Ii+1之间,不存在前趋关系,可以并发执行。
3.1.3.2 程序并发执行时的特征
- 间断性:程序在并发执行时,由于它们共享系统资源,以及为完成同一项任务而相互合作,致使在这些并发执行的程序之间形成了相互制约的关系。例如在上图中,I、C和P就是三个相互合作的程序当计算程序完成了 C i − 1 C_{i-1} Ci−1的计算后,如果输入程序 I i I_i Ii尚未完成数据的输入,则计算程序 C i C_i Ci就无法进行数据处理,必须暂停运行。只有当程序暂停的因素消失后(如 I i I_i Ii已完成数据输入),计算程序 C i C_i Ci便可恢复执行。由此可见,相互制约将导致并发程序具有“执行-暂停-执行”这种间断性的活动规律
- 失去封闭性:当系统中存在着多个可以并发执行的程序时,系统中的各资源将为它们所共享,而这些资源的状态也有这些程序来改变,致使其中任一程序在运行时,其环境都必然会受到其他程序的影响。例如,当处理机已被分配给某个进程运行时,其他程序必须等待。显然,程序的运行已经失去了封闭性
- 不可再现性:程序在并发执行时,由于失去了封闭性,也将导致其又市区可在现性。例如,有两个循环程序A和B,它们共享一个变量N。程序A每执行一次时,都要做N=N+1操作,程序B每次执行时,都要执行print(N)操作,然后执行N=0操作。程序A和B以不同的速度运行,这样就会出现以下的情况:如果N=N+1在print(N)和N=0前,那么N值分别为n+1,n+1,0;如果N=N+1在print(n)和n=0后,那么得到的n值分别为n,0,1;如果N=N+1在print(N)和N=0之间,此时得到的N值分别为n,n+1,0
3.2 并发的实现
实现并发的一种简单方式是使用操作系统级别的进程。与线程不同,进程是在其自己的地址空间中运行的独立程序。进程的优势在于,因为操作系统通常将一个进程与另一个进程隔离,因此它们不会相互干扰,这使得进程编程相对容易。相比之下,线程之间会共享内存和 I/O 等资源,因此编写多线程程序最基本的困难,在于协调不同线程驱动的任务之间对这些资源的使用,以免这些资源同时被多个任务访问。
一些编程语言旨在将并发任务彼此隔离。这些通常被称为函数式语言,其中每个函数调用不产生副作用(不会干扰到其它函数),所以可以作为独立的任务来驱动。Erlang 就是这样一种语言,它包括一个任务与另一个任务进行通信的安全机制。如果发现程序的某一部分必须大量使用并发,并且在尝试构建该部分时遇到了过多的问题,那么可以考虑使用这些专用的并发语言创建程序的这个部分。
Java 采用了更传统的方法,即在顺序语言之上添加对线程的支持而不是在多任务操作系统中分叉外部进程,线程是在表示执行程序的单个进程内创建任务。
并发会带来各种成本,包括复杂性成本,但可以被程序设计、资源平衡和用户便利性方面的改进所抵消。通常,并发性使你能够创建更低耦合的设计;另一方面,你必须特别关注那些使用了并发操作的代码。