El operador de espera no está esperando como esperaba

Estoy trabajando en una clase DelayedExecutor que demorará la ejecución de una Action pase a su método DelayExecute un cierto tiempo de timeout (consulte el código a continuación) usando las declaraciones async y espere. También quiero poder abortar la ejecución dentro del intervalo de timeout si es necesario. He escrito una pequeña prueba para probar su comportamiento de esta manera:

Código para el método de prueba :

  [TestMethod] public async Task DelayExecuteTest() { int timeout = 1000; var delayExecutor = new DelayedExecutor(timeout); Action func = new Action(() => Debug.WriteLine("Ran function!")); var sw = Stopwatch.StartNew(); Debug.WriteLine("sw.ElapsedMilliseconds 1: " + sw.ElapsedMilliseconds); Task delayTask = delayExecutor.DelayExecute(func); await delayTask; Debug.WriteLine("sw.ElapsedMilliseconds 2: " + sw.ElapsedMilliseconds); Thread.Sleep(1000); } } 

El resultado que esperaba de esta prueba era que me mostraría:

sw.ElapsedMilliseconds fuera de DelayExecute 1: …

Ran Action! ”

sw.ElapsedMilliseconds inside DelayExecute: …

sw.ElapsedMilliseconds fuera de DelayExecute 2:

Sin embargo entiendo esto y no entiendo por qué:

sw.ElapsedMilliseconds fuera de DelayExecute 1: 0

sw.ElapsedMilliseconds fuera de DelayExecute 2: 30

Ran Acción!

sw.ElapsedMilliseconds inside DelayExecute: 1015

En esta entrada del blog leí:

Me gusta pensar en “esperar” como una “espera asíncrona”. Es decir, el método asíncrono se detiene hasta que se complete lo esperado (por lo que espera), pero el subproceso real no se bloquea (por lo que es asíncrono).

Esto parece estar en línea con mis expectativas, entonces, ¿qué está pasando aquí y dónde está mi error?

Código para el ejecutor demorado :

 public class DelayedExecutor { private int timeout; private Task currentTask; private CancellationToken cancellationToken; private CancellationTokenSource tokenSource; public DelayedExecutor(int timeout) { this.timeout = timeout; tokenSource = new CancellationTokenSource(); } public void AbortCurrentTask() { if (currentTask != null) { if (!currentTask.IsCompleted) { tokenSource.Cancel(); } } } public Task DelayExecute(Action func) { AbortCurrentTask(); tokenSource = new CancellationTokenSource(); cancellationToken = tokenSource.Token; return currentTask = Task.Factory.StartNew(async () => { var sw = Stopwatch.StartNew(); await Task.Delay(timeout, cancellationToken); func(); Debug.WriteLine("sw.ElapsedMilliseconds inside DelayExecute: " + sw.ElapsedMilliseconds); }); } } 

Actualizar:

Como sugerí modifiqué esta línea dentro de mi DelayExecute

 return currentTask = Task.Factory.StartNew(async () => 

dentro

 return currentTask = await Task.Factory.StartNew(async () => 

Para que esto funcione necesito cambiar la firma en este

 public async Task DelayExecute(Action func) 

de modo que mi nueva definición es esta:

 public async Task DelayExecute(Action func) { AbortCurrentTask(); tokenSource = new CancellationTokenSource(); cancellationToken = tokenSource.Token; return currentTask = await Task.Factory.StartNew(async () => { var sw = Stopwatch.StartNew(); await Task.Delay(timeout, cancellationToken); func(); Debug.WriteLine("sw.ElapsedMilliseconds inside DelayExecute: " + sw.ElapsedMilliseconds); }); } 

Sin embargo ahora tengo el mismo comportamiento que antes. ¿Hay alguna manera de lograr lo que estoy tratando de hacer usando mi diseño? ¿O es fundamentalmente defectuoso?

Por cierto, también intenté poner a la await await delayTask; dentro de mi prueba DelayExecuteTest , pero esto me da el error

No puedo esperar ‘vacío’

Este es el método de prueba actualizado DelayExecuteTest , que no comstack:

 public async Task DelayExecuteTest() { int timeout = 1000; var delayExecutor = new DelayedExecutor(timeout); Action func = new Action(() => Debug.WriteLine("Ran Action!")); var sw = Stopwatch.StartNew(); Debug.WriteLine("sw.ElapsedMilliseconds outside DelayExecute 1: " + sw.ElapsedMilliseconds); Task delayTask = delayExecutor.DelayExecute(func); await await delayTask; Debug.WriteLine("sw.ElapsedMilliseconds outside DelayExecute 2: " + sw.ElapsedMilliseconds); Thread.Sleep(1000); } 

Hay un poco de aquí, así que voy a publicar una respuesta simple para empezar, veamos si es suficiente.

Envolviste una tarea dentro de una tarea, esto necesita una doble espera. De lo contrario, solo está esperando que la tarea interna scope su primer punto de retorno, que estará (puede) en la primera await .

Veamos su método DelayExecute con más detalle. Permítanme comenzar por cambiar el tipo de retorno del método y veremos cómo esto cambia nuestra perspectiva. El tipo de cambio de retorno es solo una aclaración. Está devolviendo una Task que no es genérica en este momento, en realidad está devolviendo una Task .

 public Task DelayExecute(Action func) { AbortCurrentTask(); tokenSource = new CancellationTokenSource(); cancellationToken = tokenSource.Token; return currentTask = Task.Factory.StartNew(async () => { var sw = Stopwatch.StartNew(); await Task.Delay(timeout, cancellationToken); func(); Debug.WriteLine("sw.ElapsedMilliseconds inside DelayExecute: " + sw.ElapsedMilliseconds); }); } 

(tenga en cuenta que esto no se comstackrá, ya que currentTask también es de tipo Task , pero es bastante irrelevante si lee el rest de la pregunta)

OK, entonces que pasa aquí?

Explicemos un ejemplo más simple primero, probé esto en LINQPad :

 async Task Main() { Console.WriteLine("before await startnew"); await Task.Factory.StartNew(async () => { Console.WriteLine("before await delay"); await Task.Delay(500); Console.WriteLine("after await delay"); }); Console.WriteLine("after await startnew"); } 

Al ejecutar esto obtengo esta salida:

 before await startnew before await delay after await startnew after await delay -- this comes roughly half a second after previous line 

Entonces, ¿por qué esto?

Bueno, tu StartNew devuelve una Task . Esta no es una tarea que ahora esperará a que se complete la tarea interna, espera a que la tarea interna regrese , lo que hará (puede) en su primera await .

Así que veamos la ruta de ejecución completa de esto numerando las líneas interesantes y luego explicando el orden en que suceden las cosas:

 async Task Main() { Console.WriteLine("before await startnew"); 1 await Task.Factory.StartNew(async () => 2 { Console.WriteLine("before await delay"); 3 await Task.Delay(500); 4 Console.WriteLine("after await delay"); 5 }); Console.WriteLine("after await startnew"); 6 } 

1. Se ejecuta la primera Console.WriteLine

nada mágico aquí

2. Iniciamos una nueva tarea con Task.Factory.StartNew y lo esperamos.

Aquí nuestro método principal puede regresar, devolverá una tarea que continuará una vez que la tarea interna haya finalizado.

El trabajo de la tarea interna no es ejecutar todo el código en ese delegado. El trabajo de la tarea interna es producir otra tarea más .

¡Esto es importante!

3. La tarea interna ahora comienza a ejecutarse.

Básicamente ejecutará todo el código en el delegado hasta e incluyendo la llamada a Task.Delay , que devuelve una Task .

4. La tarea interior llega a su primera await

Dado que esto no se habrá completado (solo se completará aproximadamente 500 ms más adelante), este delegado ahora vuelve .

Básicamente, la instrucción await creará una continuación y luego regresará.

¡Esto es importante!

6. Nuestra tarea externa ahora continúa

Desde que se devolvió la tarea interna, la tarea externa ahora puede continuar. El resultado de la llamada a la await Task.Factory.StartNew es otra tarea más, pero esta tarea solo se puede defender por sí misma .

5. La tarea interior, continúa aproximadamente 500 ms después.

Esto es ahora después de que la tarea externa ya haya continuado, y posiblemente haya terminado de ejecutarse.


Entonces, en conclusión, su código se ejecuta “como se esperaba”, pero no de la forma que esperaba.

Hay varias formas de corregir este código, simplemente reescribiré su método DelayExecute a la forma más sencilla de hacer lo que quiere hacer:

Cambia esta línea:

  return currentTask = Task.Factory.StartNew(async () => 

Dentro de esto:

  return currentTask = await Task.Factory.StartNew(async () => 

Esto permitirá que la tarea interna inicie su cronómetro y scope la primera espera antes de regresar completamente fuera de DelayExecute .

La solución similar a mi ejemplo de LINQPad anterior sería simplemente cambiar esta línea:

 await Task.Factory.StartNew(async () => 

A esto:

 await await Task.Factory.StartNew(async () => 

Después de pensarlo un poco más, me di cuenta de cómo lograr lo que buscaba, que es retrasar el inicio de una acción y poder abortarla en caso de que lo necesite. El crédito es para Lasse V. Karlsen por ayudarme a entender el problema.

La respuesta de Lasse es correcta para mi problema original, por eso lo acepté. Pero en caso de que alguien necesite lograr algo similar a lo que necesitaba, así es como lo resolví. Tuve que usar una tarea de continuación después de que la tarea comenzó con StartNew . También AbortCurrentTask el nombre del método AbortCurrentTask a AbortExecution() . La nueva versión de mi clase DelayedExecutor es esta:

 public class DelayedExecutor { private int timeout; private Task currentTask; private CancellationToken cancellationToken; private CancellationTokenSource tokenSource; public DelayedExecutor(int timeout) { this.timeout = timeout; tokenSource = new CancellationTokenSource(); } public void AbortExecution() { if (currentTask != null) { if (!currentTask.IsCompleted) { tokenSource.Cancel(); } } } public Task DelayExecute(Action func) { AbortExecution(); tokenSource = new CancellationTokenSource(); cancellationToken = tokenSource.Token; return currentTask = Task.Delay(timeout, cancellationToken).ContinueWith(t => { if(!t.IsCanceled) { var sw = Stopwatch.StartNew(); func(); Debug.WriteLine("sw.ElapsedMilliseconds inside DelayExecute: " + sw.ElapsedMilliseconds); } }); } } 

Esta clase ahora hace lo que esperaba de ella. Estas dos pruebas ahora dan el resultado esperado:

 [TestMethod] public async Task DelayExecuteTest() { int timeout = 1000; var delayExecutor = new DelayedExecutor(timeout); Action func = new Action(() => Debug.WriteLine("Ran Action!")); var sw = Stopwatch.StartNew(); Debug.WriteLine("sw.ElapsedMilliseconds outside DelayExecute 1: " + sw.ElapsedMilliseconds); Task delayTask = delayExecutor.DelayExecute(func); await delayTask; Debug.WriteLine("sw.ElapsedMilliseconds outside DelayExecute 2: " + sw.ElapsedMilliseconds); } 

Salida:

sw.ElapsedMilliseconds fuera de DelayExecute 1: 0

Ran Acción!

sw.ElapsedMilliseconds inside DelayExecute: 3

sw.ElapsedMilliseconds fuera de DelayExecute 2: 1020

y

 [TestMethod] public async Task AbortDelayedExecutionTest() { int timeout = 1000; var delayExecutor = new DelayedExecutor(timeout); Action func = new Action(() => Debug.WriteLine("Ran Action!")); var sw = Stopwatch.StartNew(); Debug.WriteLine("sw.ElapsedMilliseconds outside DelayExecute 1: " + sw.ElapsedMilliseconds); Task delayTask = delayExecutor.DelayExecute(func); Thread.Sleep(100); delayExecutor.AbortExecution(); await delayTask; Debug.WriteLine("sw.ElapsedMilliseconds outside DelayExecute 2: " + sw.ElapsedMilliseconds); } 

Salida:

sw.ElapsedMilliseconds fuera de DelayExecute 1: 0

sw.ElapsedMilliseconds fuera de DelayExecute 2: 122