Продолжается подписка на наши издания! Вы не забыли подписаться?

Создание высокопроизводительных управляемых приложений

Авторы: Gregor Noriskin

Microsoft CLR Performance Team

Жонглирование как метафора разработки ПО

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

Разные роли в процессе разработки ПО жонглируют разными "тройками". Менеджеры проектов жонглируют Возможностями, Ресурсами и Временем, разработчики – Корректностью, Производительностью и Безопасностью. Можно попробовать жонглировать большим количеством предметов, но, как скажет вам любой ученик жонглера, сложность с добавлением одного шара растет по экспоненте. Если вы, как разработчик, не считаетесь с корректностью, производительностью и безопасностью вашего кода, может выясниться, что вы занимаетесь не своим делом. Когда вы начнете думать о Корректности, Производительности и Безопасности, вы обнаружите, что сосредоточиться можно только на одном аспекте одновременно. Когда они станут частью вашей ежедневной практики, вы обнаружите, что вам больше не нужно фокусироваться на определенном аспекте. Когда вы добьетесь мастерства, вы сможете интуитивно делать выбор, и соответственно фокусировать усилия. Как и при жонглировании, ключ – в практике.

Создание высокопроизводительного кода - тоже триединая задача, состоящая из Постановки Задачи, Измерения и Понимания Целевой платформы. Если вы не знаете, насколько быстрым должен быть ваш код, как вы узнаете, что добились нужного результата? Как без измерений и профилирования кода узнать, что вы достигли своей цели, или почему вам не удается ее достичь? Если вы не понимаете платформы, с которой работаете, как вы узнаете, что оптимизировать в случае, когда вам не удается добиться повышения производительности? Эти принципы применимы к разработке высокопроизводительного кода в целом, независимо от платформы. Ни одна статья о создании высокопроизводительного кода не будет полной без упоминания этой троицы. Хотя все три части задачи одинаково важны, эта статья посвящена в основном двум последним аспектам, поскольку они применимы для написания выскопроизводительных приложений под Microsoft .NET Framework.

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

.NET Common Language Runtime

Ядро .NET Framework – это Common Language Runtime (CLR). CLR предоставляет вашему коду службы времени исполнения, JIT-компиляцию, управление памятью, безопасность и т.д. Сервисы CLR рассчитаны на высокую производительность. Следовательно, существуют способы воспользоваться этими сервисами так, чтобы получить максимальную производительность, и способы этому помешать.

Цель этой статьи – дать обзор CLR с точки зрения производительности, указать лучшие пути повышения производительности управляемого кода и показать, как измеряется производительность управляемых приложений. Эта статья не претендует на всеобъемлющее рассмотрение характеристик производительности .NET Framework. В этой статье я буду подразумевать, что производительность характеризуется пропускной способностью, масштабируемостью, временем запуска и использованием памяти.

Управляемые данные и сборка мусора

Немаловажной составной частью временных затрат приложения являются затраты времени на упроавление памятью, осуществляемого в CLR Garbage Collector (GC). Стоимость управления памятью – это функция от стоимости выделения памяти под объекты, стоимости управления этой памятью в период жизни экземпляра и стоимости освобождения более не нужной памяти.

Управляемое размещение обычно очень дешево; в большинстве случаев оно занимает меньше времени, чем займет вызов malloc или new в C/C++. Причина этого в том, что CLR не нужно сканировать список свободной памяти, чтобы найти следующий свободный блок памяти с размером, достаточным для нового объекта; у него есть указатель на следующую свободную позицию в памяти. О размещении управляемой кучи можно думать как о подобии стека. Размещение может стать причиной сборки мусора, если GC нужно освободить память для размещения нового объекта. В этом случае размещение обойдется дороже, чем malloc или new в C/C++. Закрепленные (pinned) объекты также могут повлиять на стоимость размещения. Закрепленные объекты – это объекты, которые GC приказано не перемещать при сборке мусора, обычно потому, что адрес объекта передается native API-функции.

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

GC в CLR использует алгоритм Generational Mark and Sweep (на русский язык название алгоритма можно перевести как “пометить поколение и подмести"). Управляемая куча содержит три поколения: Поколение 0 содержит новые объекты, Поколение 1 содержит несколько дольше живущие объекты, а Поколение 2 – долгоживущие. GC собирает наименьшую возможную часть кучи для освобождения памяти, достаточной для продолжения работы приложения. Сборка поколения означает сборку всех младших поколений, то есть при сборке Поколения 1 производится также сборка Поколения 0. Размер Поколения 0 устанавливается динамически в соответствии с размером кэша процессора и скоростью заема памяти приложением, его сборка обычно занимает менее 10 милисекунд. Размер Поколения 1 устанавливается динамически в соответствии со скоростью заема памяти приложением, его сборка обычно занимает от 10 до 30 милисекунд. Размер Поколения 2, как и время сборки, зависит от профиля заема памяти приложением. Именно сборка Поколения 2 наиболее существенно влияет на временные затраты при управлении памятью приложения.

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

В процессе сборки ЖЦ может перемещать живые объекты. Если эти объекты велики, стоимость перемещения будет высокой. Поэтому такие объекты размещаются в специальной области кучи - Large Object Heap. Для Large Object Heap производится сборка, но не сжатие, например, большие объекты не перемещаются. Большие объекты – это объекты, чей размер превышает 80kb. Необходимость в сборке для Large Object Heap вызывает полную сборку мусора, причем сборка Large Object Heap производится при сборке Поколения 2. Интенсивность размещения и удаления объектов из Large Object Heap может существенно влиять на стоимость управления памятью приложения.

Профили размещения

Общий профиль размещения для управляемого приложения определяет, насколько трудно придется GC при управлении памятью, ассоциированной с приложением. Чем тяжелее приходится GC при управлении памятью, тем больше циклов CPU будет отбирать GC, и тем меньше времени CPU будет уделять исполнению кода приложения. Профиль размещения – это функция от числа размещенных объектов, размера этих объектов и их сроков жизни. Наиболее очевидный путь снижения нагрузки на GC – уменьшение количества объектов. Приложения, рассчитанные на расширяемость, модульность и повторное использование, применяющие объектно-ориентированные технологии дизайна, практически всегда приводят к увеличению количества размещений объектов. Это – плата производительностью за абстрактность и "элегантность"

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

Недружественный к GC профиль размещения содержит много объектов, живущих в Поколении 2, или много короткоживущих объектов в Large Object Heap. Объектами, живущими достаточно долго, чтобы попасть в Поколение 2, и сразу же помереть, управлять труднее всего. Также, как говорилось выше, объекты из старших поколений, содержащие ссылки на объекты из младших поколений, повышают стоимость сборки мусора.

Типичный реальный профиль размещения находится где-то посередине, между двумя профилями, описанными выше. Важная метрика профиля размещения – процент времени, которое CPU тратит на GC. Этот показатель можно получить, воспользовавшись счетчиком производительности .NET CLR Memory: % Time in GC. Если значение этого счетчика превышает 30%, вам, вероятно, стоит присмотреться к профилю вашего приложения. Это не обязательно значит, что профиль размещения "плох", существуют интенсивно использующие память приложения, для которых такой уровень GC необходим и приемлем.

Если счетчик производительности ".NET CLR Memory: % Time in GC" показывает, что в среднем более 30% времени работы приложения приходится на GC, следует обратить внимание на профиль размещения.

Дружественное к GC приложение существенно больше занято сборкой Поколения 0, чем поколения 2. Это соотношение можно узнать, воспользовавшись счетчиками производительности "NET CLR Memory: # Gen 0 Collections" и "NET CLR Memory: # Gen 2 Collections"

API профилирования и CLR Profiler

В CLR входит мощный API профилирования, позволяющий сторонним разработчикам создавать собственные профилировщики для управляемых приложений. CLR Profiler – это пример инструмента для профилировки размещения памяти. Он создан CLR Product Team, использующей этот API. CLR Profiler позволяет разработчикам видеть профиль размещения памяти для их управляемых приложений.


Рисунок 1. Главное окно CLR Profiler.

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

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

Использование серверного GC

В CLR есть два разных GC: Workstation GC и Server GC. Консольные приложения и приложения Windows Forms используют Workstation GC, а ASP.NET – Server GC. Server GC оптимизирован для многопроцессорных серверных систем. Этот GC приостанавливает все потоки, исполняющие управляемый код на все время сборки, включая фазы пометки и удаления, причем GC работает параллельно на всех CPU, доступных процессу, в высокоприоритетных потоках. Если потоки в это время исполняют unmanaged-код, они приостанавливаются только после возврата неуправляемого вызова. Если вы создаете серверное приложение, которое должно работать на многопроцессорных машинах, вам следует использовать серверный GC. Если приложение работает не под ASP.NET, вам придется написать собственное unmanaged-приложение, которое явно запускает CLR.

Если вы создаете масштабируемое серверное приложение, используйте серверный GC. См. Implement a Custom Common Language Runtime Host for Your Managed App (http://msdn.microsoft.com/msdnmag/issues/01/03/clr/default.aspx).

Workstation GC создан в расчете на минимизацию времени задержек, что обычно нужно клиентским приложениям. Как правило, заметные паузы во время сборки мусора нежелательны для клиентского приложения, поскольку обычно производительность клиента - это не столько пропускная способность, сколько производительность, воспринимаемая пользователем. Workstation GC выполняет параллельную сборку мусора, то есть управляемый код во время фазы пометки продолжает исполняться. GC будет приостанавливать потоки, исполняющие управляемый код, только на время фазы удаления. Workstation GC выполняет сборку мусора только в одном потоке и, следовательно, на одном CPU.

Финализация

CLR предоставляет механизм, посредством которого освобождение ресурсов производится автоматически до того, как память, ассоциированная с некоторым объектом, освобождается. Этот механизм называется финализацией (Finalization). Обычно финализация используется для освобождения native-ресурсов, например, подключений к БД или дескрипторов ОС, используемых объектом.

Финализация – это дорогая вещь, повышающая нагрузку на GC. GC отслеживает объекты, требующие финализации, в очереди финализации (Finalizable Queue). Если во время сборки GC находит объект, который уже не живет, и требует финализации, вхождение этого объекта из Finalizable Queue перемещается в FReachable Queue. Финализация производится в отдельном потоке (Finalizer Thread). Поскольку состояние объекта может потребоваться при исполнении Finalizer-а, объект, и все объекты, на которые он указывает, продвигаются в следующее поколение. Память, ассоциированная с объектом или графом объектов, освобождается при следующей сборке мусора.

Ресурсы, которые требуется освобождать, нужно оборачивать в минимально возможный финализируемый объект. Например, если класс требует ссылок как на управляемые, так и на unmanaged-ресурсы, нужно обернуть неуправляемые ресурсы в новый финализируемый класс и сделать этот класс частью вашего класса. Родительский класс при этом не должен быть финализируемым. Это значит, что в следующее поколение продвигается только класс, содержащий unmanaged-ресурсы (предполагается, что вы не удерживаете ссылку на родительский класс в классе, содержащем unmanaged-ресурсы). Еще нужно помнить о том, что существует только один поток финализации. Если Finalizer блокирует этот поток, последующие Finalizer-ы не будут вызваны, ресурсы не будут освобождены, а приложение будет давать утечку ресурсов.

Finalizer-ы должны быть настолько простыми, насколько это возможно, и никогда не блокироваться.

Делайте финализируемым только класс-обертку вокруг unmanaged-объектов, требующих очистки.

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

Паттерн Dispose

В случае, когда срок жизни объекта известен, unmanaged-ресурсы, ассоциированные с объектом, должны освобождаться сразу по окончании этого срока. Подобный паттерн называется Disposing. Паттерн Dispose реализуется через интерфейс IDisposable (хотя самостоятельно этот паттерн реализовать тоже тривиально). Для объектов, требующих освобождения ресурсов, нужно реализовать интерфейс IDisposable, в методе Dispose которого вызывать тот же код очистки, что и в финализаторе, а также, с помощью вызова метода GC.SuppressFinalization, информировать GC о том, что объект больше не нуждается в финализации. Хорошо также заставить метод Dispose и финализатор вызывать общую функцию финализации – это позволит поддерживать только одну версию кода очистки. Кроме того, если семантика объекта такова, что метод Close будет логичнее, чем метод Dispose, стоит реализовать и метод Close; в этом случае сокет или подключение к БД логично "закрываются". Close может просто вызывать метод Dispose.

Хорошая практика – создавать метод Dispose для классов с финализатором; никогда нельзя быть уверенным, как будет использоваться класс, например, будет его срок жизни точно известен или нет. Если используемый класс реализует паттерн Dispose и вы точно знаете, когда закончите работу с объектом, вызывайте Dispose.

Обеспечьте наличие метода Dispose во всех финализируемых классах.

Вызывайте общую функцию очистки.

Если используемый объект реализует IDisposable, и вы знаете, что объект больше не нужен, вызовите Dispose.

C# предоставляет очень удобный способ автоматического освобождения объектов. Ключевое слово using позволяет идентифицировать блок кода, после которого Dispose будет вызван для ряда освобождаемых объектов.

using(DisposableType T)
{
   //Некоторые действия над T
}
//T.Dispose() вызывается автоматически

Заметки о слабых ссылках (Weak References)

Любая ссылка на объект, находящийся в стеке, регистре, глобальной (статической) переменной, или одном из других корней GC (набор ссылок, напрямую доступных GC, и являющихся корнями иерархий доступных объектов – прим. ред.), при сборке мусора сохранит объект в живых. Это, как правило, очень хорошо, при условии, что приложение не закончило работу с данным объектом. Однако возможны случаи, когда вам нужна ссылка на объект, но не нужно влияние на его срок жизни. Для этого CLR предоставляет механизм слабых ссылок (Weak References). Любую обычную ссылку можно превратить в слабую. Слабая ссылка может понадобиться, например, если вы хотите создать внешний курсор, который способен перемещаться по структуре данных, но не влияет на срок жизни объекта. Другой пример – создание кэша, отключаемого при нехватке памяти, скажем, при сборке мусора.

Создание слабой ссылки на C#:

MyRefType mrt = new MyRefType();
//...

// Создание слабой ссылки
WeakReference wr = new WeakReference(mrt); 
// на объект нет более полноценных ссылок
mrt = null; 
//...

// Был ли объект собран сборщиком мусора?
if(wr.IsAlive)
{
   // получение полноценной ссылки на объект 
   mrt = wr.Target;
}
else
{
   // Воссоздание объкта
   mrt = new MyRefType();
}

Управляемый код и CLR JIT

Управляемые сборки, являющиеся единицами распространения управляемого кода, содержат независимй от процессора язык, Microsoft Intermediate Language (MSIL или IL). CLR Just-In-Time (JIT) компилирует IL в оптимизированные инструкции целевого процессора. JIT – это оптимизирующий компилятор, но, поскольку компиляция производится во время исполнения, осуществляемый им уровень оптимизации должен быть сбалансировать со временем компиляции. Для серверных приложений это, как правило, некритично, поскольку время их запуска и отклика обычно не является проблемой, но это критично для клиентских приложений. Заметьте, что время запуска можно уменьшить компиляцией во время установки с помощью NGEN.exe.

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

Inlining методов

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

Нужно тщательно подумать, прежде чем кодировать в расчете на эти эвристики, поскольку они могут измениться в будущих версиях JIT. Интересно отметить, что ключевые слова inline и __inline в C++ не гарантируют, что компилятор произведет inlining метода (в отличие от __forceinline, которое гарантирует).

Методы доступа к свойствам (get и set) обычно хорошие кандидаты на inlining, поскольку все, что они обычно делают – инициализация private-членов данных.

Не жертвуйте корректностью методов в попытке гарантировать inlining.

Исключение проверки диапазона (Range check elimination)

Одно из многих преимуществ управляемого кода – автоматическая проверка размера массивов; при каждом обращении к массиву с использованием семантики array[index] JIT проверяет, находится ли индекс в пределах массива. В контексте циклов с большим количеством итераций и малым количеством инструкций в каждой итерации эти проверки могут быть довольно дорогими. В некоторых случаях JIT может опрделить, что такие проверки не нужны, и исключить проверку из тела цикла, производя ее один раз в начале исполнения цикла. В C# есть паттерн программирования для обеспечения исключения таких проверок: явная проверка длины массива в выражении "for". Заметьте, что незначительные отклонения от этого паттерна приведут к тому, что проверка не будет исключена.

// Проверка будет исключена
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

// Проверка НЕ будет исключена
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

Оптимизация особенно заметна, например, при поиске в больших массивах массивов (jagged arrays), где исключаются проверки как во внешнем, так и во внутреннем цикле.

Оптимизация, требующая отслеживания использования переменных

Некоторые виды оптимизации, производимые JIT, требуют отслеживания использования формальных аргументов и локальных переменных; например, их первого и последнего использования в теле метода. В версиях CLR 1.0 и 1.1 есть ограничение в 64 переменных, отслеживаемых JIT. Пример оптимизации, требующей отслеживать использование – Enregistration. В случае Enregistration значения переменных помещаются в регистры процессора, а не во фрейм стека. Доступ к таким переменным существенно быстрее, чем к находящимся во фрейме стека, даже если переменные, хранящиеся во фрейме стека, находятся в кэше процессора. Enregistration можно использовать только для 64 переменных, все остальные будут помещаться в стек. Есть и другие виды оптимизации, зависящие от отслеживания использования. Для применения максимального количества видов оптимизации число нормальных аргументов и локальных переменных не должно превышать 64. Это число может измениться в будущих версиях CLR.

Другие виды оптимизации

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

Почему я не вижу этих видов оптимизации в Visual Studio?

При использовании Start из меню Debug или нажатии F5 для запуска приложения в Visual Studio, и в Release, и в Debug-версиях все JIT-оптимизации отключены. При запуске управляемого приложения из-под отладчика, даже не в Debug-версии приложения, JIT генерирует не оптимизированные инструкции х86. Если вы хотите заставить JIT генерировать оптимизированный код, запустите приложение из Windows Explorer или используйте CTRL+F5 в Visual Studio. Если вы хотите сравнить оптимизированный и не оптимизированный код, используйте cordbg.exe.

Используйте cordbg.exe, чтобы увидеть дизассемблированный оптимизированный или не оптимизированный код, созданный JIT. После запуска приложения под cordbg.exe можно задавать режим JIT.

 (cordbg) mode JitOptimizations 1

JIT будет генерировать оптимизированный код.

(cordbg) mode JitOptimizations 0

JIT будет генерировать отладочный (неоптимизированный) код.

Value-типы

В CLR используется два разных набора типов, ссылочные типы и типы-значения (Value-типы). Ссылочные типы всегда размещаются в управляемой куче и передаются по ссылке (как и следует из их названия). Value-типы размещаются в стеке или встраиваются в объект, и по умолчанию передаются по значению, хотя их тоже можно передавать по ссылке. Value-типы очень дешево размещать и, предполагая, что они просты и невелики, дешево передавать в качестве аргументов. Хорошим примером соответствующего использования value-типов может быть value-тип Point, который содержит координаты x и y.

Value-тип Point

struct Point
{
   public int x;
   public int y;
   
   //
}

Value-типы могут также интерпретироваться как объекты; например, у них могут быть вызваны методы класса object, они могут быть приведены к object, или переданы туда, где ожидается object. При этом, однако, Value-типы преобразуются в ссылочные типы с помощью процесса, именуемого Boxing-ом. При Boxing-е value-типа в управляемой куче размещается новый объект, в который и копируется значение. Это дорогостоящая операция, способная снизить или просто уничтожить выигрыш от использования value-типов. Процесс, обратный Boxing-у, называется Unboxing-ом.

Boxing/Unboxing Value-типа

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; // Boxing i
   return (int)o + 3; // Unboxing i
}

MSIL:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // конец метода Class1::BoxUnboxValueType

Если вы реализуете собственные value-типы (struct в C#), стоит подумать о переопределении метода ToString. Если его не переопределить, вызовы ToString для вашего value-типа будут вызывать Boxing. Это так же верно для других методов, унаследованных от System.Object, в данном случае, Equals, хотя ToString, вероятно, наиболее часто вызываемый метод. Если вы заходите знать, когда и как производится Boxing вашего value-типа, поищите в MSIL инструкцию box утилитой ildasm.exe.

Переопределение метода ToString() в C# для предотвращения boxing-а

struct Point
{
   public int x;
   public int y;

   // Это предотвратит boxing при вызове ToString
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

Помните, что при создании коллекций – например, ArrayList float-ов – каждое вхождение будет подвергнуто Boxing-у при добавлении в коллекцию. Стоит задуматься об использовании массива или создании собственного класса коллекции для вашего value-типа.

Неявный Boxing при использовании коллекций

ArrayList al = new ArrayList();
al.Add(42.0F); // Неявный Boxing поскольку Add() принимает object
float f = (float)al[0]; // Unboxeing

Обработка исключений

Общей практикой является использование ошибок как средств нормального управления исполнением. В этом случае при попытке программно добавить пользователя в Active Directory, вы можете просто попытаться добавить пользователя и, получив HRESULT E_ADS_OBJECT_EXISTS, понять, что такой пользователь уже есть в каталоге. Иначе пришлось бы производить поиск по каталогу, и добавлять пользователя только при неудаче поиска.

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

В случае методов, критичных к производительности, вместо генерации исключений рассмотрите возможность возврата значения некоторого перечисления (enum).

Если вы используете VB.NET, используйте исключения вместо On Error Goto.

Потоки и синхронизация

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

Если потоки приложения не специализированы под определенные задачи, или имеют особое состояние, ассоциированное с ними, стоит подумать об использовании пула потоков. Если вы использовали раньше Win32 Thread Pool, Thread Pool CLR вам покажется очень знакомым. Одному управляемому процессу соответствует один экземпляр пула потоков. Thread Pool может приспосабливаться к количеству потоков и загрузке машины.

О многопоточности нельзя говорить отдельно от синхронизации; весь выигрыш в пропускной способности, который может дать приложению многопоточность, может быть сведен на нет плохо написанной логикой синхронизации. Гранулярность блокировок может существенно влиять на общую пропускную способность приложения, как из-за стоимости создания блокировки и управления ей, так и из-за того, что блокировки потенциально могут сериализовать исполнение. Чтобы проиллюстрировать это, приведу пример попытки добавить узел в дерево. Если дерево должно быть, например, разделяемой структурой данных, то в процессе исполнения приложения к нему должны обращаться несколько потоков, и вам придется синхронизировать доступ к дереву. Можно выбрать блокировку всего дерева на время добавления узла, что будет стоить создания всего одной блокировки, но другие потоки, пытающиеся обратиться к дереву, будут, скорее всего, заблокированы. Это пример coarse-grained блокировки. Вместо этого можно блокировать каждый узел по мере обхода дерева, что повлечет за собой затраты на создание блокировок на создание блокировок для каждого узла, но другие потоки не будут заблокированы, если только не пытаются обратиться к узлу, заблокированному в данный момент. Это пример fine-grained блокировки. Возможно, наиболее подходящим уровнем гранулярности окажется блокировка поддерева, с которым вы работаете. Заметьте, что в этом примере вы, скорее всего, будете использовать разделяемую блокировку (RWLock), поскольку одновременно должны получать доступ несколько сеансов.

Простейший и высокопроизводительный способ выполнения синхронизированных операций – использовать класс System.Threading.Interlocked. Класс Interlocked дает возможность выполнения нескольких низкоуровневых атомарных операций: Increment, Decrement, Exchange и CompareExchange.

Использование класса System.Threading.Interlocked в C#

using System.Threading;
//...
public class MyClass
{
   void MyClass() // Конструктор
   {
      // Атомарное увеличение глобального счетчика экземпляров
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      // Атомарное уменьшение глобального счетчика экземпляров
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

Возможно, наиболее часто используемый механизм синхронизации – Monitor или критическая секция секций. Блокировка Monitor может использоваться напрямую или с помощью ключевого слова lock в C#. Ключевое слово lock синхронизирует доступ к указанному блоку кода. Блокировка Monitor, за которую не слишком интенсивно конкурируют различные потоки, относительно дешева с точки зрения производительности, но становится дороже с ростом конкуренции.

C# – ключевое слово lock

// Поток попытается получить блокировку
// и блокируется, пока другие владеют этой блокировкой
lock (mySharedObject)
{
   // Поток сможет выполнить код этого блока
   // если он владеет блокировкой 
} // Поток освобождает блокировку

RWLock предоставляет механизм разделяемой блокировки: например, "читатели" могут обращаться к заблокированному ресурсу одновременно с другими "читателями", а "писатели" – нет. Там, где это применимо, RWLock дает лучшую производительность, чем Monitor, позволяющий доступ только одного "читателя" или "писателя" в один момент времени. Пространство имен System.Threading включает также класс Mutex. Mutex – это примитив синхронизации, позволяющий кросс-процессную синхронизацию. Это гораздо дороже, чем критические секции, и должно использоваться только там, где необходима кросс-процессная синхронизация.

Reflection

Reflection – это предоставляемый CLR механизм, позволяющий получить информацию о типах программно во время исполнения. Reflection существенно зависит от метаданных, содержащихся в управляемых сборках. Многие API-функции Reflection требуют поиска и разбора метаданных, что является дорогостоящими операциями.

API-функции Reflection можно разделить по производительности на три группы: сравнения типов, перечисления членов и вызова членов - каждая следующая дороже предыдущей. Операции сравнения типов – в данном случае typeof в C#, GetType, is, IsInstanceOfType и т.д. – самые дешевые из API-функций Reflection, хотя и вовсе не дешевые сами по себе. Перечисление членов позволяет программно исследовать методы, свойства, поля, события, конструкторы и прочие члены класса. Пример того, где это можно использовать – сценарии времени разработки, например, перечисление Customs Web Controls для Property Browser в Visual Studio. Наиболее дорогие из API-функций Reflection – те, что позволяют динамически вызывать члены классов, или динамически создать, скомпилировать и выполнить метод. Разумеется, есть сценарии позднего связывания, где нужны динамическая загрузка сборок, создание экземпляров типов и вызовы методов, но это свободное связывание приводит к потерям производительности. В целом, функции Reflection следует избегать в чувствительном к производительности коде. Заметьте, что даже если вы не используете Reflection напрямую, к нему могут обращаться используемые вами API.

Позднее связывание

Вызовы с поздним связыванием – пример скрытого использования Reflection. Visual Basic.NET и JScript.NET поддерживают такие вызовы. Например, вам не нужно объявлять переменную перед ее использованием. Объекты с поздним связыванием на самом деле имеют тип object, и Reflection используется для приведения объекта к корректному типу во время исполнения. Вызовы с поздним связыванием медленнее прямых вызовов. Таких вызовов следует избегать в критичном к производительности коде.

Если вы используете VB.NET и не испытываете явной потребности в позднем связывании, можно запретить его использование, включив строки Option Explicit On и Option Strict On в начало исходного файла. Эти опции заставят вас объявлять и строго типизировать переменные, а также отключат неявное приведение.

Безопасность

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

COM Interop и Platform Invoke

COM Interop и Platform Invoke прозрачно предоставляет native API управляемому коду; вызов большинства функций native API обычно не требует специального кода, хотя и может потребовать нескольких щелчков мышью. Как можно ожидать, есть стоимость, ассоциированная с вызовом native-кода из управляемого кода, и наоборот. Эта стоимость состоит из двух компонентов: фиксированной цены, ассоциированной с переходами между native- и managed-кодом, переменной цены маршалинга аргументов и возвращаемых значений. Фиксированная часть стоимости COM Interop и P/Invoke невелика: обычно менее 50 инструкций. Стоимость маршалинга в/из управляемых типов зависит от того, насколько различаются представления на разных сторонах. Типы, требующие существенных объемов трансформации, будут дороже. Например, все строки в CLR – это Unicode-строки. Если вы вызовете через P/Invoke функцию Win32 API, ожидающую массив ANSI-символов, каждый символ в строке должен быть конвертирован из Unicode в ANSI. Однако если управляемый массив целых передается туда, где ожидается native-массив целых, никакого маршалинга не потребуется.

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

Заметьте, что нет поточных моделей, ассоциированных с управляемыми потоками. Если вы намереваетесь сделать вызов COM Interop, нужно убедиться, что поток, в который направляется вызов. инициализирован в корректной поточной модели COM. Обычно это делается с использованием MTAThreadAttribute и STAThreadAttribute (хотя это может быть также сделано программно).

Счетчики производительности

При работе с .NET CLR можно использовать многие счетчиками производительности Windows. Эти счетчики должны быть первым средством, используемым программистом в случае проблем с производительностью или при попытке определить характеристики производительности управляемого приложения. Я уже упоминал несколько счетчиков, относящихся к управлению памятью и исключениям. Существуют счетчики производительности практически для любого аспекта CLR и .NET Framework. Эти счетчики всегда; связанные с ними издержки незначительны и не влияют на характеристики производительности приложения.

Другие инструменты

Кроме счетчиков производительности и CLR Profiler вам потребуется обычный профилировщик, чтобы выяснить, какие методы приложения вызываются чаще других или исполняются дольше других. Это те методы, которые нужно оптимизировать в первую очередь. Есть ряд коммерческих профилировщиков, поддерживающих работу с управляемым кодом, включая DevPartner Studio Professional Edition 7.0 от Compuware и Intel VTune Performance Analyzer 7.0. Compuware также распространяет бесплатный профилировщик для управляемого кода DevPartner Profiler Community Edition.

Заключение

Эта статья только начинает исследование CLR и .NET Framework с точки зрения производительности. Есть много других аспектов архитектуры CLR и .NET Framework, влияющих на производительность приложения. Лучший совет, который я могу дать любому разработчику – не делать никаких предположений о производительности целевой платформы для вашего приложения и используемого API. Измеряйте все!

Удачного жонглирования.

Ссылки


Copyright © 1994-2016 ООО "К-Пресс"