I just noticed something really strange with regards to garbage collection.
The WeakRef method collects the object as expected while the async method reports that the object is still alive even though we have forced a garbage collection. Any ideas why ?
class Program
{
static void Main(string[] args)
{
WeakRef();
WeakRefAsync().Wait();
}
private static void WeakRef()
{
var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
GC.Collect();
Debug.Assert(!fooRef.IsAlive);
}
private static async Task WeakRefAsync()
{
var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
GC.Collect();
Debug.Assert(!fooRef.IsAlive);
}
}
public class Foo
{
}
DisposeAsync() method when you need to perform resource cleanup, just as you would when implementing a Dispose method. One of the key differences, however, is that this implementation allows for asynchronous cleanup operations. The DisposeAsync() returns a ValueTask that represents the asynchronous disposal operation.
An async method runs synchronously until it reaches its first await expression, at which point the method is suspended until the awaited task is complete. In the meantime, control returns to the caller of the method, as the example in the next section shows.
The call to the async method starts an asynchronous task. However, because no Await operator is applied, the program continues without waiting for the task to complete. In most cases, that behavior isn't expected.
Async functions always return a promise. If the return value of an async function is not explicitly a promise, it will be implicitly wrapped in a promise. Note: Even though the return value of an async function behaves as if it's wrapped in a Promise.resolve , they are not equivalent.
The WeakRef method collects the object as expected
There's no reason to expect that. Trying in Linqpad, it doesn't happen in a debug build, for example, though other valid compilations of both debug and release builds could have either behaviour.
Between the compiler and the jitter, they are free to optimise out the null-assignment (nothing uses foo
after it, after all) in which case the GC could still see the thread as having a reference to the object and not collect it. Conversely, if there was no assignment of foo = null
they'd be free to realise that foo
isn't used any more and re-use the memory or register that had been holding it to hold fooRef
(or indeed for something else entirely) and collect foo
.
So, since both with and without the foo = null
it's valid for the GC to see foo
as either rooted or not rooted, we can reasonably expect either behaviour.
Still, the behaviour seen is a reasonable expectation as to what would probably happen, but that it's not guaranteed is worth pointing out.
Okay, that aside, let's look at what actually happens here.
The state-machine produced by the async
method is a struct with fields corresponding to the locals in the source.
So the code:
var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
GC.Collect();
Is a bit like:
this.foo = new Foo();
this.fooRef = new WeakReference(foo);
this.foo = null;
GC.Collect();
But field accesses always have something going on locally. So in that regard it's almost like:
var temp0 = new Foo();
this.foo = temp0;
var temp1 = new WeakReference(foo);
this.fooRef = temp1;
var temp2 = null;
this.foo = temp2;
GC.Collect();
And temp0
hasn't been nulled, so the GC finds the Foo
as rooted.
Two interesting variants of your code are:
var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
await Task.Delay(0);
GC.Collect();
And:
var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
await Task.Delay(1);
GC.Collect();
When I ran it (again, reasonable differences in how the memory/registers for locals is dealt with could result in different outcomes) the first has the same behaviour of your example, because while it calls into another Task
method and await
s it, that method returns a completed task so the await
immediately moves onto the next thing within the same underlying method call, which is the GC.Collect()
.
The second has the behaviour of seeing the Foo
collected, because the await
returns at that point and then the state-machine has its MoveNext()
method called again roughly a millisecond later. Since it's a new call to the behind-the-scenes method, there's no local reference to the Foo
so the GC can indeed collect it.
Incidentally, it's also possible that one day the compiler will not produce fields for those locals that don't live across await
boundaries, which would be an optimisation that would still produce correct behaviour. If that was to happen then your two methods would become much more similar in underlying behaviour and hence more likely to be similar in observed behaviour.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With