エムオーテックス株式会社が運営するテックブログです。

ElixirのAtomとメモリ最適化について学んだ話

はじめに

こんにちは、LANSCOPE セキュリティオーディター開発チームの東(あずま)です。

今回は、開発で使用しているプログラミング言語ElixirのAtomとメモリ最適化について学んだ内容をご紹介します。

Elixirの「Atom」とは

Elixirでは「:ok」や「:error」といった形でコロンから始まる「Atom」と呼ばれる不変定数があります。よく使われるケースとしては、下記のような実行結果のパターンマッチングを行う場合に使用します。

{:ok, date} = Date.new(2023, 12, 31) # OK
{:error, reason} = Date.new(2023, 13, 1) # NG

このAtomを見ない日は無いといっても過言ではないくらい頻出します。しかし、自分はパターンマッチングで使用する便利機能という認識のみで、仕様理解が出来ているとは言えない状態でした。

そこで、正しく理解するためにも公式ドキュメントの調査を行いました。

「Atom」の仕様

ElixirはErlangベースのため、Erlangの言語仕様ドキュメントを調査しました。その結果、下記の記載があることに気付きました。

An atom refers into an atom table, which also consumes memory. The atom text is stored once for each unique atom in this table. The atom table is not garbage-collected.

(AtomはAtomテーブルを参照し、テーブル自身もメモリを消費する。Atomテキストは、テーブル内の一意のAtom毎に一度だけ保存される。Atomテーブルは、ガベージ・コレクションされない。)

https://www.erlang.org/doc/efficiency_guide/advanced, 訳は引用者による

「Atomテーブルは、ガベージ・コレクションされない」と記載されています。
ガベージ・コレクションとは、不要になったメモリ領域を自動的に解放してくれる機能です。
この機能が無いということは、「Atomテーブルは永続的にメモリを占有している」ということになります。

「Atom」とメモリ最適化

そこで実際にプログラミング言語が実装されているソースコードを見てみました。 Elixirでは「String.to_atom/1」で文字列からAtomに変換できますが…

## https://github.com/elixir-lang/elixir/blob/v1.16.0/lib/elixir/lib/string.ex#L2736
def to_atom(string) when is_binary(string) do
  :erlang.binary_to_atom(string, :utf8)
end

Erlangの「binary_to_atom」を呼び出すため、既に存在するAtomでもmake_atom(uix)が呼ばれてしまい、新たに作成されてしまいます。

## https://github.com/erlang/otp/blob/OTP-26.2.1/erts/emulator/beam/erl_unicode.c#L2056
BIF_RETTYPE binary_to_atom_2(BIF_ALIST_2)
{
    return binary_to_atom(BIF_P, BIF_ARG_1, BIF_ARG_2, 0);
}

## 第四引数のmust_exist0(false)のため、既に存在するAtomでもmake_atom(uix)が呼ばれてしまう
## https://github.com/erlang/otp/blob/OTP-26.2.1/erts/emulator/beam/erl_unicode.c#L1998
static BIF_RETTYPE
binary_to_atom(Process* proc, Eterm bin, Eterm enc, int must_exist)
{
    byte* bytes;
    byte *temp_alloc = NULL;
    Uint bin_size;
    Eterm a;
....
        if (!must_exist) {
....        
                a = make_atom(uix);
            }
            else if (!erts_atom_get((char*)bytes, bin_size, &a, ERTS_ATOM_ENC_UTF8)) {
                goto badarg;
            }
....

そのため、一度作成したものはガベージ・コレクションされないことを考慮し、既存のものを流用したいです。
そこで、Elixirの「String.to_existing_atom/1」を呼ぶと...

## https://github.com/elixir-lang/elixir/blob/v1.16.0/lib/elixir/lib/string.ex#L2768
def to_existing_atom(string) when is_binary(string) do
  :erlang.binary_to_existing_atom(string, :utf8)
end

Erlangの「binary_to_existing_atom」が呼び出されるため、erts_atom_getによって既に存在するものが返されます。
これにより、新たにAtomが作成されることを防げます。

## https://github.com/erlang/otp/blob/OTP-26.2.1/erts/emulator/beam/erl_unicode.c#L2061

BIF_RETTYPE binary_to_existing_atom_2(BIF_ALIST_2)
{
    return binary_to_atom(BIF_P, BIF_ARG_1, BIF_ARG_2, 1);
}

## 第四引数のmust_exist1(true)のため、erts_atom_getが呼ばれる
## https://github.com/erlang/otp/blob/OTP-26.2.1/erts/emulator/beam/erl_unicode.c#L1998
static BIF_RETTYPE
binary_to_atom(Process* proc, Eterm bin, Eterm enc, int must_exist)
{
    byte* bytes;
    byte *temp_alloc = NULL;
    Uint bin_size;
    Eterm a;
....
    if (!must_exist) {
....
        a = make_atom(uix);
    }
    else if (!erts_atom_get((char*)bytes, bin_size, &a, ERTS_ATOM_ENC_UTF8)) {
        goto badarg;
    }
....

事前にAtomが定義されていないとエラーになるため注意が必要ですが、使いまわせる場合は使用することで最適化できると考えられます。

おわりに

今回はElixirのAtomについて、プログラミング言語の実装部分まで掘り下げていきました。 この経験を経て、公式ドキュメントやプログラミング言語を実装しているC言語ソースコードを読むことで、理解を深めることができました。

普段から使用している関数であっても、改めて調べた上で工夫を行うことで、効率的な実装に繋がると思います。

ここまでお読みいただき、誠にありがとうございます。 本内容がお役に立てれば幸いです。

参考文献