この記事はRust Advent Calendar 2024 シリーズ3の15日目の記事です。
はじめに
みなさん、アプリケーションの初回実行の遅さに悩んでいませんか?「初回の検索が遅い...」「起動に時間がかかる...」「ユーザーから苦情が...」といった問題は、多くの開発者が直面する共通の課題です。
初期化が原因なら、処理を実行するタイミングが改善の手掛かりになります。ただし、起動時の処理を初回アクセスまで遅らせるだけでは、待ち時間が移るだけの場合もあります。今回はRustの遅延初期化を使いつつ、ユーザーが検索する前にバックグラウンドでデータを準備する方法を考えます。
具体例は、郵便番号検索アプリケーション(jposta)です。初回検索の待ち時間を減らすために、どのタイミングで初期化すればよいかを見ていきます。
遅延初期化とは
遅延初期化は、必要になるまで初期化を待つ方法です。起動時に全てのデータを読み込まず、実際に使う時点まで処理を延期します。
利点は、起動時に必要な処理を絞れることです。大きな設定ファイルの読み込み、データベースへの接続、重いライブラリの初期化、キャッシュの構築などを後回しにできます。使われない機能なら、その初期化に必要なCPUやメモリも消費せずに済みます。一方、初めて使うときには初期化の完了を待つ必要があります。
必要なコンポーネントだけを初期化すれば、テストで不要な依存先を起動せずに済む場合もあります。ただし、依存関係や初期化順序の問題が自動的に解決するわけではありません。初期化処理が互いを待つ構造には注意が必要です。
エラーが分かるタイミングも変わります。使わないコンポーネントの初期化エラーを避けられる一方、使う時点まで失敗に気づけないことがあります。起動時に失敗を検出したい処理と、必要になるまで遅らせてよい処理を分けて考えます。
Rustにおける遅延初期化の進化
Rustでは、lazy_staticのマクロを使って遅延初期化する静的な値を定義できます。保持する型にはSyncが必要で、スレッド間で共有できます。注意すべきなのは、複数の値が初期化中に互いへ依存するとデッドロックしうることです。公式ドキュメントにもこの制約が記載されています。
マクロを使わずに初期化を扱う選択肢として、once_cellも使われてきました。
2024年のRust 1.80では、LazyCellとLazyLockが標準ライブラリで安定化されました。LazyLockはスレッド間の同期に対応し、LazyCellは同期を伴わない用途に使います。この記事では、静的なデータを複数スレッドから扱うためLazyLockを使います。
利用するRustのバージョンと、スレッド間で値を共有するかどうかに応じて選べます。
問題の理解:なぜ初期処理が必要か?
まず、jpostcode_rsライブラリの実装を見てみましょう:
use std::sync::LazyLock; static ADDRESS_MAP: LazyLock<HashMap<String, Vec<Address>>> = LazyLock::new(|| { let data = include_str!(concat!(env!("OUT_DIR"), "/address_data.json")); let raw_map: HashMap<String, Value> = serde_json::from_str(data).expect("Failed to parse raw data"); // ... });
このコードでは、ADDRESS_MAPへの初回アクセス時にJSONをパースします。初期化した値はLazyLockに保持され、以後のアクセスで使われます。
そのため、最初の検索には初期化の待ち時間が加わります。そこで、ユーザーが検索する前にバックグラウンドからアクセスし、初期化を先に始めます。検索までに完了すれば、その待ち時間を隠せます。まだ初期化中なら完了を待つ点は変わりません。LazyLockのドキュメントでも、初期化中のアクセスはブロックすると説明されています。
解決策:遅延初期化を活用した初期処理
従来の初期化パターン
fn new() -> App { let (search_tx, search_rx) = mpsc::channel::<String>(); let (result_tx, result_rx) = mpsc::channel(); thread::spawn(move || { while let Ok(query) = search_rx.recv() { // 初回検索時にデータ初期化が発生 = 遅い! } }); App { /* ... */ } }
改善後:標準ライブラリの機能を活用
use std::sync::{LazyLock, Mutex}; // グローバルな初期化フラグ static INITIALIZED: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false)); impl App { fn new() -> App { let (search_tx, search_rx) = mpsc::channel::<String>(); let (result_tx, result_rx) = mpsc::channel(); thread::spawn(move || { // バックグラウンドで初期化 { let mut init = INITIALIZED.lock().unwrap(); if !*init { // 軽いクエリで事前初期化をトリガー let _ = lookup_addresses("100"); let _ = search_by_address("東京"); *init = true; } } // 以降の検索は初期化済みのデータを使用 let mut cache: HashMap<String, Vec<String>> = HashMap::new(); while let Ok(query) = search_rx.recv() { // 通常の検索処理 } }); App { /* ... */ } } }
この手法の効果とメリットとデメリット
初期化を制御する部分には、標準ライブラリのLazyLockやMutexを使っています。この制御のためにライブラリを追加する必要はありません。ただし、標準ライブラリを使うことと、アプリケーション全体の性能がよくなることは別です。効果は初期化の重さや検索までの時間に左右されます。
この例では、INITIALIZEDフラグで検索関数を使った事前初期化の実行を管理します。初期化はバックグラウンドスレッドで進めるため、メインスレッドはその完了を待たずにUIの処理を進められます。
Mutexが守るのはフラグへのアクセスと、そのロックを保持して行う処理です。検索データ自体の初期化はLazyLockが管理します。機能を増やす場合も、どの処理を事前に実行するか、どの状態をロックで守るかを分けて考えます。
代わりに、メモリを早い段階で使うことになります。事前初期化したデータは、ユーザーが検索しなくても保持されます。メモリが限られる環境では、この負担も確認する必要があります。
起動時のCPU負荷も増えます。バックグラウンドに移しても、初期化の計算量そのものが減るわけではありません。バッテリーで動く機器や、検索せずに終了する使い方では、先に実行した処理が無駄になる場合もあります。
実装では、初期化の順序や依存関係に加え、初期化中に検索が来る場合も考える必要があります。処理を別スレッドへ移した分、実行順序を追うのは難しくなります。
テストでは、初期化前・初期化中・初期化後の状態を区別します。特定の実行順序でしか起きない問題は再現しにくいため、通常の検索結果だけでなく、待ち合わせの動作も確認します。
初期化に失敗した場合の扱いも必要です。上のコードは処理の流れを示す例で、失敗の通知や回復処理までは実装していません。実際のアプリケーションでは、バックグラウンドで起きた失敗をUIへどう伝えるかも決めます。
初回検索の待ち時間が、先にCPUとメモリを使う負担に見合うか。導入時には、このトレードオフを測定して判断します。
実装時の注意点
デッドロックの防止
{ // スコープによるロックの制限
let mut init = INITIALIZED.lock().unwrap();
if !*init {
*init = true;
}
} // ロックの自動解放
初期化の冪等性
if !*init { // 複数回実行されても安全な実装に let _ = lookup_addresses("100"); *init = true; }
まとめ
今回の工夫は、遅延初期化されるデータへバックグラウンドから先にアクセスし、検索が来るまでの時間を準備に使うことです。初期化コストをなくす方法ではありません。初回検索の応答時間と、起動時のCPU・メモリ使用量を比べて、先に準備する価値があるかを判断します。