OTP
我们在OPT概述里曾简单的了解过,现在让我们来进行进一步了解
理解并发和erlang的进程
1.理解并发
并发就是并行吗?不完全是,至少在讨论计算机和编程时二者并不等同。
有个常用的半正式定义是这么说的:“并发,用于形容那些无须以特定顺序执行的事物。”比如分别对两副牌排序,你可以排完一副再排另一副;三头六臂的话也可以两副并行一起来。这两个任务在执行顺序上不受约束,因此,它们是并发任务。它们的完成顺序也无所谓,你可以在两个任务间交替切换直至二者全部完成;倘若有多余的手脚((或是多个帮手),也可按真正的并行方式同时进行。
关于“并行”与“并发”的区别,另一个常见的说法是,“并行”形容两个或多个任务在同一时间同时发生,而“并发”形容两个或多个任务在一个时间段内交替进行,同一时间内只有一个任务在执行。而本书中的定义则将“并行”列为“并发”的一个概念子集。
Erlang的一大优势就是它帮你隐藏了任务实际执行的细节。如图所示,如果有额外的CPU(或核,或超线程),Erlang会利用它们并行执行更多并发任务。如果没有,Erlang会利用现有的CPU处理能力一点一点地交替执行任务。你不必操心这些细节,Erlang程序能够自动适配不同的硬件——CPU越多它们跑得越快,前提是任务的组织方式允许它们被并发执行。
分别运行于单处理器硬件和多处理器硬件上的Erlang进程。运行时系统会自动将负载分配到可用的CPU资源上
但若任务就是无法并发呢?若你的程序就必须先执行X,再轮到Y,最后是Z呢?这时你就得好好考虑一下待解决的问题中所隐含的实际的依赖关系了。也许X和Y无所谓谁先谁后,只要它们在Z之前完成就行。又或许X和Y各自完成了一部分的时候就可以部分启动Z。在这个问题上没有捷径可循,但往往稍微动下脑筋便收效甚佳,越有经验越容易。
重新考察问题,削减任务间不必要的依赖,可以令代码在现代硬件上运行得更为高效。但这通常不该是你的首要关注点。将程序中内聚性低的部分隔离成独立的任务,最重要的收益是更清晰可读的代码,你的精力也得以集中到实际问题上;相反,试图一蹴而就一次性完成多个任务只会令你事倍功半。这种隔离意味着生产效率的提高和缺陷数量的降低。但首先,我们需要一种更具体的表征独立任务的手段。
2.Erlang的进程模型
在Erlang中,并发的基本单位是进程。每个进程代表一个持续的活动,它是某段程序代码的执行代理,与其他按各自的节奏执行自身代码的进程一起并发运行。
进程拥有自己的工作内存空间和自己的信箱,其中信箱用于存放外来消息;而许多其他语言和操作系统中的线程却是共享相同内存空间的并发活动。因此与线程相比,Erlang进程更加安全,不必担心自己的数据被篡改。也就是说进程封装了状态。
由于进程不能直接改变其他进程的内部状态,容错便相对容易。无论一个进程执行的代码有多烂,其他进程的内部状态都不会受损。即便是在程序内较细粒度的层面上,你也同样可以设置这种隔离,就好像电脑桌面上的浏览器和文字处理器之间的关系一样。事实证明这种隔离非常有效,后续我们讲到进程监督时你就会有所体会了。
由于进程之间互不共享内部状态,它们只能进行复制式通信。一个进程要跟其他进程交换信息,就会发送一条消息。这条消息是发送方所持有数据的一个只读副本。消息传递的基本语义使分布式与Erlang自然地融为一体。现实生活中,你是无法共享线路上的数据的——你只能复制它。Erlang的进程间通信机制总会让接收方获取一份私有的消息副本,即便消息收发双方同处在一台机器上。初听起来可能很奇怪,但这意味着网络编程和单机编程完全一样。
这种分布透明性支持令Erlang程序员可以将网络视作一组资源的集合-—我们不用关心进程X和进程Y是否运行在不同的机器上,因为无论它们运行在何处,通信方法都一样。
3. 4种进程通信范式
我们将简要讨论一下近年来受到广泛认同的四种进程通信手段。这4种手段分别是持锁共享内存、软件事务性内存、future和消息传递。让我们从最古老但仍旧最流行的方法开始。
1.持锁共享内存
共享内存差不多算得上是我们这个时代的GOTO:身为当今主流的进程通信技术,它不仅历史悠久,而且跟GOTO语句一样,为你提供了一大把搬起石头砸自己的脚的办法。因为它,世代工程师都对并发产生了深深的恐惧(未曾对此心怀畏惧的人只是尚未尝试过罢了)。然而我们必须承认,正如GOTO一样,在一些底层场合中共享内存是无法取代的。
在这种范式下,两个或多个进程可以同时读写一块或多块常规内存区域。有时进程需要在这些内存区域上执行一些具备原子性的操作序列,其他进程在操作完成前不得访问这些区域,这就需要一种令该进程阻止其他进程访问这些区域的方法。解决之道就是锁:一种一次仅允许一个进程访问某种资源的构件。
锁的实现需要内存系统的支持,一般由硬件以特殊指令的形式提供支持。使用锁的时候进程之间必须通力合作:所有进程必须先获取锁才能访问共享内存区域,访问结束后还要将锁释放给其他进程使用。使用锁必须万分小心,差之毫厘谬以千里,因此,操作系统或编程语言分别以系统调用或语言构件的形式提供了信号量、监视器和互斥量等以基本锁为基础的高级构件,用以确保锁的请求和释放的正确性。尽管借助这些可以绕开最棘手的问题,但仍然难以克服锁的诸多缺点。
2.软件事务性内存(STM)
我们所要考察的第一种非传统方法就是STM ( Software Transactional Memory,软件事务性内存)。目前可以在Haskell编程语言的GHC实现和基于JVM的Clojure语言中看到这种机制。STM将内存当作传统数据库,用事务来决定何时写入什么内容。通常,这种实现以一种乐观方式来规避锁:将一组读写访问视为单个操作,若两个进程同时试图访问共享区域,则各自启动一个事务,最终只有一个事务会成功。另一个进程会得知事务失败,并应该在检查共享区域的新内容后重试。该模型直截了当,谁都不需要等待其他进程释放锁。
STM的主要缺点在于你必须重试失败的事务(当然,它们可能再三失败)。事务系统本身也会有比较显著的开销,另外在确定哪个进程成功之前,还需要额外的内存来存放你试图写入的数据。理想情况下,系统应该像支持虚拟内存那样对事务性内存提供硬件支持。
对程序员而言,STM的可控发性看起来比锁要好,只要竞争不会频繁导致事务重启,并发的优势就能充分得到发挥。我们认为该方法本质上是持锁共享内存的变体,它在操作系统层面的作用要更甚于应用编程层面。
3.Future、Promise及同类机制
另一个更现代的手段是采用所谓的future或promise。这个概念还有另处一些形式;在E和MultiLisp等语言以及Java的一个库中可以找到它的身影。类似的还有Id和Glasgow Haskell中的I-var和M-var、Concurrent Prolog中的并发逻辑变量,以及Oz中的数据流变量。
其基本思路是,每个future代表一个被外包到其他进程的计算结果,该进程可能跑在别的CPU甚至是别的计算机上。Future可以像其他对象一样被四处传递,但无法在计算完成之前读取结果,必须等待计算完成。这种方法虽然概念简单、简化了并发系统中的数据传递,但也令程序在远端进程故障和网络故障面前变得脆弱:计算结果尚未就绪而连接又不幸断开时,试图访问promise的值的代码便会无所适从。
4.消息传递
Erlang进程靠消息传递来通信。这意味着接收进程实际上获取了一份独立的数据副本,发送方感知不到接收方对副本所做的任何操作。向发送方回传信息的唯一途径就是反向发送另一条消息。由此而得出的一个重要结论是,无论收发双方是身处同一台机器上还是被网络所隔离,它们都能以相同的方式进行通信。
消息传递一般可分为两类:同步方式和异步方式。在同步方式下,消息抵达接收端之前发送方什么事也做不了;在异步方式下,消息一经投递发送方便可立即着手于其他事务。
同步很容易用异步实现,令接收方总是向发送方回传一个显式的回复即可。因此,Erlang中的消息传递原语是异步的。不过,发送方常常并不关心消息是否抵达——消息抵达与否其实没那么重要,因为你无法预知接收方接下来会怎样:说不定它旋即就挂掉了。这种异步的“即发即祷告”式的通信方法也意味着发送方在消息投递过程中无须挂起(特别是在慢速通信链路上发送消息时)。
当然,收发双方在这一层面的隔离并不是免费的。复制大型数据结构时成本很高,如果发送方还要保留数据副本,势必造成较高的内存消耗。在实践中,这意味着我们必须在发送消息时小心掌控消息的大小和复杂度。