← All posts

初识 Erlang 与 Elixir

目录 · Table of Contents

平时工作里写得最多的是 Ruby 和 Rails。平心而论,Ruby 的语法和元编程写起业务代码来是真的舒服,但只要做过一点跟实时推送或者高并发沾边的需求——比如之前在项目里用 Faye + Sidekiq 搞实时消息队列,或者折腾 Puma 多线程配置的时候——就会明显感觉到 MRI Ruby 的力不从心:全局解释器锁(GIL)卡在那里,内存占用也不小,连接数一上去就得靠堆机器和加 Redis 撑着。

前阵子刚用 Python 写完简单的 Scheme 解释器和 Lambda 演算模拟,对函数式编程正上头,顺便就去翻了翻一直听说但没正经写过的 Erlang,然后又顺藤摸瓜看到了前 Rails 核心团队成员 José Valim 搞出来的 Elixir。

花了几周时间把这两门语言摸了一遍之后,最大的感受就是:原来高并发和容错还可以用这么优雅的方式解决。 这篇笔记就简单聊聊我对 Erlang 和 Elixir 的一点初印象。

1. Erlang 到底神在哪里?

很多人第一次听说 Erlang,多半是因为前两年 WhatsApp 的那个著名战绩:只靠几十个工程师,单台服务器就扛住了 200 万并发长连接。

当时我看到这个数字也很纳闷:同样是一台 Linux 服务器,为什么 C++ 或 Java 调优半天都很容易卡在各种锁竞争和内存开销上,一门上世纪 80 年代末爱立信为了写电话交换机造出来的老语言,反而能这么猛?

真正去了解了 Erlang 底下的 BEAM 虚拟机,才发现它的设计思路跟我们习惯的面向对象语言完全不在一个频道上。

超轻量级进程与消息传递

在 Java、C++ 或者 Ruby 里,我们谈并发通常指的是操作系统的线程(Thread)。每个线程光栈内存就要占掉几 MB,而且所有线程共享同一块堆内存。只要有共享内存,为了防止数据写乱,你就得加各种锁(Mutex / Semaphore),一旦锁多了,死锁和性能抖动就全来了。

Erlang 的做法非常干脆:彻底干掉共享内存,一切皆进程(Process)。

注意,这里的“进程”跟操作系统的进程没半毛钱关系,它是 BEAM 虚拟机自己实现的用户态轻量级进程(也就是 Actor 模型):

  • 小到不可思议:创建一个 Erlang 进程只需要几百字节的内存,耗时也就几微秒。在一台普通笔记本上同时开几十万个进程就跟玩一样;
  • 状态完全私有:每个进程都有自己独立的一小块堆和栈,变量一旦绑定就是不可变的(Immutable),进程之间绝对不共享任何内存;
  • 只靠发消息沟通:进程 A 想找进程 B 办事,唯一的方法就是用 ! 给 B 的邮箱(Mailbox)塞一条消息,B 自己在循环里用 receive 做模式匹配取出来处理。

看一段最简单的 Erlang 代码就能明白是怎么回事:

ping_pong.erl
erlang
1-module(ping_pong).
2-export([loop/0, run/0]).
3
4loop() ->
5 receive
6 {From, ping} ->
7 From ! {self(), pong},
8 loop();
9 stop ->
10 ok
11 end.
12
13run() ->
14 %% 派生(spawn)一个新进程跑 loop/0,拿到它的进程号 Pid
15 Pid = spawn(fun ping_pong:loop/0),
16 %% 用 ! 给它发一条包含自己 Pid 的消息
17 Pid ! {self(), ping},
18 receive
19 {Pid, pong} ->
20 io:format("Got pong from ~p~n", [Pid])
21 end.

因为没有共享内存,自然也就不需要锁;因为每个进程只管自己那点小内存,垃圾回收(GC)也是每个进程各扫门前雪,不会像 Java 那样突然来一次全局 Stop-The-World 把所有请求都卡住。

“Let It Crash” 与 OTP 监督树

Erlang 另一个特别颠覆三观的设计是它的容错哲学:“Let It Crash(任其崩溃)”。

以前我们写代码,总是恨不得在每个地方都包上 try ... catch 或者 begin ... rescue,生怕哪个异常没接住把整个服务搞挂了。但 Erlang 认为,硬件会坏、网络会断、奇葩数据总会来,与其写满屏防御代码把异常硬吞下去(导致内存状态半死不活),不如一旦遇到预期外的情况,就让当前这个小进程干脆利落地死掉。

反正进程之间内存是完全隔离的,死一个进程根本不会影响到其他几万个正在干活的进程。

那死了之后谁来收拾残局呢?Erlang 标准库里的 OTP(Open Telecom Platform) 框架早就替你想好了——用 监督者(Supervisor) 把干活的进程(Worker)管起来,拼成一棵 监督树(Supervision Tree)。哪个 Worker 进程崩了,它头顶上的 Supervisor 瞬间就会收到退出信号,按照你定好的策略(比如单独重启它一个,或者把关联的几个进程一起重启)在几微秒内拉起一个干干净净的新进程。

2. 既然 Erlang 这么强,为什么还需要 Elixir?

看到这你可能会问:既然 BEAM 虚拟机和 OTP 这么厉害,那大家直接去写 Erlang 不就完了吗,为什么还要再造一门 Elixir?

说实话,当我兴致勃勃地照着《Learn You Some Erlang》写了几天 Erlang 代码之后,我就彻底理解为什么广大程序员对它望而却步了:

  1. 让人抓狂的标点符号语法:Erlang 早期借鉴了逻辑编程语言 Prolog,所以它的语句结束符不是固定的一种,而是逗号 ,、分号 ;、句号 . 混着用——函数内部表达式之间用逗号,不同模式匹配分支之间用分号,整个函数定义结束用句号。新手写代码时在后面加个分支,经常得回头把上一行末尾的 . 改成 ;,简直反人类;
  2. 痛苦的 State1、State2、State3:在 Erlang 里,同一个作用域内变量名绝对不允许重新绑定。所以如果你要对一个状态连着做三步修改,代码就会变成下面这样,万一中间想插一步,后面的变量编号全得跟着改:
erlang
1State1 = init_state(Raw),
2State2 = put_user(State1, User),
3State3 = update_timestamp(State2),
4save_state(State3).
  1. 别扭的字符串处理:在 Erlang 里,双引号 "hello" 居然不是真正的二进制字符串,而是一个存着 ASCII 码的整数列表 [104, 101, 108, 108, 111]!一旦碰到中文 UTF-8,打印出来经常是一串数字列表,想用真正的二进制字符串得写成 <<"你好"/utf8>>,写 Web 业务时非常难受。

而 Elixir 做的事情非常聪明:José Valim 一丁点都没有去动底层的 BEAM 虚拟机和 OTP,而是直接在 BEAM 之上设计了一门拥有现代语法、完善工具链和强大宏系统的语言。Elixir 代码编译之后,生成的直接就是标准的 BEAM 字节码。

换句话说:你拿到了 Erlang 三十年积累下来的全部并发与容错内功,同时还能像写 Ruby 一样愉快地写代码。

3. Elixir 最打动我的几个地方

1. 模式匹配与变量重绑定

跟 Erlang 一样,Elixir 里的 = 不是简单的赋值,而是 模式匹配(Pattern Matching)。比如我们可以直接在等号左边解构元组、列表或者 Map:

elixir
1# 匹配成功,status 绑定为 :ok,body 绑定为 "success"
2{:ok, body} = {:ok, "success"}
3
4# 如果右边是 {:error, :timeout},这行会直接触发 MatchError 崩溃
5{:ok, result} = fetch_remote_data()

但比 Erlang 人性化得多的是,Elixir 允许变量名重新绑定(Rebinding)(注意底层数据依然是不可变的,只是变量名指向了新数据),如果真的想拿变量已有的值去做匹配,加个插入符 ^(Pin 操作符)就行:

elixir
1x = 1
2x = x + 1 # 合法!不用再写 X1、X2 了
3^x = 2 # 用 ^x 固定当前值 2 进行匹配

2. 爽快到飞起的管道操作符 |>

在函数式语言里,由于没有对象方法链(obj.a().b().c()),一连串的数据转换如果写成嵌套调用 c(b(a(data))),读起来总是得从里往外倒着看。

Elixir 引入了 F# 风格的管道操作符 |>,它的作用非常简单:把左边表达式的结果,自动作为第一个参数传给右边的函数。

比如前面那段让人头疼的 Erlang State1 / State2 / State3,到了 Elixir 里直接写成这样:

elixir
1raw
2|> init_state()
3|> put_user(user)
4|> update_timestamp()
5|> save_state()

再比如处理一个字符串列表,过滤掉空串、转成大写再用逗号拼起来:

elixir
1["elixir", "", "erlang", "beam"]
2|> Enum.reject(&(&1 == ""))
3|> Enum.map(&String.upcase/1)
4|> Enum.join(", ")
5# => "ELIXIR, ERLANG, BEAM"

数据就像在水管里流动一样,从上往下读,逻辑一目了然。

3. 更清爽的 OTP GenServer

在 Erlang 里写一个标准的 gen_server 行为模式,即使你只用到其中两个回调,也得把 handle_call、handle_cast、handle_info、terminate、code_change 全都导出并实现一遍,样板代码不少。

而在 Elixir 里,通过 use GenServer 宏注入默认实现后,你只需要写你关心的那几个回调函数就行。比如写一个简单的内存队列服务:

queue_server.ex
elixir
1defmodule QueueServer do
2 use GenServer
3
4 # --- 客户端 API ---
5
6 def start_link(initial_queue \\ []) do
7 GenServer.start_link(__MODULE__, initial_queue, name: __MODULE__)
8 end
9
10 def push(item) do
11 GenServer.cast(__MODULE__, {:push, item})
12 end
13
14 def pop do
15 GenServer.call(__MODULE__, :pop)
16 end
17
18 # --- 服务端回调 ---
19
20 def handle_cast({:push, item}, state) do
21 {:noreply, state ++ [item]}
22 end
23
24 def handle_call(:pop, _from, [head | tail]) do
25 {:reply, {:ok, head}, tail}
26 end
27
28 def handle_call(:pop, _from, []) do
29 {:reply, :empty, []}
30 end
31end

你看 handle_call(:pop, ...) 的两个定义:通过函数头部的模式匹配,直接把“队列非空 [head | tail]”和“队列为空 []”拆成了两个干净的分支,连 if ... else 都省了。

4. 编译期宏(Macro)与无缝调用 Erlang

之前写 Ruby 的时候,我很喜欢 Ruby 的元编程(比如 define_method、method_missing),但它是在运行时动态修改类和对象模型的,用多了不仅慢,出了 Bug 栈回溯也很难查。

Elixir 的元编程走的是 Lisp 的路子:所有的 Elixir 代码在编译期都是一棵由三元组构成的抽象语法树(AST)。通过 quote 和 unquote,你可以用 defmacro 在编译期生成或改写代码。像 Elixir Web 框架 Phoenix 里的路由匹配,就是在编译期通过宏直接展开成几百个平铺的函数头模式匹配,所以路由查找速度几乎是 $O(1)$ 的常数级开销,跑起来比 Rails 快了不止一个数量级。

更良心的是,Elixir 可以零成本直接调用任何 Erlang 模块——在 Elixir 里,Erlang 的模块就是一个普通的原子(:atom)。比如你想用 Erlang 内置的内存表 ets 或者加密库 crypto,连 import 都不用:

elixir
1# 直接调用 Erlang 标准库的 :crypto 和 :ets 模块
2token = :crypto.strong_rand_bytes(16) |> Base.encode16()
3
4table = :ets.new(:my_cache, [:set, :protected])
5:ets.insert(table, {"user:1", "sllt"})

再加上 Elixir 出厂自带的构建工具 mix(相当于 Ruby 的 rake + bundle)、包管理器 Hex 以及支持代码补全和在线看文档的 iex 终端,整个开发体验甚至比现在的 Rails 还要顺手。

4. 小结

如果用一句话来总结这两门语言的关系:

Erlang 和它的 BEAM 虚拟机是那个深藏功与名的扫地僧,早在二三十年前就把高并发、软实时和容错隔离做到了极致;而 Elixir 则是给这位扫地僧换上了一身现代装备,让习惯了 Ruby、Python 的开发者也能毫无门槛地用上这套神功。

最近我也在试着用 Elixir 的 Phoenix 框架写一点小东西,尤其是里面的 Phoenix.Channel,单机挂几万个 WebSocket 连接内存才涨百来兆,比起以前在 Rails 里用 Faye + Redis 折腾长连接真是省心太多了。后面有空再单独写一篇聊聊 Phoenix。