(この記事は「fukuoka.ex x ザキ研 Advent Calendar 2017」の11日目です)
昨日は @zumin さんの「Elixirで一千万行のJSONデータで遊んでみた #2」でした。
はじめに
「ZEAM開発ログ v.0.2.0 Node.js と同じ原理の軽量コールバックスレッドを Elixir に実装してみた (背景編)」では次のようにまとめました。
- マルチタスクを実現する方式が進化し続けています。
- Unix ではマルチプロセス方式により,メモリ管理と一体となった形でコンテキストスイッチをしていました。
- ウェブブラウザの登場とともに マルチスレッド方式が発案され,メモリ管理情報を切り替えずにコンテキストスイッチすることで効率化するようになりました。
- Node.js では,コールバック方式により,スタックメモリを確保せずに接続要求を処理する方式が発案されました。
- 私たちは Elixir に軽量コールバックスレッドを実装し,メモリ消費を抑えて Phoenix の同時セッション最大数とレイテンシを格段に改善する方式を提案します。
「ZEAM開発ログ v.0.2.1 Node.js と同じ原理の軽量コールバックスレッドを Elixir に実装してみた (実装編)」では次のようにまとめました。
- 軽量コールバックスレッドを実装しました。コードは https://github.com/zeam-vm/zeam_callback に公開しています。
- 現状では軽量コールバックスレッドの起動
:spawn
を実装しています。 - 近い将来,軽量コールバックスレッド同士のメッセージ通信を実装する予定です。
- 軽量コールバックスレッドの実装にあたり,
Receptor
とWorker
という2つのスレッドを役割分担させました。
今回はメモリ消費量について評価してみたいと思います。
評価コード
こんな感じのコードを書きました。
defmodule ZeamEvaluation do
def diff([], _kw), do: []
def diff([kw1_tuple | kw1_tail], kw2) do
kw_key = elem(kw1_tuple, 0)
kw1_value = elem(kw1_tuple, 1)
kw2_value = kw2[kw_key]
[{kw_key, kw2_value - kw1_value}] ++ diff(kw1_tail, kw2)
end
def pr_init do
0
end
def pr_call(_pid, number) when number <= 0, do: []
def pr_call(pid, number) when number > 0 do
spawn(fn -> Process.sleep(10000) end)
[ number | pr_call(pid, number - 1)]
end
def cb_init do
ZeamCallback.Receptor.new
end
def cb_call(_pid, number) when number <= 0, do: []
def cb_call(pid, number) when number > 0 do
send(pid, {:spawn, fn(_tid) -> Process.sleep(1000) end})
[ number | cb_call(pid, number - 1)]
end
def memory_benchmark(func_init, func_call, number) do
before_memory = :erlang.memory
func_call.(func_init.(), number)
after_memory = :erlang.memory
IO.inspect diff(before_memory, after_memory)[:total]
end
def all_benchmarks do
[
{&cb_init/0, &cb_call/2, 100, "callback"},
{&cb_init/0, &cb_call/2, 1000, "callback"},
{&cb_init/0, &cb_call/2, 2000, "callback"},
{&cb_init/0, &cb_call/2, 5000, "callback"},
{&cb_init/0, &cb_call/2, 10000, "callback"},
{&pr_init/0, &pr_call/2, 100, "process"},
{&pr_init/0, &pr_call/2, 1000, "process"},
{&pr_init/0, &pr_call/2, 2000, "process"},
{&pr_init/0, &pr_call/2, 5000, "process"},
{&pr_init/0, &pr_call/2, 10000, "process"},
]
|> Enum.map(fn (x) ->
IO.puts "#{elem(x, 3)}: #{elem(x, 2)}"
memory_benchmark(elem(x, 0), elem(x, 1), elem(x, 2))
end)
end
end
cb_init
,pr_init
は初期化のコードです。これらを呼び出して得られた値を cb_call
や pr_call
の第1引数に渡します。
cb_call
と pr_call
は第2引数で与えられた number
の回数分,プロセスや軽量コールバックスレッドを生成します。
評価結果
公平にするために,一度に条件を1つだけ実行するようにしました。
プロセスを用いた場合と軽量コールバックスレッドを用いた場合のメモリ消費量は次の図のようになりました。横軸がプロセス/スレッド数,縦軸がメモリ消費量(バイト)です。
プロセスを用いた場合は1プロセスあたり約2.8KBと,思ったよりプロセスを用いた場合のメモリ消費量が多くないことに驚きました。「Elixir試飲録 (7) – Erlangの軽量プロセスはどのように実現されているのか?」によると,Erlang でプロセスを生成する時には309ワード=1236バイトだけ消費し,そのうち初期ヒープとして233ワード=932バイトを消費するとのことです。このようにヒープの初期サイズを小さくしている理由は「Erlangのシステムが何十万,何百万というプロセス数をサポートをするために,極めて保守的に設定されているから」だそうです。実際の測定結果2.8KBはそれよりは多いですが,たとえば Apache が1リクエストあたり数十MB消費することを考えると驚異的なメモリ消費量の少なさです。
軽量コールバックスレッドを用いる場合は1スレッドあたり約1.3KBということでプロセスの場合(2.8KB)の約半分のメモリ消費量になりました。このメモリ消費量はだいたい狙い通りです。軽量コールバックスレッドを用いることで,同時セッション最大数を倍くらいに増やせるんじゃないかと期待が持てます。
なお,現状の実装ではGCとの相性があまり良くないこともわかりました。性能評価のベンチーマークで軽量コールバックスレッドを作るだけ作ってメモリを解放せずに放置しているので,GCを実行してもメモリが回収されないです。
まとめ
- 軽量コールバックスレッドを用いると1スレッドあたり約1.3KBのメモリ消費量でした。
- これに対し,プロセスを用いた場合には1プロセスあたり約2.8KBのメモリ消費量でした。
- 軽量コールバックスレッドを用いたほうが,プロセスを用いるより約半分のメモリ消費量になりました。
- 現状の軽量コールバックスレッドの実装では,メモリを適切に解放していないので,GCを実行してもメモリが回収されません。
軽量コールバックスレッドについてはこれで一区切りがつきました。次回はどうするか未定ですw
明日は @twinbee さんの「Elixirで一千万行のJSONデータで遊んでみた Rustler編」です。お楽しみに。