makoto-developer's テックブログ

「軽量プロセス」はどれくらい軽いのか —— BEAM・goroutine・OSスレッドを実測する

本稿の位置づけ

Elixir を説明するとき「BEAM のプロセスは軽いので何十万個でも作れる」と言ってきた。実際そう教わったし、そう動く。だが何バイトなのかと聞かれたら答えられなかったので、測った。

測った結果、1プロセスは約 2.6KB だった。ここまでは想定内である。想定外だったのは比較対象で、goroutine がメモリで 4% しか違わなかった。生成時間も同じ桁で、繰り返して測ると goroutine のほうが速い。BEAM のプロセスが特別に軽いという理解は、少なくともメモリと生成時間に関しては正しくなかった。

さらにコンテナに入れて測ったところ、もう一つ想定外があった。BEAM は CPU の制限は正しく認識するのに、メモリの制限は認識しない。上限を知らないまま確保し続け、OOM killer に殺される。対処として max_heap_size をプロセス側に置いたが、これも素朴に設定するだけでは効かなかった。第7節に書く。


1. 何を測るか

「軽い」には少なくとも3つの意味がある。

  1. 1個あたりのメモリが小さい
  2. 生成が速い
  3. たくさん作れる

この3つを、BEAM のプロセス・Go の goroutine・OS スレッドで並べて測る。OS スレッドは Python の threading を使った。測定環境は macOS、18 スケジューラ、Elixir 1.19.5 / OTP 28、Go 1.25.1、Python 3.14 である。

2. BEAM のプロセスを50万個作る

何もせず receive で待つだけのプロセスを大量に作り、:erlang.memory(:processes) の増分を個数で割った。

def idle do
  receive do
    :stop -> :ok
  end
end

def measure(n, spawn_fun, label) do
  :erlang.garbage_collect()
  before = :erlang.memory(:processes)
  t0 = System.monotonic_time(:microsecond)
  pids = for _ <- 1..n, do: spawn_fun.()
  t1 = System.monotonic_time(:microsecond)
  used = :erlang.memory(:processes) - before
  # ...
end

結果は次のようになった。

プロセス数合計メモリ1個あたり生成時間
1,0002.67 MB2,797 B0.548 μs
10,00024.84 MB2,604 B0.891 μs
100,000248.92 MB2,610 B0.557 μs
500,0001,256.91 MB2,635 B0.524 μs

1個あたり約 2.6KB で、個数を500倍にしても変わらない。線形である。生成は 0.5〜0.9 マイクロ秒で、これも個数によらない。

10万プロセスで 249MB という数字は、実務の感覚として重要だと思う。「何十万でも作れる」は正しいが、タダではない。1GB のコンテナなら 40 万個あたりが上限になる。

単体のプロセスを覗くと内訳が見える。

項目
memory2,616 B
heap_size233 words
stack_size1
total_heap_size233 words

初期ヒープが 233 ワードある。64bit なので約 1.9KB で、これが 2.6KB の大半を占めている。

3. 状態を持たせるとどうなるか

プロセスに状態を持たせて、メモリの増え方を見た。クロージャでリストを抱えさせている。

状態のリスト長1個あたり
02,239 B
102,273 B
1002,271 B
1,00021,019 B

100要素までは増えない。初期ヒープに収まっているからである。1,000要素で 10 倍近く跳ねる。

ここに設計上の含意がある。小さい状態なら、プロセスを増やすコストは実質ゼロで済む。 「1ユーザー1プロセス」のような設計が成立するのは、状態が初期ヒープに収まる範囲だからである。逆に状態が数千要素に育つと、プロセス数 × 状態サイズがそのままメモリになる。

4. goroutine と OS スレッドを並べる

ここが本稿の主眼である。同じことを Go と Python でやった。

goroutineHeapAllocStackInuse の増分の合計)

個数合計1個あたり生成時間
1,0002.87 MB3,009 B0.561 μs
10,00023.67 MB2,482 B0.784 μs
100,000242.80 MB2,545 B0.630 μs
500,0001,201.84 MB2,520 B0.816 μs

OS スレッド(Python、RSS の増分)

本数合計1本あたり生成時間
1003.48 MB36,536 B18.83 μs
50014.11 MB29,589 B27.16 μs
1,00017.62 MB18,481 B28.52 μs
2,00035.33 MB18,522 B38.94 μs

3つを並べるとこうなる。

1個あたり生成時間実質的な上限
BEAM プロセス2,635 B0.52 μs1,048,576(既定値)
goroutine2,520 B0.82 μsメモリ次第
OS スレッド18,522 B38.94 μs12,287 で失敗

BEAM と Go はメモリで 4% 差である。生成時間は同じ桁だが、厳密に比べようとすると数字が安定しない。同じ 50万個の生成を5回繰り返すと、BEAM は 0.5〜0.6 μs で落ち着くのに対し、Go は初回 0.83 μs・2回目以降 0.25 μs になる。ランタイムが一度太ると以後が速い。上の表は各サイズを1回ずつ測った値なので、Go 側は初回の値が出ている。

言えるのは「どちらもサブマイクロ秒の同じ桁」までで、どちらが速いかはウォームアップの扱いで逆転する。 OS スレッドとの差はその曖昧さを超えていて、メモリで約 7 倍、生成時間で 40〜100 倍ある。

上限も測った。Python のスレッドは 12,287 本目で can't start new thread になった。RLIMIT_NPROC が soft 8,000 / hard 12,000 なので、OS の制限に当たっている。BEAM は既定で 1,048,576 プロセスまで作れる(起動オプションで変えられる)。桁が2つ違う。

5. 測ってみて、理解が変わったところ

「BEAM のプロセスは軽い」は正しい。だが「BEAM のプロセスは特別に軽い」は正しくなかった。 メモリは goroutine と 4% しか違わず、生成時間も同じ桁で、測り方によっては goroutine のほうが速い。

考えてみれば当然で、どちらもユーザー空間で小さなスタックを持つ実行単位を管理している。OS に頼らないという設計が同じなら、コストが同じ桁になるのは自然である。

では BEAM と Go の違いはどこにあるのか。メモリと生成時間ではないとすると、残るのは次のような点になる。

  • GC がプロセスごとに独立している。 BEAM は各プロセスが自分のヒープを持ち、GC もプロセス単位で走る。全体を止める停止時間が原理的に発生しない。Go は GC がヒープ全体を対象にする
  • プリエンプティブにスケジュールされる。 BEAM はリダクション数で強制的に切り替える。無限ループを書いても他が止まらない
  • 隔離されている。 1つのプロセスがクラッシュしても、他のプロセスのメモリは壊れない。ヒープを共有していないので構造的に守られる
  • 監視ツリーが標準にある。 落ちたら誰が再起動するかを、言語のレベルで書ける

つまり軽さは前提であって、差別化の要素ではなかった。Elixir を選ぶ理由をプロセスの軽さで説明していたのは、説明としてずれていた。 軽さの上に載っている隔離と監視の話をすべきだった。

これは自分の説明の仕方を変える必要がある発見で、測った価値があった。

6. メッセージ送受信のコスト

ついでに測った。2プロセス間で ping-pong を10万往復させた。

往復回数合計1往復あたり
100,00039.5 ms0.395 μs

1往復 0.4 マイクロ秒である。プロセス生成(0.52 μs)とほぼ同じオーダーで、どちらも実務では無視できる水準にある。GenServer を1つ立てて call するコストを心配する必要は、少なくともこの規模ではない。

7. コンテナに入れるとどうなるか

ここまでは制限のないホストでの測定である。実際に動かすのはコンテナの中なので、CPU とメモリに制限をかけて測り直した。Docker で --cpus-m を振っている。

ここから「コンテナが見ている全体」は 8 CPU である。母艦の macOS は 18 コアだが、Docker Desktop の Linux VM に 8 割り当てているためで、コンテナの中の nproc は常に 8 を返す。

7.1 CPU 制限とスケジューラ数

まず BEAM が制限を認識するかを見た。

--cpusnproc/proc/cpuinfoBEAM の schedulers_online
1881
2882
4884
8888

BEAM は正しく認識している。nproc/proc/cpuinfo も 8 のままなのに、スケジューラ数だけが cgroup の割り当てと一致する。logical_processors は 8 のままなので、論理コア数とスケジューラ数を区別したうえで後者を絞っていることになる。

Go でも同じことを測った。

--cpusGo 1.25 の GOMAXPROCSGo 1.24 の GOMAXPROCS
128
228
448
888

Go 1.24 は制限を完全に無視していた。--cpus=2 でも GOMAXPROCS=8 になる。Go 1.25 でコンテナを見るようになったが、下限が 2 に設定されており、--cpus=1 でも 2 になる。

Kubernetes で CPU リクエストを 1 未満にしている構成は珍しくない。Go 1.24 以前を使っているなら、GOMAXPROCS を明示的に設定しない限り、割り当ての 8 倍のスレッドでスケジュールしようとしていたことになる。

7.2 メモリ制限は認識しない

CPU がうまくいっていたので、メモリも同じだろうと考えた。違った。

-m 512m --memory-swap=512m(スワップも無効)でプロセスを増やし続けた結果である。

プロセス数BEAM の総メモリ
10,00095.3 MB
50,000195.9 MB
100,000321.3 MB
150,000446.9 MB
200,000Killed

終了コードは 137(128 + SIGKILL)だった。カーネルの OOM killer に殺されている。BEAM 側に例外は上がらず、ログも残らず、プロセスが消える。

cgroup の memory.max はコンテナの中から読める(536870912 と出る)。読めるのに使っていない。BEAM は上限を知らないまま確保し続け、殺されるまで走る。

CPU 制限は認識するのにメモリ制限は認識しない。 この非対称は、事前に知っていないと事故になる。落ちたときに残るのは終了コード 137 だけで、原因の手がかりがアプリ側に何も残らない。

ついでに分かったこととして、-m 512m だけを指定すると同量のスワップが付く。同じ実験でスワップを許すと、Killed になるのは 350,000 と 400,000 の間まで伸びた(BEAM の報告値で 949.3 MB)。メモリ上限を検証するときは --memory-swap も揃えないと、倍の値を測ることになる。

7.3 1プロセスあたりのコストは変わるか

制限をかけても、プロセス1個の値段は変わらなかった。

条件10万プロセスでの1個あたり50万での生成時間
ホスト(18 CPU)2,610 B0.51 μs
--cpus=8 -m 2g2,620 B0.71 μs
--cpus=1 -m 2g2,643 B0.90 μs

メモリは 1.3% 以内に収まっている。プロセスの構造そのものは環境に依存しない。

生成時間のほうは、1回だけ測ると順序が入れ替わることがある。5回繰り返して初めて安定した。1 CPU が 0.90 μs、8 CPU が 0.71 μs で、1 CPU は 1.3 倍遅い。ホストの 0.51 μs との差にはコンテナ化そのもののコストが混ざっており、Docker Desktop の仮想化層と切り分けられていない。

ただし CPU 数に対して単調ではない。--cpus=4 を挟むと 0.62 μs で、8 CPU より速かった。生成の主体はループを回す1本のプロセスなので、スケジューラを増やした分の調整コストが上回る領域があるらしい。ここは踏み込んでいない。

7.4 上限を自分で置く

BEAM が cgroup の上限を見ないなら、プロセスの側に上限を置けばよい。max_heap_size はプロセス単位のヒープ上限で、超えたプロセスを殺す。

Process.flag(:max_heap_size, %{size: 25_000_000, kill: true, error_logger: true})

size はワード単位である。64bit なので 25,000,000 ワードで 200MB になる。バイト数のつもりで書くと3桁ずれる。

同じ 512MB のコンテナで、メモリを食い続けるプロセスを1本走らせて比べた。

上限積むもの終了コード死んだもの
なしヒープ137VM ごと(OOM killer)
200MBヒープ0そのプロセスだけ
200MB4MB のバイナリ137VM ごと(OOM killer)
200MB + include_shared_binaries4MB のバイナリ0そのプロセスだけ

上2行は狙いどおりである。上限なしでは 137 で消えて何も残らないが、max_heap_size を置くとプロセスが :killed で終わり、理由の書かれたレポートが出る。

Context: maximum heap size reached

VM は total=55 MB に戻って走り続けた。そのプロセスが抱えていたものは、プロセスごと消える。

3行目が問題である。 上限はそのままで、積むものを 4MB のバイナリに変えると、また 137 で死んだ。

回数VM の総メモリそのプロセスのヒープ
059 MB0 MB
25154 MB0 MB
50250 MB0 MB
75345 MB0 MB
100440 MB0 MB

VM が 440MB まで伸びているのに、プロセスのヒープは 0MB のままである。64バイトを超えるバイナリは参照カウント方式で確保され、プロセスヒープの外に置かれる。ヒープに乗るのは参照だけなので、ヒープを見る max_heap_size は素通りする。

Web アプリで大きいものは、たいていこちら側にある。アップロードされたファイル、外部 API のレスポンス、組み立て中の JSON。素朴に max_heap_size を置いただけでは、いちばん危ない経路が守られていない。

これを埋めるのが include_shared_binaries: true である。4行目がそれで、同じバイナリの積み上げが 230MB あたりで止まり、プロセスだけが死んだ。OTP 26 でも設定は受理されるので、いつ入ったオプションなのかは調べていない。ここで確かめたのは OTP 28 での挙動だけである。

7.5 監視ツリーに任せられるか

「1プロセスだけ死ぬ」に意味があるのは、監視ツリーが拾って再起動してくれるからである。暴走するワーカーと、0.05秒ごとに生存を報告するだけのプロセスを、同じ監視ツリーの下に並べた。

[heartbeat] 0 回目 / VM total=54 MB
[hog] 起動 pid=#PID<0.106.0>
[hog] 起動 pid=#PID<0.108.0>
[heartbeat] 1 回目 / VM total=86 MB
[hog] 起動 pid=#PID<0.109.0>
[heartbeat] 2 回目 / VM total=96 MB
[hog] 起動 pid=#PID<0.110.0>
[heartbeat] 3 回目 / VM total=121 MB
** (EXIT from #PID<0.95.0>) shutdown

ワーカーは殺されては再起動を繰り返し、その間ずっと heartbeat は動き続けている。VM の総メモリは 121MB までしか伸びず、512MB には遠い。暴走した1本が、隣のプロセスを巻き込んでいない。

ただし最後は落ちる。ワーカーが 0.1 秒で上限に達するので、max_restarts: 3, max_seconds: 60 の再起動枠を 0.4 秒で使い切り、Supervisor が VM を終了させた。原因が続いているなら、結局 VM は止まる。

止まること自体は変わらない。変わるのは止まり方である。終了コードが 137 から 1 になり、shutdown という理由と、どのプロセスが何回死んだかのログが残る。OOM killer に消される場合、アプリ側には手がかりが1つも残らない。

7.6 実務としてどうするか

測った範囲で言えることは4つある。

  • メモリ上限は自分で見張る必要がある。 BEAM は教えてくれない。プロセス数か :erlang.memory(:total) を監視して、cgroup の memory.max と突き合わせる仕組みを自分で置く
  • max_heap_size には include_shared_binaries を付ける。 付けないと、バイナリを抱えて太るプロセスを素通りさせる。実務で太るのはたいていこの形である
  • CPU は BEAM に任せてよい。 OTP 28 は cgroup を正しく読む。逆に +S で手動指定すると、制限との整合を自分で保つ責任が生まれる
  • Go を同居させるなら 1.25 以上にするか GOMAXPROCS を明示する。 1.24 以前は制限を見ない

8. この測定の限界

数字を額面どおりに受け取らないための注意を書いておく。

OS スレッドの測定は Python 経由である。 純粋な pthread ではなく、CPython の Thread オブジェクトの分が乗っている。生の pthread ならもっと小さい。また RSS で測っているので、macOS が予約する 512KB の仮想スタックは含まれていない。実際に触ったページだけが数字に出る。「18KB」は下限に近い値だと考えたほうがよい。

BEAM と Go で測定の意味が違う。 BEAM は :erlang.memory(:processes) というランタイムが持つ会計値、Go は HeapAllocStackInuse の合計である。同じものを測っているとは言い切れない。4% の差に意味を持たせるべきではなく、同じ桁だという点だけを読むべきである。

環境依存である。 macOS、18スケジューラ、ARM64 での値である。Linux やコンテナ内では変わる。特に OS スレッドの上限は ulimit の設定に直接依存する。

9. 再現方法

コードは GitHub に置いてある。

https://github.com/makoto-developer/blog-examples/tree/main/beam-lightweight-process

ホストでの測定。

elixir --erl "+P 2000000" proc.exs   # BEAM(第2・3・6節)
go run goroutine.go                  # goroutine(第4節)
python3 threads.py                   # OSスレッド(第4節)
python3 limit.py                     # スレッド数の上限(第4節)

BEAM で50万プロセスを作るには +P でプロセス上限を上げる必要がある。既定の 1,048,576 でも足りるが、明示しておいた。

生成時間だけを繰り返し測るものも入れてある。1回では順序が入れ替わるので、比べるときはこちらを使う。

elixir --erl "+P 2000000" gen.exs    # 50万プロセスの生成を5回
go run gen.go                        # 50万 goroutine の生成を5回

コンテナでの測定(第7節)。

# CPU 制限とスケジューラ数
docker run --rm --cpus=2 -v "$PWD":/w -w /w elixir:1.19-otp-28-slim \
  sh -c 'nproc; elixir sched.exs'
docker run --rm --cpus=2 -v "$PWD":/w -w /w golang:1.25-alpine go run sched.go
docker run --rm --cpus=2 -v "$PWD":/w -w /w golang:1.24-alpine go run sched.go

# メモリ制限で殺されるところまで。--memory-swap を揃えないと倍測ることになる
docker run --rm -m 512m --memory-swap=512m -v "$PWD":/w -w /w \
  elixir:1.19-otp-28-slim sh -c 'elixir --erl "+P 2000000" oom.exs; echo "exit=$?"'

# スワップを許した場合(引数 swap で刻みを伸ばす)
docker run --rm -m 512m -v "$PWD":/w -w /w \
  elixir:1.19-otp-28-slim sh -c 'elixir --erl "+P 2000000" oom.exs swap; echo "exit=$?"'

# プロセス側に上限を置く(第7.4節)。none / heap / binary / shared の4通り
docker run --rm -m 512m --memory-swap=512m -v "$PWD":/w -w /w \
  elixir:1.19-otp-28-slim sh -c 'elixir grow.exs none; echo "exit=$?"'

# 監視ツリーの下で暴走させる(第7.5節)
docker run --rm -m 512m --memory-swap=512m -v "$PWD":/w -w /w \
  elixir:1.19-otp-28-slim sh -c 'elixir supervised.exs; echo "exit=$?"'

本文の表はこの出力をそのまま貼っている。ただし時間の数字は環境で変わる。
環境によらないのは、1プロセスあたりのバイト数、BEAM が CPU 制限だけを認識すること、
max_heap_size が参照カウント方式のバイナリを素通りすることの3つである。

10. 次にやること

GC の停止時間を測りたい。本稿の主張の中心である「プロセスごとに独立した GC」が、実際に停止時間として現れないことを確かめれば、第5節の話が数字で裏づけられる。goroutine との差が出るとしたらここのはずである。

include_shared_binaries の値段を測っていない。参照カウント方式のバイナリまで数えるということは、GC のたびに何かを余計に歩いているはずである。バイナリを大量にやりとりするプロセスに常時付けてよいものなのか、スループットで比べたい。

スケジューラの振る舞いも残っている。50万プロセスが全部動き始めたとき、割り当てられた CPU 数でどう捌かれるのか。第7節で --cpus=1 の生成時間が 1.3 倍になったので、実行時にはもっと差が出るはずである。

参考文献

  • Erlang/OTP ドキュメント, Erlang Efficiency Guide: Processes:初期ヒープサイズと +P オプションの説明。
  • J. Armstrong, Making reliable distributed systems in the presence of software errors, PhD thesis, KTH (2003):プロセスの隔離が信頼性の基礎であるという主張の原典。
  • Go ランタイムのスケジューラ設計ドキュメント(runtime/proc.go の冒頭コメント):goroutine のスタック管理。