Автор: Sergey Teplyakov

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

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

Ненужные аллокации

Управляемая среда, которой является платформа .NET, славится невероятной эффективностью выделения памяти. Скорость выделения памяти в управляемой куче соизмерима с выделением памяти на стеке, да и алгоритмы этих аллокаций весьма похожи: для этого достаточно инкрементировать один указатель (ну и выделить память под новый сегмент, если старый закончился). Этот процесс значительно более эффективный, по сравнению с выделением памяти в неуправляемой куче, так почему нам нужно беспокоиться о ненужных аллокациях?

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

Неявная упаковка

Предположим, у нас есть такой простой класс:

enum EmployeeType {
Worker, Manager, }
class Employee {
public int Id { get, set, }
public EmployeeType EmployeeType { get, set, } }

Ну и у него есть пара дополнительных методов, типа ToString() и GetHashCode(), реализованных следующим образом:

public override string ToString() {
return Id.ToString() + ' ' + EmployeeType.ToString(), } public override int GetHashCode() {
return Id.GetHashCode() ^ EmployeeType.GetHashCode(), }

Есть ли здесь ненужные аллокации? Да, причем в каждом методе!

В методе ToString() происходит создание двух объектов в куче. Конкатенация строк, приведенная в методе ToString() является лишь синтаксическим сахаром для вызова метода String.Concat(object, object, object), а поскольку символ пробела (' ') является экземпляром значимого типа Char, то первая упаковка происходит здесь. Второе выделение памяти более хитрое и связано с тем, что в методе ToString() происходит вызов методов EmployeeType.ToString(), который реализован в типе System.Enum, а раз так, то для вызова правильной реализации нам нужно воспользоваться таблицей виртуальных функций, существующей лишь в упакованной версии перечисления.

Аналогичная проблема существует и в методе GetHashCode(), в котором вызывается EmployeeType.GetHashCode(), вызов которого приводит к упаковке экземпляра перечисления.

ПРИМЕЧАНИЕ
Я не знаю другого места в языке C# при котором выделение памяти было бы столь неочевидным. Ведь на самом деле правила еще более сложные и упаковка будет в следующих случаях: (1) вызов невиртуального метода GetType(), (2) вызов непереопределенного виртуального метода значимого типа (структуры), (3) вызовы методов ToString() и GetHashCode() перечисления.
Самым простым правилом, которое приходит в голову, является следующее: любые вызовы методов, реализованных в типах System.ValueType или System.Enum приводят к упаковке, поскольку эти типы сами по себе являются ссылочными!

UPDATE Для упрощения этого понимания я нарисовал рисунок.

BoxingStuff

В общем так: если тело метода определено в верхней части рисунка упаковка будет, иначе нет!.

Решаются данные проблемы следующим образом:

public override string ToString() {
// Используем строку ' ', а не символ ' ' return Id.ToString() + ' ' + EmployeeType.ToString(), } public override int GetHashCode() {
// Приводим к int (или к long, если нижележащий тип перечисления // это long) и вызываем на нем GetHashCode() return Id.GetHashCode() ^ ((int)EmployeeType).GetHashCode(), }

К сожалению, от одной упаковки в методе ToString() (при вызове EmployeeType.ToString()), избавиться никак не получится (не реализуя подобный метод руками в своем классе).

LINQ

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

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

private List<,Employee>, _employees = new List<,Employee>,(), public Employee FindEmployee(int id) {
return _employees.FirstOrDefault(e =>, e.Id == id), }

Я могу насчитать 3: 2 аллокации на анонимный метод с замыканием и еще одна для перебора элементов. Лямбда-выражение выворачивается в создание экземпляра объекта-замыкания и экземпляра делегата (мы говорили об этом в прошлый раз и ранее, до этого, поэтому останавливаться подробнее я не буду). Но насколько очевидно (или неочевидно), что при вызове метода FirstOrDefault мы получим еще одну аллокацию?:

LINQ это набор методов расширения, расширяющих интерфейс IEnumerable of T, который выглядит следующим образом:

public interface IEnumerable<,T>, : IEnumerable {
IEnumerator<,T>, GetEnumerator(), }

Я уже неоднократно писал о том, что итераторы всех типов BCL являются изменяемыми структурами, иногда это может приводить к отстрелу конечностей, но эта жертва принесена в угоду эффективности. Перебирая элементы списка циклом foreach мы не получим выделение памяти, благодаря утиной типизации, но эта магия исчезает, когда мы приведем List of T к типу IEnumerable of T и вызовем GetEnumerator() через интерфейс (мы получим упаковку итератора).

Кстати, интересно, что метод Where оптимизирован таким образом, чтобы использовать итераторы конкретных типов коллекций, это явно нарушает все принципы проектирования (OCP, SRP, LSP), но это вполне нормально, когда речь заходит о столь распространенных библиотеках.

ПРИМЕЧАНИЕ
Кстати, именно поэтому при рассмотрении 8 ошибок выяснилось, что метод FirstOrDefault(predicate) работает медленнее, чем пара Where(predicate).FirstOrDefault().

Кэширование task-ов

Возможно кто-то из вас слышал о том, что в Java есть довольно забавная оптимизация, которая заключается в кэшировании экземпляров типа Integer со значениями от 10 до 10. Поскольку Integer это что-то вроде ссылочного типа, то вызывая код вида: object.ReferenceEquals(new Integer(1), new Integer(1)) мы получим True.

Так вот, в языке C# есть подобная оптимизация, но в контексте асинхронных методов. Вот простой пример, демонстрирующий эту стратегию:

static async Task<,int>, SimpleAsyncMethod(int i) {
return i, } var cachedValues =
from n in Enumerable.Range(-100, 200)
let t1 = SimpleAsyncMethod(n)
let t2 = SimpleAsyncMethod(n)
let cached = object.ReferenceEquals(t1, t2)
where cached
select n, Console.WriteLine('Cached values: {0}',
string.Join(', ', cachedValues)),

Метод SimpleAsyncMethod всего лишь возвращает значение, которое автоматически заворачивается компилятором в Task of T. Затем, с помощью LINQ запроса я вызываю метод дважды для каждого целого числа в диапазоне от 100 до 100 и получаю только те значения, для которых возвращается та же самая задача. В результате выполнения этого кода мы увидим следующее: Cached values: 1, 0, 1, 2, 3, 4, 5, 6, 7, 8.

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

-----------------

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

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).