Understanding Thread Synchronization in C#

Thread synchronization ensures that multiple threads coordinate access to shared resources—preventing race conditions, data corruption, and inconsistent state. At its core, it's about enforcing order: one thread must complete a critical operation before another begins, especially when reading from or writing to the same memory location.

Consider a simple bounded buffer—a single-slot container used for producer-consumer communication. The producer (writer) must wait if the slot is full; the consumer (reader) must wait if it’s empty. Both threads must alternate strictly: write → read → write → read… Any deviation breaks correctness and yields garbled output.

Below is a minimal but flawed implementation demonstrating unsynchronized access:

public class UnsyncedBuffer
{
    private static char _slot;
    
    public Thread Producer = new Thread(() =>
    {
        string text = "Mount Lu shrouded in mist, each view distinct—near, far, high, low, all differ. To truly know this mountain’s face, one must step outside its bounds.";
        for (int i = 0; i < 32; i++)
        {
            _slot = text[i];
            Thread.Sleep(25);
        }
    });

    public Thread Consumer = new Thread(() =>
    {
        for (int i = 0; i < 32; i++)
        {
            Console.Write(_slot);
            Thread.Sleep(35);
        }
    });
}

This code produces unpredictable output because writes and reads are not coordinated—the consumer may read stale or uninitialized values, and the producer may overwrite data before it's consumed.

Using Interlocked Operations for Lock-Free Coordination

The Interlocked class provides atomic, thread-safe operasions on numeric primitives. It avoids locks entirely, making it ideal for lightweight coordination like signaling buffer state:

public class InterlockedBuffer
{
    private static char _slot;
    private static long _state = 0; // 0 = empty, 1 = full

    public Thread Producer = new Thread(() =>
    {
        string text = "Mount Lu shrouded in mist, each view distinct—near, far, high, low, all differ. To truly know this mountain’s face, one must step outside its bounds.";
        for (int i = 0; i < 32; i++)
        {
            // Wait until slot is empty
            while (Interlocked.Read(ref _state) == 1)
                Thread.Sleep(1);

            _slot = text[i];
            Interlocked.Increment(ref _state); // mark full
        }
    });

    public Thread Consumer = new Thread(() =>
    {
        for (int i = 0; i < 32; i++)
        {
            // Wait until slot is full
            while (Interlocked.Read(ref _state) == 0)
                Thread.Sleep(1);

            Console.Write(_slot);
            Interlocked.Decrement(ref _state); // mark empty
        }
    });
}

This approach guarantees strict alternation without blocking threads via OS-level primitives—ideal for high-throughput, low-latency scenarios.

Using Monitor for Blocking Synchronization

When more complex coordination is needed—such as suspending and resuming threads based on state—the Monitor class offers precise control over object-based locking and signaling:

public class MonitoredBuffer
{
    private static char _slot;
    private static readonly object _lockObj = new object();

    public Thread Producer = new Thread(() =>
    {
        string text = "Mount Lu shrouded in mist, each view distinct—near, far, high, low, all differ. To truly know this mountain’s face, one must step outside its bounds.";
        for (int i = 0; i < 32; i++)
        {
            lock (_lockObj)
            {
                // Wait until slot is empty
                while (_slot != '\0') 
                    Monitor.Wait(_lockObj);

                _slot = text[i];
                Monitor.Pulse(_lockObj); // notify consumer
            }
        }
    });

    public Thread Consumer = new Thread(() =>
    {
        for (int i = 0; i < 32; i++)
        {
            lock (_lockObj)
            {
                // Wait until slot is full
                while (_slot == '\0') 
                    Monitor.Wait(_lockObj);

                Console.Write(_slot);
                _slot = '\0';
                Monitor.Pulse(_lockObj); // notify producer
            }
        }
    });
}

Note: Monitor requires reference-type objects for locking. Using value types (e.g., int) causes boxing and defeats synchronization. The lock statement is syntactic sugar for Monitor.Enter/Monitor.Exit, ensuring exception-safe release.

While Monitor introduces some overhead due to kernel transitions and thread suspension, it enables robust, readable coordination patterns suitable for general-purpose concurrency tasks.

Tags: thread-synchronization csharp interlocked monitor producer-consumer

Posted on Fri, 19 Jun 2026 18:14:49 +0000 by jkrystof