Каждый программист, использующий более одного потока в своей программе, сталкивался с примитивами синхронизации. В контексте .NET их очень и очень много, перечислять не буду, за меня это уже сделал MSDN.
Мне приходилось пользоваться многими из этих примитивов, и они прекрасно помогали справиться с задачами. Но в этой статье я хочу рассказать про обычный lock в десктопном приложении и о том как же появился новый (по крайней мере для меня) примитив, который можно назвать PriorityLock.
При разработке высоконагруженного многопоточного приложения где то появляется менеджер, который обрабатывает бесчисленное мн��жество потоков. Так было и у меня. И вот работал этот менеджер, обрабатывал тонны запросов от многих сотен потоков. И все у него было хорошо, а внутри трудился обычный lock.
И вот однажды пользователь (например я) нажимает кнопку в интерфейсе приложения, поток летит в менеджер (не UI поток конечно) и ожидает увидеть супер приветливый ресепшен, а вместо этого его встречает тетя Клава из самой дремучей регистратуры самой дремучей поликлиники со словами «Мне плевать кто тебя направил. У меня еще 950 таких как ты. Иди и втавай к ним. Мне всё равно как вы там разберетесь». Примерно так работает lock в .NET. И вроде все хорошо, все выполнится корректно, но пользователь явно не планировал ждать несколько секунд ответа на своё действие.
На этом душещипательная история заканчивается и начинается решение технической проблемы.
Изучив стандартные примитивы, я не нашел подходящего варианта. Поэтому решил написать свой lock, который бы имел стандартный и высокий приоритет входа. Кстати после написания я изучил и nuget, там тоже ничего подобного не нашел, хотя возможно плохо искал.
Для написания такого примитива (или уже не примитива) мне потребовались SemaphoreSlim, SpinWait и Interlocked операции. В спойлере я привел первый вариант моего PriorityLock (только синхронный код, но он и есть самый важный), и пояснения к нему.
Использование объекта класса LockMgr получилось очень лаконичным. В примере явно показана возможность переиспользования _lockMgr внутри критической секции, при этом приоритет уже не важен.
Таким образом я решил свою задачу. Обработка пользовательских действий стала выполнятся с высоким приоритетом, никто не пострадал, все выиграли.
Так как объекты класса SemaphoreSlim поддерживают асинхронное ожидание, я так же добавил себе эту возможность. Код отличается минимально и в конце статьи я приведу ссылку на исходный код.
Здесь важно отметить то, что Task не привязан к потоку никаким образом, поэтому нельзя аналогичным образом реализовать асинхрон��ое переиспользование лока. Более того, свойство Task.CurrentId по описанию MSDN не гарантирует ничего. На этом мои варианты закончились.
В поисках решения я наткнулся на проект NeoSmart.AsyncLock, в описании которого была указана поддержка переиспользования асинхронного лока. Технически переиспользование работает. Но к сожалению сам лок не является локом. Будте осторожны, если используете этот пакет, знайте, он работает НЕ правильно!
В итоге получился класс поддерживающий синхронные операции с переиспользованием, и асинхронные операции без переиспользования. Асинхронные и синхронные операции можно использовать рядом, но нельзя использовать вместе! Все из-за отсутствия поддержки переиспользования асинхронным вариантом.
Надеюсь я не одинок в таких проблемах и моё решение кому то пригодится. Библиотеку я выложил на github и в nuget.
В репозитории есть тесты, показывающие работоспособность PriorityLock. На асинхронной части этого теста проверялся NeoSmart.AsyncLock, и тест он не прошел.
Ссылка на nuget
Ссылка на github
Мне приходилось пользоваться многими из этих примитивов, и они прекрасно помогали справиться с задачами. Но в этой статье я хочу рассказать про обычный lock в десктопном приложении и о том как же появился новый (по крайней мере для меня) примитив, который можно назвать PriorityLock.
Проблема
При разработке высоконагруженного многопоточного приложения где то появляется менеджер, который обрабатывает бесчисленное мн��жество потоков. Так было и у меня. И вот работал этот менеджер, обрабатывал тонны запросов от многих сотен потоков. И все у него было хорошо, а внутри трудился обычный lock.
И вот однажды пользователь (например я) нажимает кнопку в интерфейсе приложения, поток летит в менеджер (не UI поток конечно) и ожидает увидеть супер приветливый ресепшен, а вместо этого его встречает тетя Клава из самой дремучей регистратуры самой дремучей поликлиники со словами «Мне плевать кто тебя направил. У меня еще 950 таких как ты. Иди и втавай к ним. Мне всё равно как вы там разберетесь». Примерно так работает lock в .NET. И вроде все хорошо, все выполнится корректно, но пользователь явно не планировал ждать несколько секунд ответа на своё действие.
На этом душещипательная история заканчивается и начинается решение технической проблемы.
Решение
Изучив стандартные примитивы, я не нашел подходящего варианта. Поэтому решил написать свой lock, который бы имел стандартный и высокий приоритет входа. Кстати после написания я изучил и nuget, там тоже ничего подобного не нашел, хотя возможно плохо искал.
Для написания такого примитива (или уже не примитива) мне потребовались SemaphoreSlim, SpinWait и Interlocked операции. В спойлере я привел первый вариант моего PriorityLock (только синхронный код, но он и есть самый важный), и пояснения к нему.
Скрытый текст
В плане синхронизации нету никаких открытий, пока кто-то в локе, другие не могут зайти. Если пришел high priority, его пускают вперед всех ожидающих low priority.
Класс LockMgr, с ним предлагается работать в вашем коде. Именно он является тем самым объектом синхронизации. Создает объекты Locker и HighLocker, содержит в себе семафоры, SpinWait'ы, счетчики желающих попасть в критическую секцию, текущий поток и счетчик рекурсии.
Класс Locker реализует интерфейс IDisposable. Для реализации рекурсии при завладении локом запоминаем Id потока, после проверяем его. Далее в зависимости от приоритета, в случае высокого приоритета сразу говорим что мы пришли (увеличиваем счетчик HighCount), получаем семафор High, и ждём (если нужно) освобождения лока от низкого приорита, после мы готовы получить лок. В случае низкого приорита получает семафор Low, далее ждем завершения всех high приоритетных потоков, и, забирая на время семафор High увеличиваем LowCount.
Стоит оговориться, что смысл HighCount и LowCount разный, HighCount отображает количество приоритетных потоков, которые пришли к локу, когда LowCount всего лишь означает что поток (один единственный) с низким приоритетом зашел в лок.
Класс LockMgr, с ним предлагается работать в вашем коде. Именно он является тем самым объектом синхронизации. Создает объекты Locker и HighLocker, содержит в себе семафоры, SpinWait'ы, счетчики желающих попасть в критическую секцию, текущий поток и счетчик рекурсии.
public class LockMgr
{
internal int HighCount;
internal int LowCount;
internal Thread CurThread;
internal int RecursionCount;
internal readonly SemaphoreSlim Low = new SemaphoreSlim(1);
internal readonly SemaphoreSlim High = new SemaphoreSlim(1);
internal SpinWait LowSpin = new SpinWait();
internal SpinWait HighSpin = new SpinWait();
public Locker HighLock()
{
return new HighLocker(this);
}
public Locker Lock(bool high = false)
{
return new Locker(this, high);
}
}
Класс Locker реализует интерфейс IDisposable. Для реализации рекурсии при завладении локом запоминаем Id потока, после проверяем его. Далее в зависимости от приоритета, в случае высокого приоритета сразу говорим что мы пришли (увеличиваем счетчик HighCount), получаем семафор High, и ждём (если нужно) освобождения лока от низкого приорита, после мы готовы получить лок. В случае низкого приорита получает семафор Low, далее ждем завершения всех high приоритетных потоков, и, забирая на время семафор High увеличиваем LowCount.
Стоит оговориться, что смысл HighCount и LowCount разный, HighCount отображает количество приоритетных потоков, которые пришли к локу, когда LowCount всего лишь означает что поток (один единственный) с низким приоритетом зашел в лок.
public class Locker : IDisposable
{
private readonly bool _isHigh;
private LockMgr _mgr;
public Locker(LockMgr mgr, bool isHigh = false)
{
_isHigh = isHigh;
_mgr = mgr;
if (mgr.CurThread == Thread.CurrentThread)
{
mgr.RecursionCount++;
return;
}
if (_isHigh)
{
Interlocked.Increment(ref mgr.HighCount);
mgr.High.Wait();
while (Interlocked.CompareExchange(ref mgr.LowCount, 0, 0) != 0)
mgr.HighSpin.SpinOnce();
}
else
{
mgr.Low.Wait();
while (Interlocked.CompareExchange(ref mgr.HighCount, 0, 0) != 0)
mgr.LowSpin.SpinOnce();
try
{
mgr.High.Wait();
Interlocked.Increment(ref mgr.LowCount);
}
finally
{
mgr.High.Release();
}
}
mgr.CurThread = Thread.CurrentThread;
}
public void Dispose()
{
if (_mgr.RecursionCount > 0)
{
_mgr.RecursionCount--;
_mgr = null;
return;
}
_mgr.RecursionCount = 0;
_mgr.CurThread = null;
if (_isHigh)
{
_mgr.High.Release();
Interlocked.Decrement(ref _mgr.HighCount);
}
else
{
_mgr.Low.Release();
Interlocked.Decrement(ref _mgr.LowCount);
}
_mgr = null;
}
}
public class HighLocker : Locker
{
public HighLocker(LockMgr mgr) : base(mgr, true)
{ }
}
Использование объекта класса LockMgr получилось очень лаконичным. В примере явно показана возможность переиспользования _lockMgr внутри критической секции, при этом приоритет уже не важен.
private PriorityLock.LockMgr _lockMgr = new PriorityLock.LockMgr();
public void LowPriority()
{
using (_lockMgr.Lock())
{
using (_lockMgr.HighLock())
{
// your code
}
}
}
public void HighPriority()
{
using (_lockMgr.HighLock())
{
using (_lockMgr.Lock())
{
// your code
}
}
}
Таким образом я решил свою задачу. Обработка пользовательских действий стала выполнятся с высоким приоритетом, никто не пострадал, все выиграли.
Асинхронность
Так как объекты класса SemaphoreSlim поддерживают асинхронное ожидание, я так же добавил себе эту возможность. Код отличается минимально и в конце статьи я приведу ссылку на исходный код.
Здесь важно отметить то, что Task не привязан к потоку никаким образом, поэтому нельзя аналогичным образом реализовать асинхрон��ое переиспользование лока. Более того, свойство Task.CurrentId по описанию MSDN не гарантирует ничего. На этом мои варианты закончились.
В поисках решения я наткнулся на проект NeoSmart.AsyncLock, в описании которого была указана поддержка переиспользования асинхронного лока. Технически переиспользование работает. Но к сожалению сам лок не является локом. Будте осторожны, если используете этот пакет, знайте, он работает НЕ правильно!
Заключение
В итоге получился класс поддерживающий синхронные операции с переиспользованием, и асинхронные операции без переиспользования. Асинхронные и синхронные операции можно использовать рядом, но нельзя использовать вместе! Все из-за отсутствия поддержки переиспользования асинхронным вариантом.
Надеюсь я не одинок в таких проблемах и моё решение кому то пригодится. Библиотеку я выложил на github и в nuget.
В репозитории есть тесты, показывающие работоспособность PriorityLock. На асинхронной части этого теста проверялся NeoSmart.AsyncLock, и тест он не прошел.
Ссылка на nuget
Ссылка на github
