13

Let's look at the following snippet which shows the problem.

class Program
{
    static void Main(string[] args)
    {
        var task = Start();
        Task.Run(() =>
        {
            Thread.Sleep(500);
            Console.WriteLine("Starting GC");
            GC.Collect();
            GC.WaitForPendingFinalizers();
            Console.WriteLine("GC Done");
        });

        task.Wait();

        Console.Read();
    }

    private static async Task Start()
    {
        Console.WriteLine("Start");
        Synchronizer sync = new Synchronizer();
        var task = sync.SynchronizeAsync();
        await task;

        GC.KeepAlive(sync);//Keep alive or any method call doesn't help
        sync.Dispose();//I need it here, But GC eats it :(
    }
}

public class Synchronizer : IDisposable
{
    private TaskCompletionSource<object> tcs;

    public Synchronizer()
    {
        tcs = new TaskCompletionSource<object>(this);
    }

    ~Synchronizer()
    {
        Console.WriteLine("~Synchronizer");
    }

    public void Dispose()
    {
        Console.WriteLine("Dispose");
    }

    public Task SynchronizeAsync()
    {
        return tcs.Task;
    }
}

Output produces:

Start
Starting GC
~Synchronizer
GC Done

As you can see sync gets Gc'd(more specifically finalized, we don't know about memory gets reclaimed or not). But why? Why would GC collect my object when I have a reference to it?

Research: I've spent some time investigating what happens behind the scenes, It seems that state machine generated by the C# compiler is kept as a local variable, and after the first await hit, it seems that the state machine itself goes out of scope.

So, GC.KeepAlive(sync); and sync.Dispose(); doesn't help as they live inside the state machine where as state machine itself is not there in scope.

C# compiler shouldn't have generated a code which leaves my sync instance to go out of scope when I still need it. Is this a bug in C# compiler? Or am I missing something fundamental?

PS: I'm not looking for a workaround, but rather a explanation of why the compiler does this? I googled, but didn't found any related questions, if it is duplicate sorry for that.

Update1: I've modified the TaskCompletionSource creation to hold the Synchronizer instance, that still doesn't help.

Sriram Sakthivel
  • 72,067
  • 7
  • 111
  • 189

3 Answers3

8

sync is simply not reachable from any GC root. The only reference to sync is from the async state machine. That state machine is not referenced from anywhere. Somewhat surprisingly it is not referenced from the Task or the underlying TaskCompletionSource.

For that reason sync, the state machine and the TaskCompletionSource are dead.

Adding a GC.KeepAlive does not prevent collection by itself. It only prevents collection if an object reference can actually reach this statement.

If I write

void F(Task t) { GC.KeepAlive(t); }

Then this does not keep anything alive. I actually need to call F with something (or it must be possible for it to be called). The mere presence of a KeepAlive does nothing.

Community
  • 1
  • 1
usr
  • 168,620
  • 35
  • 240
  • 369
  • 2
    `GC.KeepAlive` cannot be inlined, however, which is its sole point for being used, and its sole point for existing in the framework. – Lasse V. Karlsen Nov 16 '14 at 13:27
  • 1
    Dispose needs an object reference as it is an instance method. `GC.KeepAlive(sync)` can't be inlined, it is marked with `MethodImpl.NoInlining` – Sriram Sakthivel Nov 16 '14 at 13:27
  • Where do I say that KeepAlive was inlined? This is not at all in this answer. Dispose is inlined. Calling an instance method on an empty method does not keep the object reference alive. Why would it? The this parameter is simply an unused parameter here. – usr Nov 16 '14 at 13:29
  • 2
    I see that you added "because the task you are awaiting never completed". How can the runtime know this for sure that this task will never complete, and thus eliminate any code and thus references that would be used if the task completed. This is the halting problem, there's no way the runtime can know this. So though dispose in this case isn't going to be reached, I fail to see how the runtime can know this for certain. – Lasse V. Karlsen Nov 16 '14 at 13:29
  • No, but he has `GC.KeepAlive(sync)` in his code which should prevent collection. I'm pretty sure there is some strange interaction from the state machine generated by the compiler for the async method in all this. So even though `sync.Dispose` might follow what you said in your answer, `GC.KeepAlive(sync)` won't, so it should still not be collected. – Lasse V. Karlsen Nov 16 '14 at 13:29
  • 1
    KeepAlive only keeps an object alive if it is actually reachable. `if(false) KeepAlive(...)` does not keep anything alive. – usr Nov 16 '14 at 13:30
  • Correct, but how can the compiler or JITter know this code isn't reachable? – Lasse V. Karlsen Nov 16 '14 at 13:31
  • Example: `object o = new object(); new Action(() => GC.KeepAlive(o));`. Both objects are dead here despite the call to `KeepAlive`. His code is just a much longer version of this snippet. – usr Nov 16 '14 at 13:32
  • @usr maybe in my example my task never completes, but I have no idea how CLR knows that, but in my original code, however my task will get complete, it is done by holding a `WeakReference` of my synchronizer. So clr can't say that my task will never complete. – Sriram Sakthivel Nov 16 '14 at 13:32
  • The missing link with that Action example is that this is wrapped up in a task and I'm pretty sure the task is still considered alive. – Lasse V. Karlsen Nov 16 '14 at 13:33
  • The CLR does not know what a task is but it knows that there is no reference to the state machine and to the TCS. Both die. The state machine contained the only reference to `sync`. Where, in your mind, would some piece of code now obtain a new reference from? This object is simply dead. – usr Nov 16 '14 at 13:33
  • Why isn't there a reference to the state machine? `var task = Start();` <-- isn't that the reference? – Lasse V. Karlsen Nov 16 '14 at 13:34
  • @LasseV.Karlsen good point but the task does not reference the state machine. The chain is: statemachine => TCS => task. A task does not know what a state machine even is. – usr Nov 16 '14 at 13:35
  • If holding on to the task that was returned from calling an `async` method won't keep the tasks and related code alive I'm pretty sure the entire async/await system is useless, which also means I'm pretty sure that is not true. – Lasse V. Karlsen Nov 16 '14 at 13:35
  • 1
    Async tasks which are dead can be GC'ed. I have seen that. The thing that prevents this is that there is usually something that initiates completion such as an expiring timer or an IO. This is what holds the root under normal circumstances. Uncompletable TCS'es are collectable. `new TCS()` is clearly collectable. CPU-based tasks are rooted by the scheduler (and if the scheduler drops a task that task indeed goes away). – usr Nov 16 '14 at 13:36
  • And again, how does the runtime know this task is not completable? – Lasse V. Karlsen Nov 16 '14 at 13:40
  • It does not. The task is rooted and stays alive. But the state machine and TCS go away. That deletes the only reference to `sync`. Where, in your mind, is there still a reference? The KeepAlive is a red herring. – usr Nov 16 '14 at 13:43
  • @usr arguing that tasks will not be complete isn't plausible. Did you overlook [this comment](http://stackoverflow.com/questions/26957311/why-does-gc-collects-my-object-when-i-have-a-reference-to-it#comment42454022_26957449)? – Sriram Sakthivel Nov 16 '14 at 13:43
  • @SriramSakthivel I don't see a WeakReference in your code. In any case a WeakReference does not keep anything alive. You might find that the WeakReference is dead and then the task is not completable. But again: The CLR does not know the task is not completable. But the state machine has no roots. Where would there be a root in your mind? – usr Nov 16 '14 at 13:44
  • Ok state machine is out of the scope, alright. But isn't that against the intuition? You claim this is expected behavior? I don't think so. I strongly suspect this is a corner case with c# compiler. – Sriram Sakthivel Nov 16 '14 at 13:46
  • @SriramSakthivel see my comment above: "Async tasks which are dead can be GC'ed. I have seen that. The thing that prevents this is...". This is normal. Proof: new TCS(). Collectable. – usr Nov 16 '14 at 13:49
  • "Somewhat surprisingly it is not references from the Task or the underlying TaskCompletionSource." <-- I think this is the whole problem. This is what is not expected. – Lasse V. Karlsen Nov 16 '14 at 13:53
  • Well, there is no need to reference it. It is a good thing to allow collection of unused objects. In the code in the question he's playing tricks with finalization and relying on undocumented behavior. I'm not convinced any of those objects *should* be kept alive here. – usr Nov 16 '14 at 13:55
  • @usr check the updated question. I keep the instance of synchronizer in TCS, and thus `Task` will have it in `AsyncState` property. Still no dice. – Sriram Sakthivel Nov 16 '14 at 13:57
  • @SriramSakthivel the TCS is dead as well. It can't keep anything alive. You could experiment with creating a strong reference using GCHandle to various objects to see how it affects things. – usr Nov 16 '14 at 13:59
  • @usr TCS may be dead, but what about Task returned by TCS? It keeps the `sync` in its `AsyncState` property. Task isn't collectible isn't it, I'm awaiting over it? – Sriram Sakthivel Nov 16 '14 at 14:03
  • @SriramSakthivel await uses ContinueWith to queue the delegate that the compiler generated. The chain would be: statemachine => sync => tcs => sync. Also: tcs => tcs.Task => continuation => statemachine. But that whole jungle of references in unreachable. Nothing points into this cluster. – usr Nov 16 '14 at 14:11
  • 3
    @LasseV.Karlsen, for what is worth the halting problem doesn't say a compiler can never decide on *any* program. It says that no compiler can decide on *every* program. For many programs or fragments, it's quite possible for a compiler to figure it out, and quite a few compilers do make optimizations based on that. – Euro Micelli Nov 16 '14 at 15:15
  • Thanks for your answer and being patience with the wall of comments. +1 For you. I accepted @Noseratio's answer as his answer here and linked answer was very useful for me. Thanks again. – Sriram Sakthivel Nov 19 '14 at 06:25
8

What GC.KeepAlive(sync) - which is blank by itself - does here is just an instruction to the compiler to add the sync object to the state machine struct generated for Start. As @usr pointed out, the outer task returned by Start to its caller does not contain a reference to this inner state machine.

On the other hand, the TaskCompletionSource's tcs.Task task, used internally inside Start, does contain such reference (because it holds a reference to the await continuation callback and thus the whole state machine; the callback is registered with tcs.Task upon await inside Start, creating a circular reference between tcs.Task and the state machine). However, neither tcs nor tcs.Task is exposed outside Start (where it could have been strong-referenced), so the state machine's object graph is isolated and gets GC'ed.

You could have avoided the premature GC by creating an explicit strong reference to tcs:

public Task SynchronizeAsync()
{
    var gch = GCHandle.Alloc(tcs);
    return tcs.Task.ContinueWith(
        t => { gch.Free(); return t; },
        TaskContinuationOptions.ExecuteSynchronously).Unwrap();
}

Or, a more readable version using async:

public async Task SynchronizeAsync()
{
    var gch = GCHandle.Alloc(tcs);
    try
    {
        await tcs.Task;
    }
    finally
    {
        gch.Free();
    }
}

To take this research a bit further, consider the following little change, note Task.Delay(Timeout.Infinite) and the fact that I return and use sync as the Result for Task<object>. It doesn't get any better:

    private static async Task<object> Start()
    {
        Console.WriteLine("Start");
        Synchronizer sync = new Synchronizer();

        await Task.Delay(Timeout.Infinite); 

        // OR: await new Task<object>(() => sync);

        // OR: await sync.SynchronizeAsync();

        return sync;
    }

    static void Main(string[] args)
    {
        var task = Start();
        Task.Run(() =>
        {
            Thread.Sleep(500);
            Console.WriteLine("Starting GC");
            GC.Collect();
            GC.WaitForPendingFinalizers();
            Console.WriteLine("GC Done");
        });

        Console.WriteLine(task.Result);

        Console.Read();
    }

IMO, it's quite unexpected and undesirable that sync object gets prematurely GC'ed before I could access it via task.Result.

Now, change Task.Delay(Timeout.Infinite) to Task.Delay(Int32.MaxValue) and it all works as expected.

Internally, it comes down to the strong reference on the await continuation callback object (the delegate itself) which should be held while the operation resulting in that callback is still pending (in flight). I explained this in "Async/await, custom awaiter and garbage collector".

IMO, the fact that this operation might be never-ending (like Task.Delay(Timeout.Infinite) or incomplete TaskCompletionSource) should not be affecting this behavior. For most of naturally asynchronous operations, such strong reference is indeed held by the underlying .NET code which makes low-level OS calls (like in case with Task.Delay(Int32.MaxValue), which passes the callback to the unmanaged Win32 timer API and holds on to it with GCHandle.Alloc).

In case there is no pending unmanaged calls on any level (which might be the case with Task.Delay(Timeout.Infinite), TaskCompletionSource, a cold Task, a custom awaiter), there is no explicit strong references in place, the state machine's object graph is purely managed and isolated, so the unexpected GC does happen.

I think this is a small design trade-off in async/await infrastructure, to avoid making normally redundant strong references inside ICriticalNotifyCompletion::UnsafeOnCompleted of standard TaskAwaiter.

Anyhow, a possibly universal solution is quite easy to implement, using a custom awaiter (let's call it StrongAwaiter):

private static async Task<object> Start()
{
    Console.WriteLine("Start");
    Synchronizer sync = new Synchronizer();

    await Task.Delay(Timeout.Infinite).WithStrongAwaiter();

    // OR: await sync.SynchronizeAsync().WithStrongAwaiter();

    return sync;
}

StrongAwaiter itself (generic and non-generic):

public static class TaskExt
{
    // Generic Task<TResult>

    public static StrongAwaiter<TResult> WithStrongAwaiter<TResult>(this Task<TResult> @task)
    {
        return new StrongAwaiter<TResult>(@task);
    }

    public class StrongAwaiter<TResult> :
        System.Runtime.CompilerServices.ICriticalNotifyCompletion
    {
        Task<TResult> _task;
        System.Runtime.CompilerServices.TaskAwaiter<TResult> _awaiter;
        System.Runtime.InteropServices.GCHandle _gcHandle;

        public StrongAwaiter(Task<TResult> task)
        {
            _task = task;
            _awaiter = _task.GetAwaiter();
        }

        // custom Awaiter methods
        public StrongAwaiter<TResult> GetAwaiter()
        {
            return this;
        }

        public bool IsCompleted
        {
            get { return _task.IsCompleted; }
        }

        public TResult GetResult()
        {
            return _awaiter.GetResult();
        }

        // INotifyCompletion
        public void OnCompleted(Action continuation)
        {
            _awaiter.OnCompleted(WrapContinuation(continuation));
        }

        // ICriticalNotifyCompletion
        public void UnsafeOnCompleted(Action continuation)
        {
            _awaiter.UnsafeOnCompleted(WrapContinuation(continuation));
        }

        Action WrapContinuation(Action continuation)
        {
            Action wrapper = () =>
            {
                _gcHandle.Free();
                continuation();
            };

            _gcHandle = System.Runtime.InteropServices.GCHandle.Alloc(wrapper);
            return wrapper;
        }
    }

    // Non-generic Task

    public static StrongAwaiter WithStrongAwaiter(this Task @task)
    {
        return new StrongAwaiter(@task);
    }

    public class StrongAwaiter :
        System.Runtime.CompilerServices.ICriticalNotifyCompletion
    {
        Task _task;
        System.Runtime.CompilerServices.TaskAwaiter _awaiter;
        System.Runtime.InteropServices.GCHandle _gcHandle;

        public StrongAwaiter(Task task)
        {
            _task = task;
            _awaiter = _task.GetAwaiter();
        }

        // custom Awaiter methods
        public StrongAwaiter GetAwaiter()
        {
            return this;
        }

        public bool IsCompleted
        {
            get { return _task.IsCompleted; }
        }

        public void GetResult()
        {
            _awaiter.GetResult();
        }

        // INotifyCompletion
        public void OnCompleted(Action continuation)
        {
            _awaiter.OnCompleted(WrapContinuation(continuation));
        }

        // ICriticalNotifyCompletion
        public void UnsafeOnCompleted(Action continuation)
        {
            _awaiter.UnsafeOnCompleted(WrapContinuation(continuation));
        }

        Action WrapContinuation(Action continuation)
        {
            Action wrapper = () =>
            {
                _gcHandle.Free();
                continuation();
            };

            _gcHandle = System.Runtime.InteropServices.GCHandle.Alloc(wrapper);
            return wrapper;
        }
    }
}


Updated, here is a real-life Win32 interop example illustrating the importance of keeping the async state machine alive. The release build will crash if GCHandle.Alloc(tcs) and gch.Free() lines are commented out. Either callback or tcs has to be pinned for it to work properly. Alternatively, await tcs.Task.WithStrongAwaiter() can be used instead, utilizing the above StrongAwaiter.
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication1
{
    public class Program
    {
        static async Task TestAsync()
        {
            var tcs = new TaskCompletionSource<bool>();

            WaitOrTimerCallbackProc callback = (a, b) =>
                tcs.TrySetResult(true);

            //var gch = GCHandle.Alloc(tcs);
            try
            {
                IntPtr timerHandle;
                if (!CreateTimerQueueTimer(out timerHandle,
                        IntPtr.Zero,
                        callback,
                        IntPtr.Zero, 2000, 0, 0))
                    throw new System.ComponentModel.Win32Exception(
                        Marshal.GetLastWin32Error());

                await tcs.Task;
            }
            finally
            {
                //gch.Free();

                GC.KeepAlive(callback);
            }
        }

        public static void Main(string[] args)
        {
            var task = TestAsync();

            Task.Run(() =>
            {
                Thread.Sleep(500);
                Console.WriteLine("Starting GC");
                GC.Collect();
                GC.WaitForPendingFinalizers();
                Console.WriteLine("GC Done");
            });

            task.Wait();

            Console.WriteLine("completed!");
            Console.Read();
        }

        // p/invoke
        delegate void WaitOrTimerCallbackProc(IntPtr lpParameter, bool TimerOrWaitFired);

        [DllImport("kernel32.dll")]
        static extern bool CreateTimerQueueTimer(out IntPtr phNewTimer,
           IntPtr TimerQueue, WaitOrTimerCallbackProc Callback, IntPtr Parameter,
           uint DueTime, uint Period, uint Flags);
    }
}
Community
  • 1
  • 1
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 1
    I don't see a quality of implementation issue here. As always the GC's work is invisible to the code. If there is any way you can observe the state of the object to be finalized it will not be finalized. Except, if you play sneaky tricks with finalizers purely to observe the act of finalization. No reasonable finalizer would be affected by this object graph constellation. This is almost like using the debugger to detect JIT optimizations and then complaining that the JIT modified straight-line code in a detectable way. GC and JIT are not detectable in all reasonable cases. (+1) – usr Nov 18 '14 at 09:37
  • @usr, I respect this design decision of the CLR/C# team, although initially I almost thought it'd be a bug. Yet my concern is about actual interop coding. E.g., a stripped version of something I did run into: https://dotnetfiddle.net/ESRnZk. It will crash if `GCHandle.Alloc(tcs)` and `gch.Free()` lines are commented out, which IMO is quite not obvious. Either `callback` or `tcs` has to be pinned for it to work properly. – noseratio Nov 18 '14 at 09:53
  • 2
    Yes, that is indeed not obvious. I never would have noticed this bug. – usr Nov 18 '14 at 10:21
  • 1
    Thanks for your comprehensive answer, your linked answer was very helpful. Though it is hard for us to keep the instance alive manually, we have to accept the fact :( Thanks again. – Sriram Sakthivel Nov 19 '14 at 06:07
  • In your real life example, `callback` is GC'd and timer tries to invoke it, that's where the exception right? I get NRE while running it, but how strange there is no stacktrace ? – Sriram Sakthivel Nov 19 '14 at 07:16
  • @SriramSakthivel, right - the callback gets GC'ed and it crashes inside the interop layer, I guess that's why the stack is messed up. – noseratio Nov 19 '14 at 07:42
  • Whenever you pass a delegate to native code, you have to ensure yourself that the delegate is not GCed as long as the native code can invoke it. This has nothing to do with `Task`s, and `GCHandle.Alloc(tcs)` is not good enough. – svick Jul 29 '18 at 17:23
  • @Svick, fancy seeing you revisiting this. I think we covered it [here](https://stackoverflow.com/q/22268569/1768303) and you were a part of the discussion. So I say, ensuring that the delegate is not GC'ed *here* still does have something with the `Task` in this context. Particularly, because it's the task's async state machine (including the ref to the callback it holds) that gets GC'ed here, and `GC.KeepAlive` on the callback doesn't work as expected. Keeping that task alive with `GCHandle.Alloc` keeps the state machine alive, including the callback. – noseratio Jul 29 '18 at 22:53
  • That said, just doing `GCHandle.Alloc(callback)` would keep the outer state machine alive too, with the same net effect. However, in the OP's question, there is no callback, so doing `GCHandle.Alloc(tcs)` or `GCHandle.Alloc(tcs.Task)` is the way to make it work. Disagree with your neg here. – noseratio Jul 29 '18 at 22:53
  • @Noseratio Hmm, you're right that `GCHandle.Alloc(tcs)` will work in your case, because of the `GC.KeepAlive(callback)`. But I think it's a confusing way to do it. Rooting `callback` would be simpler and easier to understand. But I still have an issue with your answer: it suggests that `GCHandle.Alloc` is the right solution, even in the absence of PInvoke. And [it's being used as justification for using that](https://github.com/dotnet/coreclr/issues/19161#issuecomment-408686230) when you don't understand why an awaiter is being GCed in your code. – svick Jul 30 '18 at 10:37
  • @svick I see Toub's point that without `SetResult` this case might be contrived. And I'm not suggesting rooting the `Task` (or its TCS) as an ultimate solution, but what other object would you root, in the context of @SriramSakthivel's original question? His code doesn't have any interop callbacks, it's purely managed. So I only included the interop example to show the analogy with my own linked question, where I do root the callback itself. – noseratio Jul 30 '18 at 11:57
  • Still, think about this, rooting the callback would not prevent the async state machine from getting GC'ed if the callback itself doesn't reference anything from that state, i.e., any of pseudo-local variables of the `async` method. Do you think that'd be desirable and intuitive? – noseratio Jul 30 '18 at 11:58
1

You think that you are still reference to Synchronizer, because you assume that your TaskCompletionSource is still reference to the Synchronizer and your TaskCompletionSource is still "alive" (referenced by GC roots). One of these assumption is not right.

Now, forget about your TaskCompletionSource

replace the line

return tcs.Task;

by for example

return Task.Run(() => { while (true) { } });

then you won't enter the Destructor ever again.

The conclusion is that: If you want to make sure that a object won't be garbage collected, then you will have to explicitly make a strong reference to it. Don't assume that a object is "safe" because it is referenced by something not in your control.

Hiep
  • 2,483
  • 1
  • 25
  • 32