型の初期化中の循環依存関係の問題

記事のタイトルで説明されている問題に遭遇したことのある読者の一部は、おそらく遅くまで仕事を続け、デバッガーで何時間も費やしたでしょう。 他の人にとっては、これはしゃれた言葉やスラングの言葉にすぎません。 ただし、専門用語は別として、概念を明らかにしましょう。



さて、危機にしているものを示す小さな例:

using System; class Test {         static void Main()    {        Console.WriteLine(First.Beta);    } } class First {     public static readonly int Alpha = 5;     public static readonly int Beta = Second.Gamma; } class Second {     public static readonly int Gamma = First.Alpha; }
      
      







このコードの結果は0になります



もちろん、仕様を検討しないと、このような期待は機能しません。 したがって、仕様(C#4バージョンのセクション10.5.5.1)を見てみましょう。



クラスの静的フィールド変数初期化子は、クラス宣言に現れるテキスト順で実行される一連の割り当てに対応します。 クラスに静的コンストラクター(§10.12)が存在する場合、静的フィールド初期化子の実行は、その静的コンストラクターを実行する直前に発生します。 そうでない場合、静的フィールド初期化子は、そのクラスの静的フィールドを最初に使用する前の実装依存の時間に実行されます。


翻訳:

クラスの静的フィールドの初期化順序は、クラスのソースコードでの位置の順序に対応しています。 クラスに静的コンストラクターがある場合、クラスの静的フィールドの初期化コードの実行は、静的コンストラクターが呼び出される直前に配置されます。 そうではなく、静的コンストラクターが存在しない場合、静的フィールドの初期化は、特定の実装に依存する場所で実行されます。これは、静的フィールドの最初の使用前に発生します。




言語仕様に加えて、 CLI



仕様からの抜粋を引用できCLI



。これにより、型の初期化、特に循環依存関係とマルチスレッド操作に関する詳細が明らかになります。 ただし、これは行いませんが、短い抜粋をいくつか書いてください。



だからあなたの意見ではどうなりますか:



  1. Test



    初期化:これ以上のアクションは不要です。
  2. Main



    開始
  3. First



    初期化を開始します( First.Beta



    が必要なFirst.Beta



  4. First.Alpha



    を5に設定しFirst.Alpha



  5. Second



    初期化を開始します( Second.Gamma



    が必要なSecond.Gamma



  6. Second.Gamma



    First.Alpha



    設定しFirst.Alpha



    (5)
  7. 初期化終了Second



  8. First.Beta



    Second.Gamma



    設定しSecond.Gamma



    (5)
  9. First



    初期化を終了
  10. 「5」と入力します




そして、実際に起こっているのは、.Net Framework 4.5ベータがインストールされたコンピューター上です( .NET 4で型の初期化が変更されたことは知っています。これは不可能だと主張します)



  1. Test



    初期化:これ以上のアクションは不要です。
  2. Main



    開始
  3. First



    初期化を開始します( First.Beta



    が必要なFirst.Beta



  4. Second



    初期化を開始します( Second.Gamma



    が必要Second.Gamma



  5. Second.Gamma



    First.Alpha



    設定しFirst.Alpha



    (0)
  6. 初期化終了Second



  7. First.Alpha



    を5に設定しFirst.Alpha



  8. First.Beta



    Second.Gamma



    (0)に設定しFirst.Beta



  9. First



    初期化を終了
  10. タイプ0




ステップ(5)は非常に興味深いです。 First.Alpha



さらに取得するには、 First



を初期化する必要があることを知っています。 ただし、このスレッドはすでにFirst



初期化しています。そのため、すべてが正常であることを期待して、初期化をスキップします。 ただし、この時点では変数はまだ初期化されていません。 おっと...



(説明されているすべての問題を回避する微妙な点が1つあります:constキーワードの使用)



現実の世界に戻る



型の初期化時に循環依存関係を使用することが人生を台無しにすることになる理由を、私の例が明らかにしたことを願っています。 そのような場所をキャッチしてデバッグするのは非常に困難です。 そして実際、それは古典的なガイゼンバッグです。 この例では、プログラムが最初にSecond



初期化する(たとえば、別の変数にアクセスする)場合、まったく異なる結果が得られることを理解することが重要です。 そして、実際には、すべての単体テストを開始すると、それらすべてが失敗するという事実につながる可能性があります。 しかし、同時にそれらを別々に実行する場合、それらは機能します(1つを除いて、かなり可能です)。



このような状況を回避する1つの方法は、型の初期化を完全に放棄することです。 ほとんどの場合、これは必要なものです。 ただし、通常はよく知られたものを使用します。 Encoding.Utf8



TimeZoneInfo.Utc



。 どちらの場合も、これらは静的プロパティですが、静的フィールドを使用しているようです。 一見すると、 public static readonly



public static get-only




プロパティの使用は同じように見えますが、後で見るように、プロパティを使用すると利点が得られます。



私の野田タイムライブラリには、私たちと同じような瞬間があります。 そして、すべてこのライブラリの多くのタイプは不変であるため 、つまり 不変です。 これは、独自のUTC



タイムゾーンまたはISO calendar system



を作成する必要がある場合に意味があります。 さらに、公開されている値に加えて、ライブラリ内で(主にタスクをキャッシュするために)使用される多くの静的変数があります。 これにより、ライブラリのテストがより難しくなりますが、この場合のパフォーマンス上の利点は非常に重要です。



残念ながら、これらのフィールドとプロパティの膨大な数には循環的な依存関係があります。 前述したように、新しい静的フィールドを追加すると、プログラムでさまざまな故障が発生する可能性があります。 差し迫った原因を修正することはできますが、コードの整合性について懸念を感じます。 結局、1つの問題を修正したとしても、他の問題がないという保証はありません。



タイプ初期化テスト



型を初期化する際の主な問題の1つは、 AppDomain



内の型が1回だけ初期化されるという保証と組み合わせて、初期化順序に敏感です。 前に示したように、1つの初期化順序ではこれによりエラーが発生し、他の一部ではエラーが発生しない可能性があります。



私自身は、Noda Timeを開発するときに、周期的な依存関係によって問題が発生しないことを絶対に確認したいと思いました。 T.O. 型が初期化されるとき、型が初期化される順序に関係なく、サイクルが形成されないことを確認したいと思います。 論理的に考えると、1つのタイプで始まり、同じサイクルにある他のタイプで始まる循環依存関係を定義できます。 極端な場合を見逃さないようにし、可能なすべてのオプションを整理し、何も見失わないようにすることを非常に心配しています。 したがって、ブルートフォース法-徹底的な検索を適用しました。



大まかな計画は次のとおりです。



アプリケーションドメインの1回のダウンロードで循環依存関係を判断できる状況は決してないことに注意してください。 これを行うには、すべてのタイプをバイパスしてサイクルを特定し、結果を分析する必要があります。



コードがどのように機能するかの説明は、コード自体よりもはるかに大きくなり、実際、非常に理解しやすいので、記事の最後に記載します。



このソリューションはいくつかの理由であまり良くありません:



そして、これらすべての予約を考慮して...使用する価値はありますか? 間違いなくはい。 この手法は、修正された多くのバグを見つけるのに役立ちました。



循環依存関係を修正



以前は、コードに沿ってフィールドを移動するだけで、型の初期化順序を「修正」していました。 サイクルはまだ存在していましたが、それらを無害にする方法を見つけました。 このアプローチはスケーラブルではなく、見かけよりもはるかに多くの労力がかかると言えます。 コードが難しくなります...そして、ある日、2つ以上の依存関係のループが発生した場合、それを安全にするための心のパズルになります。 現時点では、非常に単純な手法を使用して、静的変数の遅延初期化を実装しています。



したがって、 static readonly field



が循環依存関係を作成するものを探す代わりに、ネストされたプライベート静的クラスでinternal static readonly field



を返すstatic readonly property



を使用しstatic readonly property



。 単一の呼び出しを保証するスレッドセーフな初期化はまだありますが、 nested



型は、必要になるまで初期化されません。

代わりに:

  // Requires Bar to be initialized - if Bar also requires Foo to be // initialized, we have a problem... public static readonly Foo SimpleFoo = new Foo(Bar.Zero);
      
      







私たちは書きます:



 public static readonly Foo SimpleFoo { get { return Constants.SimpleFoo; } } private static class Constants {    private static readonly int TypeInitializationChecking = NodaTime.Utility.TypeInitializationChecker.RecordInitializationStart();     // This requires both Foo and Bar to be initialized, but that's okay    // so long as neither of them require Foo.Constants to be initialized.    // (The unit test would spot that.)    internal static readonly Foo SimpleFoo = new Foo(Bar.Zero); }
      
      







現時点では、これらのクラスに静的コンストラクターを含めて遅延初期化を実現するかどうかを判断できません。 Foo型の初期化子がFoo.Constants型の初期化子を呼び出す場合、開始点に戻ります。 しかし、ネストされたクラスのそれぞれに静的コンストラクターを追加するのはひどく聞こえます。



おわりに



私は、実際には私の一部がコードテストの作成や回避策の実行、松葉杖の作成を好まないことを伝えたいと思います。 そして間違いなく、静的フィールドのみのストレージを避けて、型(またはその一部)の初期化を実際に取り除くことができるかどうかを検討する価値があります。 これらの依存関係をすべて見つけて、プログラムまたはユニットテストの実行を回避できれば素晴らしいと思います。 これは、静的アナライザーを使用して実行できるようにするためです。 機会があれば、 NDepend



これを助けてくれるかどうかを調べてみます。



ただし、このアプローチはある種のハッキングのように見えますが、代替手段(エラーでいっぱいのコード)よりも優れています。 そして...私は言うのを恥ずかしく思いますが、私はNoda Time



私がすべての周期的依存関係を見つけたとは思いません。 あなた自身のコードで試してみる価値があります-隠れた問題があるかもしれない場所を見てください



アプリケーション:テストコード





TypeInitializationChecker




 internal sealed class TypeInitializationChecker : MarshalByRefObject { private static List<Dependency> dependencies = null; private static readonly MethodInfo EntryMethod = typeof(TypeInitializationChecker).GetMethod("FindDependencies"); internal static int RecordInitializationStart() { if (dependencies == null) { return 0; } Type previousType = null; foreach (var frame in new StackTrace().GetFrames()) { var method = frame.GetMethod(); if (method == EntryMethod) { break; } var declaringType = method.DeclaringType; if (method == declaringType.TypeInitializer) { if (previousType != null) { dependencies.Add(new Dependency(declaringType, previousType)); } previousType = declaringType; } } return 0; } /// <summary> /// Invoked from the unit tests, this finds the dependency chain for a single type /// by invoking its type initializer. /// </summary> public Dependency[] FindDependencies(string name) { dependencies = new List<Dependency>(); Type type = typeof(TypeInitializationChecker).Assembly.GetType(name, true); RuntimeHelpers.RunClassConstructor(type.TypeHandle); return dependencies.ToArray(); } /// <summary> /// A simple from/to tuple, which can be marshaled across AppDomains. /// </summary> internal sealed class Dependency : MarshalByRefObject { public string From { get; private set; } public string To { get; private set; } internal Dependency(Type from, Type to) { From = from.FullName; To = to.FullName; } } }
      
      







TypeInitializationTest


 [TestFixture] public class TypeInitializationTest { [Test] public void BuildInitializerLoops() { Assembly assembly = typeof(TypeInitializationChecker).Assembly; var dependencies = new List<TypeInitializationChecker.Dependency>(); // Test each type in a new AppDomain - we want to see what happens where each type is initialized first. // Note: Namespace prefix check is present to get this to survive in test runners which // inject extra types. (Seen with JetBrains.Profiler.Core.Instrumentation.DataOnStack.) foreach (var type in assembly.GetTypes().Where(t => t.FullName.StartsWith("NodaTime"))) { // Note: this won't be enough to load the assembly in all test runners. In particular, it fails in // NCrunch at the moment. AppDomainSetup setup = new AppDomainSetup { ApplicationBase = AppDomain.CurrentDomain.BaseDirectory }; AppDomain domain = AppDomain.CreateDomain("InitializationTest" + type.Name, AppDomain.CurrentDomain.Evidence, setup); var helper = (TypeInitializationChecker)domain.CreateInstanceAndUnwrap(assembly.FullName, typeof(TypeInitializationChecker).FullName); dependencies.AddRange(helper.FindDependencies(type.FullName)); } var lookup = dependencies.ToLookup(d => d.From, d => d.To); // This is less efficient than it might be, but I'm aiming for simplicity: starting at each type // which has a dependency, can we make a cycle? // See Tarjan's Algorithm in Wikipedia for ways this could be made more efficient. // http://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm foreach (var group in lookup) { Stack<string> path = new Stack<string>(); CheckForCycles(group.Key, path, lookup); } } private static void CheckForCycles(string next, Stack<string> path, ILookup<string, string> dependencyLookup) { if (path.Contains(next)) { Assert.Fail("Type initializer cycle: {0}-{1}", string.Join("-", path.Reverse().ToArray()), next); } path.Push(next); foreach (var candidate in dependencyLookup[next].Distinct()) { CheckForCycles(candidate, path, dependencyLookup); } path.Pop(); } }
      
      












All Articles