VOL.III · 心跳CH 07监护 07/13

进程:最小生命单位

解剖课结束,心跳开始。前六章的 Elixir 是一门优雅的函数式语言——但说实话,优雅的函数式语言市面上不止一门。从本章起进入别人没有的部分:BEAM 进程。它轻到一台笔记本能开百万个,隔离到崩一个不溅一滴血,而且从不害怕死循环。第 1 章发的三张王牌,现在开始逐张兑现。

spawnsend/receive邮箱抢占式调度

spawn:出生只要一微秒

iex(1)> self()
#PID<0.110.0>
iex(2)> pid = spawn(fn -> IO.puts("我活过,我算过,1 + 1 = #{1 + 1}") end)
我活过,我算过,1 + 1 = 2
#PID<0.112.0>
iex(3)> Process.alive?(pid)
false

先看第 1 行:self() 返回当前进程的 PID(进程标识符)——对,你敲命令的 iex 本身就是一个进程,在 BEAM 的世界里没有「进程之外」的地方,一切代码都跑在某个进程里。第 2 行 spawn/1 接一个函数,创建新进程去执行它:新进程打完招呼,函数跑完,进程自然死亡——所以第 3 行问它还活着吗,答案是 false。出生、工作、死亡,整个生命周期干净利落,没有回收仪式。

顺便解剖一下 PID 的长相:#PID<0.112.0> 三段数字是「节点.编号.代次」——第一段 0 表示本地节点(第 12 章连上别的节点后,远程进程的这一位不是 0),中间是进程编号,末段防编号复用混淆。别解析它、别持久化它,它是门牌不是身份证:进程死了重启,门牌就换新的(第 8 章会给出正确的「按名字找人」方案)。

它有多轻?两个数字:初始内存约 2.6 KB(一条 OS 线程的默认栈是 1 MB 量级,差三个数量级),创建耗时微秒级。轻,是后面一切玩法的前提——因为造一个进程比造一个 Java 对象贵不了多少,你才能大方地「每个用户一个进程」「每个会话一个进程」「每个 WebSocket 一个进程」,而不必像线程池那样精打细算地复用。

iex(1)> :timer.tc(fn ->
...(1)>   Enum.each(1..100_000, fn _ -> spawn(fn -> :ok end) end)
...(1)> end)
{561_000, :ok}   # 56 万微秒:10 万个进程,半秒出头

send / receive:唯一的通信方式是寄信

进程之间不共享任何内存,通信只有一条路:发消息。每个进程自带一个邮箱(mailbox),别人 send 来的消息按到达顺序排队,进程用 receive 取信——而且是用模式匹配取:

iex(1)> me = self()
#PID<0.110.0>
iex(2)> spawn(fn -> send(me, {:vitals, :hr, 72}) end)
#PID<0.115.0>
iex(3)> receive do
...(3)>   {:vitals, :hr, bpm} -> "收到心率:#{bpm}"
...(3)> after
...(3)>   1000 -> "一秒内没等到"
...(3)> end
"收到心率:72"

几个机制点,每个都有后文:

  • send 永不阻塞、永不失败——把信扔进对方邮箱就走,对方死没死、处理没处理,寄件人不知道也不等待。「可靠回执」需要自己设计(下一章 GenServer.call 就是标准设计);
  • receive 是选择性接收——按模式从邮箱里挑第一封匹配的信;没有匹配的就阻塞等待,after 子句给等待加超时;
  • 消息是拷贝的——发出去的数据会复制进对方的堆(大二进制除外,那个按引用共享)。听起来浪费?这正是「独立堆、独立 GC」的代价与代换:数据不跨堆引用,回收才能各自为政。

看看自己的邮箱:flush 与 Process.info

邮箱不是黑箱,iex 里随时可以自查。flush() 把当前进程邮箱里的存货全部倒出来打印(顺便清空),是调试消息流的第一工具;Process.info(pid, :messages) 则能偷看任何进程的邮箱内容而不动它:

iex(1)> send(self(), {:vitals, :hr, 72})
iex(2)> send(self(), {:vitals, :spo2, 98})
iex(3)> Process.info(self(), :message_queue_len)
{:message_queue_len, 2}
iex(4)> flush()
{:vitals, :hr, 72}
{:vitals, :spo2, 98}
:ok

注意第 3 行的 message_queue_len——邮箱当前的积压量。现在只是个好玩的数字,到第 11 章它会是你在生产节点上查系统健康度的头号体征指标:哪个进程的邮箱在变长,哪里就是瓶颈。

消息协议:元组即信纸格式

消息可以是任意值,但久经考验的信纸格式是带标签的元组:第一格原子表意图,最后一格常常是 self()——因为 send 是单行道,对方想回信,信里必须附回邮地址:

send(pid, {:get_vitals, :bed_7, self()})   # 意图 + 参数 + 回邮地址
receive do
  {:vitals_reply, data} -> data
after
  5_000 -> {:error, :timeout}               # 永远给 receive 配超时
end

「标签元组 + 回邮地址 + 超时」三件套,就是手工版的请求-响应协议。它繁琐、容易漏(忘了超时就是永久阻塞),这正是下一章 GenServer.call 要替你标准化的东西——先手工体会一次,才知道抽象抽掉了什么。

▸ Android 类比

邮箱模型你其实天天在用:Handler / Looper / MessageQueue。主线程一个 Looper 守着一条消息队列,别的线程 post 消息过来排队处理——这就是一个手工搭建的 actor。差别在于:Android 里「带队列的线程」是稀缺资源,你只有主线程和少数 HandlerThread;BEAM 里它是空气,随手 spawn,百万级供应。当「每个并发活动配一个私有队列」不再昂贵,整个架构的形状都会变。

隔离:崩一个,不溅血

第 5 章从数据角度推过一遍,现在从进程角度亲眼看一次。在 iex 里制造一起事故:

iex(1)> spawn(fn -> 1 / 0 end)
#PID<0.118.0>
iex(2)>
14:32:07.114 [error] Process #PID<0.118.0> raised an exception
** (ArithmeticError) bad argument in arithmetic expression
    :erlang./(1, 0)
iex(2)> 1 + 1
2

除零,进程 0.118 当场死亡,错误日志打印——然后呢?然后没有然后。iex 好好的,别的进程好好的,没有全局异常,没有污染。死掉进程的堆被整块回收(连 GC 都不用跑,直接释放)。这就是第 6 章那句「敢让它炸,因为炸掉的只是一个 2.6 KB 的进程」的现场版。当然,「死了没人知道」也不行——谁来发现、谁来重启?这正是第 9 章监督树的全部内容,此处先记住:隔离是免费的,感知死亡需要登记

调度:BEAM 不怕死循环

百万进程,CPU 只有八个核,谁说了算?BEAM 启动时按核数起 N 个调度器线程,每个调度器轮流执行自己队列里的进程。关键在换人的方式——抢占式:每个进程被分配一笔「运算配额」(约 4000 个 reduction,粗略理解为函数调用次数),配额烧完,无论你在干什么,立刻下场换人

对比一下你熟悉的两种模型,这个设计的分量才显出来:

模型换人时机一个任务死循环会怎样
Node.js / 浏览器事件循环任务自己跑完才换整个系统冻住,所有请求陪葬
Kotlin 协程(协作式)挂起点(suspend 调用处)不含挂起点的循环霸占线程,同派发器的协程全部饿死
BEAM 进程(抢占式)reduction 配额烧完,强制换死循环进程自己空转,其他进程心跳如常

多核扩展也在同一套机制里解决:N 个调度器各管一条队列,忙闲不均时互相偷任务(work stealing),你的百万进程自动摊满所有核,不用写一行线程代码。IO 同样不挡路:文件、网络操作由虚拟机异步化,进程只是「挂起等消息」,不占调度器。

协作式模型的公平性靠纪律(人人记得让出),BEAM 的公平性靠物理(到点强制下场)。这就是「软实时」的来源:没有任何一个进程、任何一次 GC(每进程独立!)能造成全局停顿,延迟曲线平得不像话。代价是单线程裸算力不如 JIT 过的 JVM——BEAM 从没打算赢计算密集的比赛,它赢的是「十万个东西同时等着、动着」的比赛。

动手:邮箱与调度监护

两台仪器。第一台把「邮箱堆积」变成可视的水位:处理能力固定 25 msg/s,把发送速率拉过它,看 queue_len 涨起来——记住这个数字的样子,第 11 章你会在真系统里用 Process.info 查到它,它是 BEAM 系统健康度的头号指标。第二台是抢占对决:往两种调度模型里各注入一个死循环,看哪边的心跳先停。

DEMO邮箱与调度监护① 把速率拉过 25 看堆积 ② 注入死循环看谁扛得住
⚠ 病历警示

邮箱有两种慢性病,都源于「send 不挑食、receive 挑食」:①堆积——生产快于消费,邮箱无上限地涨,直到内存耗尽,所以 queue_len 是必须监控的体征;②死信——receive 的模式挑不中的消息永远留在邮箱里,不报错、不丢弃,悄悄占内存还拖慢后续匹配。防死信的惯例是 receive 里带一个 _other -> 兜底子句把垃圾信清走(下一章的 GenServer 帮你做了这件事)。

可是……状态呢?

还剩最后一块拼图。不可变的世界里没有可变变量,那「计数器」这种会变的东西住在哪?答案优雅得犯规:让一个进程用递归抱着它:

defmodule Counter do
  def loop(count) do
    receive do
      :incr -> loop(count + 1)      # 带着新状态递归,尾调用,不长栈
      {:get, from} ->
        send(from, {:count, count})
        loop(count)
    end
  end
end
# pid = spawn(fn -> Counter.loop(0) end)
# send(pid, :incr)

状态 count 从没被「修改」——每处理一封信,函数带着新值调用自己(第 5 章的尾递归!),旧值随风而去。receive 排队保证了并发访问天然串行:一百个进程同时发 :incr,也只是邮箱里一百封信依次处理,没有锁,没有竞态。从外面看,这个进程就是一个「状态会变的东西」;从里面看,一切依然纯函数式。进程,是不可变语言里安放可变状态的唯一容器。这个 loop 模式好用但手工,把它标准化、加上超时、错误处理、命名——那就是 OTP 的头号明星:GenServer。下一章见。

✚ 动手自测
  1. 真机跑一遍十万 spawn 的压测,记下你机器的耗时;再试一百万。
  2. 写两个进程玩乒乓:A 给 B 发 {:ping, self()},B 回 :pong,A 收到后打印。全程只用 spawn/send/receive。
  3. 把上面 Counter 敲进 iex,发几个 :incr,再用 {:get, self()} + receive 取值验证。
  4. 向同事解释:为什么「消息拷贝」是特性不是缺陷?它买来了什么?