-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Description
There is a theoretical race condition in how we initialize data in PETypeParameterSymbol (currently just C#, but soon to be VB as well since it's copying the same code style). This race condition is practically impossible to reproduce, to be clear; on x86/64, the memory model should make it impossible. On ARM, it is technically possible to hit due to the looser memory model, but would also be extremely unlikely to happen in reality. However, we should fix this so that we're not relying on undefined behavior for this scenario.
The race
The original file is here. I'm going to copy over a simplified version to this issue for demonstration purposes.
internal sealed class PETypeParameterSymbol
: TypeParameterSymbol
{
private ThreeState _lazyHasIsUnmanagedConstraint; // This is only valid when _lazyDeclaredConstaintTypes is non-default
private ImmutableArray<TypeWithAnnotations> _lazyDeclaredConstraintTypes; // This is a sentinel value
private ImmutableArray<TypeWithAnnotations> GetDeclaredConstraintTypes(ConsList<PETypeParameterSymbol> inProgress)
{
if (_lazyDeclaredConstraintTypes.IsDefault)
{
// ...
_lazyHasIsUnmanagedConstraint = hasUnmanagedModreqPattern.ToThreeState();
// This is a full read-write barrier: as long as we guarantee that _lazyDeclaredConstraintTypes is read before
// _lazyHasIsUnmanagedConstraint, we aren't in a torn state: either _lazyDeclaredConstraintTypes is non-default,
// and _lazyHasIsUnmanagedConstraint is valid, or _lazyDeclaredConstraintTypes is default, and we'll (re)do the
// initialization work on this thread before observing _lazyHasIsUnmanagedConstraint
ImmutableInterlocked.InterlockedInitialize(ref _lazyDeclaredConstraintTypes, declaredConstraintTypes);
}
}
public override bool HasUnmanagedTypeConstraint
{
get
{
GetDeclaredConstraintTypes(ConsList<PETypeParameterSymbol>.Empty);
// There is no read barrier between these locations: the JIT and/or CPU is allowed to speculatively read
// _lazyHasIsUnmanagedConstraints before/in tandem with the `if (_lazyDeclaredConstraintTypes.IsDefault)`
// call above. In a worst-case scenario, this is a race condition
return this._lazyHasIsUnmanagedConstraint.Value();
}
}
}In practice, for this to actually race, multiple things have to happen:
- The JIT has to decide that it's worth it to inline
GetDeclaredConstantTypes, or the CPU has to decide to start speculatively executing the code following the return. - Some mechanism (either JIT or CPU) has to decide that it's worth it to start speculatively executing the
elseof theif (_lazyDeclaredConstaintTypes.IsDefault)branch. - The speculative access of
_lazyHasUnmanagedConstrainthas to complete before the_lazyDeclaredConstraintTypesaccess. - Another thread writes to both
_lazyHasUnmanagedConstraintand_lazyDeclaredConstraintTypes, causing a cache miss for the non-speculative read of_lazyDeclaredConstraintTypes. - At this point, we read the final, non-default value of
_lazyDeclaredConstraintTypes. This causes us to go down theelsebranch of theif, and the speculative read of_lazyHasUnmanagedConstraintis used, which is an invalid value.
As I mentioned previously, the memory model on x86/64 guarantees that this type of speculation can't occur. On ARM, it's technically possible, just very unlikely. I have no idea what other arches, like Z, will do here.
Fixing it
We have a few options for fixing this:
- Do some horrible unsafe hacks to manually
Volatile.Readthe backing array of_lazyDefaultConstraints. This will ensure that proper read fences are put in place such that_lazyDefaultConstraintsis guaranteed to be read before_lazyHasUnmanagedConstraint. Possible, and should have the least runtime perf impact, but really horrible. - Use
Interlocked.MemoryBarrier()in theelseblock ofGetDeclaredConstraintTypes, which will ensure that in all cases,_lazyDefaultConstraintsis guaranteed to be read before_lazyHasUnmanagedConstraint. Technically has some runtime impact, but not noticeable. - Encapsulate these fields into an immutable, lazily-allocated backing object, like we do in other PE types with
UncommonData. This is the easiest to get right, since all access is through a single field and atomic operations are trivial. - Add an extension method like
VolatileIsDefault()that does anInterlocked.MemoryBarrier()on downlevel platforms, andVolatile.ReadBarrier()on .NET 10 when available, and use that instead ofIsDefaultfor these types of checks.
Thanks to @stephentoub for helping us figure out the semantics here.