Подтвердить что ты не робот

GC.AddMemoryPressure() недостаточно для запуска выполнения очереди Finalizer вовремя

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

Механизм индексирования записывается в неуправляемом C++ и может содержать значительное количество неуправляемой памяти в виде коллекций и контейнеров std::.

Каждый неуправляемый индексный экземпляр обертывается управляемым объектом; время жизни неуправляемого индекса контролируется временем жизни управляемой обертки.

Мы обеспечили (через настраиваемые, отслеживающие С++-распределители), что каждый байт, потребляемый внутри индексов, учитывается, и мы обновляем (10 раз в секунду) значение давления памяти управляемого коллектора мусора с дельтами это значение (положительный дельта-вызов GC.AddMemoryPressure(), отрицательный дельта-вызов GC.RemoveMemoryPressure()).

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

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

Мы можем фактически обойти проблему, если добавить поток сторожевого таймера, который вызывает GC::WaitForPendingFinalizers(), а затем GC::Collect() в условиях низкой памяти, однако из того, что мы прочитали, вызов GC::Collect() вручную серьезно нарушает эффективность сбора мусора, и мы этого не хотим.

Мы даже добавили, безрезультатно, пессимистический фактор давления (до 4 раз), чтобы преувеличивать объем неуправляемой памяти, сообщаемой на стороне .net, чтобы узнать, можем ли мы уговорить сборщик мусора, чтобы быстрее освободить очередь, Кажется, что поток, обрабатывающий очередь, полностью не знает о давлении памяти.

В этот момент мы чувствуем, что нам нужно выполнить ручной подсчет ссылок до Dispose(), как только счетчик достигнет нуля, но это, похоже, является излишним, особенно потому, что вся цель API давления в памяти - именно учет для таких случаев, как наша.

Некоторые факты:

  • .Net версия 4.5
  • Приложение находится в 64-битном режиме
  • Сборщик мусора работает в режиме параллельного сервера.
  • Размер индекса составляет ~ 800 МБ неуправляемой памяти
  • В любой момент времени может быть до 12 "живых" индексов.
  • Сервер имеет 64 ГБ оперативной памяти.

Любые идеи или предложения приветствуются

4b9b3361

Ответ 1

Ну, ответа не будет, но "если вы хотите явно распоряжаться внешним ресурсом, вам нужно было сделать это самостоятельно".

AddMemoryPressure() метод не гарантирует немедленного запуска сбора мусора. Вместо этого CLR использует неуправляемые статистические данные о распределении/снятии памяти, чтобы настроить собственные пороговые значения gc, и GC запускается, только если это считается подходящим.

Обратите внимание, что RemoveMemoryPressure() вообще не запускает GC (теоретически он может это сделать из-за побочных эффектов от действий, таких как установка GCX_PREEMP, но пропустить его для краткости). Вместо этого он уменьшает текущее значение мембранного давления, не более (упрощение снова).

Фактический алгоритм недокументирован, однако вы можете взглянуть на реализацию от CoreCLR. Короче говоря, ваше значение bytesAllocated должно было превысить некоторый динамически рассчитанный предел, а затем CLR запускает GC.

Теперь плохая новость:

  • В реальном приложении процесс совершенно непредсказуем, так как каждая коллекция GC и каждый сторонний код влияют на пределы GC. GC может быть вызвана, может быть вызвана позже, вообще не может быть вызвана

  • GC настраивает его на ограничение минимизации дорогостоящих коллекций GC2 (вас это интересует, поскольку вы работаете с объектами долгосрочного индекса, добавляя, что они всегда повышаются до следующего поколения из-за финализатора). Таким образом, DDOSing времени выполнения с огромными значениями давления мембраны может нанести ответный удар, так как вы поднимете планку достаточно высоко, чтобы (почти) не было возможности запустить GC, установив давление мембраны вообще. ( NB: последняя проблема будет исправлена ​​с помощью новой реализации AddMemoryPressure(), но не сегодня, определенно).

UPD:.

Хорошо, давайте двигаться дальше:)

Часть 2, или "более новая недооценить, что означает _udocumented_"

Как я уже сказал выше, вас интересуют коллекции GC 2, поскольку вы используете долгоживущие объекты.

Хорошо известно, что финализатор работает почти сразу после того, как объект был GC-ed (предполагая, что очередь финализатора не заполнена другими объектами). В качестве доказательства: просто запустите этот смысл.

Настоящая причина, по которой ваши индексы не освобождены, довольно очевидна: генерация, к которой принадлежат объекты, не является GCed. И теперь мы возвращаемся к исходному вопросу. Как вы думаете, сколько памяти вам пришлось выделить для запуска коллекции GC2?

Как я уже говорил, фактические числа недокументированы. Теоретически GC2 может вообще не называться, пока вы не будете потреблять очень большие куски памяти. И теперь действительно плохие новости: для сервера GC "в теории" и "что действительно происходит" - то же самое.

Еще один gist, на .Net4.6 x64 вывод будет таким же:

GC low latency:
Allocated, MB:   512.19          GC gen 0|1|2, MB:   194.19 |   317.81 |     0.00        GC count 0-1-2: 1-0-0
Allocated, MB: 1,024.38          GC gen 0|1|2, MB:   421.19 |   399.56 |   203.25        GC count 0-1-2: 2-1-0
Allocated, MB: 1,536.56          GC gen 0|1|2, MB:   446.44 |   901.44 |   188.13        GC count 0-1-2: 3-1-0
Allocated, MB: 2,048.75          GC gen 0|1|2, MB:   258.56 | 1,569.75 |   219.69        GC count 0-1-2: 4-1-0
Allocated, MB: 2,560.94          GC gen 0|1|2, MB:   623.00 | 1,657.56 |   279.44        GC count 0-1-2: 4-1-0
Allocated, MB: 3,073.13          GC gen 0|1|2, MB:   563.63 | 2,273.50 |   234.88        GC count 0-1-2: 5-1-0
Allocated, MB: 3,585.31          GC gen 0|1|2, MB:   309.19 |   723.75 | 2,551.06        GC count 0-1-2: 6-2-1
Allocated, MB: 4,097.50          GC gen 0|1|2, MB:   686.69 |   728.00 | 2,681.31        GC count 0-1-2: 6-2-1
Allocated, MB: 4,609.69          GC gen 0|1|2, MB:   593.63 | 1,465.44 | 2,548.94        GC count 0-1-2: 7-2-1
Allocated, MB: 5,121.88          GC gen 0|1|2, MB:   293.19 | 2,229.38 | 2,597.44        GC count 0-1-2: 8-2-1

Правильно, в худшем случае вам пришлось выделить ~ 3,5 гигабайта для запуска коллекции GC2. Я уверен, что ваши распределения намного меньше:)

NB: Обратите внимание, что работа с объектами из поколения GC1 не делает его лучше. Размер сегмента GC0 может превышать 500 мб. Вам пришлось очень стараться запускать сборку мусора на сервере:)

Резюме: подход с Add/RemoveMemoryPressure будет (почти) не влиять на частоту сбора мусора, по крайней мере на сервере GC.

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

Продолжение следует

Ответ 2

мы можем найти очень большое количество "мертвых" экземпляров индекса, которые хранятся в очереди финализации

Не имеет никакого смысла, что эти "мертвые" экземпляры не завершаются. В конце концов, вы обнаружили, что GC:: WaitForPendingFinalizers() действительно работает. Итак, что должно происходить здесь, так это то, что они на самом деле завершены, они просто ждут следующей коллекции, чтобы они могли быть уничтожены. И это занимает некоторое время. Да, это маловероятно, ведь вы уже назвали GC:: RemoveMemoryPressure() для них. И, мы надеемся, выпустили для них большое неуправляемое распределение.

Таким образом, это, безусловно, просто ложный сигнал, эти объекты занимают только кучу GC, а не неуправляемую кучу, а GC-куча не является вашей проблемой.

Мы обеспечили (через настраиваемые, отслеживающие С++-распределители), что каждый байт...

Мне не нравится звук этого. Довольно важно, чтобы вызовы GC имели некоторое соответствие фактическому созданию и завершению управляемых объектов. Очень просто сделать, вы вызываете AddMemoryPressure в свой конструктор и RemoveMemoryPressure в своем финализаторе сразу после вызова оператора С++ delete. Значение, которое вы передаете, должно быть оценкой для соответствующего неуправляемого распределения С++, оно не должно быть точным вплоть до байта, а выключение в 2 раза не является серьезной проблемой. Также не имеет значения, что распределение С++ происходит позже.

вызов GC:: Collect() вручную серьезно нарушает эффективность сбора мусора

Не паникуйте. Довольно высокие шансы, что, поскольку ваши неуправляемые распределения настолько велики, что вы редко собираете "естественно" и на самом деле нуждаетесь в принудительных распределениях. Подобно тому, что GC:: AddMemoryPressure() триггеров, он так же "принудительно", как вызов GC:: Collect(). Несмотря на то, что у него есть эвристика, которая позволяет избежать слишком частого сбора, чего вы, возможно, не особо заботитесь прямо сейчас:)

Сборщик мусора работает в режиме параллельного сервера

Не используйте GC рабочей станции, он гораздо более консервативен в отношении размера сегмента кучи.

Ответ 3

Я хочу предложить краткое описание "" Финализаторы не гарантированы для запуска". Вы можете легко протестировать его, постоянно создавая старый добрый Bitmap:

private void genButton_Click(object sender, EventArgs e)
{
    Task.Run(() => GenerateNewBitmap());
}

private void GenerateNewBitmap()
{
    //Changing size also changes collection behavior
    //If this is a small bitmap then collection happens
    var size = picBox.Size;
    Bitmap bmp = new Bitmap(size.Width, size.Height);
    //Generate some pixels and Invoke it onto UI if you wish
    picBox.Invoke((Action)(() => { picBox.Image = bmp; }));
    //Call again for an infinite loop
    Task.Run(() => GenerateNewBitmap());
}

На моей машине кажется, что если я создаю более 500 тыс. пикселей, я не могу генерировать навсегда, а .NET дает мне OutOfMemoryException. Эта вещь о классе Bitmap была верна в 2005 году, и она по-прежнему верна в 2015 году. Bitmap класс важен, потому что он существует в библиотеке в течение длительного времени. Имея исправления ошибок, улучшения производительности на этом пути, я думаю, если он не может сделать что-то, что мне нужно, тогда мне нужно изменить мою потребность.

Во-первых, вещь об одноразовом объекте - вам нужно позвонить Dispose самостоятельно. Нет, вам действительно нужно называть это самостоятельно. Серьезно. Я предлагаю включить соответствующие правила в анализ кода VisualStudio и использовать using и т.д. Соответственно.

Во-вторых, вызов метода Dispose не означает вызов delete (или free) на неуправляемой стороне. То, что я сделал, и я думаю, вам нужно использовать подсчет ссылок. Если ваша неуправляемая сторона использует С++, я предлагаю использовать shared_ptr. Поскольку VS2012, насколько я знаю, VisualStudio поддерживает shared_ptr.

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