Повышение производительности сборки мусора

87

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

Модель поколений

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

Теперь мы можем коротко обозначить эффективные приемы взаимодействия с моделью поколений и затем рассмотреть несколько примеров.

Далее описываются ситуации, демонстрирующие риски, связанные с явлением «кризиса среднего возраста». В приложении мониторинга с графическим интерфейсом, реализованном одним из наших клиентов, в главном окне постоянно отображалось 20 000 записей из журнала. Каждая запись содержала информацию об уровне важности, краткое сообщение и дополнительную контекстную информацию (иногда весьма значительного объема). Эти 20 000 записей непрерывно замещались новыми записями.

Из-за большого количества отображаемых записей, большинство их переживало два цикла сборки мусора и достигало поколения 2. Однако эти записи, по сути, являлись короткоживущими объектами, и замещались новыми вскоре после попадания в поколение 2, во всей полноте проявляя феномен «кризиса среднего возраста». В результате приложению приходилось выполнять сотни циклов полной сборки мусора в минуту, затрачивая на это примерно 50% всего времени работы кода.

Выполнив предварительный анализ, мы пришли к заключению, что отображение на экране 20 000 записей не является насущной необходимостью. Мы сократили количество отображаемых записей до 1000 и реализовали пул объектов, чтобы повторно использовать существующие объекты записей вместо создания новых. Эти меры позволили уменьшить потребление памяти приложением, но, что еще более важно, уменьшить время на сборку мусора до 0.1%, при этом частота полной сборки мусора уменьшилась до одного раза в несколько минут.

С другим проявлением «кризиса среднего возраста» мы столкнулись в реализации одного из наших веб-серверов. Система веб-серверов была разделена на несколько фронтальных серверов, принимающих запросы. Для обработки запросов эти фронтальные серверы синхронно вызывали веб-службы, выполняющиеся на внутренних серверах.

В тестовом окружении вызов веб-службы между фронтальным и внутренним сервером занимал порядка нескольких миллисекунд. Это обеспечивало непродолжительность существования объектов HTTP-запросов и быстрое их уничтожение.

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

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

Чтобы разрешить эту проблему, можно перейти на асинхронную обработку запросов или освобождать объекты запросов настолько быстро, насколько это возможно (перед выполнением синхронного обращения к службе). Применение этих двух приемов одновременно позволило сократить время на сборку мусора до 3% и повысить способность сайта к масштабированию 3 раза!

Наконец, представьте простую систему отображения двумерной графики. В подобных системах поверхность рисования является долгоживущей сущностью, постоянно перерисовывающей себя, создавая и замещая короткоживущие пиксели разного цвета и с разной степенью прозрачности. Если эти пиксели представить в виде объектов ссылочного типа, мы не только удвоили или утроили бы расход памяти, но также получили бы ссылки между поколениями и, как результат, огромный граф объектов, представляющих пиксели. Единственный выход - использовать для представления пикселей типы значений, которые помогут уменьшить расход памяти в 2-3 раза и сэкономить время на сборку мусора в десятки раз.

Закрепление

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

Финализация

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

Ниже перечислены наиболее эффективные приемы, касающиеся использования поддержки финализации в приложениях:

Разные советы и рекомендации

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

Типы значений

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

Графы объектов

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

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

Использование пулов объектов

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

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

Фреймворк Windows Communication Foundation (WCF) реализует пул массивов байтов и использует его для хранения и передачи сообщений. Фасадом пула служит абстрактный класс BufferManager из пространства имен System.ServiceModel.Channels, предоставляющий возможность получения массива байтов из пула и возврата его в пул. Фреймворк включает две внутренние реализации абстрактных базовых операций, использующих механизмы управления памятью на основе сборщика мусора и управлением пула буферов. Реализация пула (на момент написания этих строк) обеспечивает управление множеством пулов буферов разных размеров. Похожий прием используется в алгоритме Low-Fragmentation Heap (алгоритм организации динамической памяти с низкой фрагментацией), впервые реализованном в Windows XP.

Для эффективной реализации пула требуется учитывать, как минимум, следующие факторы:

В большинстве реализаций пулов дополнительные выгоды можно получить от использования механизма выбора последних использовавшихся блоков (Least Recently Used, LRU) при распределении новых объектов, потому что последние использовавшиеся блоки с большей долей вероятности окажутся в кеше процессора.

Реализация пула в .NET должна предусматривать методы выделения и освобождения экземпляров типа, размещаемого в пуле. Мы не можем организовать управление этими операциями на уровне синтаксиса (оператор new не предусматривает возможность перегрузки), но мы можем использовать альтернативный API, например, определить метод Pool.GetInstance(). Возврат объекта в пул лучше реализовать с использованием шаблона Dispose и предусматривать метод-финализатор, как запасной вариант.

В следующем примере приводится чрезвычайно упрощенная реализация пула, а также объявление типа объектов, которые могут помещаться в пул:

public class Pool<T>
{
    private ConcurrentBag<T> pool = new ConcurrentBag<T>();
    private Func<T> objectFactory;

    public Pool() { }
    public Pool(Func<T> factory)
    {
        objectFactory = factory;
    }

    public T GetInstance()
    {
        T result;
        if (!pool.TryTake(out result))
        {
            result = objectFactory();
        }
        return result;
    }

    public void ReturnToPool(T instance)
    {
        pool.Add(instance);
    }
}

public class PoolableObjectBase<T> : IDisposable
{
    private static Pool<T> pool = new Pool<T>();
    public void Dispose()
    {
        pool.ReturnToPool(this);
    }

    ~PoolableObjectBase()
    {
        GC.ReRegisterForFinalize(this);
        pool.ReturnToPool(this);
    }
}

public class MyPoolableObjectExample : 
    PoolableObjectBase<MyPoolableObjectExample> 
{
    // ...
}

Вытеснение в файл подкачки и выделение неуправляемой памяти

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

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

Представьте приложение, выполняющееся на компьютере, имеющем 8 Гбайт физической памяти (ОЗУ). Такие компьютеры скоро должны исчезнуть, но саму ситуацию легко можно распространить на любой объем памяти, пока приложение способно адресовать ее (в 64-разрядных системах это очень большой объем). Приложение выделяет 12 Гбайт памяти, из которых 8 Гбайт будут находиться в физической памяти, а остальные 4 Гбайта будут вытеснены на диск, в файл подкачки. Диспетчер памяти Windows обеспечит сохранность страниц с наиболее часто используемыми объектами в физической памяти, а страницы с редко используемыми объектами вытеснит на диск.

В нормальном режиме работы приложение может вообще не вызывать операции обмена с файлом подкачки, потому что очень редко использует объекты, хранящиеся там. Однако, когда запускается цикл полной сборки мусора, сборщик мусора должен обойти все досягаемые объекты, чтобы построить граф объектов в фазе маркировки. Для обхода всех досягаемых объектов придется прочитать с диска 4 Гбайта информации. Так как физическая память заполнена, потребуется записать на диск 4 Гбайта данных, чтобы освободить память для извлекаемых с диска объектов. Наконец, после сборки мусора приложение попытается обратиться к часто используемым объектам, которые могли быть вытеснены на диск.

На момент написания этих строк типичные жесткие диски имели скорость обмена данными, порядка 150 Мбайт/сек (даже твердотельные высокоскоростные диски SSD имеют скорость обмена всего в два раза выше). То есть, чтобы осуществить передачу 8 Гбайт данных потребуется примерно 55 секунд. В течение этого времени приложение будет ждать завершения сборки мусора (если не используется параллельный сборщик мусора). Добавление дополнительных процессоров (то есть, использование версии сборщика мусора для сервера) не поможет в этой ситуации, потому что узким местом является диск. Наличие в системе других выполняющихся приложений еще больше снизит производительность, потому что жесткому диску придется удовлетворять конкурирующие обращения к файлу подкачки.

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

Другой пример, имеющий отношение к управлению вытеснением в файл подкачки - закрепление страниц в физической памяти.

Приложения для Windows имеют документированную возможность обратиться к системе и запросить закрепление некоторой области в физической памяти (система игнорирует такие запросы только в исключительных ситуациях). Данный механизм нельзя использовать непосредственно, в сочетании со сборщиком мусора .NET, потому что управляемые приложения не могут непосредственно управлять выделением виртуальной памяти, выполняемым средой выполнения CLR. Однако, при использовании приема размещения CLR, приложение может закреплять страницы в памяти, в ходе удовлетворения запросов на выделение виртуальной памяти, посылаемых средой CLR.

Правила статического анализа кода (FxCop)

Инструмент статического анализа кода (FxCop) для Visual Studio имеет ряд правил, позволяющих устранить типичные проблемы производительности, связанные со сборкой мусора. Мы рекомендуем следовать этим правилам, потому что они помогают устранить множество ошибок на этапе кодирования. За дополнительной информацией об анализе управляемого кода с или без применения Visual Studio, обращайтесь к электронной документации на сайте MSDN.

Ниже перечислены основные правила, имеющие отношение к сборке мусора, которые распознаются в Visual Studio 11:

Итак, на протяжении нескольких статей мы знакомились с моделью и реализацией сборщика мусора в .NET, ответственного за автоматическое освобождение неиспользуемой памяти. Мы исследовали альтернативы сборке мусора на основе трассировки, включая подсчет ссылок и список свободных блоков.

В основе сборщика мусора .NET лежат следующие концепции, исследованные нами в подробностях:

Самые сильные оптимизации в работе сборщика мусора, как правило, сопровождаются ловушками:

  1. Модель поколений дает определенные выгоды правильно спроектированным приложениям, но может проявлять эффект «кризиса среднего возраста», отрицательно сказывающийся на производительности.

  2. Необходимость в закреплении объекта возникает всякий раз, когда его необходимо передать по ссылке неуправляемому коду, но она может приводить к фрагментации динамической памяти, даже в младших поколениях.

  3. Сегменты обеспечивают выделение виртуальной памяти большими блоками, но могут страдать от фрагментации, вызванной внешними причинами.

  4. Автоматическая финализация дает удобный способ освобождения неуправляемых ресурсов, но сопровождается высокими накладными расходами и часто приводит к эффекту «кризиса среднего возраста», утечкам памяти и к состоянию гонки.

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

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

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

Пройди тесты
Лучший чат для C# программистов