第 06 站展开探险地图
00装好工具前置准备01起跑线前置准备02进程与信箱R1Elixir 数据流水线可选复习R2读懂 Erlang可选复习03消息与超时04OTP 消息章法05会重启的监督树06给并发设上限07BEAM 节点失联X1两种语言对暗号双语扩展X2双语搭档双语扩展08可靠任务调度器
首页/BEAM 主线/第 06 站
06
容量容量挑战ElixirErlangBEAM

给并发设上限

该用普通函数时就直接计算,需要并发时也要给任务数量画一条线。

3小关卡
约 5 小时 · 建议分 3 次可以分几次
QUESTION · 本站只追这一问

突发一万条 URL 检查请求时,怎样限制运行数与等待数,并清楚返回 timeout、失败或 busy?

先看现场

无限 Task 吃光连接,mailbox 仍继续增长

系统为每个 URL 立刻启动 Task。连接与内存很快耗尽,请求却仍在无形排队,调用者只能等到 timeout。

能够观察到
  • 活跃 Task 数与连接数的峰值
  • 队列或 mailbox 长度,以及 P95 延迟
  • timeout、执行失败与满载拒绝的分类计数
为什么学这一站

它要解决什么

进程适合保管状态、表达生命周期和隔离错误。普通计算不必都经过 GenServer,无限 Task 也会耗尽连接与内存。工具要按状态和容量来选。

走完这一站

你会做到

  • 能根据有没有长期状态、谁需要读写,选择普通函数、进程状态或 ETS

  • 能给同时运行的任务设上限,并处理 timeout 和等待队列

  • 能从 mailbox 长度、等待时间和拒绝率看出系统是否忙不过来

出发前
  • 知道 GenServer 怎样保管状态,也知道监督树怎样照看进程
  • 用 Task 跑过至少一个可以独立完成的小任务
同一件事,两种写法

先看做什么,再看怎么写

Elixir 直接设并发上限;Erlang 轮廓展示 monitor、运行集合与等待队列。

当前代码
Elixir
# 同时检查 URL,但并发数不会超过设定上限
urls
|> Task.async_stream(
  &check_url/1,
  # 根据调度器数量设置并发上限
  max_concurrency: System.schedulers_online() * 2,
  timeout: 3_000,
  on_timeout: :kill_task,
  ordered: false
)
# 把每项结果汇总成成功与失败计数
|> Enum.reduce(%{ok: 0, error: 0}, &count_result/2)
设计选择

为什么这样写

为什么既限制运行数,也限制等待数?

这次选择

并发上限保护正在使用的资源;队列上限阻止等待工作悄悄吃光内存。满载时系统必须承认 busy。

代价与边界

async_stream 能限制同时运行数,却不是完整的持久有界队列。入口容量仍要另行设计。

换一把尺子

从 Java、Python、JavaScript 看过来

Java

熟悉的起点
bounded executor、Semaphore 与 BlockingQueue。
BEAM 在意什么
BEAM 仍需同时限制 worker、mailbox 或等待队列。
别带错直觉
虚拟线程便宜,也不会让数据库连接变无限。

Python

熟悉的起点
asyncio.Semaphore、Queue 与 worker pool。
BEAM 在意什么
异步消息很便宜,但 mailbox 默认没有容量上限。
别带错直觉
协程数量和外部资源容量不是同一个数字。

JavaScript

熟悉的起点
Promise 批次、p-limit 或 stream backpressure。
BEAM 在意什么
spawn/cast 让调用者先走,却可能把等待转移到进程信箱。
别带错直觉
异步不等于有界。
LAB
动手

一加一要不要 GenServer

约 15–25 分钟

同一批独立计算,一版 call 同一个 GenServer,一版直接调用函数。比较时间和 mailbox。

  1. 01

    实现一个只做 CPU 小计算、没有长期状态的 Calculator GenServer

  2. 02

    从多个 Task 同时 call 这一个 server,记录总时间和 mailbox

  3. 03

    把计算改成普通函数,用同一批输入再测一次

复制到终端,按回车
# 运行 workload,并返回耗时微秒数和函数结果
:timer.tc(fn -> workload.() end)
你会看到
  • 单个 server 会让原本互不依赖的计算排队执行
  • 普通函数可以留在各个调用者中运行,更容易让多个调度器一起工作
故意弄坏

把 max_concurrency 调到输入数量,并让每个任务占一个连接。记录资源峰值。

这次能看清

没有共享状态的独立计算,通常不需要先挤进同一个进程排队。

这次还不能说明

这不能说明所有 GenServer 都慢。它们主要负责状态、生命周期和消息约定。

先认词

这段代码里的关键词

01

背压

下游容量不足时,把信号传回上游。上游可以等待、减速、被拒绝或进入有界队列。

02

ETS

BEAM 中供多个进程快速读写的表。它仍有所有者;所有者退出时,表默认被删除。

03

有界并发

同时运行的任务数有上限,使 CPU、连接和内存用量更可控。

从代码里认出章法

这里用了哪些设计模式

01

Bulkhead

隔离并限制并发 worker 与外部连接的占用。

02

Bounded Buffer

给等待队列设上限,容量用尽时不再暗中堆积。

03

Backpressure

通过等待、降速或拒绝,把容量信号传回上游。

想一想

下面哪件事最没有必要交给 GenServer?

轮到你

写一个有界网址检查器

检查一组 URL,限制并发、timeout 和等待数量。输出成功率、P95 耗时和拒绝数。

提示 1先迈一步

先写下最多允许多少任务和等待项,再选择 API。

提示 2再缩小一点

把“等太久”和 HTTP 返回错误分开计数,它们不是同一件事。

提示 3离答案很近了

ordered: false 可以让先完成的任务先交出结果,不必等最慢的那个。

提示 4从终点往回想

先挑一条“过关信号”,为它写一个最小测试。如果电脑看不出结果,就把这句话改成一个真正能观察到的现象。

过关条件
  • 最大并发数和最多等待数量都能调整

  • 输入突然变多时,内存不会因为无限排队持续增长

  • 结果能分清普通失败、timeout 和因为繁忙被拒绝

带走

记住三句话

  1. 1

    进程适合表达谁保管状态、要活多久,以及出错影响到哪里。

  2. 2

    异步只表示不用原地等待,不代表任务数量已经有上限。

  3. 3

    背压让“忙不过来”被看见,并通过等待、限速、拒绝或降级把它控制住。

再读一点

去看原版资料

Task.async_streamETS User's Guide
本站结束实验做过,答案也想过,就把这一站收好。