Async / wait, awaiter personnalisé et collecteur d'ordures

je suis face à une situation où un objet géré est prématurément finalisé au milieu de async méthode.

il s'agit d'un hobby home automation project (Windows 8.1, .NET 4.5.1), où je fournis Un C# callback à un tiers non géré DLL. Le rappel est invoqué lors d'un certain événement de détection.

Pour gérer l'événement, j'utilise async/await et une simple coutume awaiter (plutôt que TaskCompletionSource). Je le fais de cette façon, en partie pour réduire le nombre de inutiles les allocations, mais surtout par curiosité comme un exercice d'apprentissage.

ci-dessous est une version très dépouillée de ce que j'ai, en utilisant un timer-file d'attente Win32 pour simuler la source d'événement non gérée. Commençons par la sortie:

Press Enter to exit...
Awaiter()
tick: 0
tick: 1
~Awaiter()
tick: 2
tick: 3
tick: 4

notez comment mon serveur est finalisé après la seconde tique. C'est inattendu.

Le code (une application console):

using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    class Program
    {
        static async Task TestAsync()
        {
            var awaiter = new Awaiter();
            //var hold = GCHandle.Alloc(awaiter);

            WaitOrTimerCallbackProc callback = (a, b) =>
                awaiter.Continue();

            IntPtr timerHandle;
            if (!CreateTimerQueueTimer(out timerHandle, 
                    IntPtr.Zero, 
                    callback, 
                    IntPtr.Zero, 500, 500, 0))
                throw new System.ComponentModel.Win32Exception(
                    Marshal.GetLastWin32Error());

            var i = 0;
            while (true)
            {
                await awaiter;
                Console.WriteLine("tick: " + i++);
            }
        }

        static void Main(string[] args)
        {
            Console.WriteLine("Press Enter to exit...");
            var task = TestAsync();
            Thread.Sleep(1000);
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            Console.ReadLine();
        }

        // custom awaiter
        public class Awaiter : 
            System.Runtime.CompilerServices.INotifyCompletion
        {
            Action _continuation;

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

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

            // resume after await, called upon external event
            public void Continue()
            {
                var continuation = Interlocked.Exchange(ref _continuation, null);
                if (continuation != null)
                    continuation();
            }

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

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                Volatile.Write(ref _continuation, continuation);
            }
        }

        // 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);
    }
}

j'ai réussi à supprimer la collection de awaiter avec cette ligne:

var hold = GCHandle.Alloc(awaiter);

cependant je ne comprends pas parfaitement pourquoi je dois créer une référence forte comme celle-ci. awaiter est référencé à l'intérieur d'une boucle sans fin. AFAICT, il n'est pas hors de portée jusqu'à ce que la tâche retournée par TestAsync est terminé (annulé/défectueux). Et la tâche elle-même est référencé à l'intérieur de pour toujours.

eventuellement, j'ai Reduit TestAsync simplement ceci:

static async Task TestAsync()
{
    var awaiter = new Awaiter();
    //var hold = GCHandle.Alloc(awaiter);

    var i = 0;
    while (true)
    {
        await awaiter;
        Console.WriteLine("tick: " + i++);
    }
}

la collecte a toujours lieu. Je soupçonne que le tout l'objet machine d'état généré par le compilateur est collecté. quelqu'un peut-il expliquer pourquoi cela se produit?

Maintenant, avec la modification mineure, le awaiter ne se fait plus ramasser les ordures:

static async Task TestAsync()
{
    var awaiter = new Awaiter();
    //var hold = GCHandle.Alloc(awaiter);

    var i = 0;
    while (true)
    {
        //await awaiter;
        await Task.Delay(500);
        Console.WriteLine("tick: " + i++);
    }
}

mise à Jour,ce violon montre comment l' awaiter l'objet est collecté sans aucun code P / invoke. Je pense, la raison pourrait être que il n'y a pas de externe références awaiteren dehors de l'état initial de l'objet machine d'état généré. Je dois étudier le code généré par le compilateur.


mise à Jour voici le code généré par le compilateur (pour ce violon, VS2012). Apparemment, le Task retournée par stateMachine.t__builder.Task ne conserve pas de référence (ou plutôt une copie) à la machine d'état elle-même (stateMachine). Ai-je raté quelque chose?

    private static Task TestAsync()
    {
      Program.TestAsyncd__0 stateMachine;
      stateMachine.t__builder = AsyncTaskMethodBuilder.Create();
      stateMachine.1__state = -1;
      stateMachine.t__builder.Start<Program.TestAsyncd__0>(ref stateMachine);
      return stateMachine.t__builder.Task;
    }

    [CompilerGenerated]
    [StructLayout(LayoutKind.Auto)]
    private struct TestAsyncd__0 : IAsyncStateMachine
    {
      public int 1__state;
      public AsyncTaskMethodBuilder t__builder;
      public Program.Awaiter awaiter5__1;
      public int i5__2;
      private object u__awaiter3;
      private object t__stack;

      void IAsyncStateMachine.MoveNext()
      {
        try
        {
          bool flag = true;
          Program.Awaiter awaiter;
          switch (this.1__state)
          {
            case -3:
              goto label_7;
            case 0:
              awaiter = (Program.Awaiter) this.u__awaiter3;
              this.u__awaiter3 = (object) null;
              this.1__state = -1;
              break;
            default:
              this.awaiter5__1 = new Program.Awaiter();
              this.i5__2 = 0;
              goto label_5;
          }
label_4:
          awaiter.GetResult();
          Console.WriteLine("tick: " + (object) this.i5__2++);
label_5:
          awaiter = this.awaiter5__1.GetAwaiter();
          if (!awaiter.IsCompleted)
          {
            this.1__state = 0;
            this.u__awaiter3 = (object) awaiter;
            this.t__builder.AwaitOnCompleted<Program.Awaiter, Program.TestAsyncd__0>(ref awaiter, ref this);
            flag = false;
            return;
          }
          else
            goto label_4;
        }
        catch (Exception ex)
        {
          this.1__state = -2;
          this.t__builder.SetException(ex);
          return;
        }
label_7:
        this.1__state = -2;
        this.t__builder.SetResult();
      }

      [DebuggerHidden]
      void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
      {
        this.t__builder.SetStateMachine(param0);
      }
    }
23
demandé sur noseratio 2014-03-08 15:17:13

1 réponses

j'ai supprimé tous les éléments P/invoke et recréé une version simplifiée de la logique de la machine d'état générée par le compilateur. Il présente le même comportement:awaiter obtient garabage recueillies après la première invocation de l'état de la machine MoveNext méthode.

Microsoft a récemment fait un excellent travail sur la fourniture de L'interface web à leur . net sources de référence, qui a été très utile. Après avoir étudié la mise en œuvre de AsyncTaskMethodBuilder et, la plupart important, AsyncMethodBuilderCore.GetCompletionAction, je le crois maintenant le comportement du GC, je vais voir a le sens parfait. Je vais essayer d'expliquer que ci-dessous.

le code:

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

namespace ConsoleApplication
{
    public class Program
    {
        // Original version with async/await

        /*
        static async Task TestAsync()
        {
            Console.WriteLine("Enter TestAsync");
            var awaiter = new Awaiter();
            //var hold = GCHandle.Alloc(awaiter);

            var i = 0;
            while (true)
            {
                await awaiter;
                Console.WriteLine("tick: " + i++);
            }
            Console.WriteLine("Exit TestAsync");
        }
        */

        // Manually coded state machine version

        struct StateMachine: IAsyncStateMachine
        {
            public int _state;
            public Awaiter _awaiter;
            public AsyncTaskMethodBuilder _builder;

            public void MoveNext()
            {
                Console.WriteLine("StateMachine.MoveNext, state: " + this._state);
                switch (this._state)
                {
                    case -1:
                        {
                            this._awaiter = new Awaiter();
                            goto case 0;
                        };
                    case 0:
                        {
                            this._state = 0;
                            var awaiter = this._awaiter;
                            this._builder.AwaitOnCompleted(ref awaiter, ref this);
                            return;
                        };

                    default:
                        throw new InvalidOperationException();
                }
            }

            public void SetStateMachine(IAsyncStateMachine stateMachine)
            {
                Console.WriteLine("StateMachine.SetStateMachine, state: " + this._state);
                this._builder.SetStateMachine(stateMachine);
                // s_strongRef = stateMachine;
            }

            static object s_strongRef = null;
        }

        static Task TestAsync()
        {
            StateMachine stateMachine = new StateMachine();
            stateMachine._state = -1;

            stateMachine._builder = AsyncTaskMethodBuilder.Create();
            stateMachine._builder.Start(ref stateMachine);

            return stateMachine._builder.Task;
        }

        public static void Main(string[] args)
        {
            var task = TestAsync();
            Thread.Sleep(1000);
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            Console.WriteLine("Press Enter to exit...");
            Console.ReadLine();
        }

        // custom awaiter
        public class Awaiter :
            System.Runtime.CompilerServices.INotifyCompletion
        {
            Action _continuation;

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

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

            // resume after await, called upon external event
            public void Continue()
            {
                var continuation = Interlocked.Exchange(ref _continuation, null);
                if (continuation != null)
                    continuation();
            }

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

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                Console.WriteLine("Awaiter.OnCompleted");
                Volatile.Write(ref _continuation, continuation);
            }
        }
    }
}

la machine d'état générée par le compilateur est une structure mutable, étant dépassée par ref. Apparemment, c'est une optimisation pour éviter les affectations supplémentaires.

La partie centrale de ce qui est de prendre place à l'intérieur de AsyncMethodBuilderCore.GetCompletionAction, où la structure de la machine de l'état actuel est la référence à la copie boxée est conservée par le rappel de continuation passé à INotifyCompletion.OnCompleted.

C'est la seule référence à la machine d'état qui a une chance de supporter le GC et de survivre après await. Task objet renvoyé par TestAsync contenir une référence à elle, uniquement de la await poursuite de rappel. Je crois que c'est fait exprès, pour préserver le comportement efficace du GC.

noter le commentaire ligne:

// s_strongRef = stateMachine;

si je le dés-commente, la copie boxée de la machine d'état n'est pas GC'ED, et awaiter reste en vie comme une partie d'elle. Bien sûr, ce n'est pas une solution, mais cela illustre le problème.

donc, j'en suis arrivé à la conclusion suivante. Alors qu'une opération async est en "vol" et qu'aucun des états de la machine d'état (MoveNext) est actuellement en cours d'exécution, c'est la responsabilité de la "gardien" de la poursuite de rappel pour mettre un forte emprise sur le rappel lui-même, pour s'assurer que la copie en boite de la machine d'état ne reçoit pas les ordures.

par exemple, en cas de YieldAwaitable (retournée par Task.Yield), la référence externe au rappel de continuation est conservée par le ThreadPool planificateur de tâches, résultat de ThreadPool.QueueUserWorkItem appel. Dans le cas où Task.GetAwaiter, c'est indirectement référencés par l'objet tâche.

Dans mon cas, le "gardien" de la poursuite le rappel est le Awaiter lui-même.

ainsi, tant qu'il n'y a pas de référence externe à la continuation de l'objet callback que le CLR connaît (en dehors de l'état de l'objet machine), l'awaiter personnalisé devrait prendre des mesures pour garder l'objet callback en vie. Cela, à son tour, maintiendrait en vie toute la machine d'état. Les étapes suivantes seront nécessaires dans ce cas:

  1. appelez le GCHandle.Alloc sur le rappel sur INotifyCompletion.OnCompleted.
  2. Appel GCHandle.Free quand l'async l'événement s'est réellement produit, avant d'invoquer le rappel de continuation.
  3. implémenter IDispose appeler GCHandle.Free si l'événement n'est jamais arrivé.

étant donné que, ci-dessous est une version du code de rappel de minuterie original, qui fonctionne correctement. notez qu'il n'est pas nécessaire de mettre un hold-up important sur le délégué de callback timer (WaitOrTimerCallbackProc callback). Il est maintenu en vie comme une partie de la machine d'état.mise à Jour: comme le souligne @svick, ce déclaration peut être spécifique à la mise en place de la machine d'état (C# 5.0). J'ai ajouté GC.KeepAlive(callback) pour éliminer toute dépendance à ce comportement, au cas où il changerait dans les futures versions du compilateur.

using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    class Program
    {
        // Test task
        static async Task TestAsync(CancellationToken token)
        {
            using (var awaiter = new Awaiter())
            {
                WaitOrTimerCallbackProc callback = (a, b) =>
                    awaiter.Continue();
                try
                {
                    IntPtr timerHandle;
                    if (!CreateTimerQueueTimer(out timerHandle,
                            IntPtr.Zero,
                            callback,
                            IntPtr.Zero, 500, 500, 0))
                        throw new System.ComponentModel.Win32Exception(
                            Marshal.GetLastWin32Error());
                    try
                    {
                        var i = 0;
                        while (true)
                        {
                            token.ThrowIfCancellationRequested();
                            await awaiter;
                            Console.WriteLine("tick: " + i++);
                        }
                    }
                    finally
                    {
                        DeleteTimerQueueTimer(IntPtr.Zero, timerHandle, IntPtr.Zero);
                    }
                }
                finally
                {
                    // reference the callback at the end
                    // to avoid a chance for it to be GC'ed
                    GC.KeepAlive(callback);
                }
            }
        }

        // Entry point
        static void Main(string[] args)
        {
            // cancel in 3s
            var testTask = TestAsync(new CancellationTokenSource(10 * 1000).Token);

            Thread.Sleep(1000);
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);

            Thread.Sleep(2000);
            Console.WriteLine("Press Enter to GC...");
            Console.ReadLine();

            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            Console.WriteLine("Press Enter to exit...");
            Console.ReadLine();
        }

        // Custom awaiter
        public class Awaiter :
            System.Runtime.CompilerServices.INotifyCompletion,
            IDisposable
        {
            Action _continuation;
            GCHandle _hold = new GCHandle();

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

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

            void ReleaseHold()
            {
                if (_hold.IsAllocated)
                    _hold.Free();
            }

            // resume after await, called upon external event
            public void Continue()
            {
                Action continuation;

                // it's OK to use lock (this)
                // the C# compiler would never do this,
                // because it's slated to work with struct awaiters
                lock (this)
                {
                    continuation = _continuation;
                    _continuation = null;
                    ReleaseHold();
                }

                if (continuation != null)
                    continuation();
            }

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

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                lock (this)
                {
                    ReleaseHold();
                    _continuation = continuation;
                    _hold = GCHandle.Alloc(_continuation);
                }
            }

            // IDispose
            public void Dispose()
            {
                lock (this)
                {
                    _continuation = null;
                    ReleaseHold();
                }
            }
        }

        // 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);

        [DllImport("kernel32.dll")]
        static extern bool DeleteTimerQueueTimer(IntPtr TimerQueue, IntPtr Timer,
            IntPtr CompletionEvent);
    }
}
14
répondu noseratio 2014-03-19 08:12:27