Multi-threading: migliorare la performance delle applicazioni web

di Marco De Sanctis, in ASP.NET 2.0,

Nessuno può negare che sia in atto una vera e propria rivoluzione nell'ambito della progettazione dei microprocessori: fino ad oggi si è assistito ad un progressivo e costante aumento delle frequenze di clock, da cui il software traeva intrinsecamente vantaggio. I limiti fisici della tecnologia del silicio sono però prossimi, tanto che il nuovo trend di AMD e Intel è divenuto quello di realizzare CPU multi-core, che, per essere sfruttate a dovere, richiedono un design del software specifico basato sul multi-threading.

Realizzare software in grado di gestire thread multipli significa fare in modo che la nostra applicazione sia in grado di eseguire più task contemporaneamente. Come è facile immaginare, ciò comporta la necessità di adeguamenti sia da un punto di vista architetturale, sia da un punto di vista del codice vero e proprio. Per questa ragione Visual Studio 2008 (codename Orcas) offrirà un supporto specifico a questa tecnologia, mentre framework quali Parallel FX o PLINQ (ovvero Parallel-LINQ) sono destinati a far parlare di sè nel prossimo futuro.

Prima di addentrarci nel codice e scoprire gli strumenti messi a disposizione dal .NET Framework può essere utile fare una piccola introduzione sulle tecniche utilizzate da Windows per eseguire più operazioni in parallelo.

Processi e thread

Quando richiediamo l'esecuzione di un programma, il sistema operativo crea un'istanza di un particolare oggetto del kernel chiamato process (processo), a cui assegna un ben definito (e isolato) spazio di indirizzamento in memoria. Un processo di per sé non è in grado di eseguire alcun codice e svolge il compito di puro e semplice contenitore di quelle che potremmo definire come le entità funzionali elementari del sistema operativo: i thread. Ogni processo dispone di almeno un thread, chiamato thread principale (o primary thread), a cui è delegata l'esecuzione del metodo Main nel caso delle applicazioni Windows, al termine del quale il processo stesso termina, liberando lo spazio di memoria e le risorse ad esso assegnate.

Il normale ciclo di vita di un'applicazione consiste nell'esecuzione consecutiva di numerosi blocchi di codice richiamati dal thread principale, che a loro volta possono richiamare ulteriori blocchi di codice. Quando ciò avviene, il chiamante risulta bloccato fintanto che la routine invocata non giunge a termine.

Un approccio di questo tipo non è per forza di cose sempre percorribile: se Microsoft Word utilizzasse un solo thread, il controllo ortografico che agisce in background in realtà produrrebbe continue interruzioni nella digitazione del testo, così pure farebbe il salvataggio automatico del documento su cui si sta lavorando. Nel caso di un'applicazione ASP.NET i risultati sarebbero ancora più penalizzanti, dato che ogni richiesta da parte di un utente impegnerebbe il server per tutta la durata di elaborazione, fino alla generazione della risposta. Fortunatamente ogni thread ha la possibilità di assegnare ad un thread secondario l'esecuzione di una funzione. In questo caso, la chiamata a quest'ultima ritorna immediatamente e i due blocchi di codice sono effettivamente eseguiti in parallelo.

La generazione di un thread è un'operazione molto meno onerosa della creazione di un nuovo processo: non è necessario infatti allocare un nuovo spazio di indirizzamento (dato che tutti i thread appartenenti allo stesso processo condividono le medesime locazioni di memoria), ma è sufficiente assegnare privatamente ad ogni thread solo una limitata porzione per contenere lo stack della funzione invocata. Un benefico effetto collaterale di quanto detto è che la comunicazione tra i thread di uno stesso processo sono estremamente semplici e avvengono in modo diretto.

Ogni thread - come accade per i processi - ha un corrispondente oggetto kernel, grazie al quale il sistema operativo riesce a mantenere internamente un catalogo di tutti i thread attivi; un processo dedicato, chiamato scheduler, assegna di volta in volta alle CPU disponibili il compito di eseguirne il codice per un certo lasso di tempo. Il risultato è che, in uno scenario mono-processore, si ha quantomeno l'illusione che più operazioni siano eseguite contemporaneamente, illusione che diviene realtà quando sono presenti più CPU (o più core); in questo caso il lavoro svolto dallo scheduler è più complesso, dato che ha anche il compito di ripartire equamente il carico tra i vari processori con l'obiettivo di massimizzarne il rendimento.

Multithreading nel .NET Framework

Cerchiamo di capire qual'è la reale utilità che un approccio basato sul multi-threading può rappresentare per le nostre applicazioni. Per farlo non c'è nulla di meglio di un esempio semplice, che possa peraltro essere ricondotto ad un caso reale. Supponiamo allora di aver realizzato un'applicazione web che si occupi di rilevare informazioni sui prezzi di vendita dei libri nei vari shop online. Il compito sostanzialmente è quello di interrogare una serie di web service dato l'ISBN del libro inserito dall'utente, popolando una lista di risultati; la logica applicativa è sintetizzata in un oggetto chiamato ShopsExplorer:

public class ShopsExplorer : IShopsExplorer
{
  private string isbn;
  private List<PriceResult> results = new List<PriceResult>();

  public string ISBN
  {
    get { return isbn; }
  }

  public List<PriceResult> Results
  {
    get { return results; }
  }

  public ShopsExplorer(string isbn)
  {
    this.isbn = isbn;
  }

  public void GetPrices()
  {
    startTime = DateTime.Now;
    try
    {
      foreach (IBookstoreProvider provider in getAvailableProviders())
      {
        results.Add(provider.GetPrice(this.isbn));
      }
    }
    finally
    {
      endTime = DateTime.Now;
      isCompleted = true;
    }
  }
}

Il codice di questa classe è decisamente semplice e merita pochi commenti: ogni sito e-commerce monitorato è gestito da un opportuno provider che ne incapsula la logica d'interrogazione, dato che questa potrebbe variare da servizio a servizio. ShopsExplorer non fa altro che ciclare all'interno di una lista di servizi (magari ricavata dalla configurazione) e popolare l'insieme dei risultati da restituire. I provider realizzati per questo esempio sono fittizi, si limitano ad attendere una manciata di millisecondi per simulare la latenza della rete e poi restituiscono un prezzo predeterminato:

public class AmazingBookstoreProvider: IBookstoreProvider
{
  public PriceResult GetPrice(string isbn)
  {
    // attendo 2 secondi
    Thread.Sleep(2000);
    return new PriceResult("Amazing", 50.00M);
  }
}

Il codice all'interno della pagina è ancora più elementare; istanzia un oggetto ShopsExplorer e ne invoca il metodo GetPrices, fornendo poi un feedback sul tempo impiegato e sui risultati ottenuti:

protected void btnGetPrices_Click(object sender, EventArgs e)
{
  ShopsExplorer explorer = new ShopsExplorer(this.txtISBN.Text);
  explorer.GetPrices();

  lblTime.Text = string.Format("Inizio: {0:T} - Fine: {1:T} - Totale: {2}", explorer.StartTime, explorer.EndTime, explorer.EndTime.Subtract(explorer.StartTime));

  gridResults.DataSource = explorer.Results;
  gridResults.DataBind();
}

Tutto sembra funzionare egregiamente, ma, già ad una prima esecuzione, ci si rende conto che c'è qualcosa di migliorabile: il processo di invocazione dei servizi remoti ha una durata piuttosto lunga, durante la quale il flusso di elaborazione della pagina è bloccato e non può proseguire nella generazione della risposta da inviare all'utente. Il risultato è che il browser resta in attesa, come se il server non rispondesse e probabilmente l'utente continuerà a premere diverse volte il pulsante in pagina (generando ulteriori richieste) prima di perdere definitivamente la pazienza ed abbandonare il nostro sito.

4 pagine in totale: 1 2 3 4

Attenzione: Questo articolo contiene un allegato.

Contenuti dell'articolo

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Approfondimenti