- 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.