はじめに
nerves_runtime の CHANGELOG にこんな一文があるのを見かけました。
Defer Nerves KV loading until actually needed.
(KV の読み込みを、本当に必要になるまで遅らせる)
実装を追ってみると、GenServer の初期化時に I/O を避け、必要になったタイミングで一度だけロードする仕組みが、シンプルなフラグ管理で実現されていました。
この実装で使われている発想を読み解きつつ、自分のコードでも使えるように要点を整理してみます。
※ 写真はイメージです
何を遅延させているのか
Nerves.Runtime.KV は、ファームウェアのメタデータ(nerves_fw_active など)を読み書きするモジュールです。
変更前の実装では、GenServer の init/1 内で次のように KV のロード処理が即座に走る構造になっていました。
この構造だと、起動時に I/O(フラッシュ読み込みなど)が走り、そこで例外が起きるとアプリケーション初期化全体へ影響する可能性があります。
変更後は、init/1 ではロードを行わず、空の状態に
invalid?: true を付けて返すだけになりました。
ここで invalid?: true というフラグがあたらしく導入されていますが、これは「まだ KV をロードしていないので、次のアクセス時に読み込みが必要」という意味のようです。
実際のロード処理は refresh/1 に集約され、
最初のアクセス時にだけバックエンドの KV を読み込む仕組みになっています。
遅延ロードの仕組み
KV のロード処理は refresh/1 に集約されています。
refresh/1 の役割はシンプルで、
-
invalid?: false→ そのまま返す(キャッシュ利用) -
invalid?: true→ バックエンドから KV をロードし直す
という動きをします。ロードに成功すると invalid? を false に戻し、以降はキャッシュをそのまま使うようになります。
すべての get/put などの操作は最初に refresh(s) を実行するため、KV がまだ読み込まれていない状態でも、自動的に最新データへ同期されます。
さらに、ロードに失敗した場合は InMemory バックエンドへ切り替える処理も用意されており、初期化時のエラーでアプリケーション全体が落ちるのを避けるよう工夫されています。
論よりRun: 遅延読み込み GenServer
同じ発想を自分のアプリで使えるように、なにかやってみましょう。
やりたいことは次の 3 つだけです。
- 起動時には何もロードしない
- 最初の
get/put時にだけ loader(遅い処理)を呼ぶ -
reload/0を呼ぶと、次回アクセス時に再ロードさせる
defmodule SampleApp.LazyCache do
use GenServer
@moduledoc """
loader 関数を使って遅延読み込みするシンプルなキャッシュの例。
"""
## Public API
def start_link(opts) do
loader = Keyword.fetch!(opts, :loader)
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, loader, name: name)
end
def get(server \\ __MODULE__, key, default \\ nil) do
GenServer.call(server, {:get, key, default})
end
def put(server \\ __MODULE__, key, value) do
GenServer.call(server, {:put, key, value})
end
def reload(server \\ __MODULE__) do
GenServer.call(server, :reload)
end
## GenServer callbacks
@impl GenServer
def init(loader) do
state = %{
loader: loader,
data: %{},
invalid?: true
}
{:ok, state}
end
@impl GenServer
def handle_call({:get, key, default}, _from, state) do
state = refresh(state)
{:reply, Map.get(state.data, key, default), state}
end
def handle_call({:put, key, value}, _from, state) do
state = refresh(state)
data = Map.put(state.data, key, value)
{:reply, :ok, %{state | data: data}}
end
def handle_call(:reload, _from, state) do
{:reply, :ok, %{state | invalid?: true}}
end
## Internal helpers
defp refresh(%{invalid?: false} = state), do: state
defp refresh(%{loader: loader} = state) do
case loader.() do
{:ok, data} ->
%{state | data: data, invalid?: false}
{:error, _reason} ->
state
end
end
end
iex から動きを確かめると、どこでロードされるかが分かりやすいです。
loader = fn ->
IO.puts("Loading from slow source...")
Process.sleep(1000)
{:ok, %{"foo" => 1, "bar" => 2}}
end
{:ok, _} = SampleApp.LazyCache.start_link(loader: loader)
SampleApp.LazyCache.get("foo")
#=> 最初の get でだけ "Loading from slow source..." と表示される
SampleApp.LazyCache.get("foo")
#=> 2 回目以降はキャッシュから返る
SampleApp.LazyCache.reload()
SampleApp.LazyCache.get("foo")
#=> 再びロードが走る
このサンプルではただ Map ロードしているだけですが、loader を設定ファイル読込や外部 API などに差し替えれば、そのまま「遅延読み込み+キャッシュ+手動で無効化」の実装として使えます。
おわりに
Nerves.Runtime.KV の実装を追ってみると、起動時に I/O を避けつつ、必要なタイミングでだけデータを読み込むという仕組みが、とてもシンプルな構成で成り立っていることが分かりました。
設定ローダーや外部サービスとの連携など、「起動時に重い処理を走らせたくない場面」で、そのまま活用できそうです。
![]()
![]()
![]()
