- PVSM.RU - https://www.pvsm.ru -
Доброго времени суток!
В этой статье я расскажу, как повысить производительность многопоточного (и не только) C#-приложения, в котором часто создаются объекты для «одноразовой» работы.
Немного про многопоточность, неблокирующую синхронизацию, использование встроенного в VS2012 профилировщика и небольшой бенчмарк.
Пул объектов [1] — порождающий шаблон проектирования, набор инициализированных и готовых к использованию объектов.
Зачем он нужен? Вкратце — для повышения производительности, когда инициализация нового объекта приводит к большим затратам. Но тут важно понимать, что встроенный в .NET сборщик мусора прекрасно справляется с уничтожением легких короткоживущих объектов, поэтому применимость пула ограничивается следующими критериями:
Немного поясню последний пункт. Если ваш объект занимает в памяти 85 000 байт и более, он попадает в кучу больших объектов (large object heap) во втором поколении сборки мусора, что автоматически делает его «долгоживущим» объектом. Прибавим к этому фрагментированность (эта куча не сжимается) и получим потенциальную проблему нехватки памяти при постоянных выделениях/уничтожениях.
Идея пула состоит в том, чтобы организовать переиспользование «дорогих» объектов, используя следующий сценарий:
var obj = pool.Take(); // нам потребовался рабочий объект. Вместо создания мы запрашиваем его из пула
obj.DoSomething();
pool.Release(obj); // возвращаем ("освобождаем") объект в пул, когда он становится не нужным
Проблемы такого подхода:
С учетом этих проблем были составлены требования к новому классу:
В некоторых реализациях, для поддержки пулом объекта, объект должен реализовать интерфейс IPoolable или аналогичные, но моей задачей было обеспечение работы пула с любыми классами, даже если они закрыты для наследования. Для этого была создана generic-оболочка PoolSlot, которая внутри содержит сам объект и ссылку на пул. Сам же пул представляет собой абстрактный generic-класс для хранения этих слотов, с двумя нереализованными методами для создания нового объекта и очистки старого.
public abstract class Pool<T>
{
public PoolSlot<T> TakeSlot() {...} // операция "взять из пула"
public void Release(PoolSlot<T> slot) {...} // операция "вернуть из пула"
/* ... */
// методы для переопределения:
protected abstract T ObjectConstructor(); // создание нового объекта, готового для использования
protected virtual void CleanUp(T item) {} // очистка использованного объекта, вызываемая автоматически
}
public class SocketClientPool : Pool<SocketAsyncEventArgs>
{
private readonly int _bufferSize;
public SocketClientPool(int bufferSize, int initialCount, int maxCapacity)
: base(maxCapacity)
{
if (initialCount > maxCapacity)
throw new IndexOutOfRangeException();
_bufferSize = bufferSize;
TryAllocatePush(initialCount); // в базовом классе объявлено несколько protected-методов; этот создает и помещает в пул указанное число новых объектов
}
protected override SocketAsyncEventArgs ObjectConstructor()
{
var args = new SocketAsyncEventArgs();
args.SetBuffer(new byte[_bufferSize], 0, _bufferSize);
return args;
}
protected override void CleanUp(SocketAsyncEventArgs @object)
{
Array.Clear(@object.Buffer, 0, _bufferSize);
}
}
Использование в коде:
var pool = new SocketClientPool(1024, 5, 10); // при старте сервера, например
/* ...где-то в коде... */
var slot = pool.TakeSlot(); // взятие слота с объектом
var args = slot.Object; // оригинальный объект для каких-либо действий
pool.Release(slot); // возвращение обратно в пул
Или даже так:
using(var slot = pool.TakeSlot()) // класс PoolSlot реализует IDisposable
{
var args = slot.Object;
}
Те, кто знаком с асинхронной моделью .NET и/или с асинхронными методами того же самого класса Socket знают, что использование такой реализации затруднено, потому что методы Socket.XxxAsync принимают на вход именно SocketAsyncEventArgs, а не какой-то там PoolSlot<SocketAsyncEventArgs>. Для вызова метода это не беда, но откуда же брать слот в обработчике окончания?
Один из вариантов — сохранить слот в свойстве SocketAsyncEventArgs.UserToken при создании объекта, для этого в пуле есть метод для переопределения HoldSlotInObject.
protected override void HoldSlotInObject(SocketAsyncEventArgs @object, PoolSlot<SocketAsyncEventArgs> slot)
{
@object.UserToken = slot;
}
/* ...где-то в коде... */
pool.Release(args.UserToken as PoolSlot<SocketAsyncEventArgs>);
Конечно, не каждый объект предоставляет пользователю такое свойство. И если ваш класс все-таки не закрыт от наследования, то предлагается специальный интерфейс IPoolSlotHolder с одним единственным свойством для хранения слота. И если я знаю, что мой объект гарантированно содержит слот, то было бы логичным дописать методы TakeObject/Release, возвращающие/принимающие сами объекты (и получать их слот внутри), что и было сделано в потомке пула.
public abstract class PoolEx<T> : Pool<T>
where T : IPoolSlotHolder
{
public T TakeObject() { ... }
public void Release(T @object) { ... }
protected sealed void HoldSlotInObject(T @object, PoolSlot<T> slot) { ... } // уже ничего переопределять не надо
}
Далее я предлагаю ознакомиться с разработкой внутренней «кухни».
Для хранения объектов «в пуле» используется коллекция ConcurrentStack. Возможное использование нескольких экземпляров пула потребовало ведение учета, какой из объектов был создан именно этим пулом.
Так был введен «реестр» на основе ConcurrentDictionary, который содержит ID слотов, когда-либо созданных пулом и флаг доступности объекта (true — «в пуле», false — «не в пуле»).
Это позволило убить сразу 2х зайцев: предотвратить ошибочное многократное возвращение одного и того же объекта (ведь стек не обеспечивает уникальности хранимых в нем объектов) и предотвратить возвращение объектов, созданных в другом пуле. Данный подход был временным решением, и далее я от него избавился.
Классическая реализация пула предполагает использование семафора (в .NET это Semaphore и SemaphoreSlim) для слежения за количеством объектов, либо других примитивов синхронизации в связке со счетчиком, но ConcurrentStack, как и ConcurrentDictionary — потокобезопасные коллекции, поэтому сам ввод-вывод объектов регулировать уже не требуется. Замечу только, что вызов свойства ConcurrentStack.Count вызывает полный перебор всех элементов, что занимает существенное время, поэтому было решено добавить свой счетчик элементов. В итоге, было получено две «атомарных» операции над пулом — Push и TryPop, на основе которых строились все остальные.
private void Push(PoolSlot<T> item)
{
_registry[token.Id] = true; // реестр: объект "в пуле"
_storage.Push(item); // возвращаем объект в хранилище
Interlocked.Increment(ref _currentCount);
}
private bool TryPop(out PoolSlot<T> item)
{
if (_storage.TryPop(out item)) // пытаемся взять объект из хранилища
{
Interlocked.Decrement(ref _currentCount);
_registry[token.Id] = false; // реестр: объект "не в пуле"
return true;
}
item = default(PoolSlot<T>);
return false;
}
Помимо ввода-вывода существующих объектов, необходимо синхронизировать и выделение новых до указанного верхнего предела.
Тут примени́м семафор, инициализированный максимальным числом элементов в пуле (верхним лимитом) и вычитающий по единице каждый раз при создании нового объекта, но проблема в том, что при достижении нуля он просто заблокирует поток. Выходом из этой ситуации мог бы стать вызов метода SemaphoreSlim.Wait(0), который при текущем значении семафора «0» почти без задержки отдает false, но было решено написать легковесный аналог этого функционала. Так появился класс LockFreeSemaphore, который при достижении нуля без задержек возвращает false. Для внутренней синхронизации он использует быстрые CAS-операции Interlocked.CompareExchange [2].
public bool TryTake() // возвращает true, если успешно вошли в семафор, иначе false (если все ресурсы заняты)
{
int oldValue, newValue;
do
{
oldValue = _currentCount; // запоминаем старое значение
newValue = oldValue - 1; // вычисляем новое значение
if (newValue < 0) return false; // если семафор уже равен 0 - возвращаем false без ожиданий
} while (Interlocked.CompareExchange(ref _currentCount, newValue, oldValue) != oldValue); // если старое значение не было изменено другим потоком, то оно заменяется новым и цикл успешно завершается
return true;
}
Таким образом, операция пула «взять объект» работает по следующему алгоритму:
Так ли нужен пул объектов? Зависит от ситуации. Вот результаты небольшого тестирования с использованием «типичного серверного объекта», SocketAsyncEventArgs с буфером на 1024 байта (время в секундах, создание пула включено):
Запросов нового объекта | Один поток, без пула | Один поток, с пулом | 25 задач*, без пула | 25 задач*, с пулом |
1 000 | 0.002 | 0.003 | 0.027 | 0.009 |
10 000 | 0.010 | 0.001 | 0.272 | 0.039 |
25 000 | 0.030 | 0.003 | 0.609 | 0.189 |
50 000 | 0.048 | 0.006 | 1.285 | 0.287 |
1 000 000 | 0.959 | 0.125 | 27.965 | 8.345 |
* задача — класс System.Threading.Tasks.Task из библиотеки TPL, начиная с .NET 4.0
Результаты прохода профилировщика VS2012 по многопоточному тесту с пулом:
Как видим, все упирается в метод ConcurrentStack.TryPop, который (будем считать) ускорять некуда. На втором месте обращение к «реестру», который отбирает примерно по 14% в обеих операциях.
В принципе, поддержка второй коллекции внутри пула и так казалась мне костыльной, поэтому признак «в пуле/не в пуле» был перенесен в сам слот, а реестр благополучно удален. Результаты тестов после рефакторинга (прирост, как и ожидалось, 30-40%):
Запросов нового объекта | 25 задач, с пулом |
25 000 | 0.098 |
1 000 000 | 5.751 |
Думаю, на этом можно остановиться.
Вкратце напомню, как решались поставленные задачи:
Исходный код с unit-тестами и тестовым приложением: Github [3]
Если интересно, могу продолжить статью реализацией асинхронных TCP и UDP сокет-серверов, для которых данный пул как раз и писался.
Автор: Dem0n13
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/net/31117
Ссылки в тексте:
[1] Пул объектов: http://ru.wikipedia.org/wiki/Объектный_пул
[2] Interlocked.CompareExchange: http://msdn.microsoft.com/ru-ru/library/801kt583.aspx
[3] Github: https://github.com/Dem0n13/AsyncSocketServers
[4] Источник: http://habrahabr.ru/post/175317/
Нажмите здесь для печати.