3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unreal C++のUnityビルドで静的初期化順序問題にハマった話

3
Posted at

はじめに

開発中のゲームでは複数の言語(数か国語)に対応しており、言語コードの定義とカルチャ名のマッピングが静的変数として管理されていました。
ボイス言語(英語/日本語)も静的変数で定義されていて、そのコンストラクタで上記のマッピングを参照して言語コードを設定する構造になっていたのですが、ある日突然ボイス言語設定通りにボイスが再生されない不具合が発生しました。

ボイス言語周辺のコードには一切触れていなかったので原因が全然わからなかったのですが、調べてみると静的初期化順序問題Unityビルドの組み合わせが原因でした。

この問題、再現条件が環境依存でかなり厄介だったので記事にまとめておきます。

静的初期化順序問題とは

C++では、異なる翻訳単位(.cppファイル)に定義されたグローバル・静的変数の初期化順序は言語仕様上未定義です。
「Static Initialization Order Fiasco」として知られる古典的な問題で、ある静的変数のコンストラクタがまだ初期化されていない別の静的変数を参照してしまうと、未初期化のデータを読むことになり意図しない挙動を引き起こします。

同一翻訳単位内であれば宣言順に初期化されることが保証されますが、翻訳単位をまたぐとどちらが先に初期化されるかはコンパイラ・リンカの実装に依存します。

サンプルコードで見る問題

敵キャラクターの種族と表示情報のマッピングを持つモジュールがあり、そのマッピングを参照してスポーンプリセットを構築するケースを考えます。

EnemyDatabase.cpp — 敵種族のマスターデータ

// 敵の種族
enum class EEnemySpecies : uint8
{
    Goblin,
    Dragon,
    Undead,
};

// 種族から表示名を引くマスターデータ
static const TMap<EEnemySpecies, FString> SpeciesDisplayNameMap = {
    { EEnemySpecies::Goblin,  TEXT("ゴブリン") },
    { EEnemySpecies::Dragon,  TEXT("ドラゴン") },
    { EEnemySpecies::Undead,  TEXT("アンデッド") },
};

// 種族からデフォルトのレベル帯を引くマスターデータ
static const TMap<EEnemySpecies, FInt32Range> SpeciesLevelRangeMap = {
    { EEnemySpecies::Goblin,  FInt32Range(1, 10) },
    { EEnemySpecies::Dragon,  FInt32Range(30, 50) },
    { EEnemySpecies::Undead,  FInt32Range(15, 25) },
};

FString GetSpeciesDisplayName(EEnemySpecies InSpecies)
{
    if (const FString* Found = SpeciesDisplayNameMap.Find(InSpecies))
    {
        return *Found;
    }
    return TEXT("Unknown");
}

FInt32Range GetSpeciesLevelRange(EEnemySpecies InSpecies)
{
    if (const FInt32Range* Found = SpeciesLevelRangeMap.Find(InSpecies))
    {
        return *Found;
    }
    return FInt32Range(1, 1);
}

SpawnPreset.cpp — スポーンプリセット

// スポーンプリセット
struct FSpawnPreset
{
    FSpawnPreset(EEnemySpecies InSpecies)
        : Species(InSpecies)
        , DisplayName(GetSpeciesDisplayName(InSpecies))    // ← ここでSpeciesDisplayNameMapを参照
        , LevelRange(GetSpeciesLevelRange(InSpecies))      // ← ここでSpeciesLevelRangeMapを参照
    {
    }

    EEnemySpecies Species;
    FString DisplayName;
    FInt32Range LevelRange;
};

// 各種族のスポーンプリセットを静的変数として定義
static const FSpawnPreset GoblinPreset(EEnemySpecies::Goblin);     // "ゴブリン", (1, 10) になってほしい
static const FSpawnPreset DragonPreset(EEnemySpecies::Dragon);     // "ドラゴン", (30, 50) になってほしい
static const FSpawnPreset UndeadPreset(EEnemySpecies::Undead);     // "アンデッド", (15, 25) になってほしい

この2つのファイルは別の翻訳単位です。
GoblinPresetのコンストラクタがSpeciesDisplayNameMapSpeciesLevelRangeMapより先に走った場合、マップはまだ空なのでFindnullptrを返し、DisplayName"Unknown"LevelRange(1, 1)になってしまいます。

Unityビルドとは

Unreal Engineのビルドシステムには「Unity Build」という仕組みがあります。
複数の.cppファイルを1つの翻訳単位にまとめてコンパイルすることで、ヘッダのパース回数を減らしビルド時間を短縮する最適化手法です。

どのファイルがどうまとめられるかはビルドシステムが自動的に決定し、以下のような要因で結合パターンが変動します。

  • ソースファイルの追加・削除・リネーム
  • ファイルサイズの変動
  • ビルド設定(bUseUnity等)の変更
  • エンジンのアップデート

つまり、関連コードに一切触れていなくても、無関係なファイルの変更がトリガーとなって結合パターンが変わり得るということです。

静的初期化順序問題とUnityビルドの兼ね合わせで発生する不具合について

先ほどのサンプルコードに戻ります。

UnityビルドによってEnemyDatabase.cppSpawnPreset.cpp同一の翻訳単位にまとめられている間は、宣言順で初期化されるため問題は起きません。
SpeciesDisplayNameMapSpeciesLevelRangeMapGoblinPreset → ... の順で初期化され、正常に動作します。

しかし、無関係なファイルの追加や削除によってUnityビルドの結合パターンが変わり、2つのファイルが別の翻訳単位に分離されると、初期化順序の保証がなくなります。
GoblinPresetのコンストラクタが先に走り、マップは空、DisplayName"Unknown" — 不具合の発生です。

今回のボイス言語の件もまさにこのパターンで、周辺のコードに一切触れていないのにある日突然壊れました。

この組み合わせが厄介なのは以下の点です。

  • 原因と結果が離れている: 壊れたコードと変更したコードに直接の関連がないため、原因の特定が非常に困難
  • 再現性が環境依存: 結合パターンはビルド環境やファイル構成で変わるため、ある環境では動き別の環境では壊れるということが起こり得る
  • 今動いていても安全ではない: たまたま正しい順序で初期化されているだけであり、いつ壊れてもおかしくない

対策

参照先を変更できる場合:Construct On First Use イディオム

マップをグローバル静的変数ではなく、関数ローカルの静的変数にします。

// EnemyDatabase.cpp
static const TMap<EEnemySpecies, FString>& GetSpeciesDisplayNameMap()
{
    static const TMap<EEnemySpecies, FString> Map = {
        { EEnemySpecies::Goblin,  TEXT("ゴブリン") },
        { EEnemySpecies::Dragon,  TEXT("ドラゴン") },
        { EEnemySpecies::Undead,  TEXT("アンデッド") },
    };
    return Map;
}

関数ローカルのstatic変数はC++11以降、初回呼び出し時にスレッドセーフに一度だけ初期化されることが保証されています。
呼び出し側がいつ初期化されてもマップは必ず構築済みの状態で参照されるので、初期化順序に依存しなくなります。

参照先を変更できない場合:遅延初期化

今回の実際のケースではマップ側のコードを変更したくなかっため、プリセット側で遅延初期化を導入して対処しました。
コンストラクタではマップを参照せず、実際にアクセスされた時点で初めて値を解決するようにします。

// SpawnPreset.cpp
struct FSpawnPreset
{
    FSpawnPreset(EEnemySpecies InSpecies)
        : Species(InSpecies)
    {
    }

    const FString& GetDisplayName() const
    {
        if (DisplayName.IsEmpty())
        {
            DisplayName = GetSpeciesDisplayName(Species);
        }
        return DisplayName;
    }

    const FInt32Range& GetLevelRange() const
    {
        if (!LevelRange.IsSet())
        {
            LevelRange = GetSpeciesLevelRange(Species);
        }
        return LevelRange.GetValue();
    }

    EEnemySpecies Species;

private:
    mutable FString DisplayName;
    mutable TOptional<FInt32Range> LevelRange;
};

GetDisplayName()GetLevelRange()が呼ばれるのは実行時なので、その時点では全ての静的変数の初期化は完了しています。
mutableを使う点がやや不格好ではありますが、参照先を変更できない場合の現実的な対策です。

おわりに

静的変数のコンストラクタから別の静的変数を参照するコードがある場合、Unityビルドの結合パターン次第でいつ壊れてもおかしくない状態です。
今動いていても安全とは限らないので、同様の構造を持つコードを見つけたら予防的に対策を入れておくことをおすすめします。

この手の問題は原因と結果が離れていて追跡が大変なので、未来の自分を救うためにも早めに対処しておきましょう...。

3
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?