Производительность .Net миф или фантастика? (исходники)Источник: GOT DOT NET Nimnul
Для преобразования символов в нижний регистр мы используем паттерн «Интерпретатор», реализованный как синглтон и создаваемый с помощью фабрики классов» - такие фразы нам приходится слышать на семинарах для программистов. А что будет, если сравнить эту фразу вот с такой: «Для преобразования символов в нижний регистр необходимо вычесть дельту между началами регистровых алфавитов. Философия Что, по-вашему, будет работать быстрее? К сожалению, большинство современных программистов, выросших на Visual Studio 2003 и старше, все реже задают себе тот вопрос. Они считают, что современные процессоры имеют настолько большую тактовую частоту, а у компьютеров есть так много памяти, что более нет нужды уделять внимание производительности в процессе написания программы. Более того, многие программисты сознательно используют языковые конструкции, которые работают медленнее других, но, по их мнению, являются более удобными в использовании. Цель этой статьи - рассказать о том, что любое удобство программирования происходит за счет производительности программы. На мой взгляд, все современные разработчики ПО делятся на две большие группы. Первая группа программистов умеет придумывать алгоритмы, а вторая не умеет (зато владеет техникой использования чужих). Эта ситуация поддерживается Microsoft, которая выпускает все более и более высокоуровневые классы на все случаи жизни. В итоге программисту уже не нужно уметь создавать - ему нужно уметь использовать. В качестве примера рассмотрим так называемую концепцию трехуровневой бизнес-модели. Первый уровень - базы данных. Все, что нужно уметь - это читать и записывать данные в БД. Далее идет уровень классов. Здесь программисту нужно копировать прочитанные данные в классы, которые составляют логику приложения. Третьим идет уровень пользовательского интерфейса (GUI), здесь разработчику нужно только копировать данные из классов в компоненты, чтобы пользователь мог с ними взаимодействовать. По сути, такой подход к разработке софта более всего напоминает копирование файлов из одних папок в другие, и таким программистом может стать любой продвинутый пользователь. Кстати говоря, с выходом Visual Studio 2005, трехуровневая модель упростилась до одноуровневой. Теперь программист может с помощью мыши указать в DataView запрос к базе данных, а все остальное за негосделает контрол. Все было бы хорошо, но если речь идет о ASP.NET, то такой упрощенный подход приводит к значительным потерям производительности. Например, медленная загрузка страницы из-за неоправданно большого количества запросов к БД или (а часто и) большого веса страницы. К счастью, у этой тенденции развития отрасли есть существенный недостаток. Чем более высокоуровневыми становятся конструкции, тем больше сужается область их применения. В этой статье мы рассмотрим несколько примеров, которые демонстрируют ситуацию. Перечисления Недавно на моем любимом форуме по программированию я увидел сообщение от начинающего программиста на тему: «C# и перечисления». Дело в том, что спрашивающий не знал, как выделить определенный бинарный флаг из числа. Напомню, что флаги в числе представляют собой порядковый бит в бинарном представлении числа. Например, число 9 (в бинарном представлении 1001), обозначает, что установлен нулевой и третий флаг. Вопрос закономерен для начинающего разработчика, но каково было мое удивление, когда я увидел ответы от авторитетнейших программистов, aвторов статей, и обладателя MVP! Рассмотрим два ответа:
Что же тут можно сказать? Мне становитсяне по себе от попытки представить скорость программ, которые пишут эти специалисты, превращая атомарные операции в циклы. Между прочим, второй ответ (от MVP) даже МЕНЕЕ производителен не смотря на кажущуюся простоту, поскольку IndexOf вызывает еще одну функцию, где в цикле ведется поиск необходимого значения. Ответ на данный вопрос должен быть, безусловно, таким:
Не будем голословными и посмотрим на результат теста.
Как видите, разница в производительности между «авторитетными» способами и обычным составляет более одного порядка в первом случае и более двух порядков во втором. Циклы Рассмотрим два вида циклов: for и forearch. Сначало сделаем два теста, что бы потом обсудить результаты. Тест скорости перебора:
Теперь немного изменим тест так, чтобы определить, сколько нужно ресурсов для начала каждого из циклов. Перед запуском теста рекомендую открыть диспетчер задачна вкладке Perfomance.
Сделаем небольшие выводы. Во-первых, скорость перечисления foreach вдвое ниже, чем for, а во-вторых, для старта foreach требуется почти в десять раз больше времени. Кроме того, в диспетчере задач ясно видно, что затрачивается 40 мегабайт памяти (диспетчер задач это не самое точное средство для измерения количества необходимой памяти, но для обнаружения слона микроскоп и не нужен). Почему же так происходит? В случае с циклом foreach перед работой создается специальный класс-перечислитель, именно на его создание и тратится столько памяти. В случае с циклом for память не затрачивается вовсе. Внимательный читатель, может сказать: «Подумаешь, проблема высосана из пальца, поскольку этот тест на десять миллионов итераций, а в реальной программе так не бывает». На самом деле, бывают ситуации, когда программа делает частые вызовы функции, которая что-то делает в небольших циклах (от одной до ста итераций). Таким образом, эти десять миллионов вызовов будут достигнуты уже через двадцать минут… а если программа работает четыре часа? Тем не менее, в мире существует множество программистов, считающих, что такие затраты оправданы (надуманным) удобством использования foreach. Я же предлагаю читателю самому убедиться, что синтаксис практически идентичен:
На этом недостатки foreach не заканчиваются, существуют еще проблемы архитектурного характера. Предположим, я делаю какой-нибудь класс, содержащий только итератор последовательного доступа (итератор необходим для работы циклов foreach). Другой программист, используя мой класс, может получить некоторые данные только последовательно. Но что если ему потребовался доступ в обратном порядке? Он будет просить меня сделать еще один итератор обратного доступа. Сам он это сделать не сможет либо потому, что ему долго придется вникать в логику класса, либо потому, что ему недоступен код. Хорошо, я сделаю ему… но другому программисту может потребоваться доступ через один элемент, через два или через три элемента… и что теперь, мне нужно для каждого делать эти итераторы? Другое дело индексатор, который используется в циклах for, преимущество его в том, что пользователь индексатора сам определит, в каком виде ему получить данные. В этом заключается большая гибкость, что еще раз подтверждает мои слова о том, что чем более высокоуровневая конструкция, тем меньше ее область применения. Объектно-ориентированное программирование Как известно, изначально языки программирования были структурного типа и отлично подходили для написания маленьких программ. Их недостаток проявился, когда появилась необходимость написания больших программ. Проблема в том, что структурный язык совершенно не предполагает каких-либо стандартов программирования, поэтому в результате работы нескольких программистов над проектом, в коде получался бардак, и развивать такую программу было чрезвычайно сложно. Более того, поиск и выявление даже простейшей логической ошибки был довольно трудоемким занятием. Объектно-ориентированное программирование (ООП) пришло на смену структурноориентированному, принеся тем самым некоторое удобство, а следовательно, и потерю производительности. Поверьте, я вовсе не противник ООП, но как говорил Будда: «Если струну дернуть слишком сильно, тогда она порвется, если слишком слабо, тогда никто не услышит звука, поэтому важно знать золотую середину». Однажды мне довелось видеть код, содержащий огромный вспомогательный класс, и если требовалось вызвать из него какуюнибудь функцию, автор программы всегда создавал его экземпляр. Представьте, какое это нерациональное использование памяти» В C# есть возможность создавать статические методы, она осталось в наследство от структурного прошлого языка C. Именно такие методы идеально подходят для вспомогательных функций, так как при их вызове нет необходимости создавать классы. В практике встречаются похожие задачи, для решения которых программистам нужно писать аналогичный код, а если этот код уже написан - нужно его просто скопировать и немного подогнать под текущую задачу. Но однажды программистам стало лень копировать, и они придумали технологию наследования в рамках ООП.
Собственно, в рамках данной статьи у меня нет возможности опубликовать полномасштабный анализ производительности ООП, поскольку получится трактат на целую книгу. Главное - обратить внимание на то, что любое удобство надо использовать разумно!. Файловые операции Многим разработчикам кажется удобным иметь возможность сохранять и загружать классы, используя сериализацию. Удобство заключается в том, что в функцию сохранения нужно передать ссылку на класс, при этом сохраняются все public свойства и поля класса. А главное - больше ничего не нуж- но делать. Для этого существуют три класса:
Чтобы оценить производительность такого подхода, мы определим функцию manual, которая будет сохранять поля класса вручную в бинарном формате.
Сравним скорость сохранения и размеры генерируемых файлов в байтах:
Тут разработчики стандартных классов совсем сплоховали. Как видно из теста, ручной способ быстрее классов XmlSerializer и BinaryFormatter примерно в 230 раз, и это притом, что файл получается меньше в 20 раз! Честно признаюсь, у меня были некоторые надежды на класс BinaryFormatter (само слово Binary в названии внушало доверие). Дело в том, что XmlSerializer сохраняет данные в формате xml, а это, как известно, не самый компактный формат. Предлагаю посмотреть на фрагменты этих файлов, что бы стало ясно, почему же размер так отличается (см. соответствующие иллюстрации). Как видно XmlSerializer сохраняет в тестовом виде. Число 2147483647 в текстовом виде весит 10 или 20 байт, в зависимости от кодировки, а в бинарном формате всего четыре байта. Плюс ко всему, каждая запись обрамляется тегами вида: <any_tag_name>any data</any_tag_name>. Это, пожалуй, самый «толстый» формат хранения данных из всех, что мне доводилось видеть, а причиной тому является обильное использование служебных данных. BinaryFormatter являете бинарным лишь отчасти, на рисунке видно, что полезные данные составляют примерно 15% от общего количества и что по-настоящему бинарныхданных здесь очень мало. Хотелось бы отметить, что при создании стандартными классами, размер файлов зависит еще и от размера названий полей. В нашем примере названия маленькие member1, member2 и т.д., но если бы они были длиннее, тогда и размер файлов был бы больше, поскольку названия полей включаются в файл. То есть, если мы сделали программу, в которой они уже используются, а потом выпустили новую версию, в которой изменились эти названия, или изменилось количество этих полей, тогда при загрузке данных произойдет ошибка, которая сделает невозможной эту загрузку. На мой взгляд, это существенный недостаток, который отсутствует при ручном сохранении. Еще один недостаток заключается в том, что эти классы сохраняют абсолютно все открытые поля, и нет возможности выбрать, что сохранять, а что нет. Довольно часто отрытые поля нужны для самой программы, и нет никакого смысла сохранять эти данные, особенно принимая в расчет такие могучие потери производительности. Отдельно хочу рассказать о SoapFormatter. Логически это более высокоуровневая конструкция класса XmlSerializer. Формат Soap был создан для реализации удаленных вызовов процедур. В .NET он используется в технологиях веб-сервисов и Remouting. Remouting сделан для облегчения разработки клиент-серверных решений. По сути, он заменяет TCP/IP. Теперь представим, что из всех способов ввода/вывода данных, Remouting использует самый плохой и это притом, что данные должны передаваться по сети, что накладывает ограничения на их размер. SoapFormatter 120 килобайт полезных данных способен превратить в шесть мегабайт бесполезных. В этом тесте сериализовались относительно маленькие объемы данных (120 Кб). Обычно приходится иметь дело с данными в десять, а то и в сто раз большими по объему. Учитывая, что процессоры нынче все же не всемогущи, можно заключить, что данная технология скоро исчезнет, поскольку вместе с мощностями железа и шириной каналов, также быстро растут и объемы данных, которые необходимо обрабатывать и пересылать. Инициализация классов Эту тему я не мог пропустить, поскольку она касается всех, кто использует классы. При компиляции инициализация всех переменных выносится в конструкторы. Если при этом присваивать внутренним переменным значения параметров конструктора, то переменная будет инициализироваться два раза. В принципе, если класс не создается большое количество раз (т.е. если не нужна производительность), тогда можно не обращать внимания на этот факт. Но, на мой взгляд, нужно иметь привычку всегда и все писать академически правильно. В связи с этим есть некоторые рекомендации:
Разница почти двойная, потому что в первом методе независимо от количества конструкторов управление получат все. Во втором же управление получат только объявленные. Сравнение типов Всего существует три способа сравнения, которое можно произвести с помощью is, as и typeof:
Разницу между is и as глазом сложно заметить, а вот конструкции с typeof лучше не использовать. Проверка на инициализацию строки Есть два популярных способа проверки: if (str != null && str != «») и if (str != string.Empty). Вопреки всем убеждениям, первый метод работает втрое быстрее, если строка равна «» и вчетверо, если строка равна null. Обратитевнимание на результат теста:.
Заключение Мы рассмотрели немного способов сделать программу лучше. Каждый должен понимать, что это далеко не все способы на все не хватит целой книги. Цель даннойстатьи показать, насколько важно задавать себе вопрос «а что быстрее?». Программа работает всегда, и если программист не уделял должного внимания производительности, то она чуть-чуть тормознет здесь, чутьчуть там, а в итоге программа будет тормозить везде и всегда. Примечание: Все тесты необходимо запускать в Release версии без отладчика. Для получения наибольшего качества теста, необходимо закрыть все программы, выключить антивирусы, фаерволы или службы, требуещее значительное количество ресурсов. Каждый тест нужно разбивать на несколько этапов. Например в тесте for и foreach сначало нужно отдельно протестировать for, закоментировав тест foreach, а потом наоборот. |