views:

223

answers:

4

I have a requirement whereby I needed to store a simple cache of a list of items. I was using List< T > for this purpose, but we have now changed the design to accomodate multiple threads.

The architecture of the system is driven by events, therefore it's quite likely that a read and write operation could collide. Since the vast majority of access will be read-only I thought that the ReaderWriterLockSlim might be a good candidate. The cache only needs to be accurate at the point of reading for that moment in time.

I have written the code below and it seems to work ok, but are there some potential pain points?

UPDATE: Whilst the code below does fix some syncronisation problems it's not 100% perfect. I have since decided to implement a much simpler class that doesn't expose all of the IList< T > operations and therefore makes it 'safer' to re-use.

public class SyncronisedList<T> : IList<T>
{
    private ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
    private IList<T> innerCache = new List<T>();

    private U ReadReturn<U>(Func<U> function)
    {
        cacheLock.EnterReadLock();
        try { return function(); }
        finally { cacheLock.ExitReadLock(); }
    }

    private void Read(Action action)
    {
        cacheLock.EnterReadLock();
        try { action(); }
        finally { cacheLock.ExitReadLock(); }
    }

    private U WriteReturn<U>(Func<U> function)
    {
        cacheLock.EnterWriteLock();
        try { return function(); }
        finally { cacheLock.ExitWriteLock(); }
    }

    private void Write(Action action)
    {
        cacheLock.EnterWriteLock();
        try { action(); }
        finally { cacheLock.ExitWriteLock(); }
    }

    public T this[int index]
    {
        get { return ReadReturn(() => innerCache[index]); }
        set { Write(() => innerCache[index] = value); }
    }

    public int IndexOf(T item) { return ReadReturn(() => innerCache.IndexOf(item)); }
    public void Insert(int index, T item) { Write(() => innerCache.Insert(index, item)); }
    public void RemoveAt(int index) { Write(() => innerCache.RemoveAt(index)); }
    public void Add(T item) { Write(() => innerCache.Add(item)); }
    public void Clear() { Write(() => innerCache.Clear()); }
    public bool Contains(T item) { return ReadReturn(() => innerCache.Contains(item)); }
    public int Count { get { return ReadReturn(() => innerCache.Count); } }
    public bool IsReadOnly { get { return ReadReturn(() => innerCache.IsReadOnly); } }
    public void CopyTo(T[] array, int arrayIndex) { Read(() => innerCache.CopyTo(array, arrayIndex)); }
    public bool Remove(T item) { return WriteReturn(() => innerCache.Remove(item)); }
    public IEnumerator<T> GetEnumerator() { return ReadReturn(() => innerCache.GetEnumerator()); }
    IEnumerator IEnumerable.GetEnumerator() { return ReadReturn(() => (innerCache as IEnumerable).GetEnumerator()); }
}

internal class Program
{
    private static SyncronisedList<int> list = new SyncronisedList<int>();

    private static void Main()
    {          
        for (int i = 0; i < 500000; i++)
        {
            var index = i;
            ThreadPool.QueueUserWorkItem((state) =>
            {
                var threadNum = (int)state;
                if (threadNum % 10 == 0)
                {
                    list.Add(threadNum);
                }
                else
                {
                    Console.WriteLine(list.Count);
                }
            }, index);
        }
        Console.ReadKey();
    }
}
+3  A: 

There are a couple of thread issues here.

1. I think the GetEnumerator functions exposes a thread issue here. They give away a reference to the innerCache that is not controlled by your locks.

Example where it may break down is if you have a thread doing a foreach over the list while another thread is removing or inserting elements.

The solution would be to copy the list and return an enumerator on that newly cloned list instead. The draw back would be memory issues if the list is long.

2. The Contains() and IndexOf() functions are more or less useless unless you have another locking method outside of the synchronised list.

Example: Thread A gets index of object, Thread B inserts/removed/updates that object, Thread A index is now stale.


I don't think this is a great idea really with a fully synchronised list. Write a customised version instead with limited functionality.

If you only need a queue or stack, implement that one with only the two or three necessary methods that are fully synchronised. If you need more functionality than that, use a List and have the different threads do the synchronisation.

Mats Fredriksson
Ah, yes, good point - on that basis I'd imagine that LINQ queries on the IEnumerable<T> would also potentially cause issues?
Codebrain
anything or anyone using the enumerator outside of the locks would have race conditions.
Mats Fredriksson
@Mats: The enumerator will throw an exception if its underlying collection is altered mid-enumeration. So you don't need to worry about subtle race bugs, you do need to worry about show-stopping exceptions!
LukeH
+5  A: 

Are you aware of the built-in SynchronizedCollection<T> class?

It uses standard Monitor-based locking rather than ReaderWriterLockSlim. You'd need to profile to determine whether this makes a significant performance difference in your particular usage scenarios.

LukeH
+1  A: 

Your implementation is ok but you still have to care about synchronization problems :

given a list {"foo"}

int index = list.IndexOf("foo");
Console.WriteLine(list[index]);

Now, what if another thread does a list.Clear() between thoses two lines ? You reader writer lock should be publicly accessible to handle thoses situations. Of course, same for Enumerator,...

Guillaume
Yes, one caveat of the class is that the List<T> is only thread-safe for a single operation at a time. If you need to perform multiple operations you'll need to wrap them in their own external syncronisation method. I'd rather not expose the internal lock.
Codebrain
@Codebrain; but then there is no need for a synchronized list in the first place if you'd have to use an external lock to alter it. You should expose the internal lock to make sure that everyone uses the same synchronization mechanism.
Patrik
A: 

Trying to make a list be all things to all people and to be thread safe is very hard.

I think you should look at the operations that your application needs and then design a class that exposes them in a thread safe way. (A list it too low level)

Your design is not likely to be thread safe in real life, as the code calling the list is likely to combine operations in an unsafe way.

Just exposing an Enumerator opens up a lot of problem – what does it mean to visit all items in a list while another thread is changing the list?

Ian Ringrose