Often when reading data from a sequence (IEnumerable<T>
) in multi-threaded code, a consistent snapshot needs to be taken inside a lock
before using it in a longer operation. I'd like to simplify that by creating an API that does it within the enumerator implementation.
So in the manner of LINQ extension methods, I have created a Synchronized
method that takes the source sequence and the lock object and provides synchronised enumerating of the sequence. When enumerating is started, the lock is acquired and held until enumerating is finished by disposing of the enumerator. Since there cannot be a lock
statement across multiple methods (MoveNext
and Dispose
), I'm calling the Monitor.Enter
and Monitor.Exit
methods instead.
Here's how it looks:
var localList = sharedList // the shared collection
.Synchronized(syncObj) // synchronised iteration (might also use sharedList)
.ToList(); // take the snapshot (or ToDictionary or ToWhatever)
I've also written a small test program to see if accessing a shared list works across multiple threads. I'm not too sure this effectively tests it but it can be made to work and fail (partially) by using or not using my new method. I've never managed to get a corrupted List
or LinkedList
though while not using locks. Maybe I need a more complex/fragile collection structure.
Now what I'm interested in is to know whether this kind of using locks is reliable (no unsynchronised access and no deadlocks), "good style", and as fast as it can be. (In this order.) Are there any obscure scenarios when this method would fail? I'm looking to use it everywhere it fits, without paying too much attention on use case restrictions.
First part of the code is my extension method and the private IEnumerable/IEnumerator
class it uses.
EnumerableExtensions.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
namespace LinqLockedTest
{
public static class EnumerableExtensions
{
/// <summary>
/// Acquires an exclusive lock on the specified object before iterating the sequence and
/// releases the lock when disposing of the <see cref="IEnumerator{T}"/>.
/// </summary>
/// <typeparam name="TSource">The type of objects to enumerate.</typeparam>
/// <param name="source">The <see cref="IEnumerable{T}"/> to synchronize.</param>
/// <param name="syncObject">The object on which to acquire the monitor lock.</param>
/// <returns>An <see cref="IEnumerable{T}"/> that provides a synchronized enumerator.</returns>
/// <exception cref="ArgumentNullException"><paramref name="source"/> or <paramref name="syncObject"/> is null.</exception>
public static IEnumerable<TSource> Synchronized<TSource>(this IEnumerable<TSource> source, object syncObject)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (syncObject == null)
throw new ArgumentNullException(nameof(syncObject));
return new SynchronizedIterator<TSource>(source, syncObject);
}
private class SynchronizedIterator<TSource> : IEnumerable<TSource>, IEnumerator<TSource>
{
private readonly IEnumerable<TSource> source;
private readonly object syncObject;
private IEnumerator<TSource> sourceEnumerator;
private bool lockTaken;
public SynchronizedIterator(IEnumerable<TSource> source, object syncObject)
{
this.source = source;
this.syncObject = syncObject;
}
#region IEnumerable<TSource> members
public IEnumerator<TSource> GetEnumerator() => this;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
#endregion IEnumerable<TSource> members
#region IEnumerator<TSource> members
public TSource Current => sourceEnumerator.Current;
object IEnumerator.Current => Current;
public void Dispose()
{
if (sourceEnumerator != null)
{
sourceEnumerator.Dispose();
sourceEnumerator = null;
if (lockTaken)
{
Monitor.Exit(syncObject);
}
}
}
public bool MoveNext()
{
if (sourceEnumerator == null)
{
Monitor.Enter(syncObject, ref lockTaken);
sourceEnumerator = source.GetEnumerator();
}
if (sourceEnumerator.MoveNext())
{
return true;
}
Dispose();
return false;
}
public void Reset() => sourceEnumerator?.Reset();
#endregion IEnumerator<TSource> members
}
}
}
Second part is the test program. I've made a .NET Core console project in Visual Studio 2017 to write it. The entire solution should be compatible with .NET Core 2.1 and .NET Standard 2.0.
The interesting call of my extension method is at the beginning of the ReadList
method. Alternative traditional code is also shown for reference.
The test can be made to fail by not calling the Synchronized
method but using the list
directly in foreach
.
When you run this code, it starts three threads: modifying the list, reading the list, and showing log messages. They run forever doing their thing. Press any key to quit. Press Pause (Break in English?) then Enter to have a break and inspect the output. I guess it would be good to have at least 3 CPU cores available for this.
Program.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LinqLockedTest
{
internal class Program
{
private static List<int> list = new List<int>();
private static ConcurrentQueue<string> log = new ConcurrentQueue<string>();
private static void Main(string[] args)
{
Task.Run(() => ModifyList());
Task.Run(() => ReadList());
Task.Run(() => ShowLog());
log.Enqueue("Test is running. Press any key to quit.\n");
Console.ReadKey(true);
}
/// <summary>
/// Modifies the shared list by adding sequential numbers at the end and then
/// removing them from the start.
/// </summary>
private static void ModifyList()
{
while (true)
{
log.Enqueue("===== Adding items =====");
for (int i = 0; i < 100000; i++)
{
lock (list)
{
list.Add(i);
}
// Adding is much faster than removing, so wait a little longer here
Thread.Yield();
}
log.Enqueue("===== Removing items =====");
for (int i = 0; i < 100000; i++)
{
lock (list)
{
list.RemoveAt(0);
}
}
}
}
/// <summary>
/// Reads from the shared list, checks the result for consistency and writes
/// occasional log messages about the progress.
/// </summary>
private static void ReadList()
{
int iter = 0;
while (true)
{
try
{
// This is the code to be tested:
// (Could also be used directly in foreach but then locks for the
// entire foreach loop instead of just while taking the snapshot
// with ToList.)
var localList = list.Synchronized(list).ToList();
// The equivalent traditional code:
//List<int> localList;
//lock (list)
//{
// localList = list.ToList();
//}
int prev = -1;
int count = 0;
foreach (int i in localList)
{
if (prev != -1)
{
if (i <= prev)
log.Enqueue($"Error: Found {i} after {prev}");
}
prev = i;
count++;
}
if (iter % 50 == 0)
log.Enqueue($"{count} items up to {prev}");
}
catch (Exception ex)
{
log.Enqueue($"Error: {ex.Message}");
}
iter++;
}
}
/// <summary>
/// Shows the log messages in the console window. This is a separate task because
/// the queue blocks the enqueuing thread much shorter than console output.
/// </summary>
private static void ShowLog()
{
while (true)
{
if (log.TryDequeue(out string message))
{
Console.WriteLine(message);
}
}
}
}
}