Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API Proposal]: Interlocked.CompareExchange and Interlocked.Exchange Generic Overload with Unmanaged Type Constraint #75385

Closed
adam-dot-cohen opened this issue Sep 10, 2022 · 9 comments
Labels
api-suggestion Early API idea and discussion, it is NOT ready for implementation area-System.Threading
Milestone

Comments

@adam-dot-cohen
Copy link

adam-dot-cohen commented Sep 10, 2022

Background and motivation

It would be useful to have a generic overload for Interlocked.Compare and Interlocked.CompareExchange with a constraint of "unmanaged" for global support of structs, etc...

Benchmark of Sample Code

The following benchmark of the proposed code for illustrative purposes only. It's based on two competing threads making random CompareExchange assignments to the same static variable 10M times (~5M each)...

Method Mean Error StdDev Ratio Rank
Sample_CompareExchange 1.098 ms 0.0069 ms 0.0058 ms 0.001 1
NetCore_CompareExchange 1,113.648 ms 3.9092 ms 3.6566 ms 1.000 2

Linqpad Benchmark Script

API Proposal

namespace System.Threading;
public static Interlocked
{
       // Out of scope Interlocked.* methods omitted

	public static T CompareExchange<T>(ref T location, T value, T comparand)
		where T : unmanaged
	{
		Interlocked.MemoryBarrier();
		if (Unsafe.AreSame(ref location, ref comparand))
			return Exchange(ref location, value);
		return location;
	}

	public static T Exchange<T>(ref T location, T value)
		where T : unmanaged
	{
		Interlocked.MemoryBarrier();
		location = value;
		Interlocked.MemoryBarrier();
		return location;
	}
}

API Usage

public enum State
{
    Idle,
    Running
}

var currentVal = State.Idle;
var newVal = State.Running;

Interlocked.CompareExchange(ref currentVal, newVal, State.Idle);

Alternative Designs

No response

Risks

Uncertain if all types under the unmanaged constraint are supported by Unsafe.AreSame in sample code. Just a prototype for illustrative purposes.

@adam-dot-cohen adam-dot-cohen added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Sep 10, 2022
@dotnet-issue-labeler
Copy link

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

@ghost ghost added the untriaged New issue has not been triaged by the area owner label Sep 10, 2022
@adam-dot-cohen adam-dot-cohen changed the title [API Proposal]: [API Proposal]: Interlocked.CompareExchange and Interlocked.Exchange Generic Overload with Unmanaged Type Constraint Sep 10, 2022
@ghost
Copy link

ghost commented Sep 10, 2022

Tagging subscribers to this area: @mangod9
See info in area-owners.md if you want to be subscribed.

Issue Details

Background and motivation

It would be useful to have a generic overload for Interlocked.Compare and Interlocked.CompareExchange with a constraint of "unmanaged" for global support of structs, etc...

Benchmark of Sample Code

The following benchmark of the proposed code for illustrative purposes only. It's based on two competing threads making random CompareExchange assignments to the same static variable 10M times (~5M each)...

Method Mean Error StdDev Ratio Rank
Custom_CompareExchange 1.098 ms 0.0069 ms 0.0058 ms 0.001 1
NetCore_CompareExchange 1,113.648 ms 3.9092 ms 3.6566 ms 1.000 2

API Proposal

namespace System.Threading;
public static InterlockedExchange
{
//
 public static T CompareExchange<T>(ref T location, T value, T comparand)
        where T : struct
    {
        Interlocked.MemoryBarrier();
        if (Unsafe.AreSame(ref location, ref comparand))
        {
            Interlocked.MemoryBarrier();
            location = value;

            Interlocked.MemoryBarrier();
        }
        return location;
    }
}

API Usage

public enum State
{
    Idle,
    Running
}

var currentVal = State.Idle;
var newVal = State.Running;

Interlocked.CompareExchange(ref currentVal, newVal, State.Idle);

Alternative Designs

No response

Risks

Uncertain of all types under the unmanaged constraint are supported by Unsafe.AreSame in sample code. Just a prototype for illustrative purposes.

Author: adam-dot-cohen
Assignees: -
Labels:

api-suggestion, area-System.Threading, untriaged

Milestone: -

@teo-tsirpanis
Copy link
Contributor

Methods of the Interlocked class imply atomicity. Structs of arbitrary size cannot be handled atomically.

@teo-tsirpanis
Copy link
Contributor

Here's a (likely) correct implementation:

using System.Runtime.CompilerServices;

public static class InterlockedNeo
{
    public static T CompareExchange<T>(ref T location, T value, T comparand) where T : unmanaged => Unsafe.SizeOf<T>() switch
    {
        // 1 => Unsafe.As<byte, T>(ref Unsafe.AsRef(Interlocked.CompareExchange(ref Unsafe.As<T, byte>(ref location), Unsafe.As<T, byte>(ref value), Unsafe.As<T, byte>(ref comparand)))),
        // 2 => Unsafe.As<ushort, T>(ref Unsafe.AsRef(Interlocked.CompareExchange(ref Unsafe.As<T, ushort>(ref location), Unsafe.As<T, ushort>(ref value), Unsafe.As<T, ushort>(ref comparand)))),
        4 => Unsafe.As<int, T>(ref Unsafe.AsRef(Interlocked.CompareExchange(ref Unsafe.As<T, int>(ref location), Unsafe.As<T, int>(ref value), Unsafe.As<T, int>(ref comparand)))),
        8 => Unsafe.As<long, T>(ref Unsafe.AsRef(Interlocked.CompareExchange(ref Unsafe.As<T, long>(ref location), Unsafe.As<T, long>(ref value), Unsafe.As<T, long>(ref comparand)))),
        _ => throw new NotSupportedException("This type cannot be handled atomically")
    };
    public static T Exchange<T>(ref T location, T value) where T : unmanaged => Unsafe.SizeOf<T>() switch
    {
        // 1 => Unsafe.As<byte, T>(ref Unsafe.AsRef(Interlocked.Exchange(ref Unsafe.As<T, byte>(ref location), Unsafe.As<T, byte>(ref value)))),
        // 2 => Unsafe.As<ushort, T>(ref Unsafe.AsRef(Interlocked.Exchange(ref Unsafe.As<T, ushort>(ref location), Unsafe.As<T, ushort>(ref value)))),
        4 => Unsafe.As<int, T>(ref Unsafe.AsRef(Interlocked.Exchange(ref Unsafe.As<T, int>(ref location), Unsafe.As<T, int>(ref value)))),
        8 => Unsafe.As<long, T>(ref Unsafe.AsRef(Interlocked.Exchange(ref Unsafe.As<T, long>(ref location), Unsafe.As<T, long>(ref value)))),
        _ => throw new NotSupportedException("This type cannot be handled atomically")
    };
}

Supporting CompareAndExchange with bytes and shorts is tracked in #64658. Exchange is not tracked anywhere, feel free to to open an issue (maybe it could be implemented with CompareAndExchange, I am not sure). Because it can't work with any arbitrary unmanaged struct, I don't think it can be a part of the BCL.

@mangod9 mangod9 removed the untriaged New issue has not been triaged by the area owner label Sep 12, 2022
@mangod9 mangod9 added this to the Future milestone Sep 12, 2022
@adam-dot-cohen
Copy link
Author

adam-dot-cohen commented Sep 13, 2022

Understood, it would be great to have framework support for more complex atomic operations - similar to those Java provides at the library level.

My apologies as I'm not a .Net Framework expert and lack sufficient knowledge of the BCL, threading, compiler, etc. (as evidenced by the use of Unsafe.AreSame in original porposal). That being said, I was going for something along lines of the below (again forgive my ignorance) that would provide broader support.

Will leave it to the experts from here.

using System.Runtime.CompilerServices;
using System.Threading;

public static class InterlockedEx
{
	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public unsafe static T CompareExchange<T>(ref T current, T value, T compareand)
		where T : unmanaged
	{
		Thread.MemoryBarrier();
		if (IsEqual((byte*)Unsafe.AsPointer(ref current), (byte*)Unsafe.AsPointer(ref compareand), Unsafe.SizeOf<T>()))
			Exchange(ref current, value);
			
		return current;
	}
	
	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	private unsafe static bool IsEqual(byte* src, byte* dst, int length)
	{
		for (int i = 0; i < length; i++)
		{
			if (*(src + i) != *(dst + i))
			{
				return false;
			}
		}
		return true;
	}
	
	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public static T Exchange<T>(ref T location, T value)
		where T : unmanaged
	{
		Thread.MemoryBarrier();
		location = value;
		Thread.MemoryBarrier();
		return location;
	}
}

@teo-tsirpanis
Copy link
Contributor

Sorry, they are still not atomic (and the implementations are wrong, they have to return the old value).

Imagine you have the following code.

Guid g = default;

Interlocked.Exchange(ref g, Guid.NewGuid());

When it does location = value on GUIDs, what the CPU will likely do is emit two mov instructions (or four if we are on 32-bit systems) from the GUID in value to the location's address in memory. Between these movs, location will contain parts from the one GUID and parts from the other (and it matters because other threads will briefly observe an invalid value). The operation is not atomic; it would have been if other threads could observe only the old GUID or the new. Contrast this with integers, if you write int x = 0xFFFFFFFF; x = 0, x can be ovserved to have only two possible values, 0xFFFFFFFF or 0.

Atomicity in the methods of the Interlocked class is essential, and can be provided only via special JIT intrinsics that are available only for structs of a specific small sizes. Implementing them for structs of arbitrary size is impossible.

@Clockwork-Muse
Copy link
Contributor

Also, even ignoring the size issue, the methods as proposed are not atomic: Interlocked.MemoryBarrier() provides no inter-thread ordering guarantees, and only prevents certain compiler/hardware optimizations.
It's completely possible, for example, that two concurrent calls to this implementation of Exchange would return the same initial value, which would not be correct.

@adam-dot-cohen
Copy link
Author

Thanks for the clarification. I was under the impression MemoryBarrier guaranteed a full fence across threads /processes / CPUs. Will revert back to locking to keep things simple for my own purposes and get myself a IL/Native analyzer before making any further proposals :)

@teo-tsirpanis teo-tsirpanis closed this as not planned Won't fix, can't repro, duplicate, stale Sep 13, 2022
@Clockwork-Muse
Copy link
Contributor

Thanks for the clarification. I was under the impression MemoryBarrier guaranteed a full fence across threads /processes / CPUs.

Even if it does, that's not sufficient: reads on multiple threads can happen before any write (paraphrasing):

Memory reordering might cause the read and/or write operations on one of the threads to be shifted beyond each other, again causing two identical reads:

|   Thread A   |   Thread B   |
|              |              |
|    read c    |              |
| ------------ |              |
|              |    read c    |
|              |              |
|    write a   |              |
|              | ------------ |
|              |    write b   |

(assuming an otherwise working version that attempts to return the old values)

Thread B should be returning a, but instead also returns c

@ghost ghost locked as resolved and limited conversation to collaborators Oct 13, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-suggestion Early API idea and discussion, it is NOT ready for implementation area-System.Threading
Projects
None yet
Development

No branches or pull requests

5 participants