Lock с приоритетами в .NET

в 11:39, , рубрики: .net, C#, lock, параллельное программирование, синхронизация

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

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

Проблема

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

И вот однажды пользователь (например я) нажимает кнопку в интерфейсе приложения, поток летит в менеджер (не UI поток конечно) и ожидает увидеть супер приветливый ресепшен, а вместо этого его встречает тетя Клава из самой дремучей регистратуры самой дремучей поликлиники со словами «Мне плевать кто тебя направил. У меня еще 950 таких как ты. Иди и втавай к ним. Мне всё равно как вы там разберетесь». Примерно так работает lock в .NET. И вроде все хорошо, все выполнится корректно, но пользователь явно не планировал ждать несколько секунд ответа на своё действие.

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

Решение

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

Для написания такого примитива (или уже не примитива) мне потребовались SemaphoreSlim, SpinWait и Interlocked операции. В спойлере я привел первый вариант моего PriorityLock (только синхронный код, но он и есть самый важный), и пояснения к нему.

Скрытый текст

В плане синхронизации нету никаких открытий, пока кто-то в локе, другие не могут зайти. Если пришел high priority, его пускают вперед всех ожидающих low priority.

Класс 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

Автор: Андрей Грачев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js