今回、記念すべき第30回はついに Elixir プロセスのホットスワップをします。
Elixir や Erlang/OTP の特徴を述べてる文書を読むとかなりの確率でホットスワップ機能が挙げられています。でも実際に使ってるという話を聞いたことがほとんどありません。唯一聞いたことがあるのは Erlang & Elixir Fest 2019 での任天堂の渡邉大洋さんの話1だけです。みなさん、文書書くときには写経してしまってて、いずれ使うだろうぐらいに思ってるところが実のところ全然使ってないんじゃないんでしょうかね。
ならばということで試しにやってみたら、チョー簡単な例はさっくり出来たのでご報告です。
超簡単な例でプロセスのスワップをする
まずはとにかくホットスワップ出来ることを目指します。次のプログラム hotswap1.ex
は GenServer で書いたカウンタです。Hotswap.start_link/1
関数を呼ぶとプロセスが生成され、Hotswap.next/1
関数を呼ぶたびに hello と言いながら 0 からの整数が印字されて行きます。引数はどちらもプロセスの名前です。ごく簡単な GenServer の例ですが、いつもとちょっとだけ違うのが @vsn "1"
があることです。これはモジュールのバージョンが "1"
であることを示していて、あとでスワップするときに使います。
defmodule Hotswap do
@behaviour GenServer
@vsn "1"
def start_link(pname) do
GenServer.start_link(__MODULE__, nil, name: pname)
end
def next(pname) do
GenServer.cast(pname, :next)
end
@impl GenServer
def init(_void) do
{:ok, 0}
end
@impl GenServer
def handle_cast(:next, n) do
IO.puts("hello, #{n}")
{:noreply, n+1}
end
end
iex してコンパイルして実行したのが以下です。
$ iex
Erlang/OTP 22 [erts-10.6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.10.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c "lib/hotswap1.ex"
[Hotswap]
iex(2)> Hotswap.start_link(:afo)
{:ok, #PID<0.117.0>}
iex(3)> Hotswap.next(:afo)
hello, 0
:ok
iex(4)> Hotswap.next(:afo)
hello, 1
:ok
iex(5)> Hotswap.next(:afo)
hello, 2
:ok
ここまではスワップの話にまで踏み込んでない単なる GenServer の動作の話です。
新しく入れ替えるためのモジュールを作る
上で動かしたプロセスを入れ替えてみます。先程のモジュールをちょっとだけ書き換えます。ファイル名は hotswap2.ex
にします。まず、@vsn "2"
にしてください。これさっきの "1"
と違う文字列であることに意味があって、値自体には意味を持ちません。なのでお好きな文字列で構いません。
このほか、Hotswap.next/1
関数を呼ぶと hello
と言ってたのを world
と言うように Hotswap.handle_cast/2
関数を書き換えます。そしてこれまでの GenServer では全く作ってこなかったコールバック関数 code_change/3
を作ります。これは、プロセスが今まで持ち回ってる状態の型を、今後の新しいプロセスが持ち回る状態の型に変換し、現状の状態の値をその型に合わせて変換する関数です。
defmodule Hotswap do
@behaviour GenServer
@vsn "2" # 別のバージョン番号を指定する
def start_link(pname) do
GenServer.start_link(__MODULE__, nil, name: pname)
end
def next(pname) do
GenServer.cast(pname, :next)
end
@impl GenServer
def init(_void) do
{:ok, 0}
end
@impl GenServer
def handle_cast(:next, n) do
IO.puts("world, #{n}") # "hello" の代わりに "world" にしておく
{:noreply, n+1}
end
@impl GenServer
def code_change("1", n, _void) do # これが入れ替え用に必要な新しい関数
{:ok, n*100}
end
end
ここで Hotswap.code_change/3
関数の3つの引数は以下の意味を持ちます。
- 入れ替えるプロセスのバージョン: @vsn で指定した文字列
- 現状でプロセスが持ち回ってる「状態」:
init
handle_cast
handle_call
の返すタプルの第2要素に来るやつ - ホットスワップのタイミング
:sys.change_code/4
からもらう付加的な値:より複雑な動作をさせる必要があるときに用いる(今回は使わない)
今回はとことん単純にするために型は同じ整数型のままで値を100倍するだけにします。
同じ型の状態を持つプロセス同士でホットスワップを行う
実際にプロセスを入れ替えてみます。@vsn "1"
の Hotswap モジュールが稼働している時に、これを入れ替えるには3つの Erlang の関数を用いて以下の操作をします。
- 新しいモジュールをコンパイルする
-
:sys.suspend/1
関数で現状のプロセスを停止する(終了はしない) -
:sys.change_code/4
関数でプロセスを入れ替える(引数は以下)- プロセスID か プロセス名
- 入れ替えるモジュール名
- 入れ替えるバージョンの文字列
-
Hotswap.code_change/3
の第3引数に渡す値(今回は使わない)
-
:sys.resume/1
関数でプロセスを再開する
$ iex
Erlang/OTP 22 [erts-10.6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.10.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c "lib/hotswap1.ex" # 元の版のモジュールをコンパイルする
[Hotswap]
iex(2)> Hotswap.start_link(:afo) # プロセスを起動する (@vsn "1")
{:ok, #PID<0.117.0>}
iex(3)> Hotswap.next(:afo)
hello, 0
:ok
iex(4)> Hotswap.next(:afo)
hello, 1
:ok
iex(5)> Hotswap.next(:afo)
hello, 2
:ok
iex(6)> c "lib/hotswap2.ex" # 新しい版のモジュールをコンパイルする
warning: redefining module Hotswap (current version defined in memory)
lib/hotswap2.ex:1
[Hotswap]
iex(7)> :sys.suspend(:afo) # プロセスを停止する
:ok
iex(8)> :sys.change_code(:afo, Hotswap, "1", nil) # プロセスを入れ替える
:ok
iex(9)> :sys.resume(:afo) # プロセスを再開する
:ok
iex(10)> Hotswap.next(:afo)
world, 300 # 入れ替わっているようだ
:ok
iex(11)> Hotswap.next(:afo)
world, 301
:ok
iex(12)> Hotswap.next(:afo)
world, 302
:ok
上で :sys.resume/1
の直後の Hotswap.next/1
の結果の文字列が hello でなく world に、数値が入れ替わる前のままだったら 3 になるはずが 300 になって、それぞれ出力されているのがわかります。
複数のプロセスのホットスワップをしてみる
プロセスが1つの場合にうまく交換できることがわかりました。同じモジュールでプロセスが複数動いている場合はどうなるのでしょうか。プロセス名 :afo と :bar を動かしてそれぞれ入れ替えてみます。
iex
Erlang/OTP 22 [erts-10.6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.10.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c "lib/hotswap1.ex"
[Hotswap]
iex(2)> Hotswap.start_link(:afo) # 1つ目のプロセスを動かす
{:ok, #PID<0.117.0>}
iex(3)> Hotswap.start_link(:bar) # 2つ目のプロセスを動かす
{:ok, #PID<0.119.0>}
iex(4)> Hotswap.next(:afo) # 1つ目のプロセスの動作チェック
hello, 0
:ok
iex(5)> Hotswap.next(:afo) # 1つ目のプロセスの動作チェック
hello, 1
:ok
iex(6)> Hotswap.next(:bar) # 2つ目のプロセスの動作チェック
hello, 0
:ok
iex(7)> Hotswap.next(:bar) # 2つ目のプロセスの動作チェック
hello, 1
:ok
iex(8)> :sys.suspend(:afo) # 1つ目のプロセスを停止
:ok
iex(9)> :sys.change_code(:afo, Hotswap, "1", nil) # 新しいのをコンパイルしわすれてプロセスを入れ替えようとして失敗する…
{:error,
{:EXIT,
{:undef,
[
{Hotswap, :code_change, ["1", 2, nil], []},
{:gen_server, :system_code_change, 4, [file: 'gen_server.erl', line: 794]},
{:sys, :do_change_code, 5, [file: 'sys.erl', line: 603]},
{:sys, :do_cmd, 6, [file: 'sys.erl', line: 495]},
{:sys, :handle_system_msg, 8, [file: 'sys.erl', line: 385]},
{:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 249]}
]}}}
iex(10)> c "lib/hotswap2.ex" # 気を取り直して、新しい版をコンパイルする
warning: redefining module Hotswap (current version defined in memory)
lib/hotswap2.ex:1
[Hotswap]
iex(11)> :sys.change_code(:afo, Hotswap, "1", nil) # もう一度、1つ目のプロセスを入れ替えてみる
:ok
iex(12)> :sys.resume(:afo) # 1つ目のプロセスを再開
:ok
iex(13)> Hotswap.next(:afo) # 1つ目のプロセスの動作チェック
world, 200 # 1つ目のプロセスは入れ替わっている
:ok
iex(14)> Hotswap.next(:afo)
world, 201
:ok
iex(15)> Hotswap.next(:bar) # 2つ目のプロセスの動作チェック
world, 2 # 2つ目のプロセスは入れ替わっておらず、前の動作を継続する
:ok
iex(16)> Hotswap.next(:bar)
world, 3
:ok
iex(17)> :sys.suspend(:afo) # 2つ目のプロセスも停止してみようとして間違えて1つ目を止める
:ok
iex(18)> :sys.resume(:afo) # 慌てず騒がず、そのまま再開できる
:ok
iex(19)> :sys.suspend(:bar) # 2つ目のプロセスを停止する
:ok
iex(20)> :sys.change_code(:bar, HotSwap, "1", nil) # 2つめのプロセスを入れ替える
:ok
iex(21)> :sys.resume(:bar) # 2つ目のプロセスを再開
:ok
iex(22)> Hotswap.next(:afo) # 念のため、まずは1つ目のプロセスの動作チェック
world, 202 # 1つ目のプロセスは入れ替わったまま動いている
:ok
iex(23)> Hotswap.next(:afo)
world, 203
:ok
iex(24)> Hotswap.next(:bar) # 2つ目のプロセスの動作チェック
world, 400 # 2つ目のプロセスも入れ替わっている
:ok
iex(25)> Hotswap.next(:bar)
world, 401
:ok
iex(26)> Hotswap.next(:bar)
world, 402
:ok
と、このように、プロセス単位で入れ替えが出来ることがわかりました。
なお途中、コンパイルしないでスワップしようとしたり、違うプロセスをうっかり停止したりしていますが、全体の動作には影響ないです。
プロセスを止めずに入れ替えることができるか
さて、プロセスを一旦止めてから入れ替えるのを「ホット」スワップと言うのかちょっと気になります。活きているとは言え一旦止まるなら「ウォーム」スワップと言うべきな気がします。
この例では状態の表現が簡単であり、入れ替えの前後で状態の型が変わらないのでひょっとしたら「停止」・「再開」がなくてもプロセスが走っている状態のままでホットなままで入れ替えられるんじゃないでしょうか。以下で :sys.suspend/1
しないでスワップしようとするとどうなるか試しました。
$ iex
Erlang/OTP 22 [erts-10.6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.10.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c "lib/hotswap1.ex"
[Hotswap]
iex(2)> Hotswap.start_link(:afo)
{:ok, #PID<0.117.0>}
iex(3)> Hotswap.next(:afo)
hello, 0
:ok
iex(4)> Hotswap.next(:afo)
hello, 1
:ok
iex(5)> Hotswap.next(:afo)
hello, 2
:ok
iex(6)> c "lib/hotswap2.ex" # 新しい版のモジュールをコンパイルする
warning: redefining module Hotswap (current version defined in memory)
lib/hotswap2.ex:1
[Hotswap]
iex(7)> :sys.change_code(:afo, Hotswap, "1", nil) # :sys.suspend/1 せずにいきなり :sys.change_code/4 してみる
{:error, {:unknown_system_msg, {:change_code, Hotswap, "1", nil}}} # エラーする
iex(8)> :sys.suspend(:afo) # セオリーどおりに一旦止めてみると…
:ok
iex(9)> :sys.change_code(:afo, Hotswap, "1", nil) # 入れ替えで :ok が返る
:ok
iex(10)> :sys.resume(:afo) # プロセスを再開して…
:ok
iex(11)> Hotswap.next(:afo) # 動作を確認すると…
world, 300 # ちゃんと入れ替わっている
:ok
iex(12)> Hotswap.next(:afo)
world, 301
:ok
iex(13)>
ということで、一旦はプロセスを止めないと入れ替えができませんでした。
やや複雑なホットスワップをする
ごくごく簡単なプロセスのホットスワップが出来ることがわかりましたので、若干複雑にしてみます。上の例では code_change/3
関数の適用の前後で状態の型は変化しませんでした。ここでは型が変わる例をやってみます。
@vsn "3"
では文字列に hello や world 以外の任意の文字列が印字できるようにします。このために新たに Hotswap.getstr/1
関数を追加します。これでユーザは出力したい文字列を指定します。この文字列を保持しておくために新しいモジュールでは状態を {str, n}
の様に {文字列, 整数} のタプルで保持します。これは元のモジュールで状態を単に n
と言った構造のない整数のみで持ち回っていたのとは異なります。
defmodule Hotswap do
@behaviour GenServer
@vsn "3"
def start_link(pname) do
GenServer.start_link(__MODULE__, nil, name: pname)
end
def next(pname) do
GenServer.cast(pname, :next)
end
def getstr(pname) do # 新しく追加する関数
GenServer.call(pname, :getstr, :infinity) # 第3引数は、入力待ち状態ではタイムアウトしないことを指定する
end
@impl GenServer
def init(_void) do
{:ok, 0}
end
@impl GenServer
def handle_cast(:next, {str, n}) do
IO.puts("#{str}, #{n}")
{:noreply, {str, n+1}}
end
@impl GenServer
def handle_call(:getstr, _from, {old, n}) do # getstr で呼ばれるコールバック関数
new = IO.gets("input string: ") |> String.trim # ユーザに新しい文字列を入力させる
{:reply, "string changed from #{old} to #{new}", {new, n}}
end
@impl GenServer
def code_change("2", n, _void) do # プロセスのバージョンを入れ返るとき、これまで n だった状態を…
{:ok, {"Hello", n}} # このバージョンからはタプルにする。
end # 入れ替えたときは文字列は "Hello" として、整数はそのまま引き継ぐことにする。
end
異なる型の状態を持つプロセスでのホットスワップを行う
版によってプロセスが保持する状態の型が変わる場合のホットスワップを試してみます。
$ iex
Erlang/OTP 22 [erts-10.6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.10.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c "lib/hotswap2.ex" # 古い版のコンパイル
[Hotswap]
iex(2)> Hotswap.start_link(:afo) # プロセスを立ち上げる
{:ok, #PID<0.117.0>}
iex(3)> Hotswap.next(:afo) # プロセスを動かしてみる
world, 0 # 文字列が world であることに注意
:ok
iex(4)> Hotswap.next(:afo)
world, 1
:ok
iex(5)> Hotswap.next(:afo)
world, 2
:ok
iex(6)> c "lib/hotswap3.ex" # 新しい版をコンパイルする
warning: redefining module Hotswap (current version defined in memory)
lib/hotswap3.ex:1
[Hotswap]
iex(7)> :sys.suspend(:afo) # プロセスを停止する
:ok
iex(8)> :sys.change_code(:afo, Hotswap, "2", nil) # プロセスを入れ替える
:ok
iex(9)> :sys.resume(:afo) # プロセスを再開する
:ok
iex(10)> Hotswap.next(:afo) # 新しいプロセスを試してみると…
Hello, 3 # 表示される文字列が代わっている
:ok
iex(11)> Hotswap.next(:afo)
Hello, 4
:ok
iex(12)> Hotswap.next(:afo)
Hello, 5
:ok
iex(13)> Hotswap.getstr(:afo) # 新しい関数で文字列を変更してみる
input string: Hello World !!!
"string changed from Hello to Hello World !!!"
iex(14)> Hotswap.next(:afo) # 試してみると…
Hello World !!!, 6 # 確かに新しい文字列に代わっている
:ok
iex(15)> Hotswap.next(:afo)
Hello World !!!, 7
:ok
iex(16)> Hotswap.next(:afo)
Hello World !!!, 8
:ok
このように GenServer によるプロセスが保持している状態の型が変わったときも、適切に型と値の変換を記述してやることでプロセスの入れ替えできることがわかりました。
バージョンダウンは出来るのか
これで @vsn
的バージョン番号は "1"
"2"
"3"
と上がってきました。これ、動いてるままで元に戻すことは出来るのでしょうか。以下では @vsn "2"
を @vsn "1"
に出来るか試してみます。
一番最初のモジュールの定義ファイル lib/hotswap1.ex
には Hotswap.code_change/3
関数がそもそもありません。ですので @vsn "2"
を指定した Hotswap.code_change/3
を追加します。これ、混乱を避けるために別ファイルにします。ファイル名は lib/hotswap2-1.ex
と「2を1へ」の気分が入った名前にしてます。
defmodule Hotswap do
@behaviour GenServer
@vsn "1"
def start_link(pname) do
GenServer.start_link(__MODULE__, nil, name: pname)
end
def next(pname) do
GenServer.cast(pname, :next)
end
@impl GenServer
def init(_void) do
{:ok, 0}
end
@impl GenServer
def handle_cast(:next, n) do
IO.puts("hello, #{n}")
{:noreply, n+1}
end
@impl GenServer
def code_change("2", n, _void) do # @vsn "2" から戻すための関数を追加する
{:ok, div(n, 100)} # 100倍してたものを(乱暴だが)100で除することにする
end
end
これを使ってバージョンを戻せるかやってみます。
$ iex
Erlang/OTP 22 [erts-10.6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
Interactive Elixir (1.10.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c "lib/hotswap2-1.ex" # 最初に @vsn "1" をコンパイルする
[Hotswap]
iex(2)> Hotswap.start_link(:afo) # プロセスを起動して…
{:ok, #PID<0.117.0>}
iex(3)> Hotswap.next(:afo) # 試してみると、うまく動いているようだ…
hello, 0
:ok
iex(4)> Hotswap.next(:afo)
hello, 1
:ok
iex(5)> c "lib/hotswap2.ex" # @vsn "2" をコンパイルする
warning: redefining module Hotswap (current version defined in memory)
lib/hotswap2.ex:1
[Hotswap]
iex(6)> :sys.suspend(:afo) # プロセス停止
:ok
iex(7)> :sys.change_code(:afo, Hotswap, "1", nil) # @vsn "1" に代えて @vsn "2" で入れ替える
:ok
:ok
iex(8)> :sys.resume(:afo) # プロセス再開
:ok
iex(9)> Hotswap.next(:afo) # 試してみると、うまく入れ替わっている…
world, 200
:ok
iex(10)> Hotswap.next(:afo)
world, 201
:ok
iex(11)> Hotswap.next(:afo)
world, 202
:ok
iex(12)> c "lib/hotswap2-1.ex" # ここで改めて元の @vsn "1" をコンパイル
warning: redefining module Hotswap (current version defined in memory)
lib/hotswap2-1.ex:1
[Hotswap]
iex(13)> :sys.suspend(:afo) # プロセス停止
:ok
iex(14)> :sys.change_code(:afo, Hotswap, "2", nil) # @vsn "2" に代えて元の @vsn "1" で入れ替える
:ok
iex(15)> :sys.resume(:afo) # プロセス再開
:ok
iex(16)> Hotswap.next(:afo) # 試してみると、元のバージョンが動いている…
hello, 2
:ok
iex(17)> Hotswap.next(:afo)
hello, 3
:ok
このように、元のバージョンに戻すことも可能です。ただし「元のバージョンに戻す」とか「バージョンダウンする」とか言うのは本当は正しい表現ではないです。正しくは「バージョン "1" を最初動かして、それをバージョン "2" に入れ替えた。さらにそれを新しいバージョンに入れ替えた。ただし、最新のバージョン名は "1" という名前であり、かつ動作の内容が一番最初のバージョンと全く同じである。」というのが正しいです。
まとめ
できるだけ簡単な例を用いて Elixir プロセスのホットスワップをしてみました。思いの他、簡単でしたのでみなさん怖がらずにやってみてください。そして Elixir や Erlang の特徴として「プロセスがホットスワップ可能であること」を高らかに宣言してください。
参考文献
- Elixir GenServer.code_change
- Erlang :sys.suspend
- Erlang :sys.change_code
- Erlang :sys.resume
- How to perform Hot Code Swapping in Elixir- #1
- はじめてなElixir(0)
- ゆびてく 後知恵のソフトウェア開発論
- 使いたい!ホットコードローディング
-
この話は [任天堂 NPNS における Erlang/OTP] (https://medium.com/@voluntas/npns-における-erlang-otp-548d91cb6deb) としてまとめられています。 ↩