并发介绍
让我们暂时忘掉电脑;我(作者)要从窗户向外看,告诉你们我看到了什么。
我(作者)看到一位女士带着狗散步。我看到一辆小汽车在寻找停车位。我看到一架飞机从头顶飞过,一艘船从旁边驶过。所有这些事情都是 并行 发生的。在本书中,我们将学习怎样把一些并行的活动,描述为一组相互通信的并行进程。我们将学习如何编写 并发程序。
在日常用语中,并发、同步及 并行 等词的意思几乎相同。但在编程语言中,我们需要更加精确。特别是,我们需要区分并发程序和并行程序。
知识点:
- concurrent, 并发
- simultaneous, 同步
- parallel, 并行
若我们只有一部单核的计算机,那么我们就永远无法在他上面运行某个并行的程序。这是因为我们只有一个 CPU,而他一次只能做一件事。不过,我们可以在一部单核计算机上,运行并发程序。计算机会在不同任务之间进行分时,从而维持不同任务并行运行的假象,the computer time-shares between the different tasks, maintaining the illusion that the different tasks run in parallel。
在接下来的小节中,我们将从一些简单的并发建模开始,进而了解使用并发解决问题的好处,最后看看突出并发和并行之间区别的一些精确定义。
建模并发
我们将从一个简单示例开始,建立一个日常场景的并发模型。请设想一下,我看到四个人出来散步。有两只狗和许多兔子。人们在互相交谈,而狗则想追逐那些兔子。
要用 Erlang 模拟这种情况,我们需要创建四个模组,分别称为 person
、dog
、rabbit
及 world
。person
的代码将位于一个名为 person.erl
的文件中,看起来可能是这样的:
-module(person).
-export([init/1]).
init(Name) -> ...
其中第一行 -module(person).
,表示该文件包含了名为 person
模组的代码。这应与文件名相同(不包括 .erl
文件扩展名)。模组名 必须 以小写字母开头。从技术上讲,模组名是个 原子;我们将在 第 3.5 节 “原子” 中详细介绍何为原子。
模组声明之后,是个 导出声明。导出声明指出模组中的哪些函数,可以从该模组 外部 调用。他们就像许多编程语言中的 public
声明。不在导出声明中的函数属于私有的,而不能从模组外部调用。
知识点:
- the module declaration, 模组声明
- export declaration,导出声明
- public
- private
-export([init/1]).
这种语法,表示有一个参数(这是 /1
的意思;不是除以一 的意思)的函数 init
,可从该模组外部调用。在我们打算导出多个函数时,可使用这种语法:
-export([FuncName1/N1, FuncName2/N2, .....]).
其中方括号 [ ...]
的意思是 “列表”,因此这个声明表示我们打算从模组中导出一个函数列表。
我们还需编写 dog
与 rabbit
的代码。
启动模拟
要启动这个程序,我们将调用 world:start()
。这是定义在一个名为 world
模组中的,其开头如此:
-module(world).
-export([start/0]).
start() ->
Joe = spawn(person, init, ["Joe"]),
Susannah = spawn(person, init, ["Susannah"]),
Dave = spawn(person, init, ["Dave"]),
Andy = spawn(person, init, ["Andy"]),
Rover = spawn(dog, init, ["Rover"]),
...
Rabbit1 = spawn(rabbit, init, ["Flopsy"]),
...
spawn
是个创建并发进程,并返回一个进程标识符的 Erlang 原语。spawn
的调用如下:
spawn(ModName, FuncName, [Arg1, Arg2, ..., ArgN])
当 spawn
被求值时,Erlang 的运行时系统,会创建出一个新进程(不是个操作系统进程,而是个由 Erlang 系统管理的轻量级进程)。一旦该进程被创建出来,他就会开始计算由那些参数所指定的代码。ModName
是有着我们打算执行代码的模组名称。FuncName
是模组中函数的名称,[Arg1, Arg2, ...]
是个包含了我们要计算函数参数的列表。因此,以下调用表示要启动一个执行函数 person:init("Joe")
的进程:
spawn(person, init, ["Joe"])
spawn
的返回值,是个可用于与这个新创建进程交互的 进程标识符,PID。
知识点:
- lightweight process, 轻量级进程
- process identifier,进程标识符
以对象类比
Erlang 中的模组,就像面向对象编程语言,object-oriented programming language, OOPL, 中的类,而进程就像 OOPL 中的对象(或类的实例)。
在 Erlang 中,
spawn
通过运行某个模组中定义的一个函数,创建出一个新的进程。在 Java 中,new
通过运行某个类中定义的一个方法,创建出一个新对象。在某门 OOPL 中,我们可以有一个类,而有数千个类实例。同样,在 Erlang 中,我们可以有一个模组,而有数千甚至数百万个执行该模组中代码的进程。所有 Erlang 的进程,都并发且独立执行,如果我们有一台百万核的计算机,他们甚至可以并行运行。
发送消息
一旦模拟启动,我们就会打算在程序中的不同进程间发送消息。在 Erlang 中,进程间不共享内存,而只能通过发送消息进行交互。这正是现实世界中,对象的行为方式。
假设 Joe 想要对 Susannah 说些什么。在程序中,我们就要写下面这样一行代码:
Susannah ! {self(), "Hope the dogs don't chase the rabbits"}
Pid !Msg
这种语法,表示将信息 Msg
发送到进程 Pid
。花括号中的 self()
参数,指的是发送信息的进程(此情形下为 Joe
)。
接收消息
要让 Susannah 的进程接收来自 Joe 的消息,我们就要写下这些代码:
receive
{From, Message} ->
...
end
当 Susannah 的进程收到一条信息时,其中的变量 From
将绑定到 Joe
,这样 Susannah 就知道信息来自谁,而变量 Message
将包含这条信息。
我们可以设想扩展我们的模型,让狗子互相发送 "woof woof rabbits"
的消息,让兔子互相发送 "panic go and hide"
的消息。
这里我们应记住的关键点,是我们的编程模型是基于对现实世界的 观察。我们有三个模组(person
、dog
和 rabbit
),因为在我们的示例中,有三种类型的并发事物。world
这个模组,是出于需要一个顶级进程启动一切。因为有两只狗,所以我们创建了两个狗的进程,而又因为有四个人,所以我们创建了四个人的进程。程序中的消息,反映了我们示例中所观察到的消息。
与其扩展这个模型,我们不如就此打住,换个思路,看看并发程序的一些特点。
并发的益处
并发编程可用于提高性能、创建出可扩展性与容错的系统,以及为控制现实世界应用编写出清晰易懂的程序。以下是之所以这样讲的一些原因:
性能
设想咱们有两项任务:A
需要 10 秒钟完成,B
需要 15 秒钟完成。在单个 CPU 上同时执行 A
和 B
将要 25 秒。而在有着两个独立运行 CPU 的计算机上,执行 A
和 B
只需 15 秒。要达到这种性能提升,我们必须编写并发程序。
直到最近,并行计算机都还很少见,而且价格昂贵,但如今多核计算机已司空见惯。顶级处理器有着 64 个核心,而且在可预见的未来,每芯片的核心数量还会稳步增加。如果咱们有个合适的问题,和一台有 64 核心的计算机,那么咱们的程序在这台计算机上运行时,可能会快六十四倍,但前提是咱们必须编写一个并发的程序。
计算机行业中最紧迫的问题之一,是由将老旧顺序代码并行化,以使其能在多核计算机上运行的难度造成的。Erlang 中就不没有这类问题。二十年前为顺序机器编写的 Erlang 程序,我们在现代多核计算机上运行时速度只会更快。
可扩展性
并发程序由小的独立进程组成。因此,我们可以通过增加进程数量和增加 CPU,轻松扩展系统。在运行时,Erlang 的虚拟机会自动将进程的执行,分配到可用的 CPU 上。
容错性
容错与可扩展性类似。容错的关键是独立和硬件冗余。Erlang 程序由许多小的独立进程组成。一个进程中的错误,不会意外导致另一进程崩溃。为防止整个计算机(或数据中心)失效,我们需要检测远端计算机的故障。进程独立性和远端故障检测,在 Erlang 虚拟机中都有建置。
Erlang 是为构建具备容错能力的电讯系统设计的,但同样的技术,也能很好地用于构建具备容错能力的可扩展 web 系统或云服务。
清晰度
在现实世界中,事情都是并行发生的,而在大多数编程语言中,事情则是顺序发生的。现实世界中的并行性,与咱们编程语言中的顺序性间的不匹配,使得以顺序语言编写现实世界中的控制问题,变得非常困难。
以 Erlang 的方式,我们就可以将现实世界中的并行性,直接映射到 Erlang 的并发。这样,代码就清晰易懂了。
既然咱们已经看到这些好处,我们将试着为并发和并行的概念,增加一些精确性。这将为我们在以后的章节中,讨论这些术语提供一个框架。
并发程序与并行计算机
在这里,我要迂腐一点,尽量给 并发 和 并行 等术语赋予准确的含义。我们要区分并行程序和并行计算机,所谓并行程序,是指在我们有并行计算机时就能运行得更快的程序,而并行计算机,则是真的有一个以上核心(或 CPU)。
- 并发程序 是以某门并发编程语言编写的程序。我们编写并发程序是出于性能、可扩展性或容错等的考虑;
- 并发编程语言,是一门有着用于编写并发程序目的的明确语言结构的语言。这些构造是该门编程语言不可或缺的一部分,同时在所有操作系统上以同样方式行事;
- 并行计算机,是一部有着多个在同一时间运行的处理单元(CPU 或核心)的计算机。
以 Erlang 编写的并发程序,是由数个通信的顺序进程集合组成。Erlang 进程是可计算单个 Erlang 函数的小小虚拟机;其不应与操作系统的进程混淆。
要以 Erlang 编写并发程序,咱们必须确定出能解决咱们问题的一组进程。我们将这种确定出进程的行为,称为 并发建模。这就好比识别出编写面向对象程序所需对象的艺术。
在面向对象设计中,选择解决问题所需的对象,是个公认的难题。在并发建模中也是如此。选择正确进程可能很难。好的进程模型和差的进程模型之间的差别,可以决定一个设计的成败。
编写好并发程序后,我们可在并行计算机上运行他。我们可在多核的计算机上运行,也可以在一组联网计算机上,或云上运行。
我们的并行程序真的会在并行计算机上并行运行吗?有时很难知道。在多核计算机上,操作系统可能会决定关闭某个核心以节省能耗。在云计算中,某台计算可能会暂停而迁移到某台新计算机上。这些都在我们的掌控之外。
现在我们已经看到并发程序与并行计算机之间的区别。并发与软件的结构有关,并行则与硬件有关。接下来我们来看看顺序编程语言和并发编程语言的区别。
知识点:
- concurrent program, 并发程序
- concurrent programming language, 并发编程语言
- parallel computer, 并行计算机
- CPU,core,处理器,核心
顺序与并发编程语言
编程语言分为两类:顺序语言和并发语言。顺序语言是那些为编写顺序程序而设计的语言,没有用于描述并发计算的语言结构。并发编程语言是为编写并发程序而设计的那些语言,语言本身具有表达并发性的一些特殊结构。
Erlang 中的并发性,是由 Erlang 虚拟机提供的,而不是由操作系统或任何外部库提供。在大多数顺序编程语言中,并发性是作为主机操作系统的并发原语的某个接口提供的,in most sequential programming languages, concurrency is provided as an interface to the concurrency primitives of the host operating system。
对基于操作系统的并发性,和基于语言的并发性的区分,非常重要,因为当咱们在使用基于操作系统的并发性时,那么咱们的程序在不同的操作系统上,将以不同方式运行。Erlang 的并发,在所有操作系统上的工作方式一致。要以 Erlang 编写并发程序,咱们只需了解 Erlang;咱们不必了解操作系统中的并发机制。
在 Erlang 中,进程和并发,是我们用于塑造和解决问题的工具。这实现了对咱们程序并发结构细粒度的控制,而使用操作系统的进程,就很难做到这点。
知识点:
- sequential programming language
- concurrent programming language
- the concurrency primitives of the host operating system
总结
我们现在已经介绍了本书的中心主题。我们谈到了作为编写高性能、可扩展和容错软件一种手段的并发,但我们没有介绍如何实现这一点的任何细节。在下一章中,我们将对 Erlang 进行一次旋风之旅,并编写我们的第一个并发程序。