1

I need to round-robin some calls between N different connections because of some rate limits in a multithreaded context. I've decided to implement this functionality using a list and a "counter," which is supposed to "jump by one" between instances on each call.

I'll illustrate this concept with a minimal example (using a class called A to stand in for the connections)

class A
{
    public A()
    {
        var newIndex = Interlocked.Increment(ref index);
        ID = newIndex.ToString();
    }
    private static int index;
    public string ID;
}

static int crt = 0;
static List<A> Items = Enumerable.Range(1, 15).Select(i => new A()).ToList();
static int itemsCount = Items.Count;

static A GetInstance()
{            
    var newIndex = Interlocked.Increment(ref crt);
    var instance = Items[newIndex % itemsCount];
    //Console.WriteLine($"{DateTime.Now.Ticks}, {Guid.NewGuid()}, Got instance: {instance.ID}");
    return instance;
}

static void Test()
{
    var sw = Stopwatch.StartNew();

    var tasks = Enumerable.Range(1, 1000000).Select(i => Task.Run(GetInstance)).ToArray();
    Task.WaitAll(tasks);
}

This works as expected in that it ensures that calls are round-robin-ed between the connections. I will probably stick to this implementation in the "real" code (with a long instead of an int for the counter)

However, even if it is unlikely to reach int.MaxValue in my use case, I wondered if there is a way to "safely overflow" the counter.

I know that "%" in C# is "Remainder" rather than "Modulus," which would mean that some ?: gymnastics would be required to always return positives, which I want to avoid.

So what I wanted to cume up with is instead something like:

static A GetInstance()
{            
    var newIndex = Interlocked.Increment(ref crt);
    Interlocked.CompareExchange(ref crt, 0, itemsCount); //?? the return value is the original value, how to know if it succeeded
    var instance = Items[newIndex];
    //Console.WriteLine($"{DateTime.Now.Ticks}, {Guid.NewGuid()}, Got instance: {instance.ID}");
    return instance;
}

What I am expecting is that Interlocked.CompareExchange(ref crt, 0, itemsCount) would be "won" by only one thread, setting the counter back to 0 once it reaches the number of connections available. However, I don't know how to use this in this context.

Can CompareExchange or another mechanism in Interlocked be used here?

Wai Ha Lee
  • 8,598
  • 83
  • 57
  • 92
Alexandru Clonțea
  • 1,746
  • 13
  • 23
  • 4
    Your implementation is broken because you ignore the return values from `Increment` and re-read the shared variable for other actions. E.g. re-reading `crt` in `GetInstance` gets you whatever the current value is, not what your last call to `Increment` on this thread incremented the value to. Similarly the one in the constructor is suspect but thankfully `A` creation is only happening once. – Damien_The_Unbeliever Feb 04 '21 at 14:08
  • 3
    Have you considered using a `Uint32` for your `crt` variable? That way you don't have to worry about negative numbers when overflow happens. – Damien_The_Unbeliever Feb 04 '21 at 14:08
  • @Damien_The_Unbeliever Thanks for pointing out the code ignoring the return values! I'll edit the post. I mistyped the "minimal example". Also for the Uint32 suggestion. I'll test it out. – Alexandru Clonțea Feb 04 '21 at 14:11
  • 1
    As a side note, the `Task.Factory.StartNew` [should be avoided](https://blog.stephencleary.com/2013/08/startnew-is-dangerous.html), in favor of the newer `Task.Run`. You can read [here](https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/) about the differences of these two methods. – Theodor Zoulias Feb 04 '21 at 14:18
  • @Damien_The_Unbeliever Interlocked.Increment only supports int and long as far as I can tell. – Alexandru Clonțea Feb 04 '21 at 14:21
  • 2
    Ah, the uint versions are new in .NET 5. – Damien_The_Unbeliever Feb 04 '21 at 14:22
  • @Damien_The_Unbeliever You can still add that as an answer for .NET 5. – Alexandru Clonțea Feb 04 '21 at 14:26
  • @Damien_The_Unbeliever I feel so dirty now.... Following up on your suggestion to use an unsigned type, I've writen this code: static ulong toPositive(long s) => (ulong)1 + long.MaxValue + (ulong)s; with the idea to map the signed value to an unsigned range (deleting now) – Alexandru Clonțea Feb 04 '21 at 16:40
  • @Damien_The_Unbeliever Even without .NET 5 he can `unchecked` cast the `int` to `uint`. Nothing will break. The only problem is that `itemsCount` isn't a multiple of `itemsCount`, so a "cycle" won't be complete at the end. – xanatos Feb 04 '21 at 17:09
  • @xanatos that can be addressed by what I wrote previously (mapping the signed range to an unsigned range of the same byte size). The cycle would be correct with that (if starting from long.MinValue instead of 0 with the counter) – Alexandru Clonțea Feb 04 '21 at 17:25
  • 1
    Related: [How should I increment a number for a round robin threading scenario with least contention?](https://stackoverflow.com/questions/10034289/how-should-i-increment-a-number-for-a-round-robin-threading-scenario-with-least) – Theodor Zoulias Feb 04 '21 at 19:26

3 Answers3

3

You could probably:

static int crt = -1;
static readonly IReadOnlyList<A> Items = Enumerable.Range(1, 15).Select(i => new A()).ToList();
static readonly int itemsCount = Items.Count;
static readonly int maxItemCount = itemsCount * 100;

static A GetInstance()
{
    int newIndex;

    while (true)
    {
        newIndex = Interlocked.Increment(ref crt);

        if (newIndex >= itemsCount)
        {
            while (newIndex >= itemsCount && Interlocked.CompareExchange(ref crt, -1, newIndex) != newIndex)
            {
                // There is an implicit memory barrier caused by the Interlockd.CompareExchange around the
                // next line
                // See for example https://afana.me/archive/2015/07/10/memory-barriers-in-dot-net.aspx/
                // A full memory barrier is the strongest and interesting one. At least all of the following generate a full memory barrier implicitly:
                // Interlocked class mehods
                newIndex = crt;
            }

            continue;
        }

        break;
    }

    var instance = Items[newIndex % itemsCount];
    //Console.WriteLine($"{DateTime.Now.Ticks}, {Guid.NewGuid()}, Got instance: {instance.ID}");
    return instance;
}

But I have to say the truth... I'm not sure if it is correct (it should be), and explaining it is hard, and if anyone touches it in any way it will break.

The basic idea is to have a "low" ceiling for crt (we don't want to overflow, it would break everything... so we want to keep veeeeeery far from int.MaxValue, or you could use uint).

The maximum possible value is:

maxItemCount = (int.MaxValue - MaximumNumberOfThreads) / itemsCount * itemsCount;

The / itemsCount * itemsCount is because we want the rounds to be equally distributed. In the example I give I use a probably much lower number (itemsCount * 100) because lowering this ceiling will only cause the reset more often, but the reset isn't so much slow that it is truly important (it depends on what you are doing on the threads. If they are very small threads that only use cpu then the reset is slow, but if not then it isn't).

Then when we overflow this ceiling we try to move it back to -1 (our starting point). We know that at the same time other bad bad threads could Interlocked.Increment it and create a race on this reset. Thanks to the Interlocked.CompareExchange only one thread can successfully reset the counter, but the other racing threads will immediately see this and break from their attempts.

Mmmh... The if can be rewritten as:

if (newIndex >= itemsCount)
{
    int newIndex2;
    while (newIndex >= itemsCount && (newIndex2 = Interlocked.CompareExchange(ref crt, 0, newIndex)) != newIndex)
    {
        // If the Interlocked.CompareExchange is successfull, the while will end and so we won't be here,
        // if it fails, newIndex2 is the current value of crt
        newIndex = newIndex2;
    }

    continue;
}
xanatos
  • 109,618
  • 12
  • 197
  • 280
  • I appreciate the effort you have put into this. I agree, proving correctness in such cases is hard. I do believe it would work, according to your explanations and the MS documentation on Interlocked. However, I would be reluctant to use this in a real scenario, because it seems a little too brittle to changes by other programmers and the reset cost seems high. I think the best option, in this use case, is still either ensuring positive results from the "%" operator or mapping the signed range to a signed range. – Alexandru Clonțea Feb 04 '21 at 18:06
  • I think this answer best answers my question of "can CompareExchange be used here". I should have phrased the question better.... – Alexandru Clonțea Feb 04 '21 at 18:13
  • @AlexandruClonțea I concur on nearly everything you wrote. It was a "proof of concept". I've done some benchmarks (with BenchmarkDotNet) on `Interlocked.CompareExchange` and it seems to be 30% slower than `Interlocked.Increment`, so the cost isn't relaly high. Once every hundred (or thousand) of full cycles you'll do a handful of extra `Interlocked.Increment`. But yes, I wouldn't let anyone play with this code. – xanatos Feb 04 '21 at 18:23
  • 1
    @AlexandruClonțea You phrased your question "how can I do something in this way" instead of "what is the best way to do solve this problem? Is this a good way?"... Typical [xy problem](https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem) – xanatos Feb 04 '21 at 18:24
  • I don't think that this will work as expected. I assume that the intention is to do the equivalent of `lock (x) crt = (crt + 1) % itemsCount` using interlocked operations, an atomic-modulo-increment in other words. The above solution will fail on the "atomic" requirement, since two threads could increment the `crt` concurrently, then both try to "fix" it by getting it back to `-1`, one thread will succeed, and the increment of the other thread will be lost. – Theodor Zoulias Feb 04 '21 at 18:24
  • 1
    @TheodorZoulias Yes, then the `crt` will be `-1` for both of them, they will both see it, break the cycle, and `Interlocked.Increment` to `0` and to `1`. – xanatos Feb 04 '21 at 18:32
  • xanatos oh, I see. I failed to grasp the importance of the outer `while` loop in combination with the special `-1` value. TBH this solution still "smells" to me. I'll have to think about it deeper before I can comment further. – Theodor Zoulias Feb 04 '21 at 18:42
  • 1
    `-1` isn't a special value. It is a reset to `0`, but `0` would become 1 immediately in the `Interlocked.Increment`, skipping the `0`, because we increment-before-using the value, so we use `-1` as the baseline. – xanatos Feb 04 '21 at 18:48
  • 1
    xanatos please ignore my previous comments. Your solution is excellent. The only case it could fail is if the `itemsCount` has a value in the vicinity of `Int32.MaxValue`. In that case it is possible for the `newIndex` to take a negative value less than -1, which is a value currently not rejected by your algorithm. You could consider rejecting these values to cover this borderline case. – Theodor Zoulias Feb 04 '21 at 20:49
  • @TheodorZoulias There is a whole section in the anwer about this argument: _The basic idea is to have a "low" ceiling for crt (we don't want to overflow, it would break everything... so we want to keep veeeeeery far from int.MaxValue, or you could use uint). The maximum possible value is: ..._ and then there is an analysis about which one is the safe (and "fair") maximum value for the ceiling – xanatos Feb 04 '21 at 21:24
  • 1
    I'll add that a good dose of scepticism is the baseline when looking at multithread code. I consider all the multithread code I see to be s##t unless it is demostrable that it isn't s##it. So I reverse the burden of proof. Normally I consider code to be good unless I can demonstrate that it is bad. – xanatos Feb 04 '21 at 21:30
  • xanatos yeap, I noticed this warning inside the answer. My point is that this is not a game-breaking flaw, and can be fixed easily by just rejecting values less than `-1`. Btw I updated my answer with a simplified version of your solution. I hope that you don't mind. :-) – Theodor Zoulias Feb 04 '21 at 22:00
  • 1
    Thanks so much guys for all the excellent information provided! I sure learned a lot @TheodorZoulias – Alexandru Clonțea Feb 04 '21 at 22:01
1

No, the Interlocked class offers no mechanism that would allow you to restore an Int32 value back to zero in case it overflows. The reason is that it is possible for two threads to invoke concurrently the var newIndex = Interlocked.Increment(ref crt); statement, in which case both with overflow the counter, and then none will succeed in updating the value back to zero. This functionality is just beyond the capabilities of the Interlocked class. To make such complex operations atomic you'll need to use some other synchronization mechanism, like a lock.


Update: xanatos's answer proves that the above statement is wrong. It is also proven wrong by the answers of this 9-year old question. Below are two implementation of an InterlockedIncrementRoundRobin method. The first is a simplified version of this answer, by Alex Sorokoletov:

public static int InterlockedRoundRobinIncrement(ref int location, int modulo)
{
    // Arguments validation omitted (the modulo should be a positive number)
    uint current = unchecked((uint)Interlocked.Increment(ref location));
    return (int)(current % modulo);
}

This implementation is very efficient, but it has the drawback that the backing int value is not directly usable, since it circles through the whole range of the Int32 type (including negative values). The usable information comes by the return value of the method itself, which is guaranteed to be in the range [0..modulo]. If you want to read the current value without incrementing it, you would need another similar method that does the same int -> uint -> int conversion:

public static int InterlockedRoundRobinRead(ref int location, int modulo)
{
    uint current = unchecked((uint)Volatile.Read(ref location));
    return (int)(current % modulo);
}

It also has the drawback that once every 4,294,967,296 increments, and unless the modulo is a power of 2, it returns a 0 value prematurely, before having reached the modulo - 1 value. In other words the rollover logic is technically flawed. This may or may not be a big issue, depending on the application.

The second implementation is a modified version of xanatos's algorithm:

public static int InterlockedRoundRobinIncrement(ref int location, int modulo)
{
    // Arguments validation omitted (the modulo should be a positive number)
    while (true)
    {
        int current = Interlocked.Increment(ref location);
        if (current >= 0 && current < modulo) return current;

        // Overflow. Try to zero the number.
        while (true)
        {
            int current2 = Interlocked.CompareExchange(ref location, 0, current);
            if (current2 == current) return 0; // Success
            current = current2;
            if (current >= 0 && current < modulo)
            {
                break; // Another thread zeroed the number. Retry increment.
            }
        }
    }
}

This is slightly less efficient (especially for small modulo values), because once in a while an Interlocked.Increment operation results to an out-of-range value, and the value is rejected and the operation repeated. It does have the advantage though that the backing int value remains in the [0..modulo] range, except for some very brief time spans, during some of this method's invocations.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 1
    I sort of suspected this, but I was not able to word it so eloquently:) So I'm better off sticking with the ?: magic to ensure positive return values for the "%" or (more realistically) sticking to the long type for the counter? – Alexandru Clonțea Feb 04 '21 at 14:34
  • 1
    @AlexandruClonțea yeap, if there is the slightest chance that an `int` could overflow, I would go with a `long`. – Theodor Zoulias Feb 04 '21 at 14:35
  • 1
    @AlexandruClonțea I have edited my initial answer almost completely. You could consider accepting xanatos's answer instead, since it has more original content. – Theodor Zoulias Feb 05 '21 at 11:07
  • I was not sure you could do that, 1st question I've asked :) – Alexandru Clonțea Feb 05 '21 at 12:23
0

An alternative to using CompareExchange would be to simply let the values overflow.

I have tested this and could not prove it wrong (so far), but of course that does not mean that it isn't.

 //this incurs some cost, but "should" ensure that the int range
 // is mapped to the unit range (int.MinValue is mapped to 0 in the uint range)
 static ulong toPositive(int i) => (uint)1 + long.MaxValue + (uint)i;

 static A GetInstance()
 {
    //this seems to overflow safely without unchecked
    var newCounter = Interlocked.Increment(ref crt);
    //convert the counter to a list index, that is map the unsigned value
    //to a signed range and get the value modulus the itemCount value
    var newIndex = (int)(toPositive(newCounter) % (ulong)itemsCount);
    var instance = Items[newIndex];
    //Console.WriteLine($"{DateTime.Now.Ticks}, Got instance: {instance.ID}");
    return instance;
 }

PS: Another part of the xy problem part of my question: At a friend's suggestion I am currently investigating using a LinkedList or something similar (with locks) to achieve the same purpose.

Alexandru Clonțea
  • 1,746
  • 13
  • 23
  • 1
    These types of operations are very complex and difficult to reason about their correctness, so I would avoid putting them casually inside the indexer of a collection. At the very least I would use a dedicated variable for either the `ToPositive(newCounter)`, or for the whole expression `(int)(ToPositive(newCounter) % (ulong)itemsCount)`. – Theodor Zoulias Feb 04 '21 at 22:28