- What makes multithreaded programming difficult is identifying which data multiple threads could access simultaneously.
- The program must synchronize such data to prevent simultaneous access.
- Synchronization is the mechanism of ensuring that two threads don’t execute a specific portion of your program at the same time.
const int _Total = int.MaxValue;
static long _Count = 0;
public static void Main()
{
Task task = Task.Factory.StartNew(Decrement);
for (int i = 0; i < _Total; i++)
_Count++;
task.Wait();
Console.WriteLine("Count = {0}", _Count);
}
static void Decrement()
{
for (int i = 0; i < _Total; i++)
_Count--;
}
- The important thing to note about code above is that the output is not 0.
- It would have been if
Decrement() was called directly (sequentially).
- When calling
Decrement() asynchronously, a race condition occurs because the individual steps within _Count++ and _Count statements intermingle.
- Race condition happens when multiple threads have simultaneous access to the same data elements.
- Allowing multiple threads to access the same data elements likely undermines data integrity, even on a single-processor computer.
- In fact, a single statement in C# will likely involve multiple steps.
- To remedy this, the code needs synchronization around the data.
- Code or data synchronized for simultaneous access by multiple threads is thread-safe.
- The runtime guarantees that a type whose size is no bigger than a native (pointer-size) integer will not be read or written partially.
- Therefore, assuming a 64-bit operating system, reads and writes to a long (64 bits) will be atomic.
- However, reads and writes to a 128-bit variable such as decimal may not be atomic.
- Therefore, write operations to change a decimal variable may be interrupted after copying only 32 bits, resulting in the reading of an incorrect value, known as a torn read.
- Note that it is not necessary to synchronize local variables.
- Local variables are loaded onto the stack and each thread has its own logical stack.
- Therefore, each local variable has its own instance for each method call.
- By default, local variables are not shared across method calls; therefore, they are also not shared among multiple threads.
- However, this does not mean local variables are entirely without concurrency issues since code could easily expose the local variable to multiple threads.
- A parallel for loop that shares a local variable between iterations, for example, will expose the variable to concurrent access and a race condition.
Classic Synchronization Using Monitor
- To synchronize multiple threads so that they cannot execute particular sections of code simultaneously, use a
monitor.
- It blocks the second thread from entering a protected code section before the first thread has exited that section.
- The beginning and end of protected code sections are marked with calls to the static methods
Monitor.Enter() and Monitor.Exit().
- It is important that all code between calls to
Monitor.Enter() and Monitor.Exit() be surrounded with a try/finally block.
- Without this, an exception could occur within the protected section and
Monitor.Exit() may never be called, thereby blocking other threads indefinitely.
static readonly object _Sync = new object();
const int _Total = int.MaxValue;
static long _Count = 0;
public static void Main()
{
Task task = Task.Factory.StartNew(Decrement);
for (int i = 0; i < _Total; i++)
{
bool lockTaken = false;
Monitor.Enter(_Sync, ref lockTaken);
try
{
_Count++;
}
finally
{
if (lockTaken)
Monitor.Exit(_Sync);
}
}
task.Wait();
Console.WriteLine("Count = {0}", _Count);
}
static void Decrement()
{
for (int i = 0; i < _Total; i++)
{
bool lockTaken = false;
Monitor.Enter(_Sync, ref lockTaken);
try
{
_Count--;
}
finally
{
if (lockTaken)
Monitor.Exit(_Sync);
}
}
}
- Calls to
Monitor.Enter() and Monitor.Exit() are associated with each other by sharing the same object reference passed as the parameter (_Sync).
- The
Monitor.Enter() overload method that takes the lockTaken parameter was only added to the framework in .NET 4.0.
- Before that, no such lockTaken parameter was available and there was no way to reliably catch an exception that occurred between the
Monitor.Enter() and try block.
- Placing the try block immediately following the
Monitor.Enter() call was reliable in release code because the JIT prevented any such asynchronous exception from sneaking in.
- However, anything other than a try block immediately following the
Monitor.Enter(), including any instructions that the compiler may have injected within debug code, could prevent the JIT from reliably returning execution within the try block.
- Therefore, if an exception did occur, it would leak the lock (the lock remains acquired) rather than executing the final block and releasing it, likely causing a deadlock when another thread tries to acquire the lock.
- Monitor also supports a
Pulse() method for allowing a thread to enter the “ready queue,” indicating it is up next for execution.
- This is a common means of synchronizing producer-consumer patterns so that no “consume” occurs until there has been a “produce.”
- The producer thread that owns the monitor (by calling
Monitor.Enter()) calls Monitor.Pulse() to signal the consumer thread (which may already have called Monitor.Enter()) that an item is available for consumption, so “get ready.”
- For a single
Pulse() call, only one thread (consumer in this case) can enter the ready queue.
- When the producer thread calls
Monitor.Exit(), the consumer thread takes the lock (Monitor.Enter() completes) and begins “consuming” the item.
- Once the consumer processes the waiting item, it calls
Exit(), thus allowing the producer (currently blocked with Monitor.Enter()) to produce again.
Using the lock Keyword
- Because of the frequent need for synchronization using
Monitor in multithreaded code, the the try/finally block could easily be forgotten.