Производительность при запуске приложения

123

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

Очень важно отличать холодный запуск (cold startup), когда приложение запускается впервые после загрузки системы, и теплый запуск (warm startup), когда приложение запускается (не в первый раз) после того, как система уже поработала некоторое время. Системные службы и фоновые агенты должны иметь очень короткое время холодного запуска, чтобы пользователю не пришлось слишком долго ждать возможности войти в систему.

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

Существует несколько факторов, объясняющих длительное время запуска. Некоторые из них применимы только к холодному запуску; другие - к обоим типам запуска:

В нашем распоряжении имеется несколько измерительных инструментов, позволяющих диагностировать наиболее вероятные причины длительного времени запуска. Sysinternals Process Monitor поможет выявить операции ввода/вывода, выполняемые прикладным процессом, независимо от того, кто является их инициатором - Windows, CLR или прикладной код. PerfMonitor и счетчики производительности из категории .NET CLR JIT могут помочь выявить чрезмерные затраты времени на JIT-компиляцию на этапе запуска приложения. Наконец, «стандартные» профилировщики (в дискретном или инструментированном режиме) способны помочь выявить участки кода, на выполнение которых тратится большая часть времени на этапе запуска.

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

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

Предварительная JIT-компиляция с помощью NGen (Native Image Generator)

JIT-компилятор очень удобен и компилирует методы, только когда они действительно вызываются, однако приложению приходится порой дорого платить своей производительностью за его использование. Для решения этой проблемы платформа .NET Framework предлагает инструмент оптимизации с названием Native Image Generator (генератор низкоуровневых образов, NGen.exe), который может компилировать сборки в машинный код (native images - низкоуровневые образы) перед запуском.

Если все сборки, используемые приложением, будут предварительно скомпилированы этим инструментом, отпадет необходимость загружать JIT-компилятор и использовать его в процессе запуска приложения. Даже при том, что сгенерированный низкоуровневый образ чаще оказывается больше оригинальной сборки, в большинстве случаев объем дискового ввода/вывода снижается, потому что отпадает необходимость загружать JIT-компилятор (clrjit.dll) и метаданные используемых сборок.

Предварительная компиляция дает еще один положительный эффект - низкоуровневые образы могут совместно использоваться процессами, в отличие от кода, который генерируется JIT-компилятором во время выполнения. Если несколько процессов на одном и том же компьютере будут использовать низкоуровневый образ одной и той же сборки, общее потребление физической памяти окажется ниже, чем при использовании JIT-компилятора. Это особенно важно в системах с открытым доступом, когда несколько пользователей подключаются к общему серверу через службу терминалов (Terminal Services) и запускают одно и то же приложение.

Чтобы скомпилировать приложение, достаточно просто указать инструменту NGen.exe, где находится главная сборка приложения (обычно .exe файл). Генератор NGen отыщет все статические зависимости основной сборки и скомпилирует их все в низкоуровневые образы. Получившиеся образы будут сохранены в кеше - по соседству с глобальным кешем сборок (GAC), в папках C:\Windows\Assembly\NativeImages_* по умолчанию.

Так как CLR и NGen управляют кешем низкоуровневых образов автоматически, вы не должны копировать низкоуровневые образы с одного компьютера на другой. Единственный доступный способ получить скомпилированные сборки в той или иной системе - воспользоваться инструментом NGen. Лучше всего это делать в процессе установки приложения (NGen поддерживает даже команду «defer» («отложить»), которая передаст компиляцию фоновой службе). Именно так поступает мастер установки .NET Framework в отношении часто используемых сборок .NET.

Ниже приводится законченный пример использования инструмента NGen для предварительной компиляции простого приложения, состоящего из двух сборок - файла main.exe и вспомогательной библиотеки *.dll. NGen благополучно определяет зависимость от этой библиотеки и создает низкоуровневые образы для обеих сборок:

JIT-компиляция приложения с помощью NGen

Во время выполнения среда CLR будет использовать низкоуровневые сборки, вообще не загружая библиотеку clrjit.dll JIT-компилятора (в выводе команды lm, что приводится ниже, библиотека clrjit.dll отсутствует). Таблицы методов типов также сохраняются в низкоуровневых образах и содержат указатели на скомпилированные версии внутри образов:

Машинный код скомпилированного с помощью NGen  приложения

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

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

При использовании CLR 4.5 в Windows 8, NGen не ждет пассивно, пока вы дадите команду скомпилировать прикладные сборки. Вместо этого CLR генерирует файлы журналов с информацией об использовании сборок, которые обрабатываются фоновым заданием поддержки инструмента NGen. Это задание решает, какие сборки можно скомпилировать, и запускает NGen с целью создания низкоуровневых образов для них. Вы все еще можете использовать команду display, чтобы получить перечень содержимого кеша низкоуровневых образов (или исследовать файлы в кеше из командной строки), но основное бремя принятия решения о предварительной компиляции теперь берет на себя среда выполнения CLR.

Фоновая JIT-компиляция в многопроцессорных системах

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

Эта особенность включена по умолчанию в приложениях для ASP.NET и Silverlight 5.

Чтобы задействовать фоновую JIT-компиляцию, необходимо вызвать два метода класса System.Runtime.ProfileOptimization. Первый метод сообщает профилировщику - где хранить необходимую информацию, а второй определяет, какой сценарий запуска выполняется. Цель второго метода - обеспечить различение существенно разных сценариев запуска, чтобы в разных ситуациях использовались разные оптимизации запуска.

Например, утилита архивирования может быть вызвана с параметром, сообщающим ей «показать список файлов в архиве», требующим выполнить один набор методов, или с параметром «создать архив для указанного каталога», требующим выполнить совершенно иной набор методов:

public static void Main(string[] args)
{
    System.Runtime.ProfileOptimization.SetProfileRoot(
    Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));

    if (args[0] == "display")
    {
        System.Runtime.ProfileOptimization.StartProfile("DisplayArchive.prof");
    }
    else if (args[0] == "compress")
    {
        System.Runtime.ProfileOptimization.StartProfile("CompressDirectory.prof");
    }

    // ... Другие сценарии запуска.
    // После определения сценария запуска следует остальной код приложения
}

Упаковщики образов

Часто для уменьшения объема ввода/вывода используют прием сжатия исходных данных. В конце концов, бессмысленно загружать 15 Гбайт установочных файлов Windows в распакованном виде, если в сжатом виде они умещаются на единственном DVD-диске. Эту идею можно распространить и на управляемые приложения, хранящиеся на диске.

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

Существует несколько коммерческих и свободно распространяемых утилит сжатия для приложений (которые обычно называются упаковщиками). Если у вас уже имеется подобный упаковщик, проверьте - поддерживает ли он сжатие приложений для .NET Applications. Некоторые упаковщики могут обслуживать только двоичные файлы неуправляемых приложений.

В качестве примера упаковщика, способного упаковывать приложения для .NET, можно назвать MPress, который распространяется бесплатно. Еще одним примером может служить упаковщик Rugland Packer for .NET Executables (RPX) - свободно распространяемая утилита. Ниже приводится пример вывода утилиты RPX, запущенной для обработки небольшого приложения:

Упаковка приложения .NET с помощью утилиты RPX

Управляемая оптимизация на основе профилирования

Управляемая оптимизация на основе профилирования (Managed Profile Guided Optimization, MPGO) - это инструмент, появившийся в Visual Studio 11 и CLR 4.5 и оптимизирующий размещение на диске низкоуровневых образов, созданных с помощью NGen. MPGO генерирует информацию об определенном периоде выполнения приложения и сохраняет ее в сборке. Впоследствии NGen использует эту информацию для оптимизации размещения сгенерированных образов.

Оптимизация образов с помощью MPGO выполняется двумя способами. Во-первых, MPGO обеспечивает совместное хранение часто используемого кода и данных на диске. Как результат, будет меньше ошибок доступа к страницам памяти при обращении к данным, потому что больше часто используемых данных уместится в одной странице памяти. Во-вторых, MPGO обеспечивает совместное хранение на диске данных, которые наверняка будут изменяться. Когда страница с данными, совместно используемыми несколькими процессами, изменяется, Windows создает скрытую копию страницы для процесса, изменившего ее (этот прием называется копированием при записи (copy-on-write)).

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

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

Оптимизация приложения с помощью MPGO

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

На момент написания этих строк не существовало планов по включению MPGO в пользовательский интерфейс Visual Studio 2012. Командная строка - единственный доступный способ добавить все описанные оптимизации в свое приложение. Так как MPGO опирается на использование NGen, это еще одна разновидность оптимизаций, которые лучше выполнять на целевой машине уже после установки.

Различные советы по оптимизации времени запуска

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

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

Если ваши сборки имеют строгие имена, поместите их в глобальный кеш сборок (GAC). Иначе при загрузке сборки потребуется проверить цифровую подпись почти каждой страницы. Проверка строгих имен, когда сборка находится за пределами глобального кеша GAC, уменьшит выгоды, получаемые от применения NGen.

Убедитесь, что низкоуровневые образы не требуют перемещения в памяти

При использовании NGen обязательно проверяйте отсутствие конфликтов базовых адресов получаемых низкоуровневых образов, требующих перемещения кода и данных в памяти. Такое перемещение - довольно дорогостоящая операция, в ходе которой выполняется изменение адресов в коде и создаются копии страниц с кодом, которые в отсутствие конфликтов могли бы использоваться совместно. Узнать базовый адрес образа можно с помощью утилиты dumpbin.exe, запустив ее с флагом /headers, как показано ниже:

Проверка базового адреса образа

Чтобы изменить базовый адрес низкоуровневого образа, измените базовый адрес в свойствах проекта в Visual Studio. Базовый адрес можно найти в диалоге Advanced Build Settings (Дополнительные параметры построения), открывающемся после щелчка на кнопке Advanced (Дополнительно) на вкладке Build:

Диалог Advanced Build Settings

Начиная с версии .NET 3.5 SP1, NGen автоматически использует механизм рандомизации адресного пространства (Address Space Layout Randomization, ASLR), когда приложение выполняется в Windows Vista или в более новой версии Windows. При использовании механизма ASLR, по соображениям безопасности базовый адрес образов выбирается случайным образом при каждом запуске приложения. В этой ситуации проблема перемещения сборок во избежание конфликтов базовых адресов в Windows Vista и более новых версиях не имеет такого большого значения.

Уменьшайте общее количество сборок

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

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