Erlang的并发优势
1.并发是指多个不同的函数能够并行运行而不互相影响。每个Erlang中的并发活动称为一个进程。进程之间相互交流的唯一途径是通过消息传递,数据以这种方式从一个进程发送到另一个进程。
2.并发模型的设计哲学:
世界是并发的。
事物之间不共享数据。
事物通过消息进行通信。
事物会出现故障。
3.并发模型及其错误处理机制从一开始就内置于Erlang中。使用轻量级进程,几十万乃至上百万进程同时并行运行并不少见,而且经常仅仅使用很少的内存。运行时系统把并发性扩展到如此的级别直接影响到了程序的开发方式,这是Erlang语言区别于其他的并发编程语言的方式。
4.Erlang的设计哲学就是为每一个事件生成一个新进程,这样,程序的结构就直接反映出多用户交互消息的并发性。在即时消息服务器系统中,一个事件可能是一个位置更新,消息的发送或者消息的接收,或者是一个登陆请求,每个进程服务于它处理的事件,并且当请求完成时终止。
5.Erlang不适用本地线程表示进程,它在虚拟机中有自己的调度程序,在尽量减少内存占用的同时使进程创建非常有效率地进行,无论系统中并发进程有多少,都可以保证这种效率,同样的道理适用于消息传递,不管进程的数量是多少,信息发送的时间都是可以忽略不计的并且是个常量。
创建进程
1.通过使用内置函数spawn(Module,Function,Arguments)可以生成一个新进程,并对Module模块中的导出函数Function以列表Arguments作为参数进行求值。内置函数spawn/3会返回一个进程标识符,我们称它为pid。
2.当在Erlang中开始编程的时候,一个常见的错误是忘记了spawn的第三个参数是一个参数列表,因此当使用参数a来对函数m:f/1生成一个进程的时候,应该调用:
spawn(m,f,[a]),而不是spawn(m,f,a)。
3.一个进程一旦生成,它会持续执行和有效,直至终止它。如果一个进程没有更多的代码可以执行,我们就说这个进程是正常终止的。反之,如果发生一个运行时错误,比如错误匹配或者case语句错误,我们就说这个进程是异常终止的。
4.生成一个进程永远不会失败,即使使用了一个没有导出或者甚至是不存在的函数,一旦这一进程生成了,spawn/3就返回进程标识符,新生成的进程将由于运行时错误而终止。
5.如果错误发生在一个新生成的进程中,它会由Erlang运行时系统中错误记录程序的另一部分来监控处理,默认情况下它会在终端中使用前面演示的格式来输出一个错误报告,相反,终端检测出的错误要格式化为一个更可读的形式。
6.内置函数processes()返回所有系统中运行的进程列表,在大多数情况下,使用这个内置函数应该没有什么问题,但在大型系统中,从终端调用processes()有可能出现极端的情况,从而产生运行时系统内存不足的错误!请不要忘记,在工业应用程序中,需要处理数以百万计同时运行的进程,运行时系统目前实现的绝对极限是数以亿计。系统的这一默认数字要低得多,但是你可以很容易地通过终端命令erl+p MaxProcesses来改变它,其中MaxProcesses是一个整数。
7.可以使用终端命令i()来查看当前运行时系统正在执行的进程,它会输出进程标识符,用来生成这个进程的函数。
消息传递
1.进程使用消息传递来进行相互通信,消息使用Pid!Message构造来发送,其中Pid是一个有效的进程标识符,而Message是属于任意Erlang数据类型的一个值。
2.每个Erlang进程都有用来存储传入消息的信箱。当一个消息发送的时候,它会从发送进程中复制到接受信箱以便于搜索。消息会以它们到达的时间次序存储到信箱里,如果两个消息从一个进程发送到另一个,那么消息接受的次序和发送它们的次序肯定相同。不同的进程发送消息的时候没有这种保证,在这种情况下这个次序取决于虚拟机的具体实现。
3.发送消息永远不会失败,因此如果你尝试发送一个消息给一个不存在的进程,那么它只会被丢弃,但不会产生一个错误。最后,消息传递是异步的:一个发送进程不会在发送消息后被暂停,它会立即继续执行其代码中的下一个表达式。
4.使用内置函数self/0,它返回它所在的执行进程的标识符。终端使用内置函数flush/0检索和显示所有发送到终端进程并保存在进程信箱中的消息,这个命令同时也删除(或刷新)信箱中的消息。
5.不能在一个模块或者终端中直接输入进程标识符。当内置函数self和spawn返回一个进程标识符的时候,需要把它绑定到一个变量,或者使用终端函数pid/3产生一个进程标识符。
6.Pid!Message是一个有效的Erlang表达式,它返回被发送的消息。因此,如果需要发送相同的消息到许多进程,可以写一连串的消息发送,如Pid1!Pid2!Pid3!Message,或者单一表达式,例如Pid3!Pid2!Pid1!Message,这相当于写Pid3!(Pid2!(Pid1!Message)),而Pid1!Message返回消息发送给Pid2,然后返回消息发送给Pid3。
7.把消息发送到不存在的进程也会永远成功。即使接受进程或者生成的进程在创建时崩溃,消息传递和spawn也总是成功,其原因和进程依赖有关,或者说它们故意没有依赖性,当进程B的终止会导致进程A的功能不正确的时候,我们就说进程A依赖于进程B。
8.进程依赖是非常重要的,而且往往会影响你的设计。在大规模并发系统中,不会想要进程相互依赖,要有尽可能少的依赖。
接受消息
1.消息可以使用receive语句从进程信箱中取得。receive语句是一个由保留字receive和end来确定界限的结构,它包含有若干语句。这些语句和case语句类似,其头部(箭头的左边)有一个模式,在语句体内(箭头的右边)有一系列的表达式。
2.在执行receive声明的时候,从信箱的第一个(最早的)消息开始依次对在receive表达式里的每一个模式进行模式匹配:
如果匹配成功,这个消息从信箱中取出,在模式的这个变量绑定到消息的匹配部分,并执行这个语句体。
如果没有匹配的语句,信箱随后的消息会逐一和所有语句模式匹配,直到一个消息匹配一个语句成功,或者所有的消息匹配所有可能的模式都失败了。
3.让receive语句成为一个单独的函数体,并绑定返回值到一个变量,这是一个很好的实践方法。
4.如果一个元组只有一个元素,请使用元素,而不是用元组。
5.当一个case声明中没有一个语句可以匹配的时候,就会抛出运行时错误。receive的语法和语义与case是非常相似的,它们的主要区别是,在receive语句中,直到有一个消息匹配成功前这个进程都是被暂停的,而在一个case声明中则会发生运行时错误。
6.一般情况下,receive语句的形式如下:
receive
Pattern1 when Guard1->exp11,...,exp1n;
Pattern2 when Guard2->exp21,...,exp2n;
...
Other ->expn1,...,expnn
end.
7.限定receive语句范围的关键字是receive和end。每个模式由任何有效的Erlang项元,包括绑定的和未绑定变量以及可选保护元组成。表达式是有效的Erlang项元或者合法的求值为项元的表达式,receive语句的返回值是被执行的语句中最后一个表达式求值的结果,在这种情况下是expin。
8.为了确保receive接受语句总是检索信箱中的第一条消息,可以使用一个未绑定的变量或者"无关紧要的"变量,前提是的确不关心它的值。
9.Erlang进程是轻量级进程,它的生成、上下文切换和消息传递都由虚拟机管理的。操作系统线程和Erlang进程之间没有任何联系,这使并发有关的操作不仅独立于底层的操作系统,而且也是非常高效的和具有很强可扩展性。
10.选择性接受:基于一定的标准我们只检索明确表示感兴趣的消息,而其他的消息留在信箱中。选择性接受往往基于进程标识符来进行选择,但顺序引用和其它标识符也是很常见的。
11.由于进程间不共享内存,因此共享数据的唯一途径是通过消息传递,发送一个消息导致消息中的数据消息从发送进程堆中复制到接受进程堆中,因此这不会导致两个进程共享存储位置(每个都可能读取或写入),这是因为它们每个都有自己的数据副本了。
注册进程
1.进程之间直接使用进程标识符来进行通信并不总是可行的,为了使用一个进程标识符,一个进程需要知道并保存它的值,利用注册进程别名来提供特定的服务是很常见的,它是一个可用于代替进程标识符的名称。
2.可以使用内置函数register(Alias,Pid)来注册一个进程,其中Alias是一个基元,而Pid是进程标识符,可以不需要成为调用内置函数register的父进程或子进程,当然只需要知道它的进程标识符。
3.一旦注册了一个进程标识符,任何进程都可以将消息发送给它,而不需要知道它的标识符。所有进程需要做的是使用结构Alias!Message,在程序里面,别名通常通过硬编码来使用。其他跟进程注册直接相关的内置函数包括unregister(Pid),registered()这个函数返回一个注册名称的列表;而whereis(Alias)则返回和Alias关联的进程标识符;终端命令regs()会输出所有已注册的进程。
4.垃圾收集器不会收集基元是Erlang的一个内存管理特性,一旦生成了一个基元,那么不管在代码中是否被引用它都会停留在基元表中。如果决定为一个瞬时的进程注册别名,而该别名是通过内置函数list_to_atom把一个字符串转换为一个基元而得到的,这就可能是一个潜在的问题。如果每天有数以百万计的用户登录系统,会导致内存耗尽。
5.把用户到进程标识符的映射保存到一个会话表中是不错的选择,最好只注册生命周期长的进程,如果真的需要将字符串转换为一个别名,使用list_to_existing_atom/1,用以确保系统不会遭受内存泄漏。
6.发送消息到一个不存在的注册进程会导致badarg错误,从而导致进程终止。这种情况不同于发送消息到一个进程不存在的进程标识符,因为进程注册假设已经提供了一种服务,所以将注册进程的不存在当做是一个错误。
超时
1.超时的receive...after结构如下:
receive
Pattern1 when Guard1->exp11,...,exp1n;
Pattern2 when Guard2->exp21,...,exp2n;
...
Other ->expn1,...,expnn
after
Timeout ->exp1,...,expn
end.
2.当一个进程到达receive语句而没有消息可以模式匹配的时候,它会等待Timeout毫秒,如果超过Timeout毫秒后还是没有消息到达,那么after语句后的表达式就会执行。Timeout是一个整数,它表明一个以毫秒为单位的时间或者是基元infinity,使用infinity作为Timeout的值就跟不包括after结构一样。Timeout是个变量,可以在每次调用函数的时候设置它,这样就可以在每次调用的时候让receive...after语句表现的和我们期望的一样。
3.receive...after语句的另一个用法是以毫秒级别暂停一个进程,或者延迟一定时间后再发送消息。
性能基准测试
使用timer:tc(Module,Function,Arguments)来进行进程调用,其中Module是需要调用的模块的名字,Function是需要调用的函数,Arguments是需要调用的参数,它返回一个元组,其中包含运行这个函数花费的时间和这个函数的返回值。
进程架构
1.不管特定目的是什么,进程行为有一个共同的模式。必须生成进程并以它们的别名注册。新生成进程的第一个动作是初始化进程循环数据,循环数据通常是传递给内置函数spawn的参数和进程初始化的结果,它存储在一个我们称之为进程状态的变量中,把这个状态传递给接受求值函数,它收到一条消息,处理它和更新状态,然后作为尾递归调用的一个参数返回。如果它处理的消息之一是stop消息,接收进程就会在自身执行完后清理并结束。这个进程设计中的反复出现的主题我们称为设计模式。
2.从反复出现的模式中来看看进程之间的区别:
各个进程传递给内置函数spawn调用的参数是不同的。
你必须决定是否要注册一个进程,如果你注册它,那么应该使用哪个别名。
在初始化进程状态的函数中,所采取的行动根据进程执行任务的不同而不同。
进程状态的存储有可能是通用的,但其内容根据不同的进程而有所不同。
在接收-求值循环中,进程会接收不同的消息和以不同的方式来处理它们。
最后,结束时各个进程的清理工作都不相同。
尾递归和内存泄漏
1.尾递归在并发编程中的重要性在这里变得很明显,因为你不知道将会调用多少次这个函数,你必须确保它在常量的内存空间中执行,当每次处理一个消息的时候不会增加递归调用堆栈。每分钟处理数千次以上的消息,且持续数小时,数天,数月或者数年都是很常见的。使用尾递归,接收/计算函数的最后一件事是调用自己,这样你就可以确保没有内存泄漏地不间断运行了。
2.当一个消息和receive声明中的所有语句都不匹配的时候会发生什么那?它会无限期的停留在信箱中,这就造成了内存泄漏,随着时间的推移还可能导致运行时系统内存溢出和崩溃。因此不处理未知消息应视为一个错误。要么刚开始就不应该发送这些消息到这个进程,要么处理它们,可能只是从信箱中取出并忽略掉。
3.忽视未知消息的防御性办法是在receive语句中使用"无关紧要的"变量,虽然这个方法很方便,但它可能并不是一个最好的方法,首先没有被处理掉的消息也许就不应该发送到这个进程,如果它们是有意发送的,它们可能因为receive语句中的一个编程错误而不能匹配。抛弃这些消息只会把发现错误变得更困难。如果抛弃了未知的消息,请一定要记录它们的出现,这样做至少以后可以比较容易地发现和纠正这些错误。
4.并发相关的瓶颈:随着时间的推移,它们发送消息的速度比处理信息的速度要快,这就导致了很长的信箱队列。信箱里有很多消息的进程的性能是非常糟糕的:
首先,这个进程本身通过选择性receive可能只匹配某一个特定类型的消息。如果该消息是在信箱队列中的最后一个,在此消息成功匹配之前整个信箱必须先遍历一遍,这将导致性能上的问题,比如CPU时间的高占用性。
其次,给一个带有长消息队列的进程发消息的进程,将会导致发送消息的规约数量增加而受到惩罚。运行时系统总是首先试图放缓发送消息的进程,从而让带有长消息队列的进程赶上来。后者的瓶颈往往表现为系统整体吞吐量减少。
发现是否有瓶颈的唯一方法是在进行系统压力测试时观察它的吞吐量和消息队列的形成。消息队列问题的简单补救措施可以通过优化代码、微调操作系统和设置虚拟机来实现。
另一种减缓消息队列增长的方法是阻塞生成消息的进程,直到它们收到确认表明发出的消息已经收到并处理,从而有效的创建一个同步调用,当系统高负荷运行的时候,使用同步调用替代异步调用会降低这个系统的最高吞吐量,但是这也比造成消息队列的增长付出的代价小得多。因此当我们知道某个地方发生瓶颈的时候,更安全的方法是引入同步调用降低吞吐量,这样就保证了高负荷运行时系统常量的请求吞吐量,而没有降低服务等级。
面向并发程序设计的个案研究
在Erlang中最好在系统中为每个真正的并发活动生成一个进程,而不是为每个任务。
竞争条件、死锁和饥饿进程
1.进程间共享数据的唯一方法是通过从一个进程到另一个进程复制数据,使用一个"不共享数据"的办法,就消除了对锁的需要,结果也就消除了大部分与内存崩溃相关的错误、死锁和竞争条件。
2.并发进程中存在的问题也有可能是由于同步消息传递引起的,特别是当通信是通过网络传播的时候,Erlang通过异步消息传递来解决这个问题。最后,Erlang系统支持的调度程序,以进程为单位的垃圾收集机制以及大规模级别的开发,确保所有进程在执行时得到相对公平的时间片。在大多数系统中,你可以预计大多数进程在一个receive生命中暂停,它等待一个事件来触发一系列活动。
3.第二个需要牢记的潜在问题设计死锁。唯一必须遵守的原则是:当进程A发送消息然后等待进程B响应的时候,实际上就是做同步调用,那么进程B在它的代码的任何地方是不允许同步调用进程A的,因为这样做,消息可能交叉造成死锁。死锁直接是由程序的构造方式造成的,这在Erlang中非常罕见,在这些少有的情形中死锁躲过了设计阶段,而在测试的早期阶段被捕获。
4.通过内置函数process_flag(priority,Priority)可以设置Priority为基元High,normal或者low,进而改变调度程序的行为,给予进程较高的优先级,进程调遣的时候就会优先处理它,你不仅仅应该谨慎使用它,事实上根本不应该使用它,因为Erlang的大部分运行时系统使用Erlang语言编写的并以普通优先级运行,所以这样就会出现死锁和饥饿,在极端的情况下,一个调度程序会让低优先级别进程比它高优先级的竞争进程占用更多的CPU时间。使用SMP的系统,这个行为变得更加不确定,因此应该这样约束,即在任何情况下,你都应该避免使用进程中的优先级别。一个正确的并发模式设计将确保系统是平衡和可确定的,而没有饥饿进程、死锁或者竞争条件。
进程管理器
1.进程管理器是一个调试工具,它用于检查Erlang系统中的进程状态。调试器主要用来跟踪程序的顺序方面的特性,而进程管理器则处理并发方面。你可以在终端中输入pman:start()(不知道为什么不可用)来启动进程管理器,这会打开一个窗口,显示的内容类似尝试使用i().命令的时候所看到的,双击任何的进程都会打开一个跟踪输出窗口,你可以通过选择文件菜单中的选项来设置你的选项。
2.每一个进程都有一个输出窗口,你可以跟踪所有的发送和接受消息,你可以跟踪内置函数和普通函数调用,以及并发相关的事件,例如进程正在生成或者终止。你也可以把你的跟踪输出从窗口重定向到一个文件里。最后,你可以选择继承层次来跟踪事件。
本章总结
1.并发进程间互相传递消息而不是共享内存。
2.消息传递是异步的,并具有选择性receive的能力,它们可以独立于其所接收的次序而被处理,这些便于编写模块化的和简洁的并发程序。