FTickableGameObjectを継承したオブジェクトが純粋仮想関数呼び出しでクラッシュする

お世話にあっております。

TickableGameObjectに関して表題のクラッシュが起きるのでご連絡です。

問題としましては、FTickableGameObjectを継承したクラスを別スレッドで生成したときに、FTickableGameObject::TickObjects()とタイミングが重なり合ったときにクラッシュが発生します。

原因はvtable初期化前にFTickableGameObject::TickObjects()が回ってきてしまい、FTickableGameObjectBaseのTick()及びGetStatId()の純粋仮想関数が呼び出されクラッシュします。

テストではsleepを挟んで強制的に発生するようにFMyTickable1クラスを使用していますが、FTickableGameObjectを直接継承したクラスでも発生していました。

FTickableGameObjectはスレッドセーフ化されているクラスという認識で間違っていませんでしょうか?それであればこの問題の修正を将来的で構いませんのでお願いしたいです。

またこの問題を修正、調査するときに気がついた点などを共有しておきます。

  • GameThreadからnewするようにすれば起きませんが、AsyncLoadingやNiagaraなどゲーム側のコードでも気をつけないと別スレッドから発生する可能性があります
    • サードパーティ製のプラグインでも気軽に使われているので今更GameThread固定化のルールは難しそうでした
  • FTickableGameObjectBase::Tick()及びGetStatId()の純粋仮想関数をやめることでクラッシュ自体は起きなくなりますが、vtableが初期化されるタイミングによっては別の問題が起きます
    • vtable初期化前にIsTickable()が呼び出されFTickableGameObjectBaseのtrueが使用されますが、Tickを呼び出すタイミングでvtableが初期化されていて継承したクラス側のTickが呼び出される場合があります、このときIsTickable()の判定が無視されているので実際にはTickしてほしくないものでもTickされてしまうという問題が起きます
    • FMyTickable2をその状態のコードにしてあります
    • 結局FTickableGameObject::TickObjects()内で使用されたIsTickable()は信用できないので、FMyTickable2::Tick()内でもIsTickable()を再判定する必要性が出てきます
  • 結局はFTickableGameObject()にある、QueueTickableObjectForAdd()をnewするオブジェクトのコンストラクタで呼び出すようなルールに変更するのが一番良いのですが、FTickableGameObjectは既にいろいろな箇所で使用されているので断念しました
    • ということで直近のプロジェクトでは、純粋仮想関数化をやめ、問題の出るクラスのみTick()内でIsTickable()を再判定するという対応でしのぎました

以上になります、よろしくお願いいたします。

再現手順

  • 添付したTickableTestプロジェクトをUE5.6に追加します
  • PIEすると表題のクラッシュが起きます​
  • TickableTestGameMode.cppにテストコードがあります

下記でTStatId()を返しているのが原因かと思われます。

エンジン側の同名関数を検索して、独自クラスを用いて同じようにして対処すると改善するかもしれません。

virtual TStatId GetStatId() const override{ return TStatId(); }

再現プロジェクトのご用意ありがとうございます。問題を再現することができたので

UE-289355 Creating FTickableObjectBase inherited class in async thread may cause pure virtual function call

としてバグ登録いたしました。

クラッシュを回避する方法として純粋仮想関数と取りやめるのは一つの適切な対応だと思います。

しかしIsTickableに関しては如何ともしがたいのでタスクの実行開始が遅延する可能性はありますが、間違いなくスレッドセーフなFTSTicker::GetCoreTickerなどを用いてインスタンスを生成することが考えられます。

FTSTicker::GetCoreTicker().AddTicker( TEXT("SpawnMyTickables"), 0.0f, [this](float) { new FMyTickable2(); return true; });

お世話になっております。

起票ありがとうございます。

確かにnewするオブジェクトをスレッドセーフのタイミングで呼び出すことは有用ですね。ただ、これはスレッドの混み具合とタイミングの話なのでコアが枯渇した状態ですと別スレッドからnewしているFTickableGameObject全てで起きる可能性があって、なかなか怖い状態だなと思いました。特にサードパーティ製のコードですと手を出しにく部分があったり、その「タスクの実行開始が遅延する」ことで他のバグを生み出しかねないので気軽に対応していくのは難しいと感じました。

ですので将来的にでも根本的に修正されることを期待しています。

QueueTickableObjectForAdd()が存在するのでnewとtickの並列化を目指していると思うのですが、それであれば基底コンストラクタで暗黙的にキューを追加するのではなく、QueueTickableObjectForAdd()を明示的に呼び出させるなどしかないのではと思います。

C++の​仕様の範囲でなんとかするのであればGameThread以外でコンストラクトする場合にはFObjectInitializerのような初期化用オブジェクトを引数で渡してそのデストラクタでQueueTickableObjectForAddする方法などを取らせることになるかと思います。

確かにそれがいいですね。基底コンストラクタを変更すると余波がすごかったのですが、オフィシャルで対応していただければなんとかなりそうです。よろしくお願いいたします。