Финализатор запущен, пока его объект все еще используется

Вывод: C#/.NET предполагается сборщиком мусора. C# имеет деструктор, используемый для очистки ресурсов. Что происходит, когда объект A удаляется сборщиком мусора в той же строке, когда я пытаюсь клонировать один из его переменных-членов? Судя по всему, на мультипроцессорах иногда выигрывает сборщик мусора...

Проблема

Сегодня на тренировке по C# преподаватель показал нам код, который содержал ошибку только при запуске на мультипроцессорах.

Подводя итог, скажу, что иногда компилятор или JIT ошибаются, вызывая финализатор объекта класса C# перед возвратом из вызванного метода.

Полный код, приведенный в документации Visual C++ 2005, будет опубликован в качестве ответа, чтобы не задавать очень-очень большие вопросы, но основные из них приведены ниже:

Следующий класс имеет свойство Hash, которое возвращает клонированную копию внутреннего массива. При построении первый элемент массива имеет значение 2. В деструкторе его значение устанавливается равным нулю.

Дело в том, что если вы попытаетесь получить свойство Hash из примера, вы получите чистую копию массива, первый элемент которого по-прежнему равен 2, поскольку объект используется (и, как таковой, не подвергается сборке/финализации мусора). ):

public class Example
{
    private int nValue;
    public int N { get { return nValue; } }

    // The Hash property is slower because it clones an array. When
    // KeepAlive is not used, the finalizer sometimes runs before 
    // the Hash property value is read.

    private byte[] hashValue;
    public byte[] Hash { get { return (byte[])hashValue.Clone(); } }

    public Example()
    {
        nValue = 2;
        hashValue = new byte[20];
        hashValue[0] = 2;
    }

    ~Example()
    {
        nValue = 0;

        if (hashValue != null)
        {
            Array.Clear(hashValue, 0, hashValue.Length);
        }
    }
}

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

public static void Main(string[] args)
{
    Thread t = new Thread(new ThreadStart(ThreadProc));
    t.Start();
    t.Join();
}

private static void ThreadProc()
{
    // running is a boolean which is always true until
    // the user press ENTER
    while (running) DoWork();
}

Статический метод DoWork — это код, в котором возникает проблема:

private static void DoWork()
{
    Example ex = new Example();

    byte[] res = ex.Hash; // [1]

    // If the finalizer runs before the call to the Hash 
    // property completes, the hashValue array might be
    // cleared before the property value is read. The 
    // following test detects that.

    if (res[0] != 2)
    {
        // Oops... The finalizer of ex was launched before
        // the Hash method/property completed
    }
}

Раз в 1 000 000 выполнений DoWork, по-видимому, сборщик мусора делает свое волшебство и пытается восстановить ex, так как он больше не упоминается в остаточном коде функции, и на этот раз он быстрее, чем метод Hash get. Так что в итоге мы имеем клон обнуленного массива байтов вместо правильного (с 1-м элементом на 2).

Я предполагаю, что есть встроенный код, который по сути заменяет строку, отмеченную [1] в функции DoWork, на что-то вроде:

    // Supposed inlined processing
    byte[] res2 = ex.Hash2;
    // note that after this line, "ex" could be garbage collected,
    // but not res2
    byte[] res = (byte[])res2.Clone();

Если мы предположили, что Hash2 — это простой метод доступа, закодированный так:

// Hash2 code:
public byte[] Hash2 { get { return (byte[])hashValue; } }

Итак, вопрос: Должно ли это работать таким образом в C#/.NET, или это можно рассматривать как ошибку компилятора JIT?

редактировать

См. объяснения в блогах Криса Брамма и Криса Лайонса.

http://blogs.msdn.com/cbrumme/archive/2003/04/19/51365.aspx
http://blogs.msdn.com/clyon/archive/2004/09/21/232445.aspx

Ответы всех были интересны, но я не мог выбрать один лучше другого. Так что я дал вам всем +1 ...

Извиняюсь

:-)

Редактировать 2

Мне не удалось воспроизвести проблему в Linux/Ubuntu/Mono, несмотря на использование одного и того же кода в одних и тех же условиях (одновременное выполнение нескольких одинаковых исполняемых файлов, режим выпуска и т. д.)


person paercebal    schedule 25.09.2008    source источник
comment
Это старый вопрос, но одно примечание для всех, кто изучает это, заключается в том, чтобы убедиться, что управляемый код C++ реализован с использованием финализатора !Example, а не деструктора ~Example (который в C++/CLI создает реализацию IDisposable.) Это особенность C++/CLI, предназначенная для облегчения перехода для разработчиков C++, которые ожидают, что деструктор будет вызываться детерминировано, когда класс выходит за пределы области видимости или удаляется (в управляемом случае из C#, когда он выходит за рамки использования ' Заявление о ликвидации)   -  person Dan Bryant    schedule 18.07.2011
comment
@Dan Bryant: Я не согласен: нотация ~XXX и !XXX из C++/CLI — это нотация, которая использовалась бы в C#, если бы они предвидели проблему утилизации. Это не причуда. Эта нотация была выбрана потому, что: 1. Разработчики C++ были справедливо оскорблены бессмыслицей финализатора Managed C++ (то есть такой же, как в C#) 2. Нотация автоматизирует написание логики методов Dispose() и Dispose(bool), которые очень сложно писать на C#. (не говоря уже о правильном написании на С#). RAII не является понятием C++. RAII — это шаблон, который правильно понимают немногие языки. C# (и тем более Java) потерпели неудачу в этом вопросе.   -  person paercebal    schedule 19.07.2011
comment
Я согласен, на самом деле, что !XXX был бы лучшим выбором для C# для обозначения финализатора, поскольку нотация ~XXX слишком похожа на деструктор C++. Их поведение существенно отличается (поскольку финализатор недетерминировано выполняется в другом потоке), что приводит к большой путанице. Я также думаю, что шаблон Dispose(bool) является плохой рекомендацией для общего использования; Я обнаружил, что в большинстве случаев я могу просто пометить свой класс как запечатанный и напрямую реализовать Dispose().   -  person Dan Bryant    schedule 19.07.2011
comment
@Dan Bryant: Моя текущая проблема заключается в том, что sealed не является опцией в коде, который я должен исправить (из-за того, что финализация / удаление приводит к сбою наших приложений .NET) ... И этот беспорядок занял у меня недели и снова займет недели, исправлен код удаления, который настолько уродлив, что я не могу смотреть на него без солнцезащитных очков, чтобы защитить глаза... Возможно, следующая итерация C# позволит нам использовать нотацию ~XXX/!XXX. В конце концов, если компилятор C++/CLI способен генерировать правильный код Finalize и Dispose, то и C# должен уметь это делать...   -  person paercebal    schedule 20.07.2011


Ответы (8)


Это просто ошибка в вашем коде: финализаторы не должны обращаться к управляемым объектам.

Единственная причина для реализации финализатора — освободить неуправляемые ресурсы. И в этом случае вам следует тщательно реализовать стандартный шаблон IDisposable.

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

В вашем примере у вас нет неуправляемых ресурсов, поэтому не следует реализовывать финализатор.

person Joe    schedule 27.09.2008
comment
Ты меня просто опередил! Я не знаю, почему все твердят о потоках, JIT и агрессивном поведении, когда все они упускают из виду реальную проблему, как вы описываете. - person Jeffrey L Whitledge; 27.09.2008
comment
Несмотря на то, что другие ответы были верными (насколько мне известно), ваши ответы оказали наибольшее влияние на мое восприятие этого шаблона Dispose. Спасибо. +1 - person paercebal; 28.09.2008
comment
Это не имеет ничего общего с управляемыми/неуправляемыми ресурсами — такое же состояние гонки существует и для неуправляемых ресурсов... верно? Или я что-то упускаю? - person Eamon Nerbonne; 20.04.2011
comment
@Earmon Ты совершенно прав. Да, в этом случае финализатор не должен касаться управляемого объекта, но управляемый объект все равно будет жить, так как у него нет своего финализатора (массивы не имеют), и он по-прежнему достижим ( объекты в списке финализаторов считаются достижимыми). Можно построить немного более надуманный пример, который доказывает, что ему все еще нужно поддерживать исходный, лежащий в основе объект живым. В этом случае, как было сказано, ему, вероятно, не следует возиться с массивом, но, насколько нам известно, это был пример кода, иллюстрирующий проблему, а не проблему. - person Lasse V. Karlsen; 18.07.2011
comment
Например, вы можете создать класс, который управляет ссылкой на что-то через неуправляемую ссылку. Метод этого класса возвращает новый объект, которому дается копия неуправляемой ссылки (плохой и глючной, я знаю, но это было сделано) для связи с устройством. В какой-то момент исходный объект завершается, закрывая открытое соединение, в то время как возвращенный объект все еще должен быть живым. Мне все же было бы интересно увидеть пример, который не содержал плохо продуманных конструкций, т.е. глючный код. - person Lasse V. Karlsen; 18.07.2011

То, что вы видите, совершенно естественно.

Вы не сохраняете ссылку на объект, которому принадлежит массив байтов, поэтому этот объект (не массив байтов) фактически свободен для сборки сборщиком мусора.

Сборщик мусора действительно может быть таким агрессивным.

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

Сборщик мусора видит, что переменная ex больше не используется в этом методе, поэтому он может, и, как вы заметили, будет собирать ее при правильных обстоятельствах (т. е. по времени и необходимости).

Правильный способ сделать это — вызвать GC.KeepAlive на ex, поэтому добавьте эту строку кода в конец вашего метода, и все должно быть хорошо:

GC.KeepAlive(ex);

Я узнал об этом агрессивном поведении, прочитав книгу Applied .NET Framework Programming автора Джеффри Рихтер.

person Lasse V. Karlsen    schedule 25.09.2008
comment
Я узнал об этом агрессивном поведении... : Итак, вы подтверждаете, что это поведение, видимое только при запуске этого кода на многоядерном процессоре, является нормальным поведением, и что это описано в книге, название которой вы упоминаете в своем посте? - person paercebal; 25.09.2008
comment
... [продолжить] ... потому что тот же код, использующий тот же класс, созданный с помощью средств сборщика мусора (gcnew вместо new), отлично работает на Managed C++. - person paercebal; 25.09.2008
comment
Это не имеет ничего общего с многоядерностью, это связано с многопоточностью, во время выполнения сервера .NET вы можете увидеть такое же поведение, если вы работаете в загруженной одноядерной системе. - person Lasse V. Karlsen; 26.09.2008
comment
Если вы прочитаете другой ответ на этот вопрос от paercebal, цитирующий какой-то текст из cbrumme, вы найдете гораздо лучшее объяснение, чем мое. Но да, это действительно естественно, и вам нужно убедиться, что объект жив в течение всего времени вашего вызова. - person Lasse V. Karlsen; 26.09.2008
comment
GC.KeepAlive — неправильный способ справиться с этим. Правильный способ - избавиться от финализатора. - person Joe; 27.09.2008

это похоже на состояние гонки между вашим рабочим потоком и потоком(ами) GC; чтобы избежать этого, я думаю, есть два варианта:

(1) измените свой оператор if, чтобы использовать ex.Hash[0] вместо res, чтобы ex не мог быть преждевременно GC'd, или

(2) заблокировать ex на время вызова Hash

это довольно изящный пример - учитель имел в виду, что в JIT-компиляторе может быть ошибка, которая проявляется только в многоядерных системах, или что этот тип кодирования может иметь тонкие условия гонки со сборкой мусора?

person Steven A. Lowe    schedule 25.09.2008
comment
Видимо, учитель посчитал, что это ошибка. Причина такова: компилятор должен знать, что мы все еще используем объект, и, таким образом, отложить его уничтожение до следующей строки. Тот же самый код на C++ работал корректно. - person paercebal; 25.09.2008
comment
Эта цепочка рассуждений работает в неуправляемом C++, потому что разработчик должен знать, когда освобождать память. В мире со сборкой мусора среда выполнения считает, что ex больше не используется и подлежит сбору. - person Scott Dorman; 25.09.2008
comment
Я предполагаю, что технически это ошибка, но она наследуется в системе со сборкой мусора при запуске нескольких (и/или длительных) потоков. - person Scott Dorman; 25.09.2008
comment
Код C++, который работал, управлялся. Не неуправляемый. Это причина, по которой учитель считал, что что-то не так. Каким-то образом код, созданный компилятором Manager C++, защищает объект во время его использования, а код, созданный компилятором C#, — нет. - person paercebal; 25.09.2008
comment
... Я считаю, что это то, что может произойти, когда у вас есть сборка мусора, а с другой - деструкторы. - person paercebal; 25.09.2008
comment
[@paercebal]: проверьте MSIL, сгенерированный C# и C++, чтобы увидеть, в чем разница для этой функции; возможно, компилятор С++ оптимизировал промежуточную переменную как ненужную... - person Steven A. Lowe; 25.09.2008
comment
Хорошо... мои предыдущие комментарии недействительны. Посмотрите на сгенерированный IL, который, вероятно, покажет то, что подозревает Стивен. Кроме того, убедитесь, что обе сборки C++ и C# были сборками Release, поскольку сборки Debug добавляют некоторые дополнительные преимущества сборщику мусора. - person Scott Dorman; 25.09.2008

Я думаю, что то, что вы видите, является разумным поведением из-за того, что все работает в нескольких потоках. Это причина для метода GC.KeepAlive(), который следует использовать в этом случае, чтобы сообщить сборщику мусора, что объект все еще используется и что он не является кандидатом на очистку.

Глядя на функцию DoWork в вашем ответе "полный код", проблема в том, что сразу после этой строки кода:

byte[] res = ex.Hash;

функция больше не делает никаких ссылок на объект ex, поэтому в этот момент она становится доступной для сборки мусора. Добавление вызова GC.KeepAlive предотвратит это.

person Scott Dorman    schedule 25.09.2008
comment
Да, совершенно верно. Дело в том, что мой бывший объект может быть завершен в той же строке, в которой я использую одно из его свойств get? - person paercebal; 25.09.2008
comment
Если я понимаю, о чем вы спрашиваете... да. В .NET объект по-прежнему доступен даже после того, как он был удален, поэтому вы все равно можете вызвать его, хотя результаты не гарантируются. Я думаю, что это часть того, что вы видите здесь. - person Scott Dorman; 25.09.2008
comment
Эээ... Нет: Моя проблема в том, что та самая строка кода, которую я использую в своем бывшем объекте (поэтому там все еще есть живая ссылка), она украдена в самом ковре под моим футов мимо сборщика мусора. Я бы поверил, что сборщик мусора будет ждать, по крайней мере, следующей строки, чтобы собрать объект. - person paercebal; 25.09.2008
comment
Итак, вы говорите, что в первый раз byte[] res = ex.Hash; линия запущена, вы попали в проблему? - person Scott Dorman; 25.09.2008

Да, это проблема с подходить раньше.

Еще веселее то, что вам нужно запустить релиз, чтобы это произошло, и вы в конечном итоге ломаете голову, говоря: «А как это может быть нулевым?».

person Jack Bolding    schedule 25.09.2008
comment
Очень хороший ответ. Ссылки выше интересны, а во второй упоминается автор текста по следующему URL-адресу: blogs.msdn.com/cbrumme/archive/2004/02/20/77460.aspx - person paercebal; 25.09.2008
comment
Да, проблема состояния гонки возникала и раньше, но я думаю, что это условие возникнет в любом из языков .NET. Судя по комментариям к другим сообщениям, похоже, что это происходит только в образце C#, версия образца C++/CLI не имеет этой проблемы. - person Scott Dorman; 25.09.2008
comment
Мне интересно... Возможно, это нормально для .NET, и это Managed C++, который добавляет дополнительный код... Завтра я попрошу VB.NET и версию Managed C++ кода, чтобы увидеть, появляется ли состояние гонки. Если я получу эти источники, я опубликую их ниже и предоставлю вам все результаты для комментариев. - person paercebal; 25.09.2008

Интересный комментарий из блога Криса Брумма

http://blogs.msdn.com/cbrumme/archive/2003/04/19/51365.aspx

class C {<br>
   IntPtr _handle;
   Static void OperateOnHandle(IntPtr h) { ... }
   void m() {
      OperateOnHandle(_handle);
      ...
   }
   ...
}

class Other {
   void work() {
      if (something) {
         C aC = new C();
         aC.m();
         ...  // most guess here
      } else {
         ...
      }
   }
}

Поэтому мы не можем сказать, как долго «aC» может жить в приведенном выше коде. JIT может сообщать об ссылке до тех пор, пока не завершится Other.work(). Он может встроить Other.work() в какой-то другой метод и сообщить о C даже дольше. Даже если вы добавите «aC = null;» после того, как вы его используете, JIT может считать это назначение мертвым кодом и удалить его. Независимо от того, когда JIT перестанет сообщать об эталоне, сборщик мусора может какое-то время не собирать его.

Гораздо интереснее побеспокоиться о самом раннем моменте, когда можно было бы собрать aC. Если вы похожи на большинство людей, вы догадываетесь, что самое раннее время, когда aC получает право на сбор, находится в закрывающей скобке предложения «if» Other.work(), где я добавил комментарий. На самом деле фигурных скобок в IL не существует. Они представляют собой синтаксический контракт между вами и компилятором вашего языка. Other.work() может перестать сообщать об aC, как только он инициирует вызов aC.m().

person paercebal    schedule 25.09.2008

Это совершенно нормально для вызова финализатора в вашем методе выполнения работы, поскольку после вызова ex.Hash CLR знает, что экземпляр ex больше не понадобится...

Теперь, если вы хотите сохранить экземпляр живым, сделайте следующее:

private static void DoWork()
{
    Example ex = new Example();

    byte[] res = ex.Hash; // [1]

    // If the finalizer runs before the call to the Hash 
    // property completes, the hashValue array might be
    // cleared before the property value is read. The 
    // following test detects that.

    if (res[0] != 2) // NOTE
    {
        // Oops... The finalizer of ex was launched before
        // the Hash method/property completed
    }
  GC.KeepAlive(ex); // keep our instance alive in case we need it.. uh.. we don't
}

GC.KeepAlive... ничего не делает :) это пустой не встраиваемый метод /jittable, единственной целью которого является обмануть GC, заставив его думать, что объект будет использоваться после этого.

ПРЕДУПРЕЖДЕНИЕ. Ваш пример совершенно действителен, если бы метод DoWork был управляемым методом C++... Вы DO должны вручную поддерживать управляемые экземпляры вручную, если вы не хотите, чтобы деструктор вызывался изнутри другая нить. IE. вы передаете ссылку на управляемый объект, который собирается удалить большой двоичный объект неуправляемой памяти после завершения, и метод использует тот же самый большой двоичный объект. Если вы не поддерживаете экземпляр живым, у вас возникнет состояние гонки между сборщиком мусора и потоком вашего метода.

И это закончится слезами. И управлял повреждением кучи...

person Palad1    schedule 25.09.2008
comment
Вероятно, вам следует вызывать GC.KeepAlive(ex), а не GC.SuppressFinalize(ex), поскольку KeepAlive разработан специально для тех случаев, когда SupressFinalize предназначен для использования в другом контексте, а именно для предотвращения финализации объекта, который уже был удален. . - person Scott Dorman; 25.09.2008
comment
Теперь моя проблема, независимо от контекста потока: в теле одной функции мой объект завершается той же самой строкой, ссылку на которую я использую для вызова свойства get. Это нормальное поведение? - person paercebal; 25.09.2008
comment
это, спасибо, поправил. Я думал о том, чтобы дать указатель на реализацию ROTOR gc, и ADD взял на себя: D - person Palad1; 26.09.2008

Полный код

Ниже вы найдете полный код, скопированный/вставленный из файла Visual C++ 2008 .cs. Поскольку сейчас я работаю в Linux и не имею никакого компилятора Mono или знаний о его использовании, сейчас я не могу проводить тесты. Тем не менее, пару часов назад я увидел этот код и его ошибку:

using System;
using System.Threading;

public class Example
{
    private int nValue;
    public int N { get { return nValue; } }

    // The Hash property is slower because it clones an array. When
    // KeepAlive is not used, the finalizer sometimes runs before 
    // the Hash property value is read.

    private byte[] hashValue;
    public byte[] Hash { get { return (byte[])hashValue.Clone(); } }
    public byte[] Hash2 { get { return (byte[])hashValue; } }

    public int returnNothing() { return 25; }

    public Example()
    {
        nValue = 2;
        hashValue = new byte[20];
        hashValue[0] = 2;
    }

    ~Example()
    {
        nValue = 0;

        if (hashValue != null)
        {
            Array.Clear(hashValue, 0, hashValue.Length);
        }
    }
}

public class Test
{
    private static int totalCount = 0;
    private static int finalizerFirstCount = 0;

    // This variable controls the thread that runs the demo.
    private static bool running = true;

    // In order to demonstrate the finalizer running first, the
    // DoWork method must create an Example object and invoke its
    // Hash property. If there are no other calls to members of
    // the Example object in DoWork, garbage collection reclaims
    // the Example object aggressively. Sometimes this means that
    // the finalizer runs before the call to the Hash property
    // completes. 

    private static void DoWork()
    {
        totalCount++;

        // Create an Example object and save the value of the 
        // Hash property. There are no more calls to members of 
        // the object in the DoWork method, so it is available
        // for aggressive garbage collection.

        Example ex = new Example();

        // Normal processing
        byte[] res = ex.Hash;

        // Supposed inlined processing
        //byte[] res2 = ex.Hash2;
        //byte[] res = (byte[])res2.Clone();

        // successful try to keep reference alive
        //ex.returnNothing();

        // Failed try to keep reference alive
        //ex = null;

        // If the finalizer runs before the call to the Hash 
        // property completes, the hashValue array might be
        // cleared before the property value is read. The 
        // following test detects that.

        if (res[0] != 2)
        {
            finalizerFirstCount++;
            Console.WriteLine("The finalizer ran first at {0} iterations.", totalCount);
        }

        //GC.KeepAlive(ex);
    }

    public static void Main(string[] args)
    {
        Console.WriteLine("Test:");

        // Create a thread to run the test.
        Thread t = new Thread(new ThreadStart(ThreadProc));
        t.Start();

        // The thread runs until Enter is pressed.
        Console.WriteLine("Press Enter to stop the program.");
        Console.ReadLine();

        running = false;

        // Wait for the thread to end.
        t.Join();

        Console.WriteLine("{0} iterations total; the finalizer ran first {1} times.", totalCount, finalizerFirstCount);
    }

    private static void ThreadProc()
    {
        while (running) DoWork();
    }
}

Для тех, кто заинтересован, я могу отправить заархивированный проект по электронной почте.

person paercebal    schedule 25.09.2008
comment
Является ли заархивированный проект таким же, как то, что вы разместили здесь? - person Scott Dorman; 25.09.2008
comment
Более менее. Мне пришлось добавить 4 пробела перед началом каждой строки, и, поскольку эти файлы были файлами Windows, в моем Linux возврат каретки \r\n/перевод строки добавлял ненужные пустые строки, которые я удалил в конце. - person paercebal; 25.09.2008