пятница, 28 июня 2013 г.

Взаимоблокировки в статических конструкторах

Поскольку в Гугл+ так и не нашлось объяснения странного поведения приведенного там кода, то я решил рассказать об этом более подробно.

Итак, вопрос заключается в следующем: что мы ожидаем увидеть при исполнении следующего кода и что мы увидим на самом деле?

class CrazyType
{
   
public static readonly int
Foo = GetFoo();

   
private static int
GetFoo()
    {
       
return Task
.Run(() => 42).Result;
    }
}

class Program
{
   
static void Main(string
[] args)
    {
       
Console.WriteLine("Main method get called!"
);
       
Console.WriteLine(CrazyType.Foo);
    }
}

Дополнительные вопросы:

1. Как изменится поведение этого кода, если добавить статический конструктор класса CrazyType и почему?

2. Как изменится поведение, если метод GetFoo изменить следующим образом:

    a. Заменяем лямбда-выражения на именованный метод класса CrazyType.GetFooImpl:

private static int GetFooImpl() { return 42; }
private static int
GetFoo()
{
   
return Task.Factory.StartNew<int>(GetFooImpl).Result;
}

    b. Заменяем лямбда-выражения на метод класса Program:

private static int GetFoo()
{
   
return Task.Factory.StartNew<int>(Program.GetFooImpl).Result;
}

    c. Выносим локальную переменную лямбда-выражения:

private static int GetFoo()
{
   
int
x = 42;
   
return Task.Factory.StartNew(() => x).Result;
}

При запуске этого кода мы получим пустой экран!

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

1. Причина взаимоблокировки

Причина «подвисания» приведенного кода заключается именно в банальном deadlock-е и никак не связана ни с TPL, ни с TaskScheduler-ами, ни с SynchronizationContext-ами. Причина несколько проще.

Давайте уберем из метода GetFoo лямбда-выражение на метод GetFooImpl и рассмотрим по шагам исполнение следующего кода:

class CrazyType
{
   
public static readonly int
Foo = GetFoo();

   
private static int GetFooImpl() { return
42; }

   
private static int
GetFoo()
    {
       
return Task.Factory.StartNew<int>(GetFooImpl).Result;
    }
}

Когда в методе Main мы обращаемся к CrazyType происходит следующее (весь этот набор шагов происходит еще до метода Main, мы вернемся к этому через минуту):

  1. [Thread1] CLR видит обращение к статическому члену типа CrazyType, что приводит к инициализации типа CrazyType.
  2. [Thread1] CLR проверяет, проинициализирован ли тип CrazyType? Ответ: Нет.
  3. [Thread1] CLR захватывает внутреннюю блокировку (условно говоря, вызывает lock(someStuff)), чтобы обеспечить уникальность вызова статического конструктора.
  4. [Thread1] CLR вызывает статический конструктор, точнее вначале инициализаторы всех членов, что приводит к вызову метода GetFoo. Наличие или отсутствие явного статического конструктора немного изменяет поведение, но на данном этапе это не важно.
  5. [Thread1] В теле метода GetFoo, мы запускаем новую задачу и в качестве тела метода указываем метод нашего же типа. Затем, обращаясь к свойству Result мы ожидаем завершения этой задачи.
  6. Через некоторое время стартует новая задача, для получения результата которой используется наш же статический метод. Задача пытается вызвать переданный ей метод, в результате чего CLR переходит к первому шагу нашего алгоритма, но уже из другого потока:
    1. [Thread2] CLR проверяет, проинициализирован ли тип CrazyType? Ответ: Нет!
    2. [Thread2] CLR захватывает внутреннюю блокировку и ЗАЛИПАЕТ, поскольку она еще не отпущена исходным потоком, который ждет завершения текущей задачи.

Проблема с deadlock-ом статического конструктора не связана с TPL и будет воспроизводится на любой версии платформы в аналогичной ситуации (создание и ожидание потока, Parallel.For и т.д.). Именно об этой проблеме писал Игорь Островский в заметке “Static Constructor Deadlocks”, и именно с подобного типа проблему я описывал в заметке “О синглтонах и статических конструкторах”.

Теперь базовое поведение должно быть понятным, но прежде чем переходить к другим тонким моментом, нужно ответить на вопрос, почему поведение немного меняется, при добавлении пустого статического конструктора?

2. Опять про BeforeFieldInit

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

При отсутствии явного статического конструктора тип помечается атрибутом BeforeFieldInit, что позволяет среде исполнения вызывать статический конструктор наиболее оптимальным образом, например, перед методом, в котором происходит обращение к типу. Например, если первое обращение к типу происходит в цикле, то отсутствие статического конструктора может видимым образом повлиять на эффективность. Мой простой тест показал, что при 10 миллионах итераций в теле метода, разница уже стала ощутимой: 140мс против 14мс.

Теперь давайте разберем остальные примеры.

3. Почему работает с Program.GetFoo?

Если заменить реализацию метода CrazyType.GetFoo следующим образом:

private static int GetFoo()
{
   
return Task.Factory.StartNew<int>(Program.GetFooImpl).Result;
}

то код будет исполняться успешно. Причина в том, что у нас теперь отсутствует взаимоблокировка, поскольку при вызове метода Program.GetFooImpl из запущенной задачи, статический конструктор класса Program уже давно отработал и причины для взаимоблокировки нет.

4. () => 42 vs () => x

При использовании () => 42 мы получаем взаимоблокировку, а при использовании () => x – нет!

Разница в этом случае кроется в деталях реализации замыканий. В первом случае компилятор генерирует анонимный статический метод и мы сталкиваемся с проблемой, описанной в разделе «1. Причина взаимоблокировки», во втором же случае, генерируется вложенный класс замыкания, статический конструктор которого вызывается независимо от статического конструктора текущего класса и взаимоблокировка пропадает.

По сути при захвате локальной переменной мы получаем следующее:

class CrazyType
{
   
public static readonly int
Foo = GetFoo();

   
private static int
GetFoo()
    {
       
// int x = 42;
        // return Task.Factory.StartNew(() => x).Result;
        // преобразуется в следующий код:
        var closure = new Closure
();
closure.x = 42;

       
return Task.Factory.StartNew<int
>(closure.AnonymousMethod).Result;
    }

   
private class Closure
    {
       
public int
x;
       
public int AnonymousMethod() { return x; }
    }
}

Что приводит к поведению, описанному в разделе «3. Почему работает с Program.GetFoo?».

Дополнительные ссылки

11 комментариев:

  1. В последнем примере должно быть

    var closure = new Closure();
    closure.x = 42;
    return Task.Factory.StartNew(closure.AnonymousMethod).Result;

    ОтветитьУдалить
  2. @Евгений: спасибо, поправил.

    ОтветитьУдалить
  3. попробовал первую версию. в релизе с Ctrl+F5 работает.

    ОтветитьУдалить
  4. код изначальный:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;

    namespace CrazyTypeApp
    {
    class CrazyType
    {
    public static readonly int Foo = GetFoo();

    private static int GetFoo()
    {
    return Task.Run(() => 42).Result;
    }
    }

    class Program
    {
    static void Main(string[] args)
    {
    Console.WriteLine("Main method get called!");
    Console.WriteLine(CrazyType.Foo);
    }
    }
    }


    могу выслать релизный екзешник, знать бы куда слать.
    выводит следующее:

    E:\CrazyTypeApp\bin\Release>CrazyTypeApp.exe
    Main method get called!
    42

    E:\CrazyTypeApp\bin\Release>

    ОтветитьУдалить
  5. @Yuri: тут уже начинаются тонкости вызова статических конструкторов в релизе для классов с beforefieldinit.

    Попробуйте добавить пустой статический конструктор и запустить еще раз.

    ОтветитьУдалить
  6. со статическим поидее везде одинаково.
    42 не выводится

    ОтветитьУдалить
  7. @Yuri: мне не понятно, почему в релизе и без статического конструктора не лочится, нужен дополнительный ресерч:)

    ОтветитьУдалить