Description
Background and motivation
Normally, one would use Volatile.Read
and Volatile.Write
to handle interlocking of numeric values between tasks. Sometimes it's beneficial to use enums in these cases, but we are prevented from doing so due to limitations in the API. Currently, one must cast their enum values back and forth when reading and writing:
MyEnum nextState = default;
Task task = Task.Run(() =>
{
...
Volatile.Write(ref nextState, success ? (int)MyEnum.SuccessState : (int)MyEnum.FailureState);
});
...
task.Wait();
MyEnum newState = (MyEnum)Volatile.Read(ref nextState);
Having overloads that take enums would be beneficial in these cases to avoid issues that occur when casting enum values (such as using incorrect numeric or enum types). For example, a ulong
backed enum must be cast to either long
or ulong
lest you lose 32 bits of data. Casting to int
above would throw away such data.
API Proposal
namespace System.Threading;
public static class Volatile
{
public static TEnum ReadEnum<TEnum>(ref TEnum location)
where TEnum : struct, Enum;
public static void WriteEnum<TEnum>(ref TEnum location, TEnum value)
where TEnum : struct, Enum;
}
In each function, sizeof(TEnum)
would be used to determine the size of the enum. Then Unsafe.As
and normal Volatile
methods would be used to massage out the value. For example, an implementation of ReadEnum
that only handled single-byte-backed enums would look like
public static TEnum ReadEnum<TEnum>(ref TEnum location)
where TEnum : struct, Enum
{
if (sizeof(TEnum) == sizeof(byte))
{
byte value = Volatile.Read(ref Unsafe.As<TEnum, byte>(ref location));
return Unsafe.As<byte, TEnum>(ref value);
}
throw new NotSupportedException("Unsupported enum underlying type.");
}
API Usage
MyEnum nextState = default;
Task task = Task.Run(() =>
{
...
Volatile.WriteEnum(ref nextState, success ? MyEnum.SuccessState : MyEnum.FailureState);
});
...
task.Wait();
MyEnum newState = Volatile.ReadEnum(ref nextState);
Alternative Designs
Alternatively, Volatile.Read<T>(ref T)
and Volatile.Write<T>(ref T, T)
can have their generic constraint removed and replaced with runtime checks (a la #99205 and #100842):
namespace System.Threading;
public static class Volatile
{
public static T Read<T>(ref T location)
/* where T : class */;
public static void Write<T>(ref T location, T value)
/* where T : class */;
}
If T : class
, it operates the same. However, if T : struct, Enum
, it would now operate as proposed. This is a non-breaking change, as it relaxes a generic constraint to allow previously non-compilable code.
It could be extended even further to work on any unmanaged struct that's eight bytes or fewer. In other words, Volatile.Read(ref myFourByteStruct)
would now be legal.
This alternate design is probably undesirable. Despite being non-breaking, it can be risky. Say I have a four-byte struct and use Volatile.(Read|Write)
on it. If, in the future, I change it to be, say, 12 bytes large, the code will still compile without warnings, but would always throw at runtime. If this alternate design is chosen, a source analyzer should be included. It would detect T
types that are greater than eight bytes and raise a compile-time warning.