Estado de tarea fallido frente a cancelado después de CancelToken.ThrowIfCancellationRequested

Por lo general, no publico una pregunta con la respuesta, pero esta vez me gustaría llamar la atención sobre lo que creo que puede ser un problema oscuro pero común. Fue provocada por esta pregunta , desde entonces revisé mi propio código antiguo y descubrí que parte de él también se vio afectado por esto.

El siguiente código comienza y espera dos tareas, task1 y task2 , que son casi idénticas. task1 solo es diferente de task2 en que ejecuta un ciclo sin fin. En mi opinión, ambos casos son bastante típicos de algunos escenarios de la vida real que realizan trabajos relacionados con la CPU.

 using System; using System.Threading; using System.Threading.Tasks; namespace ConsoleApplication { public class Program { static async Task TestAsync() { var ct = new CancellationTokenSource(millisecondsDelay: 1000); var token = ct.Token; // start task1 var task1 = Task.Run(() => { for (var i = 0; ; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } }); // start task2 var task2 = Task.Run(() => { for (var i = 0; i < 1000; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } }); // await task1 try { await task1; } catch (Exception ex) { Console.WriteLine(new { task = "task1", ex.Message, task1.Status }); } // await task2 try { await task2; } catch (Exception ex) { Console.WriteLine(new { task = "task2", ex.Message, task2.Status }); } } public static void Main(string[] args) { TestAsync().Wait(); Console.WriteLine("Enter to exit..."); Console.ReadLine(); } } } 

El violín está aquí . La salida:

 {task = task1, Message = La operación fue cancelada., Status = Canceled}
 {task = task2, Message = La operación fue cancelada., Status = Faulted}

¿Por qué el estado de task1 se task1 , pero el estado de task2 tiene un Faulted ? Tenga en cuenta que, en ambos casos, no paso el token como el segundo parámetro a Task.Run .

Hay dos problemas aquí. Primero, siempre es una buena idea pasar CancellationToken a la API Task.Run , además de ponerla a disposición de la lambda de la tarea. Al hacerlo, se asocia el token con la tarea y es vital para la correcta propagación de la cancelación activada por el token.ThrowIfCancellationRequested .

Sin embargo, esto no explica por qué el estado de cancelación para la task1 aún se propaga correctamente ( task1.Status == TaskStatus.Canceled ), mientras que no lo hace para la task2 ( task2.Status == TaskStatus.Faulted ).

Ahora, este podría ser uno de esos casos muy raros en los que la inteligente lógica de inferencia de tipo C # puede jugar contra la voluntad del desarrollador. Se discute en grandes detalles aquí y aquí . Para resumir, en el caso de task1 , el siguiente comstackdor deduce la siguiente anulación de Task.Run :

 public static Task Run(Func function) 

más bien que:

 public static Task Run(Action action) 

Esto se debe a que task1 lambda no tiene una ruta de código natural fuera del bucle for , por lo que también puede ser un Func lambda, a pesar de que no es async y no devuelve nada . Esta es la opción que el comstackdor favorece más que la Action . Entonces, el uso de dicha anulación de Task.Run es equivalente a esto:

 var task1 = Task.Factory.StartNew(new Func(() => { for (var i = 0; ; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } })).Unwrap(); 

Task.Factory.StartNew devuelve una tarea anidada de tipo Task , que se Task.Factory.StartNew en Task por Unwrap() . Task.Run es lo suficientemente inteligente como para hacer ese desenvolvimiento automáticamente cuando acepta la función Func . La tarea de estilo de promesa no envuelta propaga correctamente el estado de cancelación de su tarea interna , lanzada como una excepción OperationCanceledException por la función Func lambda. Esto no sucede con task2 , que acepta un Action lambda y no crea ninguna tarea interna. La cancelación no se propaga para task2 , porque el token no se ha asociado con task2 través de Task.Run .

Al final, este puede ser un comportamiento deseado para la task1 (ciertamente no para la task2 ), pero no queremos crear tareas anidadas detrás de la escena en ninguno de los dos casos. Además, este comportamiento para la task1 puede task1 fácilmente al introducir una break condicional del bucle for .

El código correcto para task1 debería ser este :

 var task1 = Task.Run(new Action(() => { for (var i = 0; ; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } }), token);