c# - method - Async/await, aula personalizada y recolector de basura
operador await c# (1)
Estoy lidiando con una situación donde un objeto administrado se finaliza prematuramente en el medio del método async
.
Este es un proyecto hogareño de automatización del hogar (Windows 8.1, .NET 4.5.1), donde proporciono una devolución de llamada C # a una DLL de terceros no administrada. La devolución de llamada se invoca sobre un determinado evento del sensor.
Para manejar el evento, uso async/await
await y un simple awaiter personalizado (en lugar de TaskCompletionSource
). Lo hago de esta manera, en parte para reducir el número de asignaciones innecesarias, pero principalmente por curiosidad como ejercicio de aprendizaje.
A continuación se muestra una versión muy depurada de lo que tengo, utilizando un temporizador de cola de temporizador Win32 para simular el origen de evento no administrado. Comencemos con la salida:
Press Enter to exit... Awaiter() tick: 0 tick: 1 ~Awaiter() tick: 2 tick: 3 tick: 4
Observe cómo mi awaiter se finaliza después del segundo tic. 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);
}
}
awaiter
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. El awaiter
se hace referencia dentro de un bucle sin fin. AFAICT, no saldrá del alcance hasta que la tarea devuelta por TestAsync
se complete (cancelada / en fallo). Y la tarea en sí está referenciada dentro de Main
para siempre.
Eventualmente, reduje TestAsync
a solo 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 máquina de estado generado por el compilador se está recopilando. ¿Puede alguien explicar por qué sucede esto?
Ahora, con la siguiente modificación menor, el awaiter
ya no recibe 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 de awaiter
se recoge basura sin ningún código p / invoke. Creo que la razón podría ser que no hay referencias externas a awaiter
fuera del estado inicial del objeto de máquina de estado generado. Necesito estudiar el código generado por el compilador.
Actualizado , aquí está el código generado por el compilador (para este violín , VS2012). Aparentemente, la Task
devuelta por stateMachine.t__builder.Task
no mantiene una referencia (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<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);
}
}
Eliminé todo lo relacionado con p / invoke y volví a crear una versión simplificada de la lógica de máquina de estado generada por el compilador. Exhibe el mismo comportamiento: el awaiter
obtiene un garabage recolectado 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 fuentes de referencia .NET , que 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 del GC que estoy viendo tiene mucho sentido . Trataré de explicar eso 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 estados generada por el compilador es una estructura mutable, que pasa por ref
. Aparentemente, esta es una optimización para evitar asignaciones adicionales.
La parte principal de esto está teniendo lugar dentro de AsyncMethodBuilderCore.GetCompletionAction
, donde la estructura de la máquina de estado actual queda encuadrada, y la referencia a la copia INotifyCompletion.OnCompleted
se mantiene mediante la devolución de llamada de continuación pasada 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 await
. El objeto Task
devuelto por TestAsync
no contiene una referencia a él, solo lo hace la devolución de llamada de continuación de await
. Creo que esto se hace a propósito, para preservar el comportamiento eficiente del GC.
Tenga en cuenta la línea comentada:
// s_strongRef = stateMachine;
Si no lo comento, la copia encuadrada de la máquina de estado no recibe GC, y awaiter
mantiene 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 "guardián" de la devolución de llamada de continuación mantener la retrollamada, hacer asegúrese de que la copia en caja de la máquina de estado no se recoja basura.
Por ejemplo, en el caso de YieldAwaitable
(devuelto por Task.Yield
), el ThreadPool
tareas ThreadPool
mantiene la referencia externa a la devolución de llamada de continuación, como resultado de la llamada ThreadPool.QueueUserWorkItem
. En el caso de Task.GetAwaiter
, el objeto de tarea hace referencia indirectamente a él.
En mi caso, el "guardián" de la devolución de llamada de continuación es el propio Awaiter
.
Por lo tanto, siempre que no haya referencias externas a la devolución de llamada de continuación que CLR tenga conocimiento (fuera del objeto de máquina de estado), el awaiter personalizado debe tomar medidas para mantener vivo el objeto de devolución de llamada. Esto, a su vez, mantendría viva toda la máquina de estado. Los siguientes pasos serían necesarios en este caso:
- Llame al
GCHandle.Alloc
en la devolución de llamada enINotifyCompletion.OnCompleted
. - Llame a
GCHandle.Free
cuando el evento asincrónico haya sucedido realmente, antes de invocar la devolución de llamada de continuación. - Implementar
IDispose
llamar aGCHandle.Free
si el evento nunca sucedió.
Dado que, a continuación, hay una versión del código de devolución de llamada del temporizador original, que funciona correctamente. Tenga en cuenta que no es necesario poner un fuerte control sobre el delegado de devolución de llamada del temporizador ( WaitOrTimerCallbackProc callback
).Se mantiene vivo como parte de la máquina de estado. Actualizado : como lo señala @svick, esta declaración puede ser específica de 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 compilador.
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);
}
}