1

Good day. I hope you are doing well. I'm trying to perform a unit test to test a Wrapper for a data structure I created (SortedList). This Wrapper is "thread-safe". First, I conducted a test where 10 threads accessed and added information to see if there was concurrency. Initially, I tested an unsynchronized list, and in this case, it was expected to have concurrency issues, which indeed happened. Then, I applied a synchronized list and the problem was resolved. Here is the code for the test (If you think it can be improved, any suggestions would be greatly appreciated).

Test Case:

        //Arrange
        SortedList<int> list = new();
        var synchronizedList = list.Synchronized();
        var expected = new SortedList<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        int len = 10;
        ParallelOptions options = new()
        {
            MaxDegreeOfParallelism = len
        };

        //Act
        Parallel.For(0, len, options, synchronizedList.Add);

        //Asserts
        Assert.Equal(len, list.Count);
        Assert.Equal(expected, list);

In this case, there was no issue; everything worked as expected. The problem arises when I want to create the typical concurrency example, where N threads are created and they increase a value N times. In this example, there must be concurrency without fail. So, I prepared a small test case to verify if it can handle such a problem. Here is the code:

Test Case 2:

        //Arrange
        SortedList<int> list = new()
        {
            0
        };
        var synchronizedList = list.Synchronized();
        int len = 2, maxIter = 1;
        ParallelOptions options = new()
        {
            MaxDegreeOfParallelism = len
        };

        //Act
        Parallel.For(0, len, options, i =>
        {
            for (int j = 0; j != maxIter; ++j)
            {
                synchronizedList[0]++;
            }
        });

        //Asserts
        Assert.Equal(maxIter * len, list[0]);

When I tested the test case, for some strange reason, the expected result was 2 (I initially tested with 10,000 iterations and 10 threads). However, it was giving me 1. Perplexed, I went to check the code for the issue, but I didn't see any. Here is the code snippet of the indexer in the Wrapper class:

Index:

public T this[int index] 
            {
                get
                {
                    lock (_lock)
                    {
                        return _sortedList[index];
                    }
                }
                set
                {
                    lock (_lock)
                    {
                        _sortedList[index] = value;
                    }
                }
            }

As we can see, i have the lock, so it accesses safely. In fact, I did a debug test, and in that case, it passes the test. After this, I went to the code where I initialize the _lock.

Constructor:

internal SynchronizedSortedList(SortedList<T> sortedList)
{
      _sortedList = sortedList;
      _lock = _sortedList.SyncRoot;
}

After verifying that everything was "fine", I proceed to the get method of SyncRoot in the SortedList class.

SyncRoot:

public object SyncRoot
{
     get
     {
           if (_syncRoot == null)
           {
               Interlocked.CompareExchange<object>(ref _syncRoot, new object(), null);
           }
           return _syncRoot;
     }
}

In theory, everything is fine. In fact, I went to check the source code of a much earlier version of the List class, and I saw that it had it the same way. Therefore, there shouldn't be any issues. Finally, I will show the Synchronized method.

Synchronized:

public ISortedList<T> Synchronized()
{
   return new SynchronizedSortedList(this);
}

One obvious way to fix it would be as follows:

        //Arrange
        SortedList<int> list = new()
        {
            0
        };
        var synchronizedList = list.Synchronized();
        int len = 2, maxIter = 1;
        ParallelOptions options = new()
        {
            MaxDegreeOfParallelism = len
        };

        //Act
        Parallel.For(0, len, options, i =>
        {
            for (int j = 0; j != maxIter; ++j)
            {
                lock(list.SyncRoot)
                {
                   synchronizedList[0]++;
                }
            }
        });

        //Asserts
        Assert.Equal(maxIter * len, list[0]);

In this way, it would work excellently, but it's not the way I want to do it because this Wrapper already has locks that protect the resource to prevent threads from accessing it and avoid concurrency. If someone could help me, I would greatly appreciate it because I've been dealing with this problem since yesterday and I can't think of much. Greetings.

It's a very long code, but I can provide the link to the integration test on GitHub, and here is the code for SortedList with the Wrapper.

Ts-Pytham
  • 13
  • 4
  • 3
    `synchronizedList[0]++` is not atomic. It gets a value from index 0, increments it, and sets the value back at index 0. The getting and setting are atomic but the overall sequence is not. (This is why `Interlocked.Increment()` needs to exist.) Thus threads could interrupt and contend with each other. I.e. thread one gets a value, thread two gets the same value, thread two sets back the incremented value, then thread one sets back the incremented value. In such a case the value will be incremented only once. – dbc Jun 24 '23 at 18:08
  • 1
    This is why your *One obvious way to fix it* is the correct fix. You turn the non-atomic increment operation into an atomic operation. – dbc Jun 24 '23 at 18:16
  • By the way, what is `SortedList`? .NET has the non-generic [`SortedList`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.sortedlist?view=net-7.0) and the doubly-generic [`SortedList`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.sortedlist-2?view=net-7.0) but I don't know of any `SortedList`, see [Why is there no SortedList in .NET?](https://stackoverflow.com/q/3663613). Depending on the implementation the setter might be O(log N) rather than fixed time which makes contention more likely. – dbc Jun 24 '23 at 18:17
  • OK I found it. Looking at your [setter](https://github.com/Ts-Pytham/GenericCollectionsExtension/blob/master/GenericCollectionsExtension/List/SortedList/SortedList.cs#L32) it actually does re-index the list so there's no way you could make an increment atomic by throwing in an `Interlocked.Increment()` somewhere. By the way, re-indexing the list inside a list setter violates the spirit of the `IList` contract, I can't really recommend the idea. – dbc Jun 24 '23 at 18:21
  • Does that answer your question? (Your question doesn't include a full [mcve] so I can't easily reproduce the problem.) – dbc Jun 24 '23 at 18:22
  • Ok, I think I understand that the problem is in the setter, I thought that with the lock in the Wrapper, and ensured that only a resource could access the indexer, but of course, within the setter are doing several operations that make a re-indexing, is it correct what I say? In this case, what do you recommend me to do? – Ts-Pytham Jun 24 '23 at 18:43
  • Anything that needs to be atomic either 1) Needs a lock, or 2) Needs to be able to be implemented in a simple and obviously correct way using the `Interlocked` class, or 3) actually in practice there is no 3. Writing **correct** lock-free code is very very hard, and should be avoided. To quote Eric Lippert [here](https://ericlippert.com/2014/03/12/can-i-skip-the-lock-when-reading-an-integer/): *Code which avoids locks is extremely difficult to reason about, so make your life easier: if you can, don’t write shared memory code to begin with. If you have to, use locks consistently.* – dbc Jun 24 '23 at 18:52

1 Answers1

1

Your problem is that, in the following code, synchronizedList[0]++ is not thread safe and atomic:

Parallel.For(0, len, options, i =>
{
    for (int j = 0; j != maxIter; ++j)
    {
        // The following is not thread safe:
        synchronizedList[0]++;
    }
});

That's because synchronizedList[0]++ performs the following actions:

  1. It gets the value from index 0 (with a lock).
  2. It increments the value.
  3. It sets the incremented value back at index 0 (with another lock).

The getting and setting are atomic but the overall sequence is not. Thus threads could interrupt and contend with each other. E.g. thread #1 gets a value, thread #2 gets the same value, thread #2 sets back its incremented value, then thread #1 sets back its incremented value. In such a case the value will be incremented only once -- which is exactly what you are seeing.

The correct fix for this is to make the increment atomic -- which is precisely your obvious way to fix it:

Parallel.For(0, len, options, i =>
{
    for (int j = 0; j != maxIter; ++j)
    {
        lock(list.SyncRoot)
        {
           synchronizedList[0]++;
        }
    }
});

Notes:

  1. As currently implemented, your SortedList<T> actually reindexes the list inside its setter. I can't really recommend this approach as it could lead to surprising results.

    For instance, imagine you have a SortedList<int> with two zero values instead of one and increment the first value like so:

    SortedList<int> list = new() { 0, 0 };
    list[0]++;
    

    Then the value of list[0] will be 0 not 1, because the incremented value will have been moved to the end of the list.

    The fact that there's no obvious way to integer-index a sorted collection explains why SortedSet<T>, SortedList<TKey,TValue> and the old SortedList do not implement IList or IReadOnlyList<T>.

  2. In general writing correct thread-safe code without locks is very hard. You might take a look at Eric Lippert's articles on thread safety, particularly Can I skip the lock when reading an integer?, which wraps up with:

    Code which avoids locks is extremely difficult to reason about, so make your life easier: if you can, don’t write shared memory code to begin with. If you have to, use locks consistently.

    (Emphasis his.) Jon Skeet's Volatility, Atomicity and Interlocking is also worth a look.

  3. The fact that the increment operator is not atomic is why Interlocked.Increment() needs to exist. It isn't useful to you however. Since your setter currently reindexes the list merely incrementing a value atomically would not be sufficient.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • You're absolutely right, when decomposing the synchronizedList[0]++ there are several read and write operations, and just the lock does not cover all this, but I have a question, is it possible that the setter is totally thread safe? Of course, making use of the Interlocked class and re-structuring the actual setter algorithm? Although as you commented, it is more difficult this way. – Ts-Pytham Jun 24 '23 at 20:08
  • @Ts-Pytham - In addition to Eric Lippert's article, take a look at Jon Skeet's [Volatility, Atomicity and Interlocking](https://jonskeet.uk/csharp/threads/volatility.html). If you really want to create thread-safe non-locked code you need to know that stuff cold. I know enough to spot bugs, but not enough to write such code and guarantee it's correct. – dbc Jun 24 '23 at 20:26
  • @Ts-Pytham - If you have a single integer that is read to and written from multiple threads, you can use `Interlocked.Increment(ref i)` and `Volatile.Read(ref i)`. If you have an array of int and multiple threads are reading from and writing to a specific index, you can use `Interlocked.Increment(ref array [i]);` and `Volatile.Read(ref array[i]);` (assuming `array` itself is not reallocated). But your case is much more complex, you are constantly re-sorting your list so I don't see how you can avoid locking. – dbc Jun 24 '23 at 20:29