第一章:Erlang/OTP 平台

Erlang 中,并发的基本单位是进程。每个进程代表一个持续的活动,它是某段程序代码的执行代理,与其他按各自的节奏执行自身代码的进程一起并发运行。 进程有自己的工作内存空间和自己的信箱,其中信箱用于存放外来消息。

4 种进程通信范式

  1. 持锁共享内存

  2. 软件事务性内存(STM)

  3. Future、Promise 以及同类机制

  4. 消息传递

Erlang 中的进程

Erlang 进程并不是操作系统线程。它们由 Erlang 运行时系统实现,比线程要轻量得多。运行时系统所有进程之间相互隔离;单个进程的内存不与其他进程共享,也不会被其他濒死或跑疯的进程破坏。

Erlang 中的容错架构

进程链接

Erlang 进程意外退出时,会产生一个 退出信号 。所有与濒死进程链接的进程都会收到这个信号。默认情况下,接收方会一并退出并将信号传播给与它链接的其他进程,直到所有直接或间接链接在一起的进程统统退出为止。这种联级行为,可以使一组进程像单个应用一样退出,因此系统整体重启时你不必担心是否还有残存下来未能完全关闭的进程。

监督与退出信号捕捉

OTP 实现容错的主要途径之一就是改写退出信号默认的传播行为。通过设置 trap_exit 进程标记,你可以令进程不再服从外来的退出信号,而是捕捉。这类会捕捉信号的进程有时被称为 系统进程 (称为监督者) ,其他的进程称为 工作进程

Erlang 的进程链接与监督者共同提供了一种细粒度的 重启 机制。OTP 允许监督者按预设的方式和次序来启动进程。我们还可以告知监督者如何在单个进程故障时重启其他进程、一段时间内尝试重启多次后放弃重启等。

而且监督者可以存在多层的 监督树

Erlang 运行时系统和虚拟机

Erlang 运行时系统(ERTS),它有一个特别重要的部分就是 Erlang 的虚拟机模拟器:执行 Erlang 程序编译后产出的字节码。这个虚拟机,也就是 Bogdan Erlang 抽象机(BEAM),虽然我们也可以将 Erlang 程序编译为本地机器码,但一般没那个必要,因为 BEAM 模拟器已经够快了。

调度器

ERTS 运行的时候通常就是单个操作系统进程(一般名为 beam 或 werl)。这个进程中,就跑着管理所有 Erlang 进程的调度器。

I/O 与调度

调度器还替系统优雅地处理了 I/O 问题,在系统的最底层,Erlang 以事件驱动的方式处理所有 I/O,当数据进出系统时,程序可以以非阻塞方式完成数据处理。这降低了连接建立和断开的频次,还避免了 OS 层面上的加锁开销和上下文切换。

进程隔离与垃圾回收器

它使用了 分代复制式垃圾回收器 ,它不会像其他开发的系统那样在 GC 时遭受停顿。这主要因为 Erlang 进程之间的隔离:每个进程所使用的内存都是自己的,随进程的创建和结束而分配和释放。

这首先意味着垃圾回收器可以在不影响其他进程运行的前提下单独暂停目标进程。其次,单个进程占用的内存通常较小,遍历可以快速完成。(也有占用内存量大的进程,但这些进程一般不用做出快速响应)。再次,调度器知道每个进程最后一次运行的时间,如果某个进程自上次垃圾回收后什么也没干,调度器会跳过它。

正是这些因素,让 Erlang 既可轻松使用垃圾回收器,又可以保证较短停顿时间。

第二章:Erlang 语言精要

安装 Erlang

brew install erlang

启动 Erlang Shell

安装完成后,打开终端, 然后输入 erl 即可:

[15:15:07] emacsist:~ $ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
1>

以非交互式来启动

erl -noshell

要执行批处理任务或要将 Erlang 作为守护进程运行时可以采用这个方法。

表达式

在 shell 中输入的,并不是什么命令,而是 表达式 ,它们的区别在于,表达式一定会返回一个求值结果。表达式是以 句号 结束的。比如 42.

可以通过 v(N) 来引用第 N 个表达式的结果。

Shell 函数

help() 可以列出所有 shell 函数列表。

退出 shell

调用 q() 或 init:stop()

这是最安全的退出方法。

q()init:stop() 函数的一个简写形式。

BREAK 菜单

Unix 系统中按下 Ctrl-C (Windows 下在 werl 终端用 Ctrl-Break)来唤出该菜单:

[15:27:01] emacsist:~ $ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
1>
BREAK: (a)bort (c)ontinue (p)roc info (i)nfo (l)oaded
       (v)ersion (k)ill (D)b-tables (d)istribution

a: 退出系统。 c: 返回shell p: 显示所有进程信息 i: 显示当前 erlang 系统消息 v: 显示当前运行的 erlang 版本信息 k: 显示所有 Erlang 内部活动进程,以及关闭任何故障进程(前提是你明确知道自己在做什么)

Ctrl-G

它会唤出用户开关菜单。

[15:37:56] emacsist:~ $ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
1>
User switch command
 --> h
  c [nn]            - connect to job
  i [nn]            - interrupt job
  k [nn]            - kill job
  j                 - list all jobs
  s [shell]         - start local shell
  r [node [shell]]  - start remote shell
  q                 - quit erlang
  ? | h             - this message
 --> q
[15:38:10] emacsist:~ $

Erlang 的数据类型

  1. 数值(整数和浮点数)
  2. 二进制串/位串
  3. 原子
  4. 元组
  5. 列表(和字符串)
  6. 唯一标识符(pid、端口、引用)
  7. Fun 函数

数值

你可以使用从 2 进制到 36 进制的整数(0~9 加上字符 A~Z/a~z),如:

16#FFFFfF
2#10101
36#ZZ

字符的数值编码

$字符 的格式,可以得到它们的数值编码(ASCII/Latin-1/Unicode 皆可),如:

4> $a.
97
5> $我.
25105
6>

浮点数

它是64位双精度浮点数(IEEE 754-1985 格式)。不能仅以小数点开头,如 .01 ,必须是以数字开头,如 0.01

算术运算

Erlang 采用常见的中缀表示法。如果运算符中有一个是浮点数,则运算将被转化为浮点运算。

除法分两种

  1. / ,它总是返回浮点数。如 4 / 2 结果为 2.0
  2. div ,整除运算(即运算结果会被截断),如 7 div 2 结果为 3

取余: rem 。如 15 rem 4 的结果为 3 。

二进制串与位串

二进制串: 无符号 8 位字节的序列 位串:广义的二进制串,其长度不必是 8 的整数倍

语法如下:

<<0,1,2,..., 255>>

也就是包含在 <<…>> 内逗号分隔的整数序列,整数取值范围为 0~255 .

还可以用字符串来构造二进制串:

<<"hello", 32, "dude">>

原子

它是一种仅由字符序列来标识的特殊字符串常量。因此,两个原子只要具有相同的字符表示,就完全等同。

在系统内部,这些字符串放在某张表内,并由表的下标定位,因此在运行时只要比较两个小整数就可以判断两个原子是否相等。每个原子也仅占一个字长的内存。

它的作用类似于 Java 或 C 中的 enum 常量。

你应该把原子当作一类特殊的标签,而不是普通的字符串。它们的长度上限是 255 个字符,在单个系统中原子的总数也有一个上限,目前是一百多万(准确来说是 1048576)。一般来说这个上限已经足够大了,但对于长期运行(数天、数月、数年)的系统,你应该避免动态生成诸如 ‘x_4711’‘x_4712’ 这类全局唯一的原子。

原子一经创建,即便不再使用,也永远不会被清除,除非系统重启。

元组

它是 定长序列 ,用大括号来表示,如:

{1,2,3}
{one, two, three, four}
{}
{from, "Russa"}

元素可以是同一类型,也可以是不同的数据类型:元素本身也可以是元组或其他数据类型。

Erlang 的一个标准约定是,用原子作为第一个元素来标记元组数据的类别,如:

{size, 42}
{position, 5, 2}

元组的元素项没有名称,只有编号(从 1到 N)。访问元组中的元素是常数时间复杂度的操作,跟 Java 中访问数组元素一样快速和安全。

标准库中实现了一些更为复杂的数据类型:数组、集合、字典等,但在底层,它们大都是采用各种手段基于元组实现的。

列表

列表用方括号表示,如:

[]
[1,2,3]
[[1,2,3], [4,5,6]]
[{"hello"}, {"world"}]

空表 [] 也被称为 nil

添加列表元素

| 管道符,它将右侧的与左侧的合并。如:

[2 | [1]]

得到列表 [2,1] .注意顺序,新元素是从左侧添加的。

也可以用 ++ 运算符向列表追加任意长度的列表。如:

[1,2,3,4] ++ [5,6,7,8]

可以得到列表 [1,2,3,4,5,6,7,8] 。其过程还是一样: 先是 [4 | [5,6,7,8]],再是 [3|[4,5,6,7,8]],以此类推。

注意,++ 右侧的列表不会被修改——Erlang 不允许这类破坏性修改——它只是借由一个指针成为了一个新列表的一部分。 左侧列表就不一样了。左侧列表的长度决定了 ++ 运算符的耗时。

字符串

Erlang 中双引号字符串实际上就是列表,其元素就是该字符串中各字符的数值编码所对应的整数。比如:

"abcd"
"Hello!"

它们与以下列表等价:

[97,98,99,100]
[72,101,108,108,111,33]

还可以写作:

[$a, $b, $c, $d]
[$H, $e, $l, $l, $o, $!]

字符串就是列表,也就是说你先前学到的所有处理列表的方法,同样也适用于字符串。

标识符(pid、端口和引用)

在 Erlang 中任何代码都需要一个 Erlang 进程作为载体才能执行。每个进程都有一个唯一标识符,通常称为 pid 。它是一种特殊的 Erlang 数据类型,应被视为一种不透明对象。self() 函数能告诉你当前进程(即调用 self() 的那个进程)的 pid 。

Eshell V8.3  (abort with ^G)
1> self().
<0.57.0>
2>

Fun 函数

函数式语言的一个显著特征就是可以像处理数据一样处理函数——也就是说,函数可以成为别的函数的输入,也可以成为别的函数的求值结果,还可以把函数存在数据结构中供后续使用,诸如此类。在 Erlang 中,将这种函数包装成数据的对象称为 fun 函数 (也称为 Lamdba 表达式或闭包)

项式的比较

Erlang 的各种数据类型有一个共同点:它们都可以通过内置的 <, > 和 == 运算符进行比较和排序。

原子、字符串(以及其他各种列表)和元组:按字典序排序。

不同类型间的排序规则:

  • 数值小于原子
  • 元组小于列表
  • 原子既小于元组,也小于列表(注意,字符串也是列表)

例如:

3> lists:sort([b,3,a,"z",1,c,"x",2.5,"y"]).
[1,2.5,3,a,b,c,"x","y","z"]
4>

小于或等于/大于或等于

与其他语言不同的是,小于或等于,它不写作 <= ,而是 =< 。大于或等于同同其他语言一样,都是 >= 。即,比较运算符看起来绝不像箭头就行了。

相等比较

Erlang 有两种比较运算符。

  1. 完全相等,写作 =:= ,仅当运算两侧完全等同(值和类型必须相同)时才返回 true。其否定式为 =/= . 一般来说,判断两个项式是否相等时更倾向于采用完全相等运算符。但这会导致看似相等的整数与浮点数会被判断为不相等,如 2 =:= 2.0 ,它的结果就是 false

  2. 算数相等,写作 == ,按数学法则对数值进行比较时,一般使用它。其否定式为 /= 。例如: 2 == 2.0 返回的就是 true。但请记住,针对浮点做相等判断总是有风险的,浮点数的机器表示法伴有微小的舍入误差,这可能会在本应相等的数值间引入些偏差,使 == 返回 false 。涉及浮点数时,最好只用 <, >, =< 或 >= 进行比较。

解读列表

列表一般是由空表(nil) 和所谓的列表单元共同构成。这些单元各自携带一个元素挨个儿挂接到现有列表的顶部,从而在内存中形成一个单链表。每个单元仅占两个字长的内存空间:一个用于存放元素值(或指向元素值的指针),称为首部(head),另一个是指向列表其余部分的指针,称为尾部(tail)(与 Lisp 的非常类似)

非严格列表

严格列表:最内层都以一个空表作为尾部。

非严格列表:在非列表数据之上堆叠列表单元而成的列表,如:

[1 | oops]

这就构成了一个尾部不是列表的列表单元(这里 ‘oops’ 作为尾部)。Erlang 并不禁止这样子做,也不会在运行时对这种情况进行检查。但一般来说,要是看到这样的东西,那多半是程序的某些地方写错了。

非严格列表的主要问题在于,很多函数要求输入参数必须是严格列表。

模块和函数

Erlang 将模块用作代码的容器。每个模块的名字,都是一个全局唯一的原子。

调用其他模块中的函数(远程调用)

如:

lists:reverse([1,2,3])

与此相对应的是 本地调用 (调用同一模块中的函数)。

不要将这里的远程调用与远程过程调用(remote procedure call, RPC) 混淆了。

不同元数的函数

函数参数的个数被称为 元数 .没有参数时称为 空元函数 ,一个参数的,称为 一元函数 , 以此类推。

两个函数使用同一原子作为函数名,只要它们的元数不同,Erlang 就会将它们视作两个完全不同的函数。因此函数的全名必须包含元数(以斜杠作为分隔符)。比如上面的列表反转函数的全名为 reverse/1 。如果还要强调函数所在的模块,则应该为 lists:reverse/1 ,不过,这种语法仅用于需要函数名的位置。

内置函数和标准库模块

内置函数(BIF),它们都是用 C 语言实现的。 erlang 模块中的所有函数,都是 BIF。erlang 模块,会被自动导入。即 self() 的全写为 erlang:self()

创建模块

  1. 编写源文件
  2. 编译
  3. 加载已经编译的模块,或将它放到加载路径中,以便自动加载。

编写源文件

my_module.erl

%% This is a simple Erlang module

-module(my_module).
-export([pie/0]).

pie() ->
    3.14.

pie() -> 3.14. 它为函数定义。注意,这里并不需要 return :函数的返回值就是函数体中表达式的值。注意末尾必须要有句号.

第一行的 % 表示注释。根据规范,与代码同处一行的注释,以一个 % 开头。独占一行的注释,以两个 %% 开头。

除注释外,第一行一定是模块声明,格式为 -module(…) 。 Erlang 中不是函数也不是注释的,都是声明。声明以连字符开头(-), 必须以句号结尾。模块声明是不可或缺的,且它指定的名字,必须与文件名相符(即除去 .erl 后缀以外的部分)

-export([…]) 这个是导出声明,它会告知编译器哪些函数是外部可见的。此处没有列出的函数,都是模块的内部函数。

模块的编译和加载

编译模块时,会产生一个和模块对应的扩展名为 .beam 而非 .erl 的文件,其中包含可被 Erlang 系统加载执行的指令。

在 Shell 中编译

在 Shell 中,调用函数 c(…) 来进行自动编译和加载(前提是编译通过)。该函数以 Erlang shell 的当前目录(可以用 ls() 函数列出当前目录列表)为相对路径来寻找源码文件,你甚至可以省略模块末尾的 .erl 。例如:

[17:44:44] emacsist:erlang $ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
1> ls().
my_module.erl
ok
2> c(my_module).
{ok,my_module}
3> my_module:pie().
3.14
4>
模块加载与代码路径

当 Erlang 尝试调用某个尚未加载到系统中的模块时,只要能找到与模块名对应的 .beam 文件,它就会自动尝试加载。查找 .beam 文件的目录由代码路径指定,默认情况下,当前目录也包括在内。可以调用 code:get_path() 函数查看当前代码路径的设置。

独立编译器 erlc
erlc my_module.erl
或
erlc -o ./ebin my_module.erl

按惯例,存放 .beam 文件的目录应命名为 ebin

效率

注意,不要在 shell 中评估代码的执行效率,一切应该以编译版本为黄金准则。

变量与模式匹配

语法

变量名必须以大写字母开头!(小写字母开头的已经被用于原子了),变量中的单词以驼峰体隔开。这种命名的变量在赋值之后一直未被使用的话,会触发编译警告。

变量名也可以以下划线开头,这种情况下,按常规第二个字符通常应该为大写字母。 这种命名的变量,即使不被使用,编译器也不会报警。同时,所有未被使用的变量都会被优化掉。

不过,好像新版的 Erlang,对这两种情况都会报警,如下:

[18:02:21] emacsist:erlang $ erlc my_module.erl
my_module.erl:8: syntax error before: _HelloWrold
my_module.erl:9: syntax error before: 3.14
my_module.erl:7: variable 'Helloworld' is unbound
[18:02:34] emacsist:erlang $ vim my_module.erl
Waiting for Emacs...
[18:06:16] emacsist:erlang $ erlc my_module.erl
my_module.erl:9: syntax error before: 3.14
my_module.erl:8: variable '_HelloWrold' is unbound

单次赋值

Erlang 的变量被严格地限定为只能接受单次赋值。

在大多数其他编程语言中,变量就像是起了名字的盒子,在程序中你随时可以随性盒子里的内容。Erlang 的变量却与对应的数学概念相符:它就是指代某个值的一个名字,且这种指代关系不会背地里悄悄地改变(否则方程就解不出来了)。

匹配运算符 =

它的能力可不止是简单的赋值。

如:

X = 42.

在 shell 中,可以调用函数 f() 来让 shell 遗忘先前绑定的所有变量,或遗忘指定的变量: f(变量名)

匹配 的含义:如果 X 已经被绑定到某个值,匹配运算符会检查右侧的值是否与之相等(比较时使用的是 *完全相等运算符*)

变量及其更新

要想追踪其他值,就给它另起一个名字。例如:

X = 17.
X1 = X + 1.
模式匹配

= 就是匹配运算符,因为它的功能就是模式匹配,而不是赋值。

运算符的左侧,是一个模式;右侧,是一个普通表达式。做匹配运算时,首先计算右侧的表达式,得到一个值。接着拿该值去匹配左侧的模式。若模式匹配不上,比如: 17 = 42或 true = false ,则匹配宣告失败并抛出一个原因代码(reason code) 为 badmatch 的异常。若成功,在左侧模式中出现的所有变量都会与右侧值中的相应组成部分绑定,然后程序将继续计算紧随其后的表达式。如:

{A, B, C} = {1970, "Richard", male}.

另一种常见的模式为:

{rectangle, Width, Height} = {rectangle, 200, 100}.

再多几个例子:

[1,2,3 | Rest] = [1,2,3,4,5,6,7]

这样子, Rest 就为 [4,5,7,7] 了。

[$h, $t, $t, $p, $: | Rest] = "http://www.erlang.org"

这时,Rest 绑定到了字符串 //www.erlang.org 了。

省略模式

单下划线:_ 表示省略模式,也就是说,在模式的某处用上 _ 的话就表示你不关心右侧相应位置上的值。它们也被称为 匿名变量

匹配模式与子句

子句由分号(;)分隔,且最后一个子句由句号结尾。如:

either_or_both(true, _) ->
    true;
either_or_both(_, true) ->
    true;
either_or_both(false,false) ->
    false.
case 与 if
area(Shape) ->
    case Shape of 
        {circle, Radius} ->
            Radius * Radius * math:pi();
        {square, Side} ->
            Side * Side;
        {rectangle, Height, Width} ->
            Height * Width
    end.

注意,最后一个子句后没有分号——分号是分隔符,而不是结束符。

if-then-else

根本没这玩意。你可用 case 表达式来替代:

case either_or_both(X,Y) of
    true -> io:format("yes~n");
    false -> io:format("no~n")
end.    
if 表达式
sign(N) when is_number(N) ->
    if
        N > 0 -> positive;
        N < 0 -> negative;
        true -> zero
    end.

sign(N) when is_number(N) ->
    case dummy of
        _ when N > 0 -> positive;
        _ when N < 0 -> negative;
        _ when true -> zero
    end.

fun 函数

作为现有函数别名的 fun 函数:

fun either_or_both/2

和各种其他类型的值一样,你可以将之与变量绑定:

F = fun either_or_both/2

或传递给别的函数:

yesno(fun either_or_both/2)

yesno(F) ->
    case F(true, false) of
        true -> io:format("yes~n");
        false -> io:format("no~n")
    end.

匿名 fun 函数

也称为 Lamdba 表达式。它们以 fun 关键字开头,并且以 end 关键字结束。例如:

fun () -> 0 end

闭包

一般特指: fun…end 的内部引用了在 fun 函数外部绑定的变量的情况。fun 函数能将那些变量当前的值封存起来。

异常与 try/catch

异常是什么呢?你可以将之认为是函数的另一种返回形式,区别在于它不仅会返回至调用者,还会返回至调用者的调用者,并一路向上,直至被捕获或抵达进程调用的起点(这时进程便会崩溃)为止。

异常的类别:

  • error :运行时异常,除零错误、匹配运算失败、找不到匹配的函数子句等情况时触发。一旦它们导致某个进程崩溃,Erlang 错误日志管理器便会将之记录。
  • exit :用于通报:“进程即将停止”。它们会在迫使进程崩溃的同时将进程退出的原因告知其他进程,因此一般不捕获这类异常。它不会被汇报至错误日志管理器。
  • throw :用于处理用户自定义的情况。如果不捕获 throw 异常,它便会转变为一个原因为 nocatch 的 error 异常,迫使进程终止并记录日志。

抛出异常

throw(SomeTerm)
exit(Reason)
erlang:error(Reason)

特例:exit(normal) 所抛出的异常不会被捕获。这意味着其他与之链接的进程不会将之视为反常的终止行为。(其余的所有退出原因都会被视作反常)

try … of … catch

try
    some_unsafe_function(...)
of
    0 -> io:format("nothing to do ~n");
    N -> do_something_with(N)
catch
    _:_ -> io:format("some problem~n")
end

after

类似Java中的finally

{ok, FileHandle} = file:open("foo.txt", [read]),
try
    do_something_with_file(FileHandle)
after
    file:close(FileHandle)
end    

获取栈轨迹

erlang:get_stackrace() : 是异常发生那一刻位于栈顶部的那些调用的逆序列表(最后一个位于最前)。如果它返回的是一个空表,表示直至目前为止该进程尚未捕获任何异常。

列表速构

记法

如:

[X || X <- ListOfIntegers, X > 0]

此处必须用双竖线 || ,因为单竖线已经被用在普通列表单元上了。 用 <- 来表示生成器, || 右侧除了生成器,便是约束条件,如 X > 0

映射、过滤和模式匹配

[ math:pow(X,2) || X <- ListOfIntegers, X > 0, X rem 2 == 0]

比特位语法与位串速构

位串可以写作: <> ,区段指示符可以为以下形式之一:

Data
Data:Size
Data/TypeSpecifiers
Data:Size/TypeSpecifiers

位串速构

3> << <<X:3>> || X <- [1,2,3,4,5,6,7] >>.
<<41,203,23:5>>
4>

记录语法

记录声明

-record(customer, {name="<anonymous>", address, phone}).

创建记录

#customer{}
#customer{phone="12341234"}

记录字段以及模式匹配

假设R变量绑定了一个 customer 记录,你可以使用点分记法来访问各个字段:

R#customer.name
R#customer.address

预处理与文件包含

宏的定义和使用

宏由 define 指令定义,既可带参数,也可以不带参数。如:

-define(PI, 3.14).
-define(pair(X,Y), {X, Y}).

习惯上常量名为大写,其余大部分宏为小写。在代码中使用宏时,必须加一个问号作为前缀:

circumference(Radius) -> Radius * 2 * ?PI.

取消宏定义

undef 可用于移除宏定义(前提是该宏定义存在)如:

-define(foo, false).
-undef(foo).
-define(foo, true).

预定义宏

为了方便,预处理器先定义了一些宏。如:

MODULE 宏(表示当前正在编译的模块的名称), FILE 宏(当前正在哪个文件中)和 LINE 宏(当前正身处哪个源文件的哪一行)

文件包含

-include("filename.hrl").

这类文件通常只有声明,没有函数;一般出现在模块源文件的头部,因此这些文件被称为 头文件 ,以 .hrl 为扩展名。查找这类文件时,Erlang 编译器会同时在当前目录中以及列于 包含路径 内的目录中查找名为 some_file.hrl 的文件。

利用 erlc-I 标志,或shell函数 c(…){i, Directory} 选项可以向包含路径中添加新目录。如:

1> c("src/my_module", [{i, "../include"}]).

include_lib 指令

-include_lib("kernel/inclue/file.hrl").

该指令会相对于Erlang系统现有应用的安装位置来查找文件。比如 kernel 应用可能被安装在 C:\Program Files\erl5.6.5\lib\kernel-2.12.5 。于是 include_lib 指令会将文件起始处的 kernel/ 匹配至这个路径(除去版本号)并在此目录下寻找含有 file.hrl 的子目录 include 。即便Erlang升级,你的程序也不用做任何修改。

条件编译

-ifdef(MacroName).
-ifndef(MacroName).
-else.
-endif.

例如:

-ifdef(DEBUG)
-export([foo/1]).
-endif.

你可以通过shell函数c的 {d, MacroName, Value} 选项或 erlc 命令的 -Dname=value 选项来进行控制。

进程

操纵进程

派生和链接

它有两个函数。第一个函数仅有一个参数,就是作用新进程入口的(空元)fun函数。另一个则需要模块名、函数名、和参数列表3个参数:

Pid = spawn(fun() -> do_something() end)
Pid = spawn(Module, Function, ListOfArgs)

还有一个名为 spawn_opt(…) 的版本,如:

Pid = spawn_opt(fun() -> do_something() end, [monitor])

以及:

Pid = spawn_link(...)

所有这些派生函数都会返回新进程的进程标识符,通过该标识符,父进程可以与新进程通信。但新进程对父进程却不无所知,只能通过其他方式来获取相关信息。

进程监视

Ref = monitor(process, Pid)

由Pid标识的进程一旦退出,实现监视的进程将会收到一条含有引用Ref的消息。

靠抛异常来终结进程

exit(Reason)

除非被进程捕获,否则该调用将令进程终止,并将Reason作为退出信号的一部分发送给所有与该进程链接的进程。

直接向进程发送退出信号

exit(Pid, Reason)

设置 trap_exit 标志

默认情况下,一旦接收来自相互链接的其他进程的退出信号,进程就会退出。为了避免这种行为并捕捉退出信号,进程可设置 trap_exit 标志:

process_flag(trap_exit, true)

这样除了无法捕捉的信号(kill),外来的退出信号都会被转换为无害的消息。

消息接收与选择性接收

可以用 receive 表达式从信箱中提取消息。尽管接收到的消息严格按照抵达顺序排列,接收方仍然可以自行决定要提取哪条消息。

receive
    Pattern1 when Guard1 -> Body1;
    ...
    PatternN when GuardN -> BodyN
after Time ->
    TimeoutBody
end

after… 为可选,如果省略,表示永不超时。否则 Time 必须是表示 毫秒 的整数或原子 infinity 。如果 Time 为 0,表示永不阻塞。

注册进程

每个Erlang系统都有一个本地进程注册表——这是一个用于注册进程的简单命名服务。一个名称一次只能用于一个进程。换言之,该机制仅适用于单例进程:一般都是些系统服务,这些服务在每个运行时系统中,同一时刻最多只能有一个实例。在 Erlang shell中调用内置函数 registered() 可以列出它们。

4> registered().
[erts_code_purger,init,error_logger,erl_prim_loader,
 kernel_safe_sup,standard_error_sup,inet_db,rex,user_drv,
 kernel_sup,global_name_server,global_group,user,
 file_server_2,code_server,application_controller,
 standard_error]
5>

用内置函数 whereis 可以查找当前与指定注册名对应的 pid:

4> registered().
[erts_code_purger,init,error_logger,erl_prim_loader,
 kernel_safe_sup,standard_error_sup,inet_db,rex,user_drv,
 kernel_sup,global_name_server,global_group,user,
 file_server_2,code_server,application_controller,
 standard_error]
5> whereis(user).
<0.49.0>
6>

你甚至可以直接用注册名向进程发送消息:

6> init ! {stop, stop}.
{stop,stop}
7> %                                                                                                                                                                             [22:59:14] emacsist:erlang $

你也可以用 register 函数注册自己启动的进程:

[23:02:18] emacsist:erlang $ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
1> Pid = spawn(timer, sleep, [30000]).
<0.59.0>
2> register(fred, Pid).
true
3> whereis(fred).
<0.59.0>
4> whereis(fred).
undefined
5>

进程退出后,之前注册的名称将自动回归到未定义状态。如果你想跟位于另一个Erlang 节点上的注册进程通信,可以这样子做:

{some_node_name, some_registered_name} ! Message

消息投递与信号

Erlang进程互相用 ! 运算符发送的消息只是Erlang通用信号系统的一种特殊形式。另一大类信号就是濒死进程向与之链接的相邻进程发送的退出信号;还有一小部分对程序员不可见,诸如尝试链接两个进程时发送的链接请求。投递信号时,以下的基本投递保障对所有信号都成立:

  • 如果进程P1向目标进程P2先后发送了两个信号S1和S2,这两个信号将按发送顺序到达P2(如果都能到达的话)。
  • 尽力投递所有信号。同一Erlang运行时系统内,进程之间不存在消息丢失的危险。但,两个依靠网络互联的Erlang系统之间,一旦网络连接断开,消息就有可能丢失。连接恢复后,有可能出现上例的S2最终抵达,但S1却丢失的情况。

进程字典

每一个进程都有一个私有的进程字典。通过内置函数 put(Key, Value) 和 get(Key, Value) 可以从中存取项式。无论进程字典看上去多么诱人都不要去碰它。

ETS表

ETS(Erlang Term Storage),用于存储Erlang项式(即任意Erlang数据)且可以在 进程间共享 的表。它作为Erlang运行时系统的一部分,用C实现的。原因在于ETS是Erlang中很多东西的基础。

ETS表的基本用法

创建新表: ets:new(Name, Options) .Name必须是原子,Options必须是列表。除非设置 named_table 选项,否则名称不起实际作用。ets:new/2 会返回一个标识符,用于完成针对新创建的表的各种操作,如:

T = ets:new(mytable, []),
ets:insert(T, {17, hello}),
ets:insert(T, {42, goodbye})

ETS表和数据库的另一个相似点在于,它同样只存储数据行——也就是元组。存储任何Erlang数据之前,都要先将之放入元组。原因在于, ETS会将元组中的一个字段用作表索引,默认采用第一个字段(可通过建表参数调整)

以递归代替循环

比如从0到N的累加和:

sum(0) -> 0;
sum(N) -> sum(N-1) + N.

Erlang与其他语言不同,它仅靠递归就可以创建循环,而且没有效率问题。

[13:05:30] emacsist:erlang $ cat hello.erl
-module(hello).
-export([start/0]).

start() ->
    io:format("~p~n", [sum(100000000)]).

sum(0) -> 0;
sum(N) -> sum(N-1) + N.



[12:55:27] emacsist:erlang $ time erl -noshell -run hello start -s init stop
5000000050000000
erl -noshell -run hello start -s init stop  14.58s user 20.07s system 83% cpu 41.598 total

理解尾递归

上面那个就是 非尾递归 (因为含有非尾递归调用)。 尾递归 :所有递归调用都是 尾调用 ,编译器总能从代码中准确地定位尾调用,并做出一些相应的特殊处理。

Erlang 是怎么仅靠递归来实现循环的呢?每次调用不是都要往栈上写入新的内容吗?答案是否定的,因为Erlang采用了 尾调用优化 .正是基于这个原因,尾递归函数即使不停地运行也不会将栈空间耗尽,同时还能达到和 while 循环一样高效。

第三章:开发基于TCP的RPC服务

行为模式

使用 OTP 行为模式。

  • 行为模式接口, 是一组特定函数和相关的调用规范。
  • 行为模式实现, 指的是由程序员提供具体应用相关的代码。是一个导出了接口所需要的全部函数的回调模块。实现模块中,还应包含一项属性 -behaviour(…) 用以说明该模块所实现的行为模式的名称。
  • 行为模式容器, 它执行的是某个库模块中的代码,并且会调用与行为模式实现相对应的回调模块来处理应用相关的逻辑。

测试

EUnit (单元测试)和 Common Test (OTP Test Server) 比较重型。

EUnit使用:

-include_lib("eunit/include/eunit.hrl").


%写你的测试代码:

start_test() ->
    {ok, _} = tr_server:start_link(1005).

然后在 erlang shell中输入:

eunit:test(tr_server).
或
tr_server:test().

test() 函数是由Eunit 自动生成的,同时Eunit会自动导出你编写的所有测试函数。

第四章:OTP应用与监督机制

OTP应用的组织形式

[12:51:01] emacsist:erlang $ tree my-otp-app-1.0.0
my-otp-app-1.0.0
├── doc
├── ebin
├── include
├── priv
└── src

5 directories, 0 files
[12:51:05] emacsist:erlang $

其中,只有 ebin 是必需的。

为应用添加元数据

ebin 目录,建立一个名为 .app 的文本文件,它用来描述你的应用。

它的作用在于告诉OTP如何启动应用,以及该应用如何与系统中的其他应用相融合。即组装更大的可启动、停止、监督和升级的功能单元。

application 行为模式

该模块通常命名为 _app

监督者 行为模式

根监督者行为模式,该模块通常命名为 _sup

生成Edoc文档

打开 erlang shell

edoc:application(your-server-name, "目录 .表示当前目录", []).

它会将生成的文档,放到 doc 目录下.

第五章:主要图形化监测工具的使用

Appmon(已经废弃)

打开 erlang shell ,输入:

appmon:start().

注:如果上面的命令执行不了,请用 observer:start(). ,这个是新版 erlang 的替代者。

WebTool版Appmon

webtool:start().

我在mac上打不开。这个暂时查资料也没有见什么原因。到时再看看…

[13:35:03] emacsist:lib $
[13:35:03] emacsist:lib $ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
1> webtool:start().
** exception error: undefined function webtool:start/0
2>

pman

pman:start().

好像这个也没有用了。我的erlang版本为

Erlang (BEAM) emulator version 8.3
Compiled on Wed Mar 15 18:06:57 2017

调试器

debugger:start().

要调试时,在编译时要加上调试信息选项:

erlc +debug_info -o ebin src/*.erl

然后在上面 debugger:start(). 弹出的窗口中,选择 Module-Interpret ,选择要调试的源文件,然后打上断点(在要进行断点的地方,双击即可)。这时,启动应用,然后触发要调试的函数即可。

表查看器

tv:start().

好像这个也没有用了。

工具栏

toolbar:start().

好像这个也没有用了。

第八章:分布式Erlang/OTP简介

位置透明性

在Erlang中,进程间的通信方式与接收方在本地机器还是在远程机器上无关。这点在语法层面上仍然成立。

节点

被配置成按分布式模式运行的 Erlang VM 就叫做节点。每一个节点都有一个节点名,其他节点中以通过这个名字来找到该节点并与之通信。当前本地节点的节点名可以通过内置函数 node() 获取,节点名是一个原子,格式为 nodename@hostname (不以分布式模式运行的VM的节点,永远为 nonode@nohost)。在单台主机上可以同时运行多个节点。

调用 nodes() 可以查看互联的节点。

集群

一旦两个或两个以上的 Erlang 节点能够相互感知,我们就说它们形成了一个集群。

默认情况下,Erlang集群是一个全联通网络,即集群中的每个节点,都能感知其他所有节点,任意两个节点都可以直接通信。

节点的启动

erl -name your_node_name

这样子就可以以分布式模式启动Erlang节点。(该形式适用于配有 DNS 的普通网络环境,你需要给出节点的完全限定名。

还有一种启动方式:

erl -sname your_node_name

这种适用于完全限定名不可用的情况(只要所有节点在同一子网,你就可以使用短节点名)

注意,采用短节点和长节点的节点所处的通信模式是不同的,它们之间无法形式集群。只有采用相同模式的节点,才能互联。

在我的Mac上,它们两种启动的提示符如下:

[15:18:01] emacsist:~ $ erl -sname hello1
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(hello1@yangzhiyongs-MacBook-Pro)1>






[15:16:50] emacsist:~ $ erl -name hello
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(hello@yangzhiyongs-MacBook-Pro.local)1>

节点的互联

同一集群内启动几十个节点没什么问题,但跑上几百个就比较悬了。原因在于维系机器之间的联络是需要一定通信开销的,而Erlang集群又是一个全联通网络,这部分的开销会随节点数的增加按平方规模增长。

比如 a,b 两个节点组成一个集群, c,d两个节点组成一个集群。如果a与c节点互联了的话,则 b与 c,d 也会自动进行互联。

假设,你启动了3个节点: a, b , c

然后在 a 节点上调用:

net_adm:ping('节点').

如果成功的话,就会返回 pong ,否则返回 pang 。比如:

节点a:

[15:22:29] emacsist:~ $ erl -sname a
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(a@yangzhiyongs-MacBook-Pro)1> net_adm:ping('b@yangzhiyongs-MacBook-Pro').
pong
(a@yangzhiyongs-MacBook-Pro)2> nodes().
['b@yangzhiyongs-MacBook-Pro']
(a@yangzhiyongs-MacBook-Pro)3>

节点b:

[15:22:25] emacsist:~ $ erl -sname b
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(b@yangzhiyongs-MacBook-Pro)1> nodes().
['a@yangzhiyongs-MacBook-Pro']
(b@yangzhiyongs-MacBook-Pro)2>

节点c:

Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(c@yangzhiyongs-MacBook-Pro)1>

Erlang节点如何定位其他节点并建立通信

EPMD 进程(Erlang Port Mapper Daemon):

[15:35:18] emacsist:~ $ ps aux | grep epmd
emacsist         29193   0.0  0.0  2461456    596   ??  S     3:16下午   0:00.02 /usr/local/Cellar/erlang/19.3/lib/erlang/erts-8.3/bin/epmd -daemon

你每启动一个节点,它都会检查本地机器上是否运行着EPMD,如果没有,节点就会自动启动EPMD。它会跟踪在本地机器上运行的每个节点,并记录分配给它们的端口。

当Erlang节点试图与某远程节点通信时,本地的EPMD就会联络远程机器上的EPMD(默认使用 TCP/IP,端口为 4369),询问在远程机器上有没有叫相应名字的节点。如果有,远程的EPMD就会回复一个端口号,通过该端口便可以直接与远程节点通信 。

不过EPMD不会自动搜寻其他EPMD——只有在某个节点主动搜寻其他节点时通信才能建立。

注意,Erlang默认的分布式模型基于这样一个假设:集群中所有节点都运行在一个受信任的网络内。如果这个假设不成立,或者其中某些机器需要与外界通信,那么你应该直接在TCP(或UDP等)之上配合恰当的应用层协议来实现非受信任网络上的通信。

如果成功启动过一次节点的话,可以在 用户主目录 (Windows上一般为 C:/Documents and Settings/ 或 C:/Users/ 目录)下会有一个 .erlang.cookie 文件。

你也可以在shell中,获取当前节点的cookie:

[15:31:54] emacsist:~ $ erl -sname b
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(b@yangzhiyongs-MacBook-Pro)1> auth:get_cookie().
'YVCOJVPHXHCVZYICUVIJ'
(b@yangzhiyongs-MacBook-Pro)2>

从shell中获取的字符串与你在 .erlang.cookie 文件看到的应该相同。Erlang节点只有在知晓其他节点的 magic cookie的情况下才能与它们通信。

节点在启动的时候,会读取 .erlang.cookie 文件,如果存在,则以它为自己的 magic cookie,如果找不到,则节点会新建一个 cookie 文件并写入一个随机字符串。

默认情况下,每个节点都会假定所有与自己打交道的节点都拥有和自己一样的 cookie 。

要让运行于两台不同机器上的节点相互通信,最简单的办法就是将其中一台机器随机生成的 cookie 文件复制到另一台机器上即可。

对于更为复杂的配置,你可以用内置函数 set_cookie(Node, Cookie) 来设置 cookie ,这样,节点可以用不同的 cookie 与不同的节点通信。原则上说,集群中每个节点的 cookie 都可以不上,但在实践中,整个系统往往会共用一个 cookie .

远程 shell

其实shell进程并不关心与自己连接的终端是否和自己处在同一节点。下面演示了,如何在节点a里,通过 erlang shell连接节点b:

[16:10:04] emacsist:~ $ erl -sname a
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.3  (abort with ^G)
(a@yangzhiyongs-MacBook-Pro)1>
User switch command
 --> r 'b@yangzhiyongs-MacBook-Pro'
 --> j
   1  {shell,start,[init]}
   2* {'b@yangzhiyongs-MacBook-Pro',shell,start,[]}
 --> c
Eshell V8.3  (abort with ^G)
(b@yangzhiyongs-MacBook-Pro)1>

退出远程 shell 千万要小心

在使用完远程shell后,你可能会打入: q(). ,这时千万不要回车。这个是 init:stop(). 的简写,用于关闭执行该命令的节点,也就是你的远程节点。

要想安全地退出,请使用 Ctrl-GCtrl-C ,这两个组合只对本地节点有效。

第九章:用 Mnesia为cache增加分布工支持

Mnesia 是一套轻量级的软实时分布式数据存储系统,支持冗余复制和事务,特别适合于存储离散的Erlang数据块,尤其擅长RAM中的数据存储。

使用步骤:

  1. 初始化 Mnesia
  2. 启动节点
  3. 建立数据库模式
  4. 启动 Mnesia
  5. 建立数据库表
  6. 向新建的表中插入数据
  7. 对数据做一些基本查询

初始化数据库

启动节点

erl -mnesia dir '"/tmp/mnsia_store"' -name mynode

注意,dir 后面的参数格式(单引号用于保留字符串两端的双引号)。

节点启动后,还需要在即将参与冗余复制的所有节点上建立一套空的初始数据模式。

建立数据库模式

它是一些描述信息,其中记录着当前数据库中存有哪些表,表的详细情况又如何。一般来说不用关注它,它只是 Mnesia 用于跟踪自身数据的一种手段。

mnsia:create_schema([node()]).

启动 Mnesia

mnsia:start().

mnsia:info().

建表

mnsia:create_table(Name, Options)

Options 是一张 {Name, Value} 选项列表。所在选项中,最重要的是 attributes 。如果没有它,Mnesia 会假定记录仅有两个字段,分别名为 key 和 val 。

表的主键永远都是记录的第一个字段

Mnesia 表的类型

  • set :表中的键是唯一的,如果插入的记录与现存某个表项的主键相同,则新的记录会覆盖旧的。
  • bag :可以容纳多个具有相同主键的记录,但这些记录至少要有一个字段的值不相等。
  • ordered_set :上面两个都是是哈希表实现。ordered_set 则可以按主键的顺序保存记录。

    mnesia:create_table(table_name, [{type, bag},...])
    

表的存储类型

(hello@yangzhiyongs-MacBook-Pro.local)3> mnesia:info().
---> Processes holding locks <---
---> Processes waiting for locks <---
---> Participant transactions <---
---> Coordinator transactions <---
---> Uncertain transactions <---
---> Active tables <---
schema         : with 1        records occupying 413      words of mem
===> System info in version "4.14.3", debug level = none <===
opt_disc. Directory "/tmp/mnesia_store" is used.
use fallback at restart = false
running db nodes   = ['hello@yangzhiyongs-MacBook-Pro.local']
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = []
disc_copies        = [schema]
disc_only_copies   = []
[{'hello@yangzhiyongs-MacBook-Pro.local',disc_copies}] = [schema]
2 transactions committed, 0 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
ok

ram_copies :仅驻留于内存 disc_copies :表示它会被写入磁盘;为了提高读取速度,这些表会被会部加载进内存 disc_only_copyies : 仅存储在磁盘,所以比上面两种要表许多。这种存储类型还不支持 ordered_set 表。

注意:不同节点上的表,可以有不同的存储类型。比如,有一个节点是写磁盘,其他的是RAM的。

向表录入数据

mnesia:write(...)
mnsia:dirty_write(...)

事务

Mnesia 具有通常所说的ACID性质。要进行事务非常简单:

将逻辑写入到一个(不含参数)的fun表达式,然后将它传递给 mnesia:transaction/1 即可。

脏操作

dirty_ 为前缀的 Mnesia 函数都是脏操作,它不会考虑事务或数据库锁。

执行基本查询

mnesia:dirty_read(table_name, key)
mnesia:read(...)