Programmazione asincrona deadlock

in C#, Informatica, Programmazione

Programmazione asincrona codice misto

Reading Time: 3 minutes

Sviluppando codice asincrono si tende spesso ad implementare buona parte delle funzioni sfruttando le potenzialità fornite dall’utilizzo di async ed await. Un pò come se rendere i comportamenti asincroni fosse in qualche modo “contagioso”.  Se all’interno del codice vengono sviluppate fuzioni sincrone, lo sviluppo “misto” può generare problematiche nel flusso di esecuzione e nelle performance della nostra applicazione.

La migrazione parziale di metodi in modalità asincrona, lasciandone inalterate altre può generare deadlock

Prendendo come esempio il codice

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }

  // This method causes a deadlock when called in a GUI or ASP.NET context.
  public static void Test()
  {
    // Start the delay.
    var delayTask = DelayAsync();
    // Wait for the delay to complete.
    delayTask.Wait();
  }
}

la funzione statica Test() richiama la funzione asincrona DelayAsync() nella quale viene richiamato Task.Delay(1000), o meglio un blocco di 1000 msec. All’interno della funzione Task viene atteso il completamento della funzione DelayAsync() richiamando delayTask.Wait(). Nell’esecuzione all’interno di un’applicazione console non si verificheranno problemi particolari, ma eseguendola all’interno di una soluzione ASP.NET o GUI, verrà generato un deadlock.

Il motivo principale del deadlock è da ricercare dalla modalità con cui await gestisce il contesto in cui opera. Per default await utilizza il contesto corrente per aspettare che il Task che attende sia terminato. Semplificando potremmo pensare che il contesto corrente sia “catturato” e “utilizzato” per riprendere le operazioni al termine del Task.

Il contesto è il SynchronizationContext se non è null altrimenti è il TaskScheduler corrente. Le applicazioni Web o le applicazione GUI hanno un particolare SynchronizationContext che consente di eseguire soltanto una porzione di codice alla volta: quando l’attesa con await è terminata, tenta di eseguire la parte del metodo async rimanente del contesto “catturato”. Attenzione, però, perchè il contesto ha già al suo interno un thread che sta attendendo che il codice async sia terminato. Siamo quindi nella situazione tipica del deadlock: ciascun thread sta attendendo l’altro.

Nelle applicazioni console il contesto non è un singolo SynchronizationContext ma un pool di SynchronizationContext. Al termine dell’attesa con await la restante esecuzione viene schedulata utilizzando un thread del pool a disposizione. In questo modo il metodo può terminare la sua escuzione, senza generare alcun deadlock.

Il comportamento tra applicazione WEB/GUI e applicazione console è quindi differente e questo potrebbe falsare i risultati di eventuali test: nel nostro caso un test sviluppato come console application non produrrebbe lo stesso risultato di un test di tipo WEB/GUI.

Una possibile soluzione al problema è quella di rendere il codice il più possibile asincrono: da notare che dalla versione 7.1 di c# è possibile realizzare applicazioni console con async Main().

Gestione delle eccezioni

Ciascun Task memorizza al suo interno un elenco di eccezioni: quando ci troviamo in presenza di un await Task è possibile rilevare il tipo di eccezione che si è verificata, perchè viene rilanciata la prima eccezione che si è verificata. Possiamo quindi gestire le eccezioni in maniera tradizionale, ad esempio filtandole per tipo.

Quando però l’attesa del completamento di un Task viene effettuato tramite Task.Wait oppure Task.Result, le eccezioni generate sono memorizzate in forma aggregata all’interno di AggregateException. E’ sempre preferibile intercettare l’eccezione in forma semplice e non aggregata, perchè è più facile da gestire e da tipizzare. Nel codice seguente:

class Program
{
  static void Main()
  {
    MainAsync().Wait();
  }
  static async Task MainAsync()
  {
    try
    {
      // Asynchronous implementation.
      await Task.Delay(1000);
    }
    catch (Exception ex)
    {
      // Handle exceptions.
    }
  }
}

se il blocco try-catch fosse stato inserito all’interno del metodo Main sarebbe stato possibile intercettare solo eccezioni di tipo AggregateException. Gestendo invece l’eccezione all’interno di MainAsync possiamo gestire l’eccezione con il suo tipo originario.