Articoli DevLeap

Articoli DevLeap

ASP.NET 2.0 Async Techniques

Articolo pubblicato su Computer Programming nel 2006.

Questo articolo, diviso in due parti unite in questo testo, introduce le problematiche di programmazione asincrona (e di conseguenza multithread) in ASP.NET, ripercorrendo passo per passo le modalità disponibili in ASP.NET 1.x per arrivare alle novità della versione 2.0. Il flusso, anche se con meno dettagli per questioni di spazio, ripercorre gli argomenti dei corsi www.devleap.it su ASP.NET 1.x e 2.0 Core. Per ogni demo, l'articolo contiene il codice relativo che potete copiare direttamente; all'indirizzo www.thinkmobile.it:9000/Async ho pubblicato tutti gli esempi funzionanti live in modo che possiate vedere e verificare il tutto su un server Windows 2003 già configurato: a sinistra trovate un menù che porta alle varie pagine riportate in questo articolo.

 

Quando si parla di programmazione asincrona facciamo riferimento all'esecuzione di operazioni su thread differenti, parallelizzando diverse richieste a risorse remote in modo da annullare il più possibile i tempi di attesa su ciascuna di esse: iniziare un articolo con un frase criptica solitamente incuriosisce il lettore :-)

Proviamo a spiegarci con alcuni esempi partendo da un client Windows (o mobile) tradizionale per poi spostarci su ASP.NET. Il mio obiettivo è chiarire e semplificare, il più possibile, concetti che esistono da sempre nello sviluppo di applicazioni e calarli piano piano nella realtà web che, come sempre, differisce dallo sviluppo di applicazioni tradizionali. Forniamo esempi molto semplici e facilmente riproducibili. In questo primo articolo ci dilungheremo un po' anche su IIS, Thread Pool e Asynchronous Thread Queue (ATQ) per inquadrare bene la problematica e dare al lettore elementi concreti di valutazione, piuttosto che segnalare solamente le carattestistiche di ASP.NET senza inquadrarle nello scenario complessivo.

 

Supponiamo di avere un client Windows che deve effettuare due chiamate a web service remoti per ottenere alcune informazioni. Utilizzeremo per praticità un solo web service il cui codice .NET 2.0 è il seguente.

 

---- SalesmanManagerWS.asmx ---

<%@ WebService Language="C#" CodeBehind="~/App_Code/SalesmanManagerWS.cs" Class="SalesmanManagerWS" %>

 

--- SalesmanManagerWS.asmx.cs ---

using System;

using System.Web;

using System.Web.Services;

using System.Web.Services.Protocols;

using System.Threading;

 

[WebService(Namespace = "http://tempuri.org/")]

[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]

public class SalesmanManagerWS : System.Web.Services.WebService

{

    public SalesmanManagerWS () {

 

        //Uncomment the following line if using designed components

        //InitializeComponent();

    }

 

    [WebMethod]

    public bool SalvaAgente(string idAgente, string descrizione)

    {

        Thread.Sleep(2000);

        try

        {

            SalesmanManager sm = new SalesmanManager();

            sm.AggiornaAgente(idAgente, descrizione);

            return true;

        }

        catch

        {

            return false;

        }

    }

   

}

 

Come si nota, il web service chiama una classe del Business Layer per salvare i dati di un agente nel DB. Per effettuare prove in locale simuliamo un lavoro di due secondi con una Thread.Sleep(2000). In pratica, visto che lavorando in locale si azzerano i tempi di connessione, imponiamo due secondi come tempo di esecuzione del metodo remoto. Nel ricreare l'esempio potete togliere la chiamata al mio Business Layer e adattare il Thread.Sleep ad un valore vicino al vostro scenario.

 

Costruendo il nostro client .NET, per semplicità, assegneremo al click su un bottone, la chiamata sincrona al metodo SalvaAgente:

 

SalesmanManagerWS.SalesmanManagerWS ws = new SalesmanManagerWS.SalesmanManagerWS();

bool ris = ws.SalvaAgente("robertob", "Roberto Brunetti");

 

Sicuramente la risposta non arriverà prima di due secondi visto che il web service internamente spende due secondi a vuoto. Durante questo tempo il thread che gestisce l'interfaccia utente (la Windows Form) resta bloccato in attesa di una risposta: non è possibile interagire con altri elementi del form e tantomeno con il form stesso (ad esempio minimizzare la finestra oppure spostarla).

 

Eseguire una chiama asincrona risolve il problema del blocco del thread principale consentendo all'utente di interagire con il form durante la chiamata. Dalla versione 1.0, il framework .NET mette a disposizione, sulle chiamate ai web service, un pattern che utilizza BeginMetodo e EndMetodo per iniziare una chiamata asincrona senza doversi preoccupare di creare e gestire thread da codice. In pratica la chiamata a BeginSalvaAgente crea, dietro le quinte, un secondo thread per l'esecuzione della richiesta remota. A tale metodo può essere passato un evento che verrà richiamato in automatico al termina della richiesta remota (Callback). Da questo evento è possibile ottenere il risultato della chiamata tramite il metodo EndSalvaAgente. In pratica nel metodo button_click si esegue la chiamata asincrona:

 

SalesmanManagerWS.SalesmanManagerWS ws = new SalesmanManagerWS.SalesmanManagerWS();

IAsyncResult ar = ws.BeginSalvaAgente("robertob", "Roberto Brunetti", AgenteSalvato, ws);

 

definendo poi un event handler per recuperare il risultato al termine della chiamata:

 

private void AgenteSalvato(IAsyncResult ar)

    {

        SalesmanManagerWS.SalesmanManagerWS ws = (SalesmanManagerWS.SalesmanManagerWS)ar.AsyncState;

        bool ris = ws.EndSalvaAgente(ar);

    }

 

La chiamata impiega sempre un tempo non inferiore ai due secondi, ma il thread principale non viene bloccato durante questa attesa. Non mi dilungo su questi metodi visto che sono presenti nel .NET Framework (e .NET Compact Framework) sino dal febbraio 2002, mese di uscita ufficiale del framework. Abbiamo quindi risolto, senza impazzire con il codice, il primo problema.

 

Complichiamo, si fa per dire, l'esempio richiamando due web service (nel nostro caso dimostrativo chiameremo sempre lo stesso metodo). Eseguendo due chiamate sincrone è ovvio che il tempo di risposta totale non possa essere inferiore a 4 secondi. Il thread resta bloccato per almeno due secondi per effettuare la prima chiamata e poi per almeno altri due secondi per aspettare la seconda risposta. In questo caso una chiamata (anzi due) asincrona al web service, non solo fa sì che il thread non resti bloccato, ma consente di parallelizzare le due richieste ottenendo un tempo di risposta notevolmente inferiore. Test sulla mia macchina evidenziano che la chiamata sincrona a un web service (a regime) impiega circa 2,016 secondi, la chiamata sincrona a due web service impiegano circa 4,016 secondi. Effettuando chiamate asicrone si ottiene un tempo di riposta di 2,016 per una sola richiesta e di 2,031 secondi per entrambe le richieste.

 

Facciamo il punto per poi spostarci nel mondo ASP.NET: nel caso di una sola richiesta, con una chiamata asincrona non possiamo migliorare il tempo di risposta in quanto il nostro web service impiega comunque almeno 2 secondi per essere eseguito; con una chiamata asincrona miglioriamo solamente l'interattività dell'utente con il form in quanto la chiamata viene spostata su un thread diverso da quello che gestisce la user interface (e non è poco). Nel caso di due chiamate (o più di due ovviamente) con le chiamate asincrone riusciamo a parallelizzare le richieste da più thread concorrenti (senza dover creare e gestire a mano i thread) abbassando il tempo di risposta totale (sempre che la linea lo permetta). Ad esempio se dobbiamo effettuare 3 richieste che normalmente impiegano rispettivamente 3 secondi, 4 secondi e 6 secondi, nel caso di chiamate sincrone il miglior risultato che possiamo ottenere è superiore ai 13 secondi, con chiamate asincrone possiamo arrivare fino a 6 secondi. Con chiamate sincrone avremo un solo thread in esecuzione, nel caso di chiamate asincrone avremo fino a 3 thread in contemporanea.

 

E' bene sottolineare da subito che l'utlizzo di più thread, di per se, peggiora i tempi di esecuzione in quanto carichiamo il sistema con la gestione di più thread che implica operazioni di thread switch; abbiamo però dimostrato come l'utlizzo di queste tecniche sia particolarmente efficace durante l'utilizzo di risorse remote in quanto si possono parallelizzare le richieste e diminuire i tempi morti di attesa nelle risposte.

 

Il pattern BeginMetodo/EndMetodo è disponibile nel .NET Framework 1.x per le chiamate ai web service (la classe proxy autogenerata da Visual Studio o con WSDL.exe contiene questa coppia di metodi per ogni metodo esposto dal web service), per i metodi esposti da molte classi del namespace System.IO e del namespace System.Net e nella versione 2.0 anche per accedere a SQL Server tramite i metodi della SqlClient. In pratica ovunque si debba lavorare con risorse remote come il file system, le risorse in rete e perchè no anche SQL Server.

 

Il .NET Framework 2.0 espone anche un nuovo e semplificato pattern per le chiamate asincrone, alternativo a Begin/End, che non richiede la definizione di IAsyncResult/AyncCallback. In pratica la chiamata si riassume nel codice seguente:

 

SalesmanManagerWS.SalesmanManagerWS ws = new SalesmanManagerWS.SalesmanManagerWS();

 

// Nuova riga

ws.SalvaAgenteCompleted += this.OnSalvaAgenteCompleted;

ws.SalvaAgenteAsync("Robertob", "Roberto Brunetti");

 

// Non occre definire il delegate (fa lui)

//IAsyncResult ar = ws.BeginSalvaAgente("robertob", "Roberto Brunetti", AgenteSalvato, ws);

 

e l'evento di callback diventa:

 

private void OnSalvaAgenteCompleted(object sender, SalesmanManagerWS.SalvaAgenteCompletedEventArgs e)

{

    // N.B. Typed Result (SalvaAgenteCompletedEventArgs)

    // Via ar.AsyncState

    // SalesmanManagerWS.SalesmanManagerWS ws = (SalesmanManagerWS.SalesmanManagerWS)ar.AsyncState;

    // bool ris = ws.EndSalvaAgente(ar);

      bool ris = e.Result;

}

 

Da notare come il risultato sia tipizzato, non occorrano cast rispetto alla classe proxy e quanto il codice sia più semplice da leggere rispetto all'esempio iniziale (le righe commentate rappresentano il codice Begin/End); sfortunatamente questo nuovo pattern non è disponibile per tutte le classi che implementano metodi asincroni: ha quindi senso capire e utilizzare anche la sintassi Begin/End in quanto per molte classi (fra cui i vari oggetti SqlClient) occorre ancora lavorare con questo pattern. Creare thread secondari consente anche di eseguire in asincrono operazioni di inizializzazione e start-up di form e applicazioni senza "rubare" tempo prezioso al thread principale durante le attese di risorse remote.

 

Inquadrate problematiche e soluzioni per un client Windows Form passiamo a ASP.NET che presenta una serie di problematiche diverse: non siamo più lato-client con una interfaccia "mono-user", ma operiamo server-side con diverse richieste in contemporanea. Ad ogni richiesta che arriva via Http viene assegnato un thread che recupera la risorsa da disco e la invia come risposta: questo compito viene assolto da Internet Information Service su macchine Windows. Nel caso in cui la richiesta sia per una risorsa gestita da ASP.NET, IIS tramite la ISAPI Application (o Extension che dir si voglia) ASPNET_ISAPI.DLL, ridirige la richiesta verso  il processo ASPNET_WP in IIS 5 o W3WP in IIS 6. Quando il motore di ASP.NET ha completato il suo lavoro, IIS invia la risposta al client che ha richiesto la risorsa.

 

IIS Thread Pool e Asynchronous Thread Queue

Immaginate cosa succederebbe se per ogni richiesta venisse creato un thread diverso: se sul sito arrivano 1.000 richieste concorrenti avremmo 1.000 thread concorrenti nel processo di Internet Information Service, "tutti il lotta fra di loro sullo stesso sistema" per recuperare le risorse e inviarle al client: come abbiamo accennato nella prima parte dell'articolo avere tanti thread significare caricare il processore per gestire il time slice di ogni thread, l'assegnazione delle risorse della macchina al thread in esecuzione e il thread switch per passare da un thread all'altro. Senza dilungarci, in pratica, il costo di gestione dei thread influirebbe in modo pesante sul tempo di esecuzione delle singole richieste. Dall'altra parte non sarebbe possibile processare le richieste con un solo thread in quanto il centisimo utente aspetterebbe il completamento delle 99 richieste precedenti prima di vedere arrivare una risposta. "Scalando" (applicando su scala server J) il ragionamento che abbiamo appena fatto per un client sarebbe molto intelligente sfruttare i tempi morti di ogni richiesta per iniziare a processare una seconda richiesta: ad esempio quando viene recuperata una pagina html o una immagine da disco, durante il tempo di attesa necessario per spostare le testine sul disco e rintracciare la risorsa potremmo dare un po' di tempo CPU ad un'altra richiesta: così facendo, come abbiamo esasperato nel primo esempio del web service che impiega due secondi (e sperando che il nostro disco non ci metta due secondi per recuperare una risorsa :-)) una seconda richiesta può essere portata avanti mentre la prima attende una risorsa. E' importante però che il thread che aspetta l'IO del disco venga recuperato e assegnato ad altre operazioni per evitare appunto la creazione (e il costo di gestione) di troppi thread sul sistema. Inoltre, anche la creazione di un thread ha un costo, così come la sua terminazione: sarebbe bene non creare e uccidere un thread per ogni richiesta altrimenti si richia che il costo di creazione/distruzione appesantisca il sistema.

Da questa idea, sin dalle prime versioni di IIS, così come per molti application server, si è pensato alla creazione di un thread pool e a una coda di richieste: mi spiego meglio. Un thread pool è un insieme finito di thread che viene creato in un processo. Partiamo da un caso con pochissime richieste per inquadrare il tutto: nel momento in cui arriva la prima richiesta viene creato un thread T1, nel momento in cui arriva la seconda richiesta viene creato un secondo thread T2; nel momento in cui arriva la terza richiesta è probabile che la prima abbia già terminato l suo lavoro: il thread T1, al termine del lavoro sulla prima richiesta, anzichè essere "ucciso" viene inserito nel pool di thread e reso quindi disponibile alla richiesta numero 3. Arriva la richiesta numero 4: se ci sono thread disponibili nel thread pool la richiesta viene assegnata ad un thread esistente, altrimenti viene creato un nuovo thread T3. Tutto questo fino al limite dei thread configurabile per il thread pool.

Pensate che in IIS 4.0 il numero dei thread del thread pool era impostato per default a 10 !!! In pratica IIS 4 non può processare che 10 richieste contemporanee: prima di spaventarsi ripercorriamo un attimo il ragionamento: occorre sfruttare al massimo i tempi morti durante l'esecuzione di una richiesta/risposta http, come ad esempio l'accesso al disco o a una risorsa in rete (non stiamo ancora parlando di applicazioni ASP.NET) per evitare che il numero di thread cresca a dismisura e causi problemi di performance. Visto che le richieste in IIS prevedono nella maggioranza dei casi l'accesso a risorse su disco (pagine html, immagini e così via) e questo accesso sia solitamente velocissimo (anche per la cache dei file più acceduti) avere un numero di thread così basso consente di mantenere "leggero" il sistema pur fornendo un ottimo throughput. Il rapporto, sempre parlando in media, è circa 100:10 (notare che non è 10:1); questo rapporto indica che per 100 richieste che arrivano via Http sono necessari una decina di thread per soddisfare comunque tutte e cento le richieste per risorse normali; per normali intendo pagine di contenuti, immagini, css; se il server deve fare strema video o scaricare immagini da 100 MB cadauna ovviamente il ragionamento 100:10 dovrebbe essere rivisto. Il punto chiave di questi esempi non sono i numeri di per se, ma la relatività della teoria: voglio dire che il ragionamento non cambia se su un sito con risorse pesanti il rapporto debba essere 100:20.

Supponiamo di avere i 10 thread del thread pool impegnati da richieste esistenti: cosa succede alla undicesima richiesta ? Con la teoria esposta fino a adesso dovremmo rispondere: al client arriverà una risposta "Server Too Busy". Ovviamente non è così altrimenti potremmo affermare che IIS non è un gran che come web server J Le richieste, prima di essere processate, vengono inserite in una coda e prelevate dai vari thread disponibili nel thread pool per essere eseguite. Solo al riempimento di tale coda il server risponderà "Server Too Busy". Si utilizza quindi una tecnica (che ha senso utilizzare nelle nostre applicazioni per scenari simili tramite servizi di accodamento custom o tramite MSMQ) molto intelligente: si accodano le richieste, fino ad un certo limite, e si processano in parallelo solo un numero finito di richieste (con i thread del thread pool) per cercare di ottimizzare il più possibile il bilanciamento fra richieste processabili dal sistema e affaticamento del sistema stesso. Ovviamente questi limiti (thread pool e numero di richieste accodabili, così come altri parametri relativi) possono essere adattati al proprio scenario. Non abbiamo lo spazio per una trattazione esaustiva di questi parametri anche perchè dobbiamo arrivare al cuore dell'articolo ovvero ASP.NET e le pagine asincrone: ricordatevi però sempre che aumentare in modo indiscriminato questi parametri, a prima vista molto bassi, non è una buona idea. Ad esempio superare il valore 60-80 per i thread del thread pool non è quasi mai una buona idea. Vi rimando al Resource Kit di IIS 6.0 per una trattazione approfondita di questi parametri e le tecniche di stress test per verificare il valore ottimale.

 

ASP.NET Thread Pool

Se la richiesta che ha raggiunto IIS è destinata ad una risorsa ASP.NET, questa viene inviata al motore di ASP.NET da ASPNET_ISAPI.dll e, a questo punto senza sorpresa, vi comunico che anche ASP.NET ha una coda per gestire le richieste prima di essere assegnate ai thread di un thread pool interno al processo di ASP.NET (o i processi di ASP.NET nel caso di IIS 6).

Anche ASP.NET ragiona quindi con la teoria che abbiamo appena esposto: appena arriva una richiesta, questa viene accodata; viene poi recuperato un thread del thread pool per processare la richiesta. Anche in questo caso il riempimento della coda fa sì che venga rifiutata la richiesta. Per default ASP.NET ha un thread pool di 25 thread (alcuni devono però essere usati dal motore per operazioni di routine come controllo della cache, ricompilazione, gestione delle session...anche se sapete da sempre che non andrebbero usate !!!).

Non dovrebbe sembrare strano, a questo punto, che il valore di default del thread pool di IIS sia più basso rispetto a quello di ASP.NET: la richiesta arriva a IIS e viene accodata, appena si rende disponibile un thread si inizia a processare la richiesta che viene passata a ASP.NET. Il thread in IIS può essere rimesso immediatamente nel pool e reso disponibile per altre richieste (HTML, immagini e così via) mentre ASP.NET processa la richiesta, sicuramente più complessa, rispetto a prelevare un semplice file da disco. Se arriva una seconda richiesta per ASP.NET, magari è lo stesso thread che ha inviato la prima richiesta ad inviare anche la seconda richiesta. Quando le risposte da parte di ASP.NET sono pronte viene recuperato un thread libero nel thread pool di IIS per mandare la risposta al client. Ottimo no ?

 

ASP.NET Sync/Async

A questo punto diventa chiara la seguente affermazione: occorre occupare per meno tempo possibile i thread del thread pool durante l'esecuzione di una richiesta in modo da renderli disponibili alle richieste entranti. Come fare ? Se una elaborazione occupa tempo CPU (ad esempio per fare dei calcoli) difficilmente possiamo fare a meno di occupare un thread, ma se la richiesta deve accedere a risorse (disco, rete, web service, database) possiamo liberare il prima possibile il thread (effettuando una chiamata asincrona) in modo da farlo lavorare su un'altra richiesta. Predisponiamo il terreno anche in ASP.NET per effttuare i vari test che proseguiremo in un prossimo articolo dove andremo in dettaglio su ASP.NET 1.x e ASP.NET 2.0 per capire le differenze e le nuove facilitazioni.

Per testare il tutto ho preparato la seguente Master Page che visualizza (menu per le varie demo a parte) un trace delle operazioni e il sorgente della pagina richiesta. In pratica la master page espone un metodo, richiamabile dalle varie pagine delle demo, AddTraceMessage che visualizza il tempo dall'inizio della richiesta, il numero di thread occupati e liberi del thread pool e la stringa di trace. Questa la mia master page:

 

--- Master.Master ---

<%@  Language="C#" %>

<%@ Import Namespace="System.IO" %>

<%@ Import Namespace="System.Threading" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">

 

<script runat="server">

 

    void Page_Init()

    {

        AddTraceMessage("Page_Init");

        Page.PreRenderComplete += new EventHandler(this.PagePreRenderComplete);

    }

 

    void PagePreRenderComplete(Object source, EventArgs e)

    {

        AddTraceMessage("PreRenderComplete");

 

        using (TextReader r = new StreamReader(Request.PhysicalPath))

        {

            SourceLabel.Text = HttpUtility.HtmlEncode(r.ReadToEnd());

        }

    }

 

    StringBuilder _trace = new StringBuilder();

    DateTime _pageStartTime = DateTime.Now;

 

    public void AddTraceMessage(string message)

    {

        double t = (DateTime.Now - _pageStartTime).TotalSeconds;

        Int32 i;

        Int32 totalWorkerThreads;

        // Numero massimo di thread del thread pool

        ThreadPool.GetMaxThreads(out totalWorkerThreads, out i);

        Int32 availableWorkerThreads;

        ThreadPool.GetAvailableThreads(out availableWorkerThreads, out i);

        // Thread di tutto il processo

        Int32 totalThreadCounts = System.Diagnostics.Process.GetCurrentProcess().Threads.Count;

       

 

        lock (_trace)

        {

            _trace.AppendFormat("[{0:000}] {1:00.000} - {5} - [{3}/{4}/{6}] -- {2}\r\n",

                Thread.CurrentThread.GetHashCode(), t, "Thread " + AppDomain.GetCurrentThreadId() + " " + message,

                totalWorkerThreads - availableWorkerThreads, totalWorkerThreads,

                Thread.CurrentThread.IsThreadPoolThread ? "TP" : "!TP",

                totalThreadCounts);

        }

    }

</script>

 

<html xmlns="http://www.w3.org/1999/xhtml">

<head id="Head1" runat="server">

    <title>Sample Page</title>

</head>

<body>

    <form id="form" runat="server">

        <table>

            <tr>

                <td valign="top">

                    <p>

                        <asp:SiteMapPath ID="SiteMapPathNode" runat="server" Font-Names="Verdana" PathSeparator=" : "

                            EnableViewState="False">

                            <PathSeparatorStyle Font-Bold="True" ForeColor="#990000" />

                            <CurrentNodeStyle ForeColor="#333333" />

                            <NodeStyle Font-Bold="True" ForeColor="#FF8000" />

                            <RootNodeStyle Font-Bold="True" ForeColor="#990000" />

                        </asp:SiteMapPath>

                        <asp:SiteMapDataSource ID="SiteMapDS" runat="server" />

                    </p>

                    <p>

                        <asp:TreeView ID="SiteTreeView" runat="server" DataSourceID="SiteMapDS" ImageSet="Simple"

                            Font-Names="Verdana" Font-Size="0.9em" EnableViewState="False" NodeWrap="True">

                            <SelectedNodeStyle ForeColor="#990000" Font-Bold="True" />

                            <NodeStyle VerticalPadding="2px" ForeColor="Black" />

                            <HoverNodeStyle Font-Underline="True" ForeColor="#5555DD" />

                            <ParentNodeStyle />

                        </asp:TreeView>

                    </p>

                </td>

                <td valign="top">

                    <asp:Label ID="Label1" runat="server" Text="Page Trace:" Font-Bold="True" Font-Names="Verdana"

                        Font-Size="0.8em" ForeColor="Maroon" />

                    <pre><%= _trace.ToString() %></pre>

                    <hr />

                    <asp:Label ID="Label2" runat="server" Text="Page Source:" Font-Bold="True" Font-Names="Verdana"

                        Font-Size="0.8em" ForeColor="Maroon" />

                    <pre><asp:Label ID="SourceLabel" runat="server" /></pre>

                </td>

            </tr>

        </table>

    </form>

</body>

</html>

 

 

Come si può notare, viene intercettato il Page_Init per inserire un messaggio iniziale nel trace e abbonarsi all'evento PreRenderComplete nel cui event handler viene letto il sorgente della pagina e valorizzata la label che lo visualizza. Il PreRenderComplete è un punto importante nell'esecuzione di pagine asincrone e lo analizzeremo in dettaglio nel prossimo articolo.

 

A questo punto possiamo costruire la prima pagina (referenziando la master page) che esegue una chiamata sincrona al nostro famoso (e lento) web service che impiega 2 secondi per essere eseguito.

 

--- Sync Page Chiama 1 Web Service  ---

<%@ Page Language="C#" AutoEventWireup="true" MasterPageFile="~/MasterPage.master" %>

<%@ MasterType VirtualPath="~/MasterPage.master" %>

 

<script runat="server">

 

    void Page_Load() {

        Master.AddTraceMessage("SalvaAgente 1.0 Sync");

        SalesmanManagerWS.SalesmanManagerWS ws = new SalesmanManagerWS.SalesmanManagerWS();

        bool ris = ws.SalvaAgente("robertob", "RobertoBrunetti");

        Master.AddTraceMessage("SalvaAgente 1.0 Finito");

        Master.AddTraceMessage("Risultato " + ris.ToString());

    }

 

</script>

 

Il codice non è diverso da quanto facevamo nel click del bottone della Windows Form all'inizio dell'articolo. Viene istanziato il web service e invocato il metodo SalvaAgente. Il resto del codice ci consente di inserire messaggi nel trace, che come abbiamo visto nella master page, visualizza informazioni importanti sui thread. Ecco il risultato della esecuzione (depurato del sorgente e del menu per questioni di spazio):

 

[007] 00,000 - TP - [1/100/25] -- Thread 2264 Page_Init

[007] 00,000 - TP - [1/100/25] -- Thread 2264 SalvaAgente 1.0 Sync

[007] 02,016 - TP - [1/100/27] -- Thread 2264 SalvaAgente 1.0 Finito

[007] 02,016 - TP - [1/100/27] -- Thread 2264 Risultato True

[007] 02,016 - TP - [1/100/27] -- Thread 2264 PreRenderComplete

 

Il trace ci mostra, in prima battuta, che la pagina ha richiesto 2,016 secondi per essere eseguita: ricordatevi che sotto i due secondi non possono scendere in quanto il metodo del nostro web service impiega almeno due secondi per essere eseguito. Per semplicità ho pubblicato tutti gli esempi all’indirizzo http://thinkmobile.it:9000/async: visto che la master page visualizza il sorgente non avete bisogno di riscriverli…copiateli direttamente.

 

Il trace mostra 5 messaggi di trace, che grazie al metodo esposto dalla master page visualizza, per ogni chiamata, l'hash code del thread, il tempo di esecuzione dall'inizio della richiesta, TP nel caso in cui si stia utilizzando un thread del thread pool, l'id del thread e il messaggio passato come parametro al metodo. Page_Init e PreRenderComplete vengono aggiunti dal codice della master page. I valori fra parentesi quadre [X/XXX/XX] rappresentano rispettivamente il numero dei thread impegnati nel thread pool, il numero massimo di thread ospitabili e il numero di thread liberi a disposizione.

Come si può notare per eseguire questa pagina che effettua la chiamata sincrona al metodo del web service viene impiegato un solo thread (il 2264) e il tempo fra la chiamata (SalvaAgente 1.0 Sync) e la risposta (SalvaAgente 1.0 Finito) è di poco superiore ai due secondi: questo indica, come abbiamo avuto modo di dire più volte, che il thread aspetta l'intero ciclo della chiamata al metodo. In pratica con una chiamata sincrona stiamo impegnando un solo thread del thread pool per tutta la durata della richiesta; se il thread pool dispone di 20 thread significa che potremmo effettuare fino a 20 richieste in contemporanea e che la ventunesima richiesta aspetterebbe in coda circa due secondi per essere iniziata a processare. Se la coda può ospitare al massimo 100 richieste significa che prima di arrivare a processare la 81 richiesta occorre aspettare 4 trance da 2 secondi (8 secondi almeno). Inoltre la coda si libera di 20 posti dopo almeno due secondi: in pratica a regime possiamo accettare massimo 20 richieste ogni 2 secondi (supponendo sempre che le richieste arrivino nello stesso istante).

 

Proviamo a chiamare due web service in modo sincrono:

 

--- Sync Page Chiama 2 Web Service  ---

<%@ Page Language="C#" AutoEventWireup="true" MasterPageFile="~/MasterPage.master" %>

<%@ MasterType VirtualPath="~/MasterPage.master" %>

 

<script runat="server">

 

    void Page_Load() {

        Master.AddTraceMessage("SalvaAgente 1.0 Sync");

        SalesmanManagerWS.SalesmanManagerWS ws = new SalesmanManagerWS.SalesmanManagerWS();

        bool ris = ws.SalvaAgente("robertob", "RobertoBrunetti");

        Master.AddTraceMessage("SalvaAgente 1.0 Finito");

        Master.AddTraceMessage("Risultato 1 " + ris.ToString());

 

        SalesmanManagerWS.SalesmanManagerWS ws2 = new SalesmanManagerWS.SalesmanManagerWS();

        ris = ws2.SalvaAgente("paolopi", "PaoloPialorsi");

        Master.AddTraceMessage("SalvaAgente 1.0 Finito");

        Master.AddTraceMessage("Risultato 2 " + ris.ToString());

       

    }

 

</script>

 

Ed ecco il risultato:

 

[001] 00,000 - TP - [1/100/22] -- Thread 3292 Page_Init

[001] 00,000 - TP - [1/100/22] -- Thread 3292 SalvaAgente 1.0 Sync

[001] 02,547 - TP - [1/100/24] -- Thread 3292 SalvaAgente 1.0 Finito

[001] 02,563 - TP - [1/100/24] -- Thread 3292 Risultato 1 True

[001] 04,578 - TP - [1/100/24] -- Thread 3292 SalvaAgente 1.0 Finito

[001] 04,578 - TP - [1/100/24] -- Thread 3292 Risultato 2 True

[001] 04,578 - TP - [1/100/24] -- Thread 3292 PreRenderComplete

 

Anche in questo caso stiamo impegnando un solo thread (il 3292) per l'intera esecuzione della pagina che giustamente dura non meno di 4 secondi (4,578 in questo caso) perchè le due richieste ai due web service sono sequenziali e sincrone e non consentono di parallelizzare le due richieste. Ovviamente abbiamo peggiorato l'esecuzione in quanto adesso ogni richiesta impiega non meno di 4 secondi quindi possiamo accettare un massimo di 20 richieste ogni 4,5 secondi circa.

 

Stress (ma non troppo) Test

 

Mettendo il sito sotto stress rispetto ad un tempo di risposta di 2,2 secondi circa per la chiamata singola, otteniamo un tempo medio di risposta di 16,3 secondi e varie risposte Server Too Busy per il problema accennato: il dramma però sta nel fatto che il processore della macchina è al 24% di utilizzo; in pratica i tempi di risposta diventano inaccettabili anche con pochi client su una macchina che non è carica: il processore passa la maggior parte del tempo ad aspettare le risposte remote e non viene sfruttato come potrebbe. Mettere un processore più potente per cercare di velocizzare l'applicazione sarebbe l'errore più grande che potremmo fare, in quanto il collo di bottiglia non è il processore, ma il tempo di attesa sincrono della nostra banalissima applicazione. Se portate questo esempio nel mondo reale dove una pagina di un portale fa una decina di chiamate remote a web service otteniamo una situazione catastrofica anche con 20 utenti collegati e un hardware da decine di migliaia di euro.

Dobbiamo migliorare i tempi di attesa e quindi sfruttare meglio la CPU. La soluzione però non è effettuare chiamate asincrone di per se. Vediamo il perchè con un esempio che purtroppo viene raccomandato da molti come soluzione al problema appena citato. Prima di implementarlo aspettate di vedere i risultati e leggere il prossimo articolo dove cercherò di spiegare il motivo per cui modificare solamente le chiamate sincrone in asincrone sia più distruttivo. Il motivo che scopriremo è che ASP.NET 1.x esegue le pagine in modo sincrono e questo comportamento è il default anche per ASP.NET 2.0. Andiamo per gradi.

 

Proviamo quindi a iniziare con il pattern BeginMetodo a eseguire la chiamata in asincrono. Ecco il codice:

 

--- Sync Page Chiama 1 Web Service BeginXXX   ---

<%@ Page Language="C#" AutoEventWireup="true" MasterPageFile="~/MasterPage.master" %>

<%@ MasterType VirtualPath="~/MasterPage.master" %>

 

<script runat="server">

 

    void Page_Load() {

        Master.AddTraceMessage("SalvaAgente 1.0 Begin");

        SalesmanManagerWS.SalesmanManagerWS ws = new SalesmanManagerWS.SalesmanManagerWS();

        IAsyncResult ar = ws.BeginSalvaAgente("robertob", "RobertoBrunetti", AgenteSalvato, ws);

        Master.AddTraceMessage("SalvaAgente 1.0 Fine Chiamata");

       

        // Occorre aspettare la fine