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.