blexin

Sviluppo
Consulenza e
Formazione IT


Blog

Evolvi la tua azienda

Vediamo come le keyword async e await possono semplificare la programmazione multithreading in C# in casi reali

Async e await in applicazioni reali

Mercoledì 13 Novembre 2019

Quando utilizziamo un metodo che impiega tempo per essere eseguito, come per la lettura di un file di grandi dimensioni o il download di risorse pesanti dalla rete, in una applicazione sincrona l’intera applicazione smette di rispondere fino al completamento dell’attività. In questi casi è di grande aiuto la programmazione asincrona, che ci permette di parallelizzare l’esecuzione di diversi task e, se necessario, attenderne il completamento.

Esistono diversi tipi di modelli per questo tipo di approccio alla programmazione: l’APM (Asynchronous Programming Model), il modello asincrono basato su eventi (EAP) e il TAP, il modello asincrono basato su attività (Task). Vediamo come in C# possiamo implementare il terzo, utilizzando le parole chiave async e await.

Uno dei principali problemi della scrittura di codice asincrono è la manutenibilità, dato che questo tipo di programmazione tende a complicare non poco il codice. Fortunatamente, C# 5 ha introdotto un approccio semplificato, in cui il compilatore esegue il lavoro difficile, che prima veniva svolto dallo sviluppatore, e l'applicazione mantiene una struttura logica simile al codice sincrono.

Facciamo un esempio. Supponiamo di avere un progetto .NET Core in cui dobbiamo gestire tre entità: Area, Company e Resource.

public class Area
{
    public int Id { get; set; }

    [Required]
    [StringLength(255)]
    public string Name { get; set; }
}
 
public class Company
{
    public int Id { get; set; }

    [Required]
    [StringLength(255)]
    public string Name { get; set; }
}

public class Resource
{
    public int Id { get; set; }

    [Required]
    [StringLength(255)]
    public string Name { get; set; }
}

Supponiamo inoltre di dover salvare i valori di queste entità su un database, utilizzando Entity Framework Core. Il DbContext sarà qualcosa del genere:

public class AppDbContext : DbContext
{
    public DbSet<Area> Areas { get; set; }
    public DbSet<Company> Companies { get; set; }
    public DbSet<Resource> Resources { get; set; }
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)  {}
 
    override protected void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Area> ().HasData(
            new Area { Id = 1, Name = "Area1"},
            new Area { Id = 2, Name = "Area2"},
            new Area { Id = 3, Name = "Area3"},
            new Area { Id = 4, Name = "Area4"},
            new Area { Id = 5, Name = "Area5"});
        modelBuilder.Entity<Company> ().HasData(
            new Area { Id = 1, Name = "Company1"},
            new Area { Id = 2, Name = "Company2"},
            new Area { Id = 3, Name = "Company3"},
            new Area { Id = 4, Name = "Company4"},
            new Area { Id = 5, Name = "Company5"});
        modelBuilder.Entity<Resource>().HasData(
            new Area { Id = 1, Name = "Resource1"},
            new Area { Id = 2, Name = "Resource2"},
            new Area { Id = 3, Name = "Resource3"},
            new Area { Id = 4, Name = "Resource4"},
            new Area { Id = 5, Name = "Resource5"});
    }
}

Come potete vedere dal codice, abbiamo anche inserito qualche dato di esempio su cui lavorare. Supponiamo adesso di voler esporre con un Controller Api questi dati sia singolarmente per ogni entità, che con un metodo che li aggreghi tutti, restituendoli con un’unica chiamata.

Con un approccio sincrono, il Controller Api apparirebbe così:

[ApiController]
[Route("[controller]")]
public class DataController : ControllerBase
{
    private readonly AppDbContext db = null;
    
    public DataController(AppDbContext db)
    {
        this.db = db;
    }

    public IActionResult Get()
    {
        var areas = this.GetAreas();
        var companies = this.GetCompanies();
        var resources = this.GetResources();
        return Ok(new { areas = areas, companies = companies, resources = resources });
    }
 
    [Route("areas")]
    public Area[] GetAreas() 
    {
        return this.db.Areas.ToArray();
    }

    [Route("companies")]
    public Company[] GetCompanies() 
    {
        return this.db.Companies.ToArray();
    }
 
    [Route("resources")]
    public Resource[] GetResources() 
    {
        return this.db.Resources.ToArray();
    }
}

Il metodo Get() chiamerà al suo interno i tre metodi che restituiscono i singoli risultati, attendendo che sia completata l’esecuzione di ognuno prima di passare al successivo. I tre metodi non sono legati tra loro, non ho bisogno di attendere l’esecuzione di uno di loro per poterne invocare un altro. Posso quindi creare tre Task indipendenti per parallelizzare l’esecuzione.
Un primo approccio può essere basato sul metodo Run della classe Task, che accoda il lavoro specificato da eseguire nel pool di thread e restituisce un oggetto Task che rappresenta tale lavoro. In questo modo, i metodi vengono eseguiti contemporaneamente su differenti thread del pool:

public IActionResult Get()
{
    var areas = Task.Run(() = > this.GetAreas());
    var companies = Task.Run(() = > this.GetCompanies());
    var resources = Task.Run(() = > this.GetResources());        
    Task.WhenAll(areas, companies, resources);
    return Ok(new { areas = areas.Result, companies = companies.Result, resources = resources.Result });
}

La proprietà Result di Task conterrà il risultato dell’elaborazione. Il metodo WhenAll permette di sospendere l’esecuzione del thread corrente fino al completamento di tutti i Task.
Eseguendo il codice però, notiamo una cosa interessante: la chiamata si interrompe lanciando l’eccezione seguente:

"AggregateException: One or more errors occurred. (A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913 )".

Questo errore ci dice da una parte che i metodi vengono effettivamente eseguiti contemporaneamente su thread diversi, ma siccome utilizzano la stessa istanza del DbContext per la connessione al db, viene sollevata un’eccezione.
La classe DbContext non può assicurare un funzionamento threadsafe, problema che possiamo aggirare facilmente non facendoci creare più una singola istanza del DbContext dal motore di dependency injection di .NET Core, ma creando noi una istanza separata per ogni metodo. A titolo di esempio, vediamo come cambierebbe il metodo GetAreas():

public class DataController : ControllerBase
{
    private readonly DbContextOptionsBuilder <AppDbContext> optionsBuilder = null;
    
    public DataController(IConfiguration configuration)
    {
        this.optionsBuilder = new DbContextOptionsBuilder <AppDbContext> ()
            .UseSqlite(configuration.GetConnectionString("DefaultConnection"));
    }
 
    [Route("areas")]
    public Area[] GetAreas() 
    {
        using(var db = new AppDbContext(this.optionsBuilder.Options))
        {
            return db.Areas.ToArray();
        }
    }
}

Ok, ora funziona. In realtà però, Entity Framework Core fornisce dei metodi per fare chiamate asincrone con lo stesso DbContext, come ad esempio il metodo ToArrayAsync, che crea un array da un IQueryable<T> enumerandolo in modo asincrono. Questo metodo restituisce un Task<TSource[]>, un'attività che rappresenta l'operazione asincrona, in questo modo non abbiamo più bisogno di usare Task.Run():

public IActionResult Get()
{
    var areas = this.GetAreas();
    var companies = this.GetCompanies();
    var resources = this.GetResources();
    Task.WhenAll(areas, companies, resources);
    return Ok(new { areas = areas.Result, companies = companies.Result, resources = resources.Result });
}
 
[Route("areas")]
public Task<Area[]> GetAreas() 
{
    return db.Areas.ToArrayAsync();
}

Tuttavia, Microsoft non assicura che questi metodi asincroni funzionino in ogni circostanza, perché comunque il DbContext non è progettato per essere threadsafe. Per maggiori info potete consultare questo link: https://docs.microsoft.com/en-us/ef/core/querying/async

Quindi, la best practice quando lavorate con Entity Framework Core è di avere un DbContext per ogni operazione asincrona o attendere che ognuna di essa sia finita prima di lanciarne un’altra.
Tutto bene finché quello che dobbiamo fare è una chiamata asincrona e restituirne il risultato. Ma se volessimo invece fare delle operazioni con il risultato prima di restituirlo? Ad esempio, se volessimo aggiungere un elemento alla lista? Dobbiamo attendere i risultati, aggiungere l’elemento e restituire la lista modificata:

[Route("companies")]
public Task<Company[]> GetCompanies() 
{
    using (var db = new AppDbContext(this.optionsBuilder.Options))
    {
        var data = this.db.Companies.ToListAsync().Result;
        data.Insert(0, new Company() { Id = 0, Name = "-"});
        return data.ToArray();
    }
}

Peccato che questo codice non compili, perché il data.ToArray() restituisce un array, non un Task. Infatti, qui ci servono 3 thread: il chiamante principale (Get()), la query sul database (this.db.Companies.ToListAsync()) e un thread che aggiunge il valore alla lista. Come facciamo? Abbiamo tre modi, vediamoli tutti e tre con i nostri tre metodi singoli. Il primo, che abbiamo già visto, può utilizzare il metodo Task.Run():

[Route("companies")]
public Task<Company[]> GetCompanies()
{
     return Task.Run(() =>
     {
          using (var db = new AppDbContext(this.optionsBuilder.Options))
          {
               var data = db.Companies.ToList();
               data.Insert(0, new Company() { Id = 0, Name = "-" });
               return data.ToArray();
          }
     });
}

In alternativa possiamo utilizzare il metodo ContinueWith() che si applica ai Task e in cui possiamo specificare un nuovo Task da eseguire appena quello precedente è concluso:

[Route("resources")]
public Task <Resource[]> GetResources()
{
     using (var db = new AppDbContext(this.optionsBuilder.Options))
     {
          return db.Resources.ToListAsync()
              .ContinueWith(dataTask = >
              {
                   var data = dataTask.Result;
                   dataTask.Result.Insert(0, new Resource() { Id = 0, Name = "-" });
                   return data.ToArray();
              });
     }
}

Oppure, possiamo far fare il lavoro sporco al compilatore e sfruttare le keyword async e await, che creeranno per noi il Task:

[Route("areas")]
public async Task <Area[]> GetAreas()
{
     using (var db = new AppDbContext(this.optionsBuilder.Options))
     {
          var data = await db.Areas.ToListAsync();
          data.Insert(0, new Area() { Id = 0, Name = "-" });
          return data.ToArray();
     }
}

Come potete notare in questo ultimo metodo, il codice è molto più semplice e ci nasconde la creazione del Task, permettendoci di dare una return sincrona. Immaginatevi uno scenario in cui le chiamate sono più di una e come questo approccio renda tutto più lineare.

Come effetto collaterale del nostro refactoring, abbiamo adesso un metodo GetAreas() che è diventato una action asincrona, il che significa che quando arriveranno diverse richieste a questa API, il thread del pool allocato per la richiesta sarà rilasciato per dare disponibilità ad altre richieste finché il DbContext non avrà terminato il fetch dei dati.

Spero di avervi incuriosito abbastanza da approfondire l’argomento. L’utilizzo di async e await è molto comodo in tanti scenari e, oltre a rendere il codice più pulito e lineare, può portare ad un incremento delle performance generali dell’applicazione.

Trovate tutto il codice qui: https://github.com/fvastarella/Programmazione-asincrona-con-async-await

Alla prossima!

Autore

Servizi

Evolvi la tua azienda