Каждый программист, использующий более одного потока в своей программе, сталкивался с примитивами синхронизации. В контексте .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