Click here to Skip to main content
15,884,962 members
Articles / General Programming / Threads
Tip/Trick

Smarter than lock(), Cleaner than TryEnter

Rate me:
Please Sign up or sign in to vote.
4.85/5 (16 votes)
11 Apr 2014CPOL3 min read 60.5K   19   10
Lock has pitfalls: let's use Monitor.TryEnter with less boilerplate

Introduction

Locking is important for synchronizing access to data shared between threads. Ideally, you want to avoid the need for this but sometimes it is not possible or practical. So instead, we must lock - and unlock - correctly, and ideally without cluttering up the codebase.

C# provides us the lock(...) {} statement. Trouble is, this has pitfalls: notably because it blocks the current thread until it actually acquires the lock. If another thread has locked an object and (for example) is stuck in an infinite loop, when you attempt to lock the same object, you will wait on it forever because lock does not time out - and the application will be deadlocked.

In my opinion, it is better to use timeouts so your code will not wait forever if something goes wrong, so a better strategy is to use Monitor.TryEnter, and include a maximum timeout (you did declare a constant for it, instead of hardcoding - right?). Then, you add a try/finally so you will always call Monitor.Exit afterward.

All told, you will end up with a lot of surrounding code like this:

C#
// at class-level
private readonly object syncObject = new object();
private const int TimeoutMaxWait = 50; // milliseconds

// at each location you require a lock
if (Monitor.TryEnter(syncObject, TimeoutMaxWait))
{
    try
    {
        // useful work
    }
    finally
    {
        Monitor.Exit(syncObject);
    }
}
else
{
    // failed to get lock: throw exceptions, log messages, get angry etc.
}  

Now, there is nothing wrong with this, but if we have a class with a lot of methods that require locking, we will have a lot of this boilerplate code. Maybe we could do something about it, and reduce the above code to:

C#
using (Lock())
{
    // useful work
} 

Using the Code

Whether this works for you is heavily dependent on what you do when it fails to lock - I throw an exception in all cases. If you want to do something specific at each call site, your code will be more complex.

The main piece behind this solution is an IDisposable object, Key which holds a reference to the object we locked on (this allows re-use across multiple classes). If we put the Key in a using statement, it will unlock itself.

C#
public class Key : IDisposable
{
    private object padlock;

    public Key(object locker)
    {
        padlock = locker;
    }

    public void Dispose()
    {
        // when this falls out of scope (after a using {...} ), release the lock
        Monitor.Exit(padlock);
    }
} 

One Way - Less Code

Then add a method to the class to acquire and return a Key.

C#
protected Key Lock()
{
    if (System.Threading.Monitor.TryEnter(this.syncObject, LockTime))
        return new Key(this.syncObject);
    else
        // throw exception, log message, etc.
        throw new System.TimeoutException("(class) failed to acquire the lock.");
} 

With this method, just wrap your work with a single using call.

C#
using (Lock())
{ ... } 

Again, I throw exceptions because it fits the component where I am using this code. The error message could easily be made into a parameter.

Another Way - DIY Failure

If you want to handle failure your way, create a Key manually which still gives you the try, finally and Monitor.Exit calls for free. Your locking code would look like:

C#
if (Monitor.TryEnter(this.syncObject, LockTime))
{
    using (new Key(this.syncObject))
    {
        // do useful work
    }
}
else
{
    // do what you want when it fails
} 

That's a bit cleaner than the original, and most importantly - you can't forget to unlock it.

Note - What to Lock

You should always lock on a private readonly object, ideally one which is kept for the purpose of synchronization. I have seen code examples like this:

C#
lock (myList) // do not do this
{
    myList = new List(); // do not do this either
    myList.AddRange(something);
} 

If the thread is pre-empted and someone now tries to lock on myList, it's a different object - so the locking is ineffective.

Locking on this or on typeof(...) is a practice that is discouraged by MSDN because these are not necessarily safe to use for locks.

History

  • 2014/4/11: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Scotland Scotland
Hobbyist developer from the UK.
I just make stuff for the benefit of making stuff.

Comments and Discussions

 
QuestionMy vote of 5 Pin
Steve Hageman23-Nov-18 7:28
Steve Hageman23-Nov-18 7:28 
QuestionLockTime - is this TimeoutMaxWait? Pin
PaperTape23-Dec-14 0:01
professionalPaperTape23-Dec-14 0:01 
AnswerRe: LockTime - is this TimeoutMaxWait? Pin
Steve Hageman23-Nov-18 7:31
Steve Hageman23-Nov-18 7:31 
SuggestionAn alternative solution Pin
Henrik Jonsson13-Apr-14 3:49
Henrik Jonsson13-Apr-14 3:49 
GeneralRe: An alternative solution Pin
Oleg A.Lukin13-Apr-14 19:35
Oleg A.Lukin13-Apr-14 19:35 
GeneralRe: An alternative solution Pin
FatCatProgrammer14-Apr-14 4:11
FatCatProgrammer14-Apr-14 4:11 
AnswerRe: An alternative solution Pin
Henrik Jonsson15-Apr-14 8:48
Henrik Jonsson15-Apr-14 8:48 
Thanks for reviewing the code.

When Lock is used in the way shown, the constructor will be called outside the compiler-created try block and thus the Dispose with Monitor.Exit will never be executed in case of a timeout. See the following dissassembly:
IL_0000:  ldsfld     object Lock.Program::syncObject
IL_0005:  ldc.i4.s   100
IL_0007:  newobj     instance void Lock.Lock::.ctor(object,int32) // using ( new Lock(syncObject, 100) )
IL_000c:  stloc.0
.try
{
  // Perform work here
  IL_0019:  leave.s    IL_0029
}  // end .try
finally
{
  IL_001b:  ldloca.s   CS$3$0000
  IL_001d:  constrained. Lock.Lock
  IL_0023:  callvirt   instance void [mscorlib]System.IDisposable::Dispose()  // Lock.Dispose()
  IL_0028:  endfinally
}  // end handler

However, after seing this code and reading Eric Lippert's post [^] about locks and exception I realize that there is a risk that an exception (thread interruption) is thrown between the invocation of Monitor.TryEnter and the entering of the try-block. Thus, an even safer implementation of Lock should be
C#
public struct Lock2 : IDisposable
{
   private object lockedObject;
   private bool lockTaken;

   public void Lock ( object onObject, int timeoutMillisecond )
   {
#if DEBUG
      if( onObject == null ) throw new ArgumentNullException("onObject in Lock must not be null."); 
      if( lockedObject != null ) throw new InvalidOperationException("Illegal use of Lock: Lock method must only called once per Lock instance.");
      if( onObject.GetType().IsValueType ) throw new InvalidOperationException("Illegal use of Lock. Must not lock on a value type object."); 
#endif
      lockedObject = onObject;
      Monitor.TryEnter ( onObject, timeoutMillisecond, ref lockTaken );
      if( lockTaken == false )
      {
          throw new System.TimeoutException ( "Did not aquire lock in specified time." );
      }
   }

   public void Dispose ( )
   {
#if DEBUG
      if( lockedObject == null ) throw new InvalidOperationException("Illegal use of Lock. Lock must have been called before releasing/disposing.");
#endif
     if ( lockTaken )
     {
        lockTaken = false;
        Monitor.Exit ( lockedObject );
     }
  }
}

Here we have added some debug-mode checking to enforce the correct use of Lock, like this
C#
using ( var lck = new Lock2() )
{
   lck.Lock ( syncObject, 100 /* ms */);
       
   // Do work here
}

Slightly more code but safer.
GeneralRe: An alternative solution Pin
FatCatProgrammer15-Apr-14 10:41
FatCatProgrammer15-Apr-14 10:41 
GeneralMy vote of 5 Pin
Volynsky Alex12-Apr-14 21:41
professionalVolynsky Alex12-Apr-14 21:41 
QuestionThis is bad advice. Pin
Alois Kraus12-Apr-14 21:22
Alois Kraus12-Apr-14 21:22 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.