lock, but Correctly: Thread-Safe C# Without Deadlocks

When Threads Collide

If you've ever debugged a counter that skips values or a balance that goes negative when multiple threads access it, you've experienced a race condition. Two threads read the same value, both increment it, and both write back the same resultone increment disappears. Your data is corrupted, but the program doesn't crash, making the bug hard to reproduce and harder to fix.

The lock statement in C# protects shared state by ensuring only one thread executes a critical section at a time. When thread A holds a lock, thread B waits. This mutual exclusion prevents race conditions where multiple threads modify the same data simultaneously. Lock makes concurrent code correct by serializing access to shared resources.

You'll learn how to choose what to lock on, avoid deadlocks when using multiple locks, and understand when lock is the right tool versus higher-level concurrent collections. By the end, you'll write thread-safe code that protects your data without introducing new concurrency bugs.

Basic Lock Syntax and Behavior

The lock statement takes a reference-type object and ensures only one thread can execute the protected code block at a time. When a thread enters a lock, it acquires exclusive ownership. Other threads attempting to lock on the same object will block until the first thread exits. Once released, one waiting thread (if any) acquires the lock and proceeds.

Lock is syntactic sugar over Monitor.Enter and Monitor.Exit with proper exception handling. The compiler generates a try-finally block to guarantee the lock releases even if an exception occurs inside. You never need to manually release locksthe finally block handles it automatically when you exit the lock scope.

Here's a simple example demonstrating lock protection:

Counter.cs - Thread-safe counter
public class Counter
{
    private readonly object _lock = new();
    private int _count;

    public void Increment()
    {
        lock (_lock)
        {
            _count++;  // Protected: only one thread at a time
        }
    }

    public void IncrementBy(int amount)
    {
        lock (_lock)
        {
            _count += amount;
        }
    }

    public int GetValue()
    {
        lock (_lock)
        {
            return _count;  // Even reads need protection
        }
    }

    // Incorrect - no lock!
    public int GetValueUnsafe()
    {
        return _count;  // Race condition: might read mid-update
    }
}

The _lock object serves as a synchronization sentinelthreads compete to acquire it. All methods that access _count use the same lock object, ensuring mutual exclusion. Even the getter needs a lock because reading an int isn't atomic on all platforms, and you want a consistent snapshot. The unsafe version might read a partially updated value if another thread is mid-write.

Choosing the Right Lock Object

Never lock on this, a Type object (typeof), or string literals. Locking on this lets external code deadlock your class by locking on the same instance. Locking on Type objects creates global locks that affect unrelated code. String literals are interned, so two parts of your program might unknowingly lock on the same string instance.

Always use private readonly object fields dedicated solely to synchronization. Create one lock object for each independent resource you're protecting. If your class has two unrelated pieces of state, use two lock objects so threads can access different resources simultaneously. This reduces contention and improves concurrency.

Good and bad lock targets:

LockTargets.cs - What to lock on
public class BankAccount
{
    // GOOD: Private dedicated lock objects
    private readonly object _balanceLock = new();
    private readonly object _transactionLock = new();

    private decimal _balance;
    private List<Transaction> _transactions = new();

    // GOOD: Separate locks for independent resources
    public void Deposit(decimal amount)
    {
        lock (_balanceLock)
        {
            _balance += amount;
        }
    }

    public void AddTransaction(Transaction txn)
    {
        lock (_transactionLock)
        {
            _transactions.Add(txn);
        }
    }

    // BAD: Never lock on this
    public void WithdrawBad(decimal amount)
    {
        lock (this)  // External code can lock on your instance!
        {
            _balance -= amount;
        }
    }

    // BAD: Never lock on strings
    private const string LockString = "balance";
    public void DepositBad(decimal amount)
    {
        lock (LockString)  // All code with this literal shares lock!
        {
            _balance += amount;
        }
    }

    // BAD: Never lock on Type objects
    public void TransferBad(decimal amount)
    {
        lock (typeof(BankAccount))  // Global lock affects all instances!
        {
            _balance -= amount;
        }
    }
}

The good examples use private objects that external code can't access. Separate locks for balance and transactions allow concurrent deposits and transaction logging. The bad examples show common mistakes: locking this lets malicious or buggy code deadlock your class, locking strings creates accidental shared locks, and locking Type objects serializes all instances unnecessarily.

Avoiding Deadlocks

Deadlock occurs when thread A holds lock X and waits for lock Y while thread B holds lock Y and waits for lock X. Both threads wait forever. The primary defense is lock ordering: always acquire multiple locks in a consistent order throughout your application. If every thread acquires lock A before lock B, deadlock becomes impossible.

Minimize the scope of locks. Only protect the actual critical sectiondon't hold locks while calling external methods, performing I/O, or doing expensive computations. External code might try to acquire other locks, creating deadlock risks. Extract the work you need to do under the lock, release it, then do expensive operations outside.

Deadlock prevention techniques:

Transfer.cs - Safe multi-lock operations
public class Account
{
    private readonly object _lock = new();
    private readonly int _id;
    private decimal _balance;

    public Account(int id, decimal initialBalance)
    {
        _id = id;
        _balance = initialBalance;
    }

    // GOOD: Lock ordering prevents deadlock
    public static void Transfer(Account from, Account to, decimal amount)
    {
        // Always lock in ID order
        Account first = from._id < to._id ? from : to;
        Account second = from._id < to._id ? to : from;

        lock (first._lock)
        {
            lock (second._lock)
            {
                if (from._balance >= amount)
                {
                    from._balance -= amount;
                    to._balance += amount;
                }
            }
        }
    }

    // BAD: Inconsistent lock order causes deadlock
    public void TransferToBad(Account other, decimal amount)
    {
        lock (_lock)
        {
            lock (other._lock)  // If two threads transfer opposite
            {                    // directions, deadlock!
                if (_balance >= amount)
                {
                    _balance -= amount;
                    other._balance += amount;
                }
            }
        }
    }

    // GOOD: Timeout prevents indefinite deadlock
    public bool TryTransfer(Account to, decimal amount, int timeoutMs)
    {
        bool fromLocked = false;
        bool toLocked = false;

        try
        {
            Monitor.TryEnter(_lock, timeoutMs, ref fromLocked);
            if (!fromLocked) return false;

            Monitor.TryEnter(to._lock, timeoutMs, ref toLocked);
            if (!toLocked) return false;

            if (_balance >= amount)
            {
                _balance -= amount;
                to._balance += amount;
                return true;
            }
            return false;
        }
        finally
        {
            if (toLocked) Monitor.Exit(to._lock);
            if (fromLocked) Monitor.Exit(_lock);
        }
    }
}

The Transfer method enforces lock ordering by sorting accounts by ID. No matter which direction you transfer, locks are always acquired in the same order, preventing deadlock. The bad example locks in arbitrary orderthread 1 transferring AB and thread 2 transferring BA will deadlock. The timeout version uses Monitor.TryEnter to fail gracefully if locks can't be acquired, avoiding indefinite hangs.

Monitor Class and Advanced Scenarios

The lock statement compiles to Monitor.Enter and Monitor.Exit calls. Monitor provides additional capabilities: timeouts with TryEnter, pulse/wait signaling between threads, and conditional waiting. Use lock for simple mutual exclusion. Use Monitor directly when you need these advanced features.

Monitor.Wait releases the lock and blocks until another thread calls Monitor.Pulse or PulseAll on the same object. This enables producer-consumer patterns and other coordination scenarios. However, modern code typically uses higher-level primitives like BlockingCollection or Channels instead of raw Wait/Pulse.

Monitor equivalence and advanced usage:

MonitorExamples.cs - Beyond basic lock
public class ResourcePool
{
    private readonly object _lock = new();
    private readonly Queue<Resource> _available = new();

    // Lock statement
    public void ReturnResource(Resource resource)
    {
        lock (_lock)
        {
            _available.Enqueue(resource);
        }
    }

    // Equivalent Monitor code
    public void ReturnResourceExplicit(Resource resource)
    {
        bool lockTaken = false;
        try
        {
            Monitor.Enter(_lock, ref lockTaken);
            _available.Enqueue(resource);
        }
        finally
        {
            if (lockTaken)
                Monitor.Exit(_lock);
        }
    }

    // Timeout for deadlock detection
    public bool TryGetResource(out Resource? resource, int timeoutMs)
    {
        resource = null;
        bool lockTaken = false;

        try
        {
            Monitor.TryEnter(_lock, timeoutMs, ref lockTaken);
            if (!lockTaken)
                return false;

            if (_available.Count > 0)
            {
                resource = _available.Dequeue();
                return true;
            }
            return false;
        }
        finally
        {
            if (lockTaken)
                Monitor.Exit(_lock);
        }
    }

    // Wait/Pulse for signaling (prefer modern alternatives)
    public Resource GetResourceOrWait()
    {
        lock (_lock)
        {
            while (_available.Count == 0)
            {
                Monitor.Wait(_lock);  // Releases lock until Pulse
            }
            return _available.Dequeue();
        }
    }
}

The first two methods show that lock is just cleaner syntax for Monitor.Enter/Exit. The timeout version demonstrates checking lock acquisitionuseful for diagnostics or failing fast. The Wait/Pulse example shows thread coordination but is error-prone. Modern code should use BlockingCollection<T> or System.Threading.Channels instead.

Mistakes to Avoid

Using lock with async/await: Never use lock in async methods or across await boundaries. Lock is tied to a specific thread, but async code can resume on different threads. This causes deadlocks or releases locks on the wrong thread. Use SemaphoreSlim.WaitAsync() instead for async synchronization.

Holding locks during I/O: Don't perform network calls, file operations, or database queries inside locks. I/O is slow and unpredictable. If you hold a lock during I/O, all other threads waiting for that lock are blocked unnecessarily. Read data under the lock, release it, then do I/O.

Locking on value types: You can't lock on value types like int or struct. If you try lock(myInt), C# boxes the value into a new object each time, so different lock calls use different objects. This provides no synchronization. Always lock on reference types.

Assuming lock protects all access: Lock only protects code inside the lock block. If some code accesses shared state without locking, you still have race conditions. Every access to shared mutable state must use the same lock. Document which lock protects which data.

Try It Yourself: Pub/Sub with Thread Safety

Build a thread-safe event publisher that multiple threads can subscribe to and publish events concurrently without race conditions.

Steps

  1. Create: dotnet new console -n ThreadSafeEvents
  2. Navigate: cd ThreadSafeEvents
  3. Replace Program.cs with the code below
  4. Run: dotnet run
Program.cs
var publisher = new EventPublisher();

// Multiple threads subscribing
var tasks = new List<Task>();

for (int i = 0; i < 5; i++)
{
    int threadId = i;
    tasks.Add(Task.Run(() =>
    {
        publisher.Subscribe($"Thread-{threadId}");
        Thread.Sleep(100);
        publisher.Publish($"Event from Thread-{threadId}");
    }));
}

await Task.WhenAll(tasks);
Console.WriteLine($"\nTotal events: {publisher.GetEventCount()}");

class EventPublisher
{
    private readonly object _lock = new();
    private readonly List<string> _subscribers = new();
    private int _eventCount;

    public void Subscribe(string subscriber)
    {
        lock (_lock)
        {
            _subscribers.Add(subscriber);
            Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] " +
                $"{subscriber} subscribed");
        }
    }

    public void Publish(string eventData)
    {
        List<string> snapshot;

        lock (_lock)
        {
            _eventCount++;
            snapshot = new List<string>(_subscribers);
        }

        // Notify outside lock to avoid holding it during I/O
        foreach (var sub in snapshot)
        {
            Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] " +
                $"Notifying {sub}: {eventData}");
        }
    }

    public int GetEventCount()
    {
        lock (_lock)
        {
            return _eventCount;
        }
    }
}
ThreadSafeEvents.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Run result

[14:23:01.234] Thread-0 subscribed
[14:23:01.245] Thread-1 subscribed
[14:23:01.256] Thread-2 subscribed
[14:23:01.267] Thread-3 subscribed
[14:23:01.278] Thread-4 subscribed
[14:23:01.334] Notifying Thread-0: Event from Thread-0
[14:23:01.345] Notifying Thread-1: Event from Thread-1
...

Total events: 5

Notice how Publish copies the subscriber list inside the lock, then notifies outside. This minimizes lock duration and prevents deadlocks if notification triggers more subscriptions.

Troubleshooting

What should I lock on in C#?

Lock on private readonly object fields dedicated to synchronization. Never lock on this, a Type object, or strings. Create a private readonly object _lock = new(); for each independent resource you're protecting. This prevents external code from interfering with your locks.

How do I avoid deadlocks with lock statements?

Always acquire multiple locks in the same order across your entire application. Document lock ordering and use timeouts with Monitor.TryEnter if you can't guarantee order. Minimize the work inside locks and never call external code while holding a lockit might acquire other locks and cause deadlock.

Is lock the same as Monitor in C#?

Yes. The lock statement is syntactic sugar for Monitor.Enter/Exit with proper try-finally cleanup. Lock is simpler and safer for most cases. Use Monitor directly only when you need timeouts (TryEnter), pulse/wait signaling, or conditional acquisition.

Should I use lock or async locks for async code?

Never use lock in async methodsit can cause deadlocks. Use SemaphoreSlim for async synchronization: await semaphore.WaitAsync(); try { } finally { semaphore.Release(); }. Only use lock for synchronous CPU-bound critical sections, not around await operations.

Back to Articles