высокопроизводительный код на платформе net

Отрывок.Подберите подходящий размер пула потоков

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

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

Можно также установить максимальное их количество, воспользовавшись методом SetMaxThreads, но данный прием подвержен аналогичным рискам.

Чтобы выяснить нужное количество потоков, оставьте этот параметр в покое и проанализируйте свое приложение в устойчивом состоянии, воспользовавшись методами ThreadPool.GetMaxThreads и ThreadPool.GetMinThreads или счетчиками производительности, которые покажут количество потоков, задействованных в процессе.

Не прерывайте потоки

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

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

Вообще, стоит всегда задействовать Task-объекты — API для прерывания задачи Task не предоставляется. Чтобы получить возможность согласованно завершить работу потока, нужно, как отмечалось ранее, воспользоваться маркером отмены CancellationToken.

Не меняйте приоритет потоков

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

Лучше понизить приоритет потока, если в нем выполняется что-то, что может подождать завершения выполнения задач обычной приоритетности. Одной из веских причин понижения приоритета потока может быть обнаружение вышедшего из-под контроля потока, выполняющего бесконечный цикл. Безопасно прервать работу потока невозможно, поэтому единственный способ вернуть данный поток и ресурсы процессора — перезапуск процесса. До тех пор пока не появится возможность закрыть поток и сделать это чисто, понижение приоритета вышедшего из-под контроля потока будет вполне разумным средством минимизации последствий. Следует заметить, что даже потокам с пониженным приоритетом все же со временем гарантируется запуск: чем дольше они будут обделены запусками, тем выше будет устанавливаемый системой Windows их динамический приоритет. Исключение составляет приоритет простоя THREAD_‑PRIORITY_IDLE, при котором операционная система спланирует выполнение потока только в том случае, когда ей в буквальном смысле будет больше нечего запускать.

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

Если применяется пул потоков, то любые изменения приоритетов сбрасываются при каждом возвращении потока в пул. Если при использовании библиотеки Task Parallel продолжить управлять базовыми потоками, следует иметь в виду, что в одном и том же потоке до его возвращения в пул могут запускаться несколько задач.

Синхронизация потоков и блокировки

Как только разговор заходит о нескольких потоках, возникает необходимость их синхронизации. Синхронизация заключается в обеспечении доступа только одного потока к совместно используемому состоянию, например к полю класса. Обычно синхронизация потоков выполняется с помощью таких объектов синхронизации, как Monitor, Semaphore, ManualResetEvent и т. д. Иногда их неформально называют блокировками, а процесс синхронизации в конкретном потоке — блокировкой.

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

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

Нужно ли вообще заботиться о производительности?

Сперва обоснуйте необходимость повышения производительности. Это возвращает нас к принципам, рассмотренным в главе 1. Не для всего кода вашего приложения производительность одинаково важна. Не весь код должен подвергаться оптимизации n-й степени. Как правило, все начинается с «внутреннего цикла» — кода, выполняемого наиболее часто или наиболее критического для производительности, — и распространяется во все стороны, пока затраты не превысят получаемую выгоду. В коде есть множество областей, гораздо менее важных с точки зрения производительности. В такой ситуации, если нужна блокировка, спокойно применяйте ее.

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

А нужна ли вообще блокировка?

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

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

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

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

Этот код можно преобразовать следующим образом:

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

Порядок предпочтения синхронизации

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

1. lock/класс Monitor — сохраняет простоту, доступность кода для понимания и обеспечивает хороший баланс производительности.

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

3. Простые методы взаимной блокировки Interlocked — в некоторых сценариях могут оказаться более подходящими, но, как только ситуация начнет усложняться, перейдите к использованию блокировки lock.

И наконец, если действительно можно будет доказать пользу от их применения, задействуйте более замысловатые, сложные блокировки (имейте в виду: они редко оказываются настолько полезными, как вы ожидаете):

Для Хаброжителей скидка 25% по купону — .NET

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.

Источник

Здравствуйте, дорогие читатели!

высокопроизводительный код на платформе net

Нас, конечно, не удивило, что такую книгу уже растаскивают на цитаты, однако выяснилось, что уважаемый автор Бен Уотсон даже выложил на сайте «Codeproject» целую статью, написанную по мотивам одной из глав. К сожалению, объем этого материала слишком велик для хабропубликации, однако мы решили все-таки перевести первую часть статьи, чтобы вы могли оценить материал книги. Приглашаем к прочтению и к участию в опросе. Кроме того, если все-таки целесообразно перевести и вторую часть — пишите в комментариях, постараемся учесть ваши пожелания.

Если бы я попытался сформулировать общий принцип, лежащий в основе этой статьи, то он был бы таков:

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

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

Сравнение классов и структур

Экземпляры класса всегда выделяются в куче, а доступ к этим экземплярам осуществляется путем разыменования указателя. Передавать их дешево, ведь речь идет всего лишь о копии указателя (4 или 8 байт). Однако у объекта также имеются некоторые фиксированные издержки: 8 байт для 32-битных процессов и 16 байт для 64-битных процессов. В эти издержки входит указатель на таблицу методов плюс поле блока синхронизации. Если создать объект без полей и просмотреть его в отладчике, то окажется, что на самом деле его размер составляет не 8, а 12 байт. В случае с 64-битными процессами объект будет иметь размер 24 байт. Дело в том, что минимальный размер зависит от выравнивания блоков памяти. К счастью, эти «лишние» 4 байт будут использоваться полем.

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

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

В некоторых ситуациях эффективность может разительно отличаться. Пусть величина издержек на отдельно взятый объект и кажется мизерной, но попробуйте рассмотреть массив объектов, а потом сравните его с массивом структур. Предположим, в структуре данных содержится 16 байт данных, длина массива равна 1 000 000, и мы работаем в 32-разрядной системе.

Для массива объектов общий расход пространства составляет:

12 байт издержек массива +
(размер указателя 4 байт × 1,000,000) +
(( издержки 8 байт + 16 байт данных) × 1,000,000)
= 28 MB

Для массива структур получаем принципиально иной результат:

12 байт издержек массива +
(16 байт данных × 1,000,000)
= 16 MB

В случае с 64-битным процессом массив объектов занимает более 40 MB, тогда как массив struct требует всего 16 MB.

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

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

Массив struct содержит множество значений, последовательно расположенных в памяти. Обратиться к элементу в массиве struct очень просто. Как только корректная запись найдена, у нас уже есть верное значение. Таким образом, может возникать огромная разница в скорости доступа при переборе большого массива. Если значение уже находится в кэше процессора, то к нему можно обратиться на порядок быстрее, чем если оно расположено в оперативной памяти.

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

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

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

Проблема возникает в последней строке, которая пытается изменить существующий Point в списке. Это невозможно, поскольку вызов points[0] возвращает копию оригинального значения, которая нигде дополнительно не сохраняется. Правильный способ изменения Point:

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

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

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

Все эти подструктуры можно вынести в отдельный класс:

Однако при этом дополнительно возникают 12 или 24 байт издержек на каждый объект Order.

Если вам требуется передавать различным методам объект OrderTimes целиком, то, возможно, такие издержки и оправданны, но почему бы просто не передать ссылку на целый объект Order? Если у вас одновременно обрабатываются тысячи объектов Order, это приведет к значительной активизации сборки мусора. Кроме того, в памяти пойдут дополнительные операции разыменования.

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

Переопределение методов Equals и GetHashCode для структур

При работе со структурами исключительно важно переопределять методы Equals и GetHashCode. Если этого не сделать, то вы получите их версии, задаваемые по умолчанию, которые отнюдь не способствуют высокой производительности. Чтобы оценить, насколько это нехорошо, откройте просмотрщик промежуточного языка и взгляните на код метода ValueType.Equals. Он связан с рефлексией по всем полям структуры. Однако это оптимизация для двоично-совместимых типов. Двоично-совместимым (blittable) называется такой тип, который имеет одинаковое представление в памяти как в управляемом, так и в неуправляемом коде. К их числу относятся только примитивные числовые типы (например, Int32, UInt64, но не Decimal, не являющийся примитивным) и IntPtr/UIntPtr. Если структура состоит только из двоично-совместимых типов, то реализация Equals может фактически выполнять побайтное сравнение памяти в рамках всей структуры. Просто избегайте такой неопределенности и реализуйте собственный метод Equals.

Если просто переопределить Equals(object other), то у вас все равно получится неоправданно низкая производительность, поскольку данный метод связан с приведением и упаковкой типов значений. Вместо этого реализуйте Equals(T other), где T – тип вашей структуры. Для этого предназначен интерфейс IEquatable, и все структуры должны реализовывать его. Компилятор при работе всегда отдает предпочтение более строго типизированной версии, если это возможно. Рассмотрим пример:

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

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

Виртуальные методы и запечатанные классы

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

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

К виртуальным методам концептуально близки запечатанные классы, например:

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

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

Если вы пишете библиотеку классов, которую предполагается использовать в разнообразных ситуациях, особенно вне вашей организации, следует действовать осторожнее. В таком случае наличие виртуального API может быть важнее голой производительности – чтобы ваша библиотека была удобна для переиспользования и настраивания. Но если код пишется для внутрикорпоративных нужд и часто меняется, старайтесь обеспечить более высокую производительность.

Мономорфная заглушка также позволяет обнаружить, если что-то пойдет неправильно. Если в какой-то момент место вызова начнет использовать метод другого типа, то CLR в итоге заменит заглушку другой мономорфной заглушкой для нового типа.

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

Если такая проблема возникнет, ее можно решить одним из двух способов:

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

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

Хорошая статья о диспетчеризации интерфейсов есть в блоге Ванса Моррисона.

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

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

Промежуточный язык здесь выглядит так:

Это означает, что относительно несложно найти большинство источников упаковки в вашем коде: просто воспользуйтесь ILDASM для преобразования всего вашего IL в текст и выполните поиск.

Очень распространенная ситуация, в которой возможна нерегулярная упаковка — это использование API, принимающего object или object[] в качестве параметра. Наиболее тривиальными из них являются String.Format или традиционные коллекции, в которых хранятся только ссылки на объекты, и работы с которыми требуется полностью избежать по той или иной причине.

Кроме того, упаковка может происходить при присваивании структуры интерфейсу, например:

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

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

Каким будет значение boxedVal после этого?

При упаковке значение копируется, и между оригиналом и копией не остается никакой связи. Например, значение val может измениться на 14, но boxedVal сохранит исходное значение 13.

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

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

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

Сравнение for и foreach

Рассмотрим проект ForEachVsFor, в котором есть следующий код:

Собрав его, попробуйте декомпилировать этот код при помощи инструмента для рефлексии IL. Вы увидите, что первый foreach на самом деле компилируется как цикл for. Промежуточный язык будет таков:

Здесь много операций сохранения, загрузки, добавления и одна ветка – все довольно просто.

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

У нас 4 вызова виртуальных методов, блок try-finally и (здесь не показано) выделение памяти для локальной переменной перечислителя, в которой отслеживается состояние перечисления. Такая операция гораздо затратнее, чем обычный цикл for: используется больше процессорного времени и больше памяти!

Не забывайте, что базовая структура данных здесь — по-прежнему массив, а значит, цикл for использовать можно — но мы идем на обфускацию, приводя тип к интерфейсу IEnumerable. Здесь важно учитывать факт, уже упоминавшийся в начале статьи: глубокая оптимизация производительности часто идет вразрез с абстракциями кода. Так, foreach — это абстракция цикла, IEnumerable – абстракция коллекции. Вместе они дают такое поведение, которое исключают простую оптимизацию с применением цикла for, перебирающего массив.

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

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

Абсолютно недопустимо неверное приведение. Если оно произойдет, вы получите исключение InvalidCastException, стоимость которого на порядки превысит «цену» операции приведения.

См. проект CastingPerf в исходном коде к этой книге, где отмечено количество приведений тех или иных типов.

При тестовом прогоне на моем компьютере получился такой результат:

Оператор ‘is’ — это приведение, тестирующее результат и возвращающее булево значение.

Оператор ‘as’ напоминает стандартное приведение, но возвращает null, если приведение не срабатывает. Как видно из вышеприведенных результатов, эта операция гораздо быстрее, чем выдача исключения.
Никогда не применяйте такой паттерн, где делается два приведения:

Лучше используйте для приведения ‘as’ и кэшируйте результат, а потом проверяйте возвращаемое значение:

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

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *