This is embarrassing, but for quite awhile I had been using C#’s lock
keyword wrong. This is how I had thought that it worked:
When an object Foo is locked by thread A, none of Foo’s members can be changed in another thread B without blocking B until the lock is released.
Most C# devs probably read that and either chuckled or cringed. That’s an understandable reaction, because, once again, it is very wrong. However, I’m probably not the only one to have been confused by lock
, hence this blog post. This is how it actually works:
When an object Foo is locked by thread A and another thread B also tries to lock Foo, then thread B will be blocked until the lock is released by thread A.
There is a world of difference between those two! lock
is much less powerful than I previously thought. Here are some important notes about it and what it doesn’t do:
- A locked object can have its members changed from another thread.
- If an object is locked by one thread and one of the object’s members is locked by another thread, then neither thread will be blocked since the locks are on different objects.
- Locks are taken on objects, not variables. So if an object Foo is locked by thread A and then Foo is reassigned to a new object, then when thread B locks Foo, B will not be blocked by A’s lock.
Let’s see some examples to illustrate, especially that last point.
Here are 2 classes. InnerClass
just has a public int
that multiple threads will increment. OuterClass
has a public instance of InnerClass
and a public method that we’ll call from multiple threads with different lock behavior. The method loops 20 times to increment Inner
‘s field and write the new value as well as the name of the current thread to the console. A 1 millisecond sleep ensures that the method doesn’t run so fast that the threads don’t have time to interfere with one another.
class InnerClass
{
public int FieldThatAllTheThreadsWant = 0;
}
class OuterClass
{
public InnerClass Inner = new InnerClass();
public void ChangeFieldAndWriteToTheConsole()
{
for (int i = 0; i < 20; ++i)
{
++Inner.FieldThatAllTheThreadsWant;
Console.WriteLine("{0}; field = {1}",
Thread.CurrentThread.Name,
Inner.FieldThatAllTheThreadsWant);
Thread.Sleep(1);
}
}
}
We’ll make a static instance of OuterClass
called Outer
and call its method from these three methods that lock on Outer
, Outer.Inner
, or nothing.
static OuterClass Outer = new OuterClass();
static void LockOnOuter()
{
lock (Outer)
{
Outer.ChangeFieldAndWriteToTheConsole();
}
}
static void LockOnInner()
{
lock (Outer.Inner)
{
Outer.ChangeFieldAndWriteToTheConsole();
}
}
static void DontLock()
{
Outer.ChangeFieldAndWriteToTheConsole();
}
Here’s a method that shows the wrong way to use lock
. It makes three threads that each call a different one of the three methods we made above, then writes the final value of the field to the console. What I would hope to see based on my earlier misunderstandings of lock
is for each thread to write 20 consecutive lines to the console and for the final value to be 60. Run the code and each thread’s 20 lines are anything but consecutive. That should be no surprise to anyone reading this, but what may surprise some is that the final value will not always be 60!
static void WrongWay()
{
var t1 = new Thread(LockOnOuter)
{
Name = "T1"
};
var t2 = new Thread(LockOnInner)
{
Name = "T2"
};
var t3 = new Thread(DontLock)
{
Name = "T3"
};
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine("Final value: {0}", Outer.Inner.FieldThatAllTheThreadsWant);
}
Per the documentation it turns out that while reading and writing primitive types like int
is always atomic, “there is no guarantee of atomic read-modify-write, such as in the case of increment or decrement”. If we wanted to guarantee in the above code that the result will always be 60, we could use the Increment
method from the Interlocked
static class.
So we’ve seen how not to use lock
. This is how it should be used. Multiple threads locking on the same object, in this case on Outer
. Run it and the two threads will have their messages appear consecutively and the final value will always be 40.
static void RightWayOuter()
{
var t1 = new Thread(LockOnOuter)
{
Name = "T1"
};
var t2 = new Thread(LockOnOuter)
{
Name = "T2"
};
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("Final value: {0}", Outer.Inner.FieldThatAllTheThreadsWant);
}
Of course, we could also lock on Outer.Inner
instead of Outer
. That’s shown below for completeness. Since both threads lock on the same object, the result is the same as above.
static void RightWayInner()
{
var t1 = new Thread(LockOnInner)
{
Name = "T1"
};
var t2 = new Thread(LockOnInner)
{
Name = "T2"
};
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("Final value: {0}", Outer.Inner.FieldThatAllTheThreadsWant);
}
Lastly, I’d like to point out a subtlety of lock
that I had never considered before I started this post. A lock is taken on an object, not on a variable. That sentence probably makes perfect sense when read, but it means we have to be careful about reassigning a variable being locked.
To illustrate, take a look at the method below. It’s identical to RightWayOuter
, except that between the two threads starting we reassign Outer
. This means that the two threads lock on different objects even though they lock on the same variable. Run the code and see that the threads’ messages are not all consecutive and the final is 20, not 40.
static void WrongWayChangingVariable()
{
var t1 = new Thread(LockOnOuter)
{
Name = "T1"
};
var t2 = new Thread(LockOnOuter)
{
Name = "T2"
};
t1.Start();
// sleep to ensure that t1 has actually started before reassigning
Thread.Sleep(10);
Outer = new OuterClass();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("Final value: {0}", Outer.Inner.FieldThatAllTheThreadsWant);
}
I hope you came away with a better understanding of lock
or that you at least got to laugh at my old mistakes. Leave a comment if this helped, if there are any errors I should correct, if you’ve got a question, or if you want to share your own embarrassing misconceptions. Source code for this example is freely available here.