lock

The lock statement lets one thread at a time run a block of code; use a dedicated lock object and keep lock order fixed.

Syntaxlock (lockObject) { /* critical section */ } // same as: using (lockObject.EnterScope())

The lock statement makes a block of code a critical section: a region only one thread may be inside at a time. Any other thread that arrives waits at the opening brace. That is how a multi-step update, such as read-add-write, is made to behave as one step.

The shorthand and the longhand

.NET 9 added a type built for this job, System.Threading.Lock. When the compiler can see the expression is exactly that type, C# 13 and later turn the statement into this, and the two are the same thing:

lock (gate) { /* body */ }            // shorthand
using (gate.EnterScope()) { /* body */ }   // what it compiles to

Lock on any other reference type and it compiles instead to Monitor.Enter(obj, ref taken) in a try with Monitor.Exit(obj) in the matching finally. Both forms release the lock even when the body throws, which is the reason to use the statement rather than calling Monitor yourself.

What to lock on

Use a private readonly Lock field that nothing else touches. Do not lock on this, on a Type, or on a string. All three are reachable by code outside your class, and string literals are shared across the whole program, so unrelated code can block you. Hold the lock for as few lines as you can.

Deadlock

A deadlock is two threads each waiting forever for a lock the other holds. It happens when one code path takes lock A then lock B while another takes B then A. The fix is a written rule: every path that needs both takes them in the same order.

Example

Example · csharp
int total = 0;
Lock gate = new();   // System.Threading.Lock, added in .NET 9

// Two tasks each add one, fifty thousand times.
Task AddMany() => Task.Run(() =>
{
    for (int n = 0; n < 50_000; n++)
    {
        // Only one thread can be inside this block at a time, so the
        // three steps of total += 1 cannot be interleaved.
        lock (gate)
        {
            total += 1;
        }
    }
});

await Task.WhenAll(AddMany(), AddMany());

Console.WriteLine($"total: {total}");

// Output:
// total: 100000

When to use it

  • Keeping a counter and its matching list in step when several tasks append to both.
  • Protecting an in-memory lookup table that a background task replaces while requests read it.
  • Making an expensive object build exactly once no matter how many callers arrive together.
  • Reviewing a hang report by checking whether two code paths take the same two locks in different orders.

More examples

The three forms side by side

Read this to recognise all three in other people's code. The Monitor form is what you will find in anything written before .NET 9, and in code that locks on a plain object; it is not wrong, just longer. Note the bool: Monitor.Enter sets it to true only once the lock is actually held, so the finally does not try to exit a lock it never took.

Example · csharp
using System.Threading;

public sealed class Ledger
{
    private readonly Lock _gate = new();
    private readonly object _oldGate = new();
    private int _total;

    // Shorthand. This is what you write.
    public void AddWithLockStatement(int amount)
    {
        lock (_gate)
        {
            _total += amount;
        }
    }

    // Longhand for a Lock object. The method above compiles to exactly this.
    public void AddWithEnterScope(int amount)
    {
        using (_gate.EnterScope())
        {
            _total += amount;
        }
    }

    // Longhand when the lock object is any other reference type.
    public void AddWithMonitor(int amount)
    {
        bool taken = false;
        try
        {
            Monitor.Enter(_oldGate, ref taken);
            _total += amount;
        }
        finally
        {
            if (taken)
            {
                Monitor.Exit(_oldGate);
            }
        }
    }
}

A deadlock, and the rule that removes it

One thread in Pay holds the accounts lock and waits for audit. Another thread in Report holds audit and waits for accounts. Neither can ever continue, and every later caller of either method joins the queue. The fix is not a timeout: it is to pick a fixed order, write it down, and make Report take the accounts lock first like everyone else.

Example · csharp
using System.Threading;

public sealed class Accounting
{
    private readonly Lock _accountsGate = new();
    private readonly Lock _auditGate = new();

    public void Pay()
    {
        lock (_accountsGate)      // accounts, then audit
        {
            lock (_auditGate)
            {
                // move the money, then write the audit row
            }
        }
    }

    public void Report()
    {
        lock (_auditGate)         // audit, then accounts - this is the bug
        {
            lock (_accountsGate)
            {
                // read the audit row, then read the balances
            }
        }
    }
}

Guarding two fields as one unit

This is the case an atomic integer operation cannot cover, because the rule to keep spans two fields. Note that the read path locks too: a lock only protects data if every piece of code touching that data takes the same lock. Returning a copy of both values at once also means the caller can use them without holding anything.

Example · csharp
using System.Threading;

public sealed class Basket
{
    private readonly Lock _gate = new();
    private int _itemCount;
    private decimal _total;

    public void Add(decimal price)
    {
        lock (_gate)
        {
            _itemCount += 1;
            _total += price;
        }
    }

    // Readers take the same lock, so they never see a count whose
    // matching price has not been added yet.
    public (int Count, decimal Total) Snapshot()
    {
        lock (_gate)
        {
            return (_itemCount, _total);
        }
    }
}

Discussion

  • Be the first to comment on this lesson.