Async / await, personalizador y recolector de basura.

Estoy tratando con una situación en la que un objeto administrado se finaliza prematuramente en medio del método async .

Este es un proyecto de automatización doméstica de hobby (Windows 8.1, .NET 4.5.1), donde proporciono una callback de C # a una DLL de terceros no administrada. La callback se invoca en un determinado evento de sensor.

Para manejar el evento, uso async/await await y un simple observador personalizado (en lugar de TaskCompletionSource ). Lo hago de esta manera, en parte para reducir el número de asignaciones innecesarias, pero sobre todo por curiosidad como un ejercicio de aprendizaje.

A continuación se muestra una versión muy reducida de lo que tengo, utilizando un temporizador de cola de temporizador Win32 para simular el origen de eventos no administrados. Vamos a empezar con la salida:

 Presione Enter para salir ...
 Un mesero()
 marca: 0
 garrapata: 1
 ~ Awaiter ()
 marca: 2
 marca: 3
 garrapata: 4

Tenga en cuenta cómo mi aguardiente se finaliza después de la segunda marca. Esto es inesperado.

El código (una aplicación de consola):

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

Me las arreglé para suprimir la colección de awaiter con esta línea:

 var hold = GCHandle.Alloc(awaiter); 

Sin embargo, no entiendo completamente por qué tengo que crear una referencia fuerte como esta. Se awaiter referencia al awaiter dentro de un bucle sin fin. AFAICT, no está fuera del scope hasta que la tarea devuelta por TestAsync se complete (cancele / falla). Y la tarea en sí se hace referencia dentro de Main para siempre.

Finalmente, reduje TestAsync a esto:

 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 colección todavía tiene lugar. Sospecho que todo el objeto de la máquina de estado generado por el comstackdor se está recolectando. ¿Puede alguien explicar por qué esto está sucediendo?

Ahora, con la siguiente modificación menor, el awaiter ya no obtiene la recolección de basura:

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

Actualizado , este violín muestra cómo el objeto del awaiter se recolecta en la basura sin ningún código p / invocar. Creo que la razón podría ser que no hay referencias externas a un awaiter fuera del estado inicial del objeto de máquina de estado generado. Necesito estudiar el código generado por el comstackdor.


Actualizado , aquí está el código generado por el comstackdor (para este violín , VS2012). Aparentemente, la Task devuelta por stateMachine.t__builder.Task no guarda una referencia a (o más bien, una copia de) la propia máquina de estado ( stateMachine ). ¿Me estoy perdiendo de algo?

  private static Task TestAsync() { Program.TestAsyncd__0 stateMachine; stateMachine.t__builder = AsyncTaskMethodBuilder.Create(); stateMachine.1__state = -1; stateMachine.t__builder.Start(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(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); } } 

He eliminado todas las cosas de p / invoke y he recreado una versión simplificada de la lógica de la máquina de estado generada por el comstackdor. Presenta el mismo comportamiento: el awaiter obtiene el garabage-cobrado después de la primera invocación del método MoveNext la máquina de estado.

Microsoft ha realizado recientemente un excelente trabajo al proporcionar la interfaz de usuario web a sus fonts de referencia de .NET , eso ha sido muy útil. Después de estudiar la implementación de AsyncTaskMethodBuilder y, lo más importante, AsyncMethodBuilderCore.GetCompletionAction , ahora creo que el comportamiento de GC que estoy viendo tiene mucho sentido . Intentaré explicarlo a continuación.

El código:

 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 máquina de estado generada por el comstackdor es una estructura mutable, que se pasa por alto por ref . Al parecer, esta es una optimización para evitar asignaciones extra.

La parte central de esto se lleva a cabo dentro de AsyncMethodBuilderCore.GetCompletionAction , donde la estructura de la máquina de estado actual se encuentra AsyncMethodBuilderCore.GetCompletionAction , y la referencia a la copia en el recuadro se mantiene mediante la callback de continuación que se pasa a INotifyCompletion.OnCompleted .

Esta es la única referencia a la máquina de estado que tiene la oportunidad de soportar el GC y sobrevivir después de la await . El objeto de Task devuelto por TestAsync no contiene una referencia a él, solo lo hace la callback de continuación de await . Creo que esto se hace a propósito, para preservar el comportamiento eficiente de GC.

Note la línea comentada:

 // s_strongRef = stateMachine; 

Si lo anulo, la copia en caja de la máquina de estado no recibe GC’ed, y el awaiter permanece vivo como parte de ella. Por supuesto, esto no es una solución, pero ilustra el problema.

Entonces, he llegado a la siguiente conclusión. Mientras que una operación asíncrona está en “vuelo” y ninguno de los estados de la máquina de estado ( MoveNext ) se está ejecutando actualmente, es responsabilidad del “encargado” de la devolución de llamada de continuación para poner una fuerte retención en la callback en sí, para hacer Asegúrese de que la copia en caja de la máquina de estado no se recoja.

Por ejemplo, en el caso de YieldAwaitable (devuelto por Task.Yield ), el progtwigdor de tareas ThreadPool conserva la referencia externa a la callback de continuación, como resultado de la llamada de ThreadPool.QueueUserWorkItem . En el caso de Task.GetAwaiter , el objeto de tarea hace una referencia indirecta .

En mi caso, el “guardián” de la callback de continuación es el propio Awaiter .

Por lo tanto, siempre que no haya referencias externas a la callback de continuación que CLR conoce (fuera del objeto de máquina de estado), el observador personalizado debe tomar medidas para mantener vivo el objeto de callback. Esto, a su vez, mantendría vivo a toda la máquina de estado. Los siguientes pasos serían necesarios en este caso:

  1. Llame al GCHandle.Alloc en la callback en INotifyCompletion.OnCompleted .
  2. Llame a GCHandle.Free cuando el evento asíncrono haya ocurrido realmente, antes de invocar la callback de continuación.
  3. Implemente IDispose para llamar a GCHandle.Free si el evento nunca ha ocurrido.

Dado que, a continuación se muestra una versión del código de callback del temporizador original, que funciona correctamente. Tenga en cuenta que no es necesario poner una retención fuerte en el delegado de callback del temporizador ( WaitOrTimerCallbackProc callback ). Se mantiene vivo como parte de la máquina de estado. Actualizado : según lo señalado por @svick, esta statement puede ser específica a la implementación actual de la máquina de estado (C # 5.0). He agregado GC.KeepAlive(callback) para eliminar cualquier dependencia de este comportamiento, en caso de que cambie en las futuras versiones del comstackdor.

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