并发入门实战:通过 Exercism Elixir 的 Take-a-Number 系列理解进程、PID 与 GenServer
并发入门实战:通过 Exercism Elixir 的 Take-a-Number 系列理解进程、PID 与 GenServer
【免费下载链接】elixirExercism exercises in Elixir.项目地址: https://gitcode.com/gh_mirrors/elixi/elixir
摘要
想在实战中理解 Elixir 并发编程的核心概念?Exercism 的 Take-a-Number 系列练习正是为此而生。本文将通过「排队叫号机」这一贴近生活的场景,带你一步步掌握Elixir 进程的创建、PID的本质、进程间消息传递,以及如何用 OTP 的GenServer优雅地管理并发状态。无论你是 Elixir 新手,还是想补全并发知识体系的学习者,这份实战指南都能帮你建立从裸进程到 GenServer 的完整认知链路。
为什么用「排队叫号机」来学习 Elixir 并发?
银行、医院里的叫号机,本质上是一个有状态的服务进程:它记住当前发到几号,每次叫号递增,还能支持重置。这个模型与 Elixir 的进程模型几乎一一对应:
- 叫号机 = 一个 Elixir 进程
- 顾客手里的号码 = 进程之间的消息
- 当前号码 = 进程内部维护的状态
Exercism Elixir 官方仓库中,exercises/concept/take-a-number/与exercises/concept/take-a-number-deluxe/两个练习构成了完美的进阶路线:前者让你手写全部并发原语,后者让你用OTP 的 GenServer重写同样的逻辑。同样的业务,两种实现,对比学习的效果远胜死记硬背。
| 对比维度 | take-a-number | take-a-number-deluxe |
|---|---|---|
| 进程创建 | spawn/1手动创建 | GenServer.start_link/3 |
| 消息处理 | 手写receive循环 | handle_call/handle_cast/handle_info |
| 状态管理 | 递归参数传递 | GenServer 内置状态 |
| 难点等级 | ⭐ 入门 | ⭐⭐ 进阶 |
第一步:用 spawn 创建 Elixir 进程并理解 PID
在exercises/concept/take-a-number/lib/take_a_number.ex中,你的第一个任务(测试文件take_a_number_test.exs的 task 1)是实现start/0,返回一个新的进程标识符(PID)。
Elixir 中所有代码都运行在进程里,spawn/1可以显式地在全新进程中执行函数:
pid = spawn(fn -> :ok end) # => #PID<0.125.0>这里的关键认知有三点:
- PID 就是进程的邮箱地址——只要拿到 PID,就能向该进程投递消息(对应概念文档
concepts/pids/introduction.md); - Elixir 进程极其轻量——它不是操作系统进程,内存和 CPU 开销都极小,一个应用跑成百上千个进程毫无压力;
- 每个进程都有独立的邮箱——消息先进入邮箱排队,只有进程主动
receive才会被取出(见concepts/processes/introduction.md)。
测试会校验is_pid(pid)、pid != self(),确保你返回的是其他新进程的 PID,而不是当前进程。
第二步:Elixir 进程间通信的核心——send 与 receive 消息传递
掌握了 PID 之后,第二步就是用send/2和receive/1让进程真正"说话"。take-a-number 的 task 2 要求实现:收到{:report_state, sender_pid}消息后,把当前号码发回给发送者。
send(pid, {:report_state, self()}) # 发消息:报告你的状态 receive do number -> number # 收到回复 end而进程内部则用receive/1模式匹配邮箱里的消息:
receive do {:report_state, sender_pid} -> send(sender_pid, current_number) :stop -> exit(:normal) end理解这条消息传递链路,就理解了 Elixir 并发的全部秘密——进程之间不共享内存,只交换消息。想收到回复,就必须把发送者的 PID(self())放进消息里,这是 Elixir 消息传递最经典的习惯用法。
第三步:用 receive 循环维护进程状态
光能收发一次消息还不够,叫号机必须持续服务。task 3 要求连续发出 1、2、3、4、5……这需要进程在receive之后递归调用自己,把新的号码作为参数传入下一轮——这就是经典的receive loop模式:
def loop(current_number) do receive do {:report_state, sender_pid} -> send(sender_pid, current_number) loop(current_number) # 状态不变,继续循环 {:take_a_number, sender_pid} -> send(sender_pid, current_number + 1) loop(current_number + 1) # 状态递增,继续循环 :stop -> :ok # 退出循环,进程结束 end end状态不放在变量里"原地修改",而是作为递归参数不断传递——这是函数式并发的精髓。task 4 和 task 5 还会要求你处理:stop消息优雅退出,并忽略未知消息(比如有人对叫号机喊"我要找经理" 😄),保证进程不被意外消息搞崩溃。
第四步:从裸进程升级到 GenServer——OTP 行为实战
手写 receive loop 能帮你理解原理,但生产级 Elixir 项目几乎都用GenServer(generic server,通用服务器)来封装这套模式。它本质上就是把你手写的 receive loop 抽象成了可复用的行为(behaviour),概念细节见concepts/genserver/introduction.md。
在exercises/concept/take-a-number-deluxe/lib/take_a_number_deluxe.ex中,练习给出了完整的客户端 API 骨架:
start_link/1—— 启动进程(对应init/1回调)report_state/1—— 查询状态(同步,handle_call)queue_new_number/1—— 取新号(同步,handle_call)serve_next_queued_number/2—— 叫号,支持优先号(同步,handle_call)reset_state/1—— 重置叫号机(异步,handle_cast)
你需要实现的正是三个核心回调:
| 回调 | 对应客户端函数 | 语义 | 典型返回值 |
|---|---|---|---|
init/1 | start_link/1 | 初始化状态 | {:ok, state} |
handle_call/3 | call/2系函数 | 同步请求,需要回复 | {:reply, reply, state} |
handle_cast/2 | cast/2系函数 | 异步请求,无需回复 | {:noreply, state} |
handle_info/2 | 直接send/2 | 处理任意消息 | {:noreply, state} |
状态则用结构体封装在exercises/concept/take-a-number-deluxe/lib/take_a_number_deluxe/state.ex中:min_number、max_number、queue(号码队列,实现见同目录下的queue.ex)、auto_shutdown_timeout(自动关闭超时)。
deluxe 版还藏着一个进阶彩蛋——超时自动关闭:init/1返回{:ok, state, timeout}后,若指定毫秒数内没有消息到达,进程会自动触发handle_info(:timeout, state)实现自我回收。这是 GenServer 回调返回三元组的一个绝佳实战案例。
实践指南:3 步跑通 Take-a-Number 系列练习
- 获取练习源码:
git clone https://gitcode.com/gh_mirrors/elixi/elixir,进入exercises/concept/take-a-number/目录; - 阅读测试驱动开发:先看
test/take_a_number_test.exs,每个测试都标注了task_id,按 1→5 的顺序逐个实现lib/take_a_number.ex中的函数; - 运行测试验证:在练习目录执行
mix test,绿灯全亮后,再挑战 deluxe 版本(take-a-number-deluxe/),体会 GenServer 如何把同样逻辑写得更加清晰。
总结:一条通往 Elixir 并发的完整学习路径
通过 Take-a-Number 系列,你实际上完成了一次从底层到框架的完整进阶:
- ✅ 用
spawn创建Elixir 进程,理解PID作为邮箱地址的本质 - ✅ 用
send/receive完成进程间消息传递 - ✅ 用receive loop 递归维护进程状态
- ✅ 用GenServer封装状态与消息处理,掌握 OTP 行为三件套
handle_call/handle_cast/handle_info
当你能用两种方式实现同一台"叫号机",Elixir 的并发模型就已经不再是抽象概念,而是你信手拈来的实战技能了。下一步,不妨把 take-a-number-deluxe 的队列换成Task或Agent再实现一遍——并发世界的大门,正为你敞开。🚀
【免费下载链接】elixirExercism exercises in Elixir.项目地址: https://gitcode.com/gh_mirrors/elixi/elixir
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
