📜 ⬆️ ⬇️

Lock with priorities in .NET

Each programmer using more than one thread in his program encountered synchronization primitives. In the context of .NET there are a lot of them, I will not list them, MSDN has already done this for me.

I had to use many of these primitives, and they perfectly helped to cope with the tasks. But in this article I want to tell you about the usual lock in the desktop application and how a new (at least for me) primitive appeared that can be called PriorityLock.

Problem


When developing a high-load multi-threaded application, a manager appears somewhere that processes innumerable threads. So it was with me. And this manager was working, processing tons of requests from many hundreds of threads. And everything was good for him, and inside he worked the usual lock.

Then one day the user (for example, I) presses a button in the application interface, the stream flies to the manager (not the UI stream of course) and expects to see a super friendly reception, but instead he is met by Aunt Klava from the most dense registry of the most densely polyclinic with the words “I don't give a damn who sent you I still have 950 people like you. Go and go to them. I don't care how you figure it out. ” This is how lock in .NET works. And everything seems to be fine, everything will be executed correctly, but the user clearly did not plan to wait a few seconds for an answer to his action.
')
At this sentimental story ends and begins to solve a technical problem.

Decision


Having studied the standard primitives, I did not find a suitable option. Therefore, I decided to write my own lock, which would have a standard and high input priority. By the way, after writing, I studied nuget, I did not find anything like that there either, although I was probably looking bad.

For writing such a primitive (or no longer primitive) I needed SemaphoreSlim, SpinWait and Interlocked operations. In the spoiler, I gave the first version of my PriorityLock (only synchronous code, but it is the most important one), and explanations to it.

Hidden text
In terms of synchronization, there are no discoveries until someone is in the lock; others cannot enter. If high priority has arrived, it is allowed to wait for all those who are waiting for low priority.

Class LockMgr, it is proposed to work with it in your code. That he is the very object of synchronization. Creates Locker and HighLocker objects, contains semaphores, SpinWaits, counters wanting to get into the critical section, current stream and recursion counter.

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); } } 

The Locker class implements the IDisposable interface. To implement recursion when locking it, we remember the thread Id, then check it. Further, depending on the priority, in the case of high priority, we immediately say that we have arrived (we increase the HighCount counter), we get the High semaphore, and wait (if necessary) to release the lock from the low priority, after which we are ready to receive the lock. In the case of a low priority, it receives the Low semaphore, then we wait for the completion of all high priority streams, and, taking the High semaphore for a while, we increase the LowCount.

It is worth mentioning that the meaning of HighCount and LowCount is different, HighCount displays the number of priority flows that came to lock when LowCount only means that the flow (one and only) with low priority went to lock.

 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) { } } 


Using an object of class LockMgr turned out very concise. The example clearly shows the possibility of reusing _lockMgr inside the critical section, and the priority is no longer important.

 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 } } } 

So I solved my problem. The processing of user actions has become executed with high priority, no one was hurt, everyone won.

Asynchrony


Since the objects of the SemaphoreSlim class support asynchronous waiting, I also added this possibility to myself. The code is minimal and at the end of the article I will provide a link to the source code.

It is important to note here that Task is not tied to a stream in any way; therefore, it is impossible to similarly implement asynchronous reuse of a lock. Moreover, the Task.CurrentId property described by MSDN does not guarantee anything. At this my options are over.

In search of a solution, I came across a project NeoSmart.AsyncLock , in the description of which was indicated support for re-using asynchronous lok. Technically, reuse works. But unfortunately the lock itself is not a lock. Be careful if you use this package, you know, it does NOT work correctly!

Conclusion


The result was a class that supports synchronous operations with reuse, and asynchronous operations without reuse. Asynchronous and synchronous operations can be used side by side, but cannot be used together! All because of the lack of support for re-using the asynchronous option.

I hope I am not alone in such problems and my decision will be useful to someone. I posted the library on github and in nuget.

There are tests in the repository that show the performance of PriorityLock. On the asynchronous part of this test, NeoSmart.AsyncLock was tested, and it did not pass the test.

Nuget link
Github link

Source: https://habr.com/ru/post/458160/


All Articles