Ласкаво просимо до dev.net.ua Увійти | Приєднатися | Допомога
Performance & Threading build bricks

Целевая аудитория: Разработчики .NET

Уровень подготовки: Средний, Высокий

Исходный код и примеры к статье (20 Кб)

Содержание

  1. Вступление
  2. Безопасный перебор коллекции
  3. Таблица поиска с динамическим заполнением
  4. Вычисление по требованию
  5. Что не вошло в статью
  6. Заключение

Вступление

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

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

Для .NET разработчика платформа предоставляет следующие ключевые рычаги для создания многопоточного приложения:

1) Объекты синхронизации;

2) Асинхронные обработчики, пул потоков;

3) Частичная поддержка многопоточности классами WinForms.

В качестве дополнений – библиотека Parallel Extensions[1] для распараллеливания циклов и перебора коллекций.

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

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

Все приведенные ниже концепты спроектированы для работы в многопоточной среде и протестированы на предмет корректной работы.


Безопасный перебор коллекции

На рисунке 1 представлена следующая ситуация – имеется разделяемая между двумя потоками коллекция. Первый поток (А) каким-либо образом изменяет её содержимое, а второй (Б) периодически выполняет перебор элементов коллекции. Реализация интерфейса IEnumerator в классах BCL[2] такова, что при изменении коллекции из другого потока перечислитель возбудит исключение InvalidOperationException.

Пример работы двух потоков с разделяемыми данными

Рисунок 1 – Пример работы двух потоков с разделяемыми данными

Типичное решение проблемы – использовать lock-секции для блока foreach в потоке Б и кода модификации коллекции в потоке А. Однако, представим, что изменяемая коллекция используется в программе в N местах. Это значит, что нам необходимо вставить N блоков синхронизации везде, где будет происходить обращение к разделяемым данным. Недостатки такого подхода следующие:

  • Дублируется код – нужно писать N одинаковых строк вместо одной;
  • Снижается уровень абстракции – внешние классы должны знать о внутреннем устройстве другого класса;
  • Повышается возможность ошибки – можно попросту забыть написать секцию синхронизации;
  • Нарушается принцип единой ответственности – целостность данных внутреннего объекта должна обеспечиваться извне.

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

   1: using System.Collections;
   2: using System.Collections.Generic;
   3: using System.Threading;
   4:  
   5: namespace Concepts.Private
   6: {
   7:     internal sealed class SyncEnumerator 
   8:         : IEnumerator
   9:     {
  10:         private readonly IEnumerator mEnumerator;
  11:         private readonly object mSyncRoot;
  12:  
  13:         internal SyncEnumerator(IEnumerable enumerable, 
  14:                                 object syncRoot)
  15:         {
  16:             mSyncRoot = syncRoot;
  17:  
  18:             Monitor.Enter(mSyncRoot);
  19:             mEnumerator = enumerable.GetEnumerator();
  20:         }
  21:  
  22:         #region IEnumerator Members
  23:  
  24:         public TEnumerationType Current
  25:         {
  26:             get { return mEnumerator.Current; }
  27:         }
  28:  
  29:  
  30:         public void Dispose()
  31:         {
  32:             mEnumerator.Dispose();
  33:             Monitor.Exit(mSyncRoot);
  34:         }
  35:  
  36:         object IEnumerator.Current
  37:         {
  38:             get { return mEnumerator.Current; }
  39:         }
  40:  
  41:         public bool MoveNext()
  42:         {
  43:             return mEnumerator.MoveNext();
  44:         }
  45:  
  46:         public void Reset()
  47:         {
  48:             mEnumerator.Reset();
  49:         }
  50:  
  51:         #endregion
  52:     }
  53: }
 
Данный класс берет на себя заботу об установке секции синхронизации при создании объекта и её снятии при его уничтожении. Данный класс полностью совместим с lock-секциями[3].

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

   1: using System.Collections;
   2: using System.Collections.Generic;
   3:  
   4: namespace Concepts.Private
   5: {
   6:     internal sealed class SyncEnumerable : 
   7:         IEnumerable
   8:     {
   9:         private readonly IEnumerable mInput;
  10:         private readonly object mSyncRoot;
  11:  
  12:         public SyncEnumerable(IEnumerable input, object syncRoot)
  13:         {
  14:             mInput = input;
  15:             mSyncRoot = syncRoot;
  16:         }
  17:  
  18:         #region IEnumerable Members
  19:  
  20:         public IEnumerator GetEnumerator()
  21:         {
  22:             return new SyncEnumerator(mInput, mSyncRoot);
  23:         }
  24:  
  25:         IEnumerator IEnumerable.GetEnumerator()
  26:         {
  27:             return new SyncEnumerator(mInput, mSyncRoot);
  28:         }
  29:  
  30:         #endregion
  31:     }
  32: }

Но и это еще не все. Классы SyncEnumerable и SyncEnumerator никогда не отдаются «наружу» - они инкапсулируют логику синхронизации и должны быть скрыты от сторонних глаз. Разработчику предоставляется один открытый метод – Wrap:

   1: using System.Collections.Generic;
   2:  
   3: namespace Concepts
   4: {
   5:     using Private;
   6:  
   7:     public static class SyncEnumerable
   8:     {
   9:         /// 
  10:         /// Wraps the specified input enumerator into synchronization scope 
  11:         /// using  as synchronization object.
  12:         /// 
  13:         /// The type of the enumeration type.
  14:         /// The input enumerable object.
  15:         /// The synchronization object.
  16:         /// Result enumerable that is thread-safe.
  17:         public static IEnumerable
  18:             Wrap(IEnumerable input,
  19:                                    object syncRoot)
  20:         {
  21:             return new SyncEnumerable(input, syncRoot);
  22:         }
  23:     }
  24: }

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

Пример использования:

   1: class SharedData
   2: {
   3:     private List<string> m_Items = new List<string>();
   4:  
   5:     public void Add(string item)
   6:     {
   7:         lock (this)
   8:             m_Items.Add(item);
   9:     }
  10:  
  11:     public IEnumerable<string> Items
  12:     {
  13:         get
  14:         {
  15:             return SyncEnumerable.Wrap(m_Items, this);
  16:         }
  17:     }
  18: }

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

Таблица поиска с динамическим заполнением

В приложениях часто возникает необходимость получить значение, соответствующее произвольному ключу. Например, наличие атрибута у произвольного типа, значение сложной функции в некоторой точке, константное значение из БД и т.п. Если пары “ключ-значение” инвариантны ко времени работы приложения, то логично произвести их кэширование, чтобы не вычислять эти соответствия каждый раз, когда они понадобятся. Для решения поставленной задачи, как нельзя кстати, подходит контейнер Dictionary. Однако, потребуется еще несколько строк вспомогательного кода, кода для многопоточной синхронизации (на всякий случай) и в результате – логика работы таблицы поиска будет размазана по коду. Рассмотрим класс LookupTable, который инкапсулирует в себе весь вспомогательный код и предоставляет наружу очень «тонкий» интерфейс взаимодействия:

Диаграмма классов для LookupTable

Рисунок 2 – Диаграмма классов для LookupTable

Алгоритм работы таблицы поиска изображен на рисунке 3.

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

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

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

Рассмотрим пример:

Некоторое приложение очень активно использует технологию отражения (Reflection) для проверки данных в уровне бизнес - логики:

   1: class DomainModel
   2: {
   3:         private int m_Price;
   4:  
   5:         [RangeAttribute(0, 1000)]
   6:         [RangeAttribute(1, 1000)]
   7:         [RangeAttribute(2, 1000)]
   8:         [RangeAttribute(3, 1000)]
   9:         [RangeAttribute(4, 1000)]
  10:         [RangeAttribute(5, 1000)]
  11:         [RangeAttribute(6, 1000)]
  12:         [RangeAttribute(7, 1000)]
  13:         [RangeAttribute(8, 1000)]
  14:         [RangeAttribute(9, 1000)]
  15:         [RangeAttribute(0, 999)]
  16:         public int Price
  17:         {
  18:             get { return m_Price; }
  19:             set
  20:             {
  21:                 ValidateProperty(this, "Price", value);
  22:                 m_Price = value;
  23:             }
  24:         }
  25: }

В методе ValidateProperty происходит проверка устанавливаемого свойства на соответствие ряду условий, описаных с помощью атрибутов, которыми помечено данное поле. Для получения списка атрибутов используется механизм рефлексии[4], а именно методы:

  • Type.GetProperty
  • Attribute.GetCustomAttributes

Пример простейшей реализации метода ValidateProperty:

   1: private static void ValidateProperty (object domainObject, string propertyName, object value)
   2: {
   3:     var objectType = domainObject.GetType();
   4:     var prop = objectType.GetProperty(propertyName);
   5:     var attrs = Attribute.GetCustomAttributes(prop, typeof (ValidationAttribute));
   6:  
   7:     foreach (ValidationAttribute attr in attrs)
   8:     {
   9:         if(!attr.IsValid(value))
  10:             throw new ArgumentException("Value is invalid",propertyName);
  11:     }
  12: }

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

   1: static LookupTable m_Attrs = new LookupTable
   2:             (
   3:                 t => (ValidationAttribute[])Attribute.GetCustomAttributes(t, typeof(ValidationAttribute))
   4:             ); 
   5:  
   6:         private static void ValidateProperty (object domainObject, string propertyName, object value)
   7:         {
   8:             var objectType = domainObject.GetType();
   9:             var prop = objectType.GetProperty(propertyName);
  10:             var attrs = m_Attrs[prop];
  11:  
  12:             foreach (ValidationAttribute attr in attrs)
  13:             {
  14:                 if (!attr.IsValid(value))
  15:                     throw new ArgumentException("Value is invalid", propertyName);
  16:             }
  17:         }

Следующая таблица показывает сравнение производительности* наивного и оптимизированного решения[5]:

 

Время работы

Выигрыш производительности

Неоптимизированный код

8351

0%

Таблица поиска

110

760%

* Данные получены на 100 000 итераций вызова сеттера для свойства класса типа Int32, помеченного одиннадцатью тестовыми атрибутами.

В приложении к статье в проекте LookupTableExample используется другой пример – там кэшируется значение некоторой функции f(x:double).


Вычисление по требованию

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

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

· В какой момент времени нужно производить обновление представления?

· Где и как хранить состояние согласованности модели и представления?

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

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

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

· Валидный (Valid) – инкапсулированный объект находится в согласованном состоянии;

· Невалидный (Invalid) – инкапсулированный объект не согласован с моделью.

На рисунке 4 приведен конечный автомат, поясняющий механизм работы данного концепта.

Граф переходов-состояний концепта «вычисление по требованию»

Рисунок 4 – Граф переходов-состояний концепта «вычисление по требованию»

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

Диаграмма класса ManualCache

Рисунок 5 – Диаграмма класса ManualCache

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

Что не вошло в статью

Изначально классы проектировались для использования в .NET Framework 2.0, так что в них не используются возможности С# 3.0. Некоторые конструкции могли быть более компактными. Например, использование Extension Methods позволило бы писать вместо:

SyncEnumerable.Wrap(input, syncRoot)

просто:

input.AsSynchronized(syncRoot).

Реализацию синтаксического сахара я оставляю читателям как домашнее задание.

Заключение

Платформа .NET очень активно развивается – между выходом .NET 1.1 и 2.0 прошло - , между 2.0 и 3.0 - , 3.0 – 3.5; не за горами релиз .NET Framework 4.0. При таких темпах тяжело быть в курсе всех новинок, и, наверное, нет еще такого разработчика, который знаком со всеми возможностями платформ 2.0, 3.0 и 3.5. И, зачастую, незнание всех возможностей инструмента приводит к тому, что разработчика «изобретает велосипед». В данной статье я предоставил набор типичных решений, которые дадут возможность не создавать лишних сущностей сверх необходимого и помогут сосредоточиться на решении более важных задач.


[1] Данная библиотека предоставляет набор классов, которые позволяют распараллеливать операции For, ForEach и им подобные между ядрами и процессорами, что позволяет максимально широко использовать возможности аппаратной платформы.

[2] Base Class Library

[3] Ключевое слово lock в C# является синтаксическим сахаром и транслируется компилятором в конструкцию:

   1: Monitor.Enter(syncRoot);
   2: try { … }
   3: finally { Monitor.Leave(syncRoot); }

[4] Reflection

[5] Конфигурация тестовой машины: Core 2 Duo 1.66 Ghz, 4GB Ram 633Mhz, Windows Web Server x64 2008

Коментарі

mormat сказав:

Корисна стаття :-) tnx!

PS: Щодо LookupTable - мабуть виграш продуктивності 7600% ?

# March 1, 2009 12:56 AM

ArchitectDS сказав:

Сомнительной ценности статейка с гордым лейблом “Уровень подготовки: Средний, Высокий”.

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

- Это мысль, наверно, лет 25 назад была бы актуальна. :)

И далее: гений автора предлагает заблокировать доступ к коллекции одним элементом синхрнизации (”тенденции создания приложений”, наверно не дошли еще до разделения операций чтения и записи :) ),

Кстати, до готового к использованию какого-либо контейнера единой моделью синхронизации, так дело и не дошло….

Ну и в конце первого примера, забыл автор предупредить, что ежели кто не вызовет явно Dispose (где снимается блокировка), то долго будут ждать остальные доступа….

Финально убила фраза:

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

Осталось узнать, видел ли автор бутылку настоящую :)

# July 7, 2009 6:59 AM
Анонімні коментарі деактивовані. Увійдіть або Зареєструйтесь щоб мати доступ до ресурсів Спільноти.