-
Notifications
You must be signed in to change notification settings - Fork 4.9k
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
Added support of atomic writes for custom value types #65635
Conversation
Tagging subscribers to this area: @dotnet/area-system-collections Issue DetailsAdded support of atomic writes for custom value types used as a generic argument of
|
JITs, AOTs and interpreters don't have to implement copying structs (non-primitives) atomically. |
@omariom , probably, I'm wrong but ECMA-335 says
Dissasembly of the code confirming my assumption (see linked discussion). |
I don't think it's safe, jit can enregister a struct (put its fields into registers) so then when it needs to save it to some memory location it does several movs, e.g. https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKBuIGYACXDKAVzA0YGUaBvGoyGMGjAJYA7LtjSNgAbhoBfGnSZlGAYX6Dh3ZourDGuocRQ8YGbgAoAlKcZ9jj47kYBeRhJgB3HvZOjNiejOSywKGkjEqGxirUSkA |
I don't think mono guarantees atomic moves for small structs either. |
@EgorBo , regarding to which is atomic. Anyway, if it considered as risky then we need more clarification in ECMA standard. |
A small recap of the issue from my side:
|
It's not just initialization, e.g.: https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKBuIGYACXDKAVzA0YGUaBvGoyGMGjAJYA7LtjSNgAbhoBfGnSZlGAYX6Dh3ZuVm5Si6sMa6hkrtxgZuACgCUlxn2GvzAN2xRmjAF4DU3NvXzlA5gA6BU9hY0jcENChYgB2OWShFWolIA at any point jit might decide to promote a structure and then have problems with merging the stores into a single 64 bit one. |
For me, as for library writer, this statement in ECMA is completely unclear. That's why I decided to check my assumption in .NET codebase. Now I see the gap between what is stated in the standard and the actual behavior of the runtime. How I should treat this? Is .NET runtime not compliant? Is spec not accurate? Or my misunderstanding take the place? From my point of view, this is a strange inconsistency because every small value type can be reinterpreted as primitive type which provides atomic write. As a result, we can have ugly workaround: static void AtomicWrite<T>(T input, ref T location)
{
if (!typeof(T).IsValueType)
{
location = input; // reference type
}
else if (Unsafe.SizeOf<T>() > IntPtr.Size)
{
throw new InvalidOperationException("Cannot write atomically");
}
else if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
location = input; // wrapped reference type
}
else
{
switch (Unsafe.SizeOf<T>())
{
case sizeof(byte):
Unsafe.As<T, byte>(ref location) = Unsafe.As<T, byte>(ref input);
break;
case sizeof(short):
Unsafe.As<T, short>(ref location) = Unsafe.As<T, short>(ref input);
break;
case sizeof(int):
Unsafe.As<T, int>(ref location) = Unsafe.As<T, int>(ref input);
break;
case sizeof(long) when IntPtr.Size == sizeof(long):
Unsafe.As<T, long>(ref location) = Unsafe.As<T, long>(ref input);
break;
default:
throw new InvalidOperationException("Cannot write atomically");
}
}
} According to the current implementation, this method will work for small value type correctly. Why? |
There's also:
So if user code outside of I can understand @sakno's remarks with respect to inconsistency. This is something to be taken into consideration when working on: |
@GSPP , if so, then we cannot treat primitive types as tearing-free data type as well because I can reinterpret them as bytes: int field;
Unsafe.Add(ref As<int, byte>(ref field), 1) = 10; |
I found a source of this inconsistency. It is ECMA-334 (C# language specification), 10.6:
ECMA-335 doesn't have this restriction. |
} | ||
// Section 12.6.6 of ECMA CLI explains which types can be read and written atomically without | ||
// the risk of tearing. See https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-335.pdf | ||
private static bool IsValueWriteAtomic => Unsafe.SizeOf<TValue>() <= IntPtr.Size; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be worth to keep it as a field rather than property to not risk any perf impact cc: @stephentoub
Thanks for the discussion. As was pointed out, this isn't valid: there's no guarantee that types with a size <= IntPtr.Size are written atomically, e.g. SharpLab. It's why ConcurrentDictionary is written the way it is. |
@stephentoub , but this behavior is inconsistent with ECMA standard. Otherwise, we just leaving this issue without any solution. |
This was a PR making an invalid change to ConcurrentDictionary. We don't need to keep it open to discuss ECMA spec language that won't impact the end state of the PR. Feel free to open an issue for discussion if you believe it's warranted. Thanks. |
Open #70384 with the ECMA-335 clarification |
@stephentoub , @jkotas I would like to continue work on this PR and provide atomic read/write for |
Can you elaborate? |
Sure, here is the prototype of internal void WriteValueAtomically(TValue value)
{
Debug.Assert(IsValueWriteAtomic);
// object reference can be updated atomically
// as well as primitive data types.
// If underlying data type is not primitive we need to reinterpret
// it accordingly to satisfy constraints described in Section 12.6.6 of ECMA-335
if (RuntimeHelpers.IsReferenceOrContainsReferences<TValue>())
{
_value = value;
}
else
{
// JIT or AOT is able to eliminate redundant branches because
// sizeof(TValue) is a constant
switch (Unsafe.SizeOf<TValue>())
{
case sizeof(ulong):
Unsafe.As<TValue, ulong>(ref _value) = Unsafe.As<TValue, ulong>(ref value);
break;
case sizeof(uint):
Unsafe.As<TValue, uint>(ref _value) = Unsafe.As<TValue, uint>(ref value);
break;
case sizeof(ushort):
Unsafe.As<TValue, ushort>(ref _value) = Unsafe.As<TValue, ushort>(ref value);
break;
case sizeof(byte):
Unsafe.As<TValue, byte>(ref _value) = Unsafe.As<TValue, byte>(ref value);
break;
default:
Debug.Fail("Unexpected size of TValue", $"Expected 1, 2, 4, or 8 bytes but actual is {Unsafe.SizeOf<TValue>()}");
break;
}
}
} |
This isn't valid. The "ContainsReferences" part means this could be an arbitrarily large struct that has at least one reference field.
The reading/writing of these as the primitive types is only guaranteed to be atomic if the field is appropriately aligned. |
@stephentoub , all these issues are taken into account:
|
If I have a struct: struct S
{
public ushort Value1, Value2;
} when contained in something else that needn't be 4-byte aligned. What forces it to be? |
|
That doesn't mean every one of its fields is aligned as such. I copied out new Node<byte, S>().PrintValueAlignment();
struct S
{
public ushort Value1, Value2;
}
sealed class Node<TKey, TValue> where TValue : unmanaged
{
internal readonly TKey _key;
internal TValue _value;
internal volatile Node<TKey, TValue>? _next;
internal readonly int _hashcode;
internal unsafe void PrintValueAlignment()
{
fixed (TValue* ptr = &_value)
{
Console.WriteLine($"Value address: {(nint)ptr}");
Console.WriteLine($"4-byte aligned: {Math.DivRem((long)ptr, 4).Remainder == 0}");
Console.WriteLine($"8-byte aligned: {Math.DivRem((long)ptr, 8).Remainder == 0}");
}
}
} When I run that, I get results like this:
|
I tried the same on my machine but the address is always 4-bytes aligned. Anyway, we can assume that |
ReadUnaligned/WriteUnaligned is not atomic. |
I would like to put the reference to #2167 proposal. In future, we can check alignment of the type. If it is equal to 4 or 8 then we can reinterpret memory location correctly and then write value atomically. Some of the types such as |
Added support of atomic writes for custom value types used as a generic argument of
S.C.C.ConcurrentDictionary<TKey, TValue>
class. See #65600 discussion./cc @danmoseley