Проблема циклических зависимостей при инициализации типов

в 6:00, , рубрики: .net, clr, Программирование, типы

Некоторые из читателей, которые когда-либо сталкивались с проблемой, описанной в названии статьи, наверняка оставались на работе до поздна и проводили много часов в отладчике. Для других это может быть не более чем игрой слов и жаргонными словечками. Однако, давайте отойдем от жаргона в сторону и раскроем понятия:

  • Инициализация типа: это код, который выполняется чтобы проинициализировать все статические переменные класса и выполнить статический конструктор;
  • Циклическая зависимость: два кусочка кода, которые зависят друг от друга. В нашем случае это два класса, инициализация типов которых требует уже проинициализированного типа другого класса.

Ну и небольшой пример, чтобы показать, о чем идет речь:

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

Конечно же, если не смотреть в спецификацию, то любые ожидания как это будет работать не более чем предположения. Потому посмотрим в спецификацию (section 10.5.5.1 of the C# 4 version ):

The static field variable initializers of a class correspond to a sequence of assignments that are executed in the textual order in which they appear in the class declaration. If a static constructor (§10.12) exists in the class, execution of the static field initializers occurs immediately prior to executing that static constructor. Otherwise, the static field initializers are executed at an implementation-dependent time prior to the first use of a static field of that class.

Перевод:

Порядок инициализации статических полей класса соответствует порядку их расположения в исходном тексте класса. Если в классе присутствует статический конструктор, выполнение кода инициализации статических полей класса располагается прямо перед вызовом статического конструктора. В противном случае, если статического конструктора не существует, инициализация статических полей выполняется в месте, зависящем от конкретной реализации: это происходит перед первым использованием статического поля.

В дополнение к специикации языка, можно привести выдержку из спецификации CLI, которая раскрывает больше деталей о инициализации типов, особенно о циклических зависимостях и многопоточной работе. Однако, я не буду этого делать, а только напишу пару коротких выдержек:

  • Гарантируется потокобезопасность при инициализации типов
  • Если CLI замечает, что тип A необходимо проинициализировать и при этом он же находится в процессе инициализации в том же самом потоке, CLI продолжает работу таким образом, как будто тип A уже проинициализирован.

Итак, что бы могло случиться по вашему мнению:

  1. Проинициализировать Test: никаких дальнейших действий не требуется
  2. Начать выполнение Main
  3. Начать инициализацию First (так как нам необходим First.Beta)
  4. Установить First.Alpha в 5
  5. Начать инициализацию Second (так как нам необходим Second.Gamma)
  6. Установить Second.Gamma в First.Alpha (5)
  7. Закончить инициализацию Second
  8. Установить First.Beta в Second.Gamma (5)
  9. Закончить инициализацию First
  10. Напечатать «5»

А здесь описано то, что происходит в реальности — на моем компьютере, с установленным .Net Framework 4.5 beta (я знаю, что инициализация типов была изменена в .NET 4. Я не знаю, были ли изменения в .Net 4.5, но я не буду утверждать, что это не возможно)

  1. Проинициализировать Test: никаких дальнейших действий не требуется
  2. Начать выполнение Main
  3. Начать инициализацию First (так как нам необходим First.Beta)
  4. Начать иннициализацию Second (нам понадобится Second.Gamma)
  5. Установить Second.Gamma в First.Alpha (0)
  6. Закончить инициализацию Second
  7. Установить First.Alpha в 5
  8. Установить First.Beta в Second.Gamma (0)
  9. Закончить инициализацию First
  10. Напечатать 0

Шаг (5) очень интересен. Мы знаем, что нам необходимо проинициализировать First чтобы получить далее First.Alpha. Однако этот поток уже инициализирует First, потому мы пропускам инициализацию, надеясь что все в порядке. Однако в этой точке инициализации переменной еще не произошло. Упс…

(Существует одна тонкость, которая позволит избежать всех описанных проблем: использование ключевого слова const)

Назад в реальный мир

Надеюсь, мой пример прояснил для вас, почему использование циклических зависимостей при инициализации типов — дело которое вам сильно подпортит жизнь. Такие места очень трудно отлавливать и отлаживать. И по сути это классический Гайзенбаг. В нашем примере важно понимать, что если так случится что программа проинициализирует первым Second (например, чтобы получить доступ к другой переменной), то мы получим совершенно другой результат. И, на практике, можно получить такую ситуацию, когда запуск всех юнит-тестов приведет к тому что все они завалятся. Но если при этом запускать их по отдельности, они сработают (вполне возможно, кроме одного).

Один из путей, чтобы избегать подобных ситуаций — это отказаться от инициализации типов вообще. В большинстве случаев это как раз то что надо. Однако, обычно мы используем хорошо известные вещи. Такие как Encoding.Utf8, или TimeZoneInfo.Utc. Заметьте, что в обоих случаях это статические свойства, но мне кажется, они за собой несут использование статических полей. На первый взгляд кажется, что использование public static readonly полей и public static get-only свойств одинаково, однако, как мы увиим позже, использование свойств дайт свои приемущества.

Моя библиотека Noda Time имеет несколько похожих на наш, моментов. И все потому что многие типы этой библиотеки immutable, т.е. неизменяемые. Это имеет смысл, когда необходимо создать свою временную зону UTC, или ISO calendar system. Причем в дополнении к публично-видимым значениям, мы имеем множество статических переменных, используемых внутри библиотеки (в основном для задач кэширования). Все это делает библиотеку сложнее и трудной для тестирования, однако бенефиты производительности в данном случае очень и очень значительные.

К сожалению, огромное количество этих полей и свойств имеют циклические зависимости. Как я упоминал ранее, когда мы добавляем новое статическое поле, это может привести к самым различным поломкам в программе. Я могу исправить непосредственную причину, однако это оставит во мне чувство обеспокоенности о целостности кода. Ведь если я устранил одну проблему, это не дает никаких гарантй что нет других.

Тестирование инициализации типов

Один из главных вопросов при инициализаии типов — это чувствительность к порядку инициализации в комбинации с гарантией того что тип в пределах AppDomain будет проинициализирован лишь однажды. Как я показал ранее, возможно, что при одном порядке инициализации это вызовет ошибку, а при каком-либо другом никакой ошибки не возникнет.

Для себя я решил что при разработке Noda Time, я хочу быть абсолютно уверенным что циклические зависимости не создадут для меня никаких проблем. Т.о. я хочу удостовериться что при инициализации типов не образуется циклов, причем независимо от того, в каком порядке они инициализируются. Если размышлять логически, мы можем определить циклическую зависимость, которая начинается с какого-то одного типа, начиная инициализацию с других типов, находящихся в этом же самом цикле. Я очень беспокоюсь чтобы не пропустить ни каких крайних случаев, и чтобы перебрать все варианты, которые возможны и не выпустить ничего из виду. Потому я применил метод грубой силы — полный перебор.

Вот наш грубый план:

  • Начинаем с пустым списком зависимостей;
  • Для каждого типа целевой сборки:
    • Создать новый AppDomain
    • Загрузить туда сборку
    • Инициализировать тип (выполнить над ним действие, чтобы запустить процесс инициализации)
    • Просмотреть Stack Trace от начала каждой инициализации типа и записать все зависимости.
  • Просмотреть циклические зависимости в итоговом списке

Прошу заметить что у нас никогда не выйдет ситуации, когда мы сможем определить циклическую зависимость за одну загрузку домена приложения. Для этого необходимо обойти все типы и выявить циклы, анализируя результаты.

Описание того как работает код выйдет намного большим чем сам код и на самом деле он очень легок для понимания, так что я расположу его в конце статьи.

Это решение не является очень хорошим по нескольким причинам:

  • Создание нового AppDomain и загрузка в него сборок из программы юнит-тестирования может оказаться не таким простым как могло бы быть. Мой код не корректно отрабатывает в связке с NCrunch.И я уверен, что если я это исправлю, остальные системы юнит-тестирования все равно будут ломать мою программу.
  • Он основан на том, что каждый инициализатор типа будет содержать необходимую для работы системы строчку кода:
    private static readonly int TypeInitializationChecking = NodaTime.Utility.TypeInitializationChecker.RecordInitializationStart();
  • Плохо не только то что необходимо добавлять строку кода в каждый интересующий нас тип. Это плохо еще тем, что эта строчка будет вызываться каждый раз при инициализации типа. А также будет отбирать минимум 4 байта из кучи, а это очень плохо, если программа запущена не в режиме тестирования. Я, конечно, мог бы использовать директивы препроцессора для того чтобы убрать этот код из версии, не для тестирования. Но от этого код будет выглядеть еще более грязным;
  • Этот метод находит циклические зависимости только для тех версий .Net, на которых были прогнаны тесты. Если учесть что существуют различия в различных версиях .Net Framework, я бы не был уверен в том что тесты покроют 100% ситауций. Аналогично, если мы сменим текущую CultureInfo, или любую другую, казалось бы, постояную, переменную окружения, тесты могут заработать совершенно иным образом.
  • Также в данной реализации я не смотрю на ситуации, когда код многопоточен. Для таких ситуаций, я опять же, не уверен что это будет работать корректно.

И, учитывая все эти оговорки… Стоит ли это использовать? Однозначно, да. Эта методика помогла мне найти множество багов, которые были исправлены.

Исправление циклических зависимостей

В прошлом, я «исправлял» порядок инициализации типов просто перемещая по коду поля. Циклы все еще существовали, однако я вычислил как сделать их безвредными. Я могу сказать что этот подход не масштабируемый и стоит гораздо больших усилий чем кажется. Код становится трудным… И если вы однажды получите цикл в более чем из двух зависимостей, это будет задачка для ума, как сделать его безопасным. На данный момент я использую очень простую технику чтобы осущиствить отложенную инициализацию статических переменных.

Так что вместо того чтобы искать что за static readonly field создает вам циклическую зависимость, вы используете static readonly property, которое возвращает internal static readonly field, во вложенном, приватном статическом классе. Мы все еще имеем потоко-безопасную инициализацию с гарантией единичного вызова, однако 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, мы вернемся к исходной точке. Но добавление статических конструкторов в каждых из nested классов звучит ужасно.

Заключение

Я хочу вам сказать, что какая-то часть меня в реальности не любит писать тестирование кода или делать обходные пути и городить костыли. И определенно, стоит задуматься, можно ли на самом деле избавиться от инициализации типов (или его части), избегая хранения только в статических полях. Было бы совсем замечательно, если бы можно было найти все эти зависимости измегая запуска программы или юнит-тестов. Чтобы это можно было сделать при помощи статического анализатора. Когда у меня будет шанс, я попробую выяснить, может ли мне в этом помочь 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(); 
    } 
}

Автор: SunexDevelopment


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js