runtime: Guarantee that newly allocated memory can't be stale for any thread

Is it possible for another thread to have a stale view of the memory containing the fields that a constructor just initialized?

I would guess for safety there would have to be a runtime guarantee of a write barrier every time an instance is allocated, or some other guarantee that there’s no way the memory could be stale for other threads, but I’m not sure and I would like a definitive answer.

This is what it comes down to, practically. In this example, does this.action = action need to be Volatile.Write(ref this.action, action)? Or does the runtime take care of this with 100% certainty via implicit write barrier or other means so that I can’t possibly need Volatile.Write in the constructor (assuming I don’t share this with another thread during construction, of course)?

public sealed class OnDisposeAction : IDisposable
{
    private Action action;

    // Thread 1 does this:
    public OnDisposeAction(Action action)
    {
        this.action = action;
    }

    // Interlocked because sometimes multiple threads race for this,
    // but for the purposes of this question, only thread 2 ever calls this:
    public void Dispose() => Interlocked.Exchange(ref action, null)?.Invoke();
}

// Thread 1
Volatile.Write(sharedObject._x, new OnDisposeAction(() => { }));
// sharedObject._x.action is not null
// (waits)

// Thread 2
Volatile.Read(ref sharedObject._x).Dispose();
// sharedObject._x.action is disposed and set to null; the write barrier
// causes shared memory to be updated to `null` at the address of sharedObject._x.action
// (waits)

// Thread 1
Volatile.Write(sharedObject._x, null);
GC.Collect();
// Let's suppose the runtime happens to pick the exact same memory location
// to allocate the new OnDisposeAction that it used for the now-freed previous instance:
Volatile.Write(sharedObject._x, new OnDisposeAction(() => { }));
// If the constructor does not guarantee an implicit write barrier,
// shared memory still has `null` at the address of sharedObject._x.action.
// (waits)

// Thread 2
Volatile.Read(ref sharedObject._x).Dispose();
// This should have disposed the instance, but instead it no-ops.
// Interlocked.Exchange synced thread 2's memory with shared memory
// but shared memory still has `null` for this memory address.
// Thread 1's memory hasn't pushed the write of `this.action = action` to shared memory.

Thank you.

/cc @maoni and @swgillespie (via @tannergooding) /cc @sharwell from conversation

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Comments: 17 (17 by maintainers)

Most upvoted comments

@jnm2

I can’t make Action volatile or the compiler yells at me for using ref on it for Interlocked.Exchange.

It doesn’t yell, the compiler does not generate that warning for Interlocked methods. You might assume it does, because it did in the past (before Roslyn).

In this example, does this.action = action need to be Volatile.Write(ref this.action, action)?

This is not enough as Volatile.Write is a store-release barrier and such write can still be reordered with the subsequent normal store of the OnDisposeAction object.

Plus to me it’s counter-intuitive that you’d mark the storage location as volatile rather than the load/store operation. I wish the volatile keyword did nothing more or less than force you to access it only as a Volatile or Interlocked ref target.

volatile keyword, Volatile.Write

Both of these, as well as volatile. IL prefix, work the same. There is no difference in the guarantees that they provide.