miércoles, 27 de octubre de 2010

Páginas ASP.NET asíncronas

La verdad es que antes de comenzar a explicar como programar una página ASP.NET asíncrona, la pregunta sería ¿Realmente que supone o que valor añadido me da una página asíncrona? y ¿Cuándo utilizar una página asíncrona?. Digo esto porque yo mismo he tenido que experimentar un proceso de entendimiento para encontrar las respuestas a estas preguntas. Al comienzo quería a toda costa hacer “millones” de páginas asíncronas, pero ahora y después de entender cómo funcionan, ya sólo quiero hacer unas “cientas”… ;-)

Desde el punto de vista del rendimiento, una página asíncrona supone liberar hilos del pool de ASP.NET mientras se completa la operación solicitada, y así poder garantizar la escalabilidad de nuestro sitio web porque siguientes peticiones que se produzcan no serán encoladas y podrán ser atendidas de inmediato por los threads libres del pool, que como podrás imaginar es finito y si se acaba… pues se acaba nuestra aplicación (al menos hasta que alguna página libere algún thread y pueda procesar la petición encolada) y tendremos un bonito Error 503.

Si quieres profundizar más en la configuración de los hilos disponibles en el pool de IIS, visita el siguiente enlace http://blogs.msdn.com/b/tmarq/archive/2007/07/21/asp-net-thread-usage-on-iis-7-0-and-6-0.aspx

De este modo, la ventajas de las página asíncronas están claras, pero ¿Cuándo utilizarlas? pues cuando tengamos operaciones que puedan ser lentas y sobretodo se presten a ser ejecutadas de forma asíncrona. Esto significa que, en principio, operaciones como consultas a bases de datos, lectura y escritura de ficheros, invocación de servicios web, envío de correos, etc. se prestan a ser asíncronas pero sobretodo “pueden” ser asíncronas, es decir, tienen métodos Begin<Operación> (que comienzan la operación de forma asíncrona y devuelven de inmediato el control a la aplicación) y tienen métodos End<Operacion> (que se invocan mediante un delegado al finalizar la operación asíncrona e informan sobre el resultado de la operación).

Esto quiere decir que si no hacemos nosotros mismos la implementación de un modelo asíncrono para nuestras clases (que poder se puede y no es tan complica, ver IAsyncResult en http://msdn.microsoft.com/en-us/library/ms228963.aspx), ”sólo” las clases que nos brinden esta característica serán susceptibles de ser utilizadas de forma asíncrona.

De momento y que yo tenga constancia o que haya querido utilizar en algún momento:

  • System.IO
  • System.Net
  • Proxies de servicios web
  • SqlClient

Otra cosa a tener en cuenta es como “percibe” el usuario una página asíncrona:

  • Comienza a cargar la página
  • La página se queda “en cargando” un buen rato mientras se completa la operación asíncrona
  • Finalmente se renderiza la página y el usuario obtiene un resultado visible

Con esto quiero decir que una página .aspx no es un formulario de Windows Forms con un botón cancelar, con una UI que sigue respondiendo, etc, sino que la página se queda “congelada” y “esperando”…

Vale que también podemos hacer una petición AJAX y así no vemos una bonita página en blanco, pero si vamos por la vía de la página .aspx con una petición síncrona (quiero decir un PostBack síncrono, perdón por liar las cosas) el comportamiento descrito será el obtenido.

Realmente y desde el punto de vista del ciclo de vida de una página .aspx, lo que ocurre es que la página se ejecuta normalmente hasta el evento PreRender, momento en el que se lanza la operación asíncrona y queda la página a la espera de la finalización de la operación lanzada. Una vez finalizada la operación se retoma el restante ciclo de vida de la página y la misma finaliza ya con normalidad.

Otro factor a tener en cuenta y que a mi personalmente me ha costado “asimilar” es ¿Puedo lanzar un super-proceso del que no tengo un tiempo estimado claro de finalización en una página .aspx (por ejemplo un job de sql server que puede tardar 2 horas)? Pues yo diría que lo vamos a intentar, pero que a priori una página .aspx no parece el mejor lugar para ello, así que yo te recomendaría que mejor no lo intentes (avisado quedas). Por favor, que tu petición asíncrona sea lenta no significa que pueda ser infinita. Yo personalmente, si una página se carga y en un tiempo razonable no me ha dado una respuesta, me empiezo a poner nervioso… y acabaré dando F5, cerrando el navegador o cualquier otro perrería por la que el programador se acordará de mí ;-)

Pasos para hacer una página asíncrona

  • Incluir el atributo Asyn=”true” en la directiva Page.
    • Esto hace que la página implemente IHttpAsyncHanlder en vez de HttpHandler.
  • Utilizar uno de los siguientes modelos de programación asíncrona disponibles: 
    • AddOnPreRenderCompleteAsync
    • RegisterAsyncTask

Con cualquiera de los métodos expuestos el concepto a lograr es el mismo: Registrar una operación asíncrona que se ejecutará antes de renderizar la página, es decir, antes del evento Page_PreRenderComplete.

Con el primer método (AddOnPreRenderCompleteAsync) sólo registramos 1 operación asíncrona, mientras que con el segundo (RegisterAsyncTask) podemos registrar varias operaciones asíncronas. Aunque con AddOnPreRenderCompleteAsync podríamos encadenar manualmente varias operaciones asíncronas, yo personalmente prefiero el modelo propuesto por RegisterAsyncTask.

Como ya hemos dicho antes, necesitamos algo que se preste a ser asíncrono, es decir, alguna operación que haya implementado el patrón de programación asíncrona propuesto por .NET (IAsyncResult y los métodos Begin<Operation> y End<Operation>) así que para nuestro ejemplo vamos a utilizar el método BeginExecuteNonQuery de SqlCommand.

Para ello tendremos inicialmente el siguiente código en nuestra página:

    Dim _connection As SqlClient.SqlConnection

    Dim _command As SqlClient.SqlCommand

 

    Function BeginAsyncOperation( _

        ByVal sender As Object, _

        ByVal e As EventArgs, _

        ByVal cb As AsyncCallback, _

        ByVal state As Object) As IAsyncResult

 

        Dim connectionString As String = _

            ConfigurationManager.ConnectionStrings.Item("AdventureWorksLT2008ConnectionString").ConnectionString

        Dim sqlConnectionStrinbBuilder As New SqlClient.SqlConnectionStringBuilder(connectionString)

        ' AsynchronousProcessing es obligado si queremos lanzar comandos asíncronos contra Sql Server

        sqlConnectionStrinbBuilder.AsynchronousProcessing = True

        _connection = New SqlClient.SqlConnection(sqlConnectionStrinbBuilder.ToString)

        _connection.Open()

        _command = New SqlClient.SqlCommand("GetCustomers", _connection)

        _command.CommandType = CommandType.StoredProcedure

        ' La siguiente sentencia devuelve inmediatamente el control al código

        Dim result As IAsyncResult = _command.BeginExecuteNonQuery(cb, state)

        Return result

    End Function

 

    Sub EndAsyncOperation(ByVal ar As IAsyncResult)

        Try

            ' Recuperar el comando que lanzó BeginExecuteNonQuery

            Dim command As SqlClient.SqlCommand = CType(ar.AsyncState, SqlClient.SqlCommand)

            ' Finalizar llamada asíncrona

            command.EndExecuteNonQuery(ar)

            command.Dispose()

            command.Connection.Dispose()

        Catch ex As Exception

            'Si hubo errores en BeginExecuteNonQuery, lo sabremos aquí y siempre después de haber llamado a EndExecuteNonQuery, sino y al haber sido ejecutada la llamada asíncrona en otro Thread, la excepción se habrá producido pero en “ESTE THREAD ACTUAL” jamás lo sabremos

        End Try

    End Sub

 

De este código podemos comentar lo siguiente:

  • Las variables de conexión y comando son miembros privados a nivel de clase porque en la función de finalización de la operación asíncrona tenemos que seguir teniendo una referencia a las mismas para llamar al método Dispose.
  • Es necesario incluir el atributo AsynchronousProcessing en la cadena de conexión contra nuestro Sql Server si queremos lanzar operaciones asíncronas.

 

Ahora implementemos la opción 1, esto es AddOnPreRenderCompleteAsync

 

Para ello y en nuestro Page_Load o en cualquier evento de usuario de un control (por ejemplo btnBoton_Click) pondremos el siguiente código:

 

Dim metodoInicial As New BeginEventHandler(AddressOf BeginAsyncOperation)

 

Dim metodoFinal As New EndEventHandler(AddressOf EndAsyncOperation)

 

AddOnPreRenderCompleteAsync(metodoInicial, metodoFinal)

 

Lo que hacemos aquí es:

  • Declarar una variable del tipo BeginEventHandler y otra del tipo EndEventHandler (ambos tipos son delegados)
  • Registrar la operación asíncrona que será llamada automáticamente antes del evento PreRender_Complete.

Si vamos por el camino de RegisterAsyncTask, entonces tenemos que añadir otra función más y es para controlar un posible timeout de la llamada asíncrona (tranquilo que después hablaremos más sobre el timeout) :

 

    Sub TimeoutAsyncOperation(ByVal ar As IAsyncResult)

        ' Finalizar llamada asíncrona

        _command.EndExecuteNonQuery(ar)

        _command.Dispose()

        _connection.Dispose()

    End Sub

 

Y ahora ya podemos registrar las operaciones asíncronas (las que queramos puesto que pueden ser más de una):

 

        Dim metodoInicial As New BeginEventHandler(AddressOf BeginAsyncOperation)

 

        Dim metodoFinal As New EndEventHandler(AddressOf EndAsyncOperation)

 

        Dim metodoTimeout As New EndEventHandler(AddressOf TimeoutAsyncOperation)

 

        Dim tarea As New PageAsyncTask(metodoInicial, metodoFinal, metodoTimeout, Nothing)

 

        RegisterAsyncTask(tarea)

 

Lo que hacemos aquí es:

  • Declarar una variable del tipo BeginEventHandler, y otras dos del tipo EndEventHandler.
  • Registrar las operaciones asíncronas.

Entonces ¿Cuales son las diferencias entre AddOnPreRenderCompleteAsync y RegisterAsyncTask?

Lo cierto es que RegisterAsyncTask gana por goleada:

  • Se puede controlar un timeout de la operación asíncrona porque, bien a través del atributo AsyncTimeout de la directiva page o bien durante el registro de la operación asíncrona, se puede especificar un valor de timeout en segundos. Todo esto en contraste con AddOnPreRenderCompleteAsync que no tiene en cuenta este valor de timeout.
  • Se pueden llamar a varias operaciones asíncronas sin necesidad de ir encadenándolas (imagínate con AddOnPreRenderCompleteAsync esperando a que acabe una con el método EndAsyncOperation para comenzar otra… uff, mejor múltiples RegisterAsyncTask)
  • RegisterAsyncTask permite pasar un parámetro de estado a los métodos Begin (esto puede ser útil para compartir información entre operaciones)
  • Con RegisterAsyncTask en los métodos End y Timeout está disponible el contexto (HttpContext.Current), la cultura de la página, la impersonalización, etc. mientras que en AddOnPreRenderCompleteAsync no está disponible.
  • Posibilidad de controlar cuando comenzar la operación asíncrona con el método ExecuteRegisteredAsyncTasks (Por defecto recordar que las operaciones asíncronas se ejecutaran después del evento PreRender y antes del evento PreRenderComplete)

Una aclaración al respecto de registrar varias tareas asíncronas con RegisterAsyncTask es que es conveniente saber que no se pisan unas a otras, esto es que primero se espera a completar la primera operación asíncrona registrada para inmediatamente comenzar con la segunda y así sucesivamente. Bueno en realidad este comportamiento lo podemos configurar a la hora de registrar la tarea con el parámetro executeInParallel que por defecto es False.

Otra aclaración es que si intentamos registrar una operación asíncrona con cualquier de los 2 métodos válidos y lo hacemos después del evento PreRenderComplete no se tendrá en cuenta y no se ejecutará.

Timeouts

Lo último que quiero decir sobre este modelo de programación asíncrona de páginas .aspx es que tienes que tener en cuenta los timeouts “varios” que intervienen en el proceso y su forma de comportarse:

Para establecer el tiempo que una página .aspx puede estar procesando antes de fallar por un timeout, tenemos disponible:

  • httpRuntime.executionTimeout en el web.config
  • Server.ScriptTimeout (que en la representación del anterior valor pero a nivel de página)

Al respecto de Server.ScriptTimeout te diré que si lo cambias en el código de una página “sólo” afectará a esa página, da igual lo que diga la documentación, te lo digo yo y tú te lo crees ;-)

Cualquier de los anteriores valores no se tienen en cuenta si debug=”true” en el web.config, bueno en realidad se tienen en cuenta pero es que ASP.NET los pone automáticamente con el valor 30000000, casi 1 año!

Por defecto el valor de httpRuntime.executionTimeout es de 110 (pero tampoco te fíes mucho porque me parece a mi que según versión de ASP.NET puede variar ligeramente).

¿Y me importan estos valores cuando hablo de operaciones asíncronas? Pues yo creo que NO.

De hecho el valor que te importa es AsyncTimeout (por defecto 45 y que es independiente de si está o no activa la depuración).

También decirte que AsyncTimeout no es por llamada asíncrona, sino para “todas” las llamadas asíncronas que haya en la página y que realmente lanza el método TimeoutAsyncOperation, pero es más, aunque falle por ejemplo por timeout la segunda tarea registrada de tres, ASP.NET seguirá lanzado la tercera tarea para dar automáticamente otro TimeoutAsyncOperation (elemental!), pero que no por ello ha dejado de lanzarse el comando y tampoco pensemos que porque llamemos a EndExecuteNonQuery en el manejador de Timeout estamos cancelando el comando… VAMOS, UN POCO CUTRE, luego la pregunta ¿Habiéndome dado un primer timeout como paro la sangría de operaciones asíncronas que irremediablemente darán otro timeout automático?

Pues te comento que la única solución es que las clases que utilizamos para la llamada asíncrona tengan un método Cancel() o algo parecido, por ejemplo para SqlCommand.Cancel() pero me da a mi que no es muy definitivo. Pues ver más información al respecto en http://www.pluralsight-training.net/community/blogs/mike/archive/2005/11/04/16213.aspx

Ya para terminar y porque llevo 2 días con este post, hay un tercer método un poco más “salvaje” pero igualmente efectivo que básicamente consiste en pasar de cualquiera de los métodos propuestos y hacerlo un poco a la bravas. De hecho con este método no se lanza la operación asíncrona antes de PreRenderComplete sino que la página se devuelve al cliente por completo (incluso se lanza Page_Unload) y después y al rato (cuando haya finalizado la tarea asíncrona) se procesa la vuelta (incluso si el cliente cerro el navegador por ejemplo).

De hecho, no quiero decir ninguna tontería pero, de este modo, si se podría lanzar una tarea de la que no sabemos cuanto tiempo tardará, de hecho este método no tiene cuenta AsyncTimeout así que le da un poco igual…

Para empezar no es necesario especificar Async = True, y además se ve como simplemente se llama a la operación asíncrona como si fuera casi un formulario de Windows. A mi esto me parece un poco descontrol y lo desaconsejo, pero…

        Dim connectionString As String = ConfigurationManager.ConnectionStrings.Item("AdventureWorksLT2008ConnectionString").ConnectionString

        Dim sqlConnectionStrinbBuilder As New SqlClient.SqlConnectionStringBuilder(connectionString)

        sqlConnectionStrinbBuilder.AsynchronousProcessing = True

        Dim connection As New SqlClient.SqlConnection(sqlConnectionStrinbBuilder.ToString)

        connection.Open()

        Dim command As New SqlClient.SqlCommand("COMANDO", connection)

        command.BeginExecuteNonQuery(AddressOf EndAsyncOperation, Nothing)

Por último comentarte que la programación asíncrona en Web Forms no sólo se limita a formularios web (.aspx) sino que también es muy sencillo implementar la asincronía en controladores genéricos (.ashx).

Un saludo!.

Biblografía

http://msdn.microsoft.com/en-us/magazine/cc163725.aspx

http://www.pluralsight-training.net/community/blogs/mike/archive/2005/11/04/16213.aspx

1 comentario:

  1. No se encuentran muchos ejemplos REAL WORLD en codeproject o github:

    PageAsyncTask task = new PageAsyncTask( BeginInvoke,
    EndInvoke, EndTimeOutInvoke, null);
    Page.RegisterAsyncTask(task);

    var slowTask1 = new _05_PageAsyncTasksSlowTask();
    var slowTask2 = new _05_PageAsyncTasksSlowTask();
    var slowTask3 = new _05_PageAsyncTasksSlowTask();

    PageAsyncTask task1 = new PageAsyncTask(slowTask1.OnBegin, slowTask1.OnEnd, slowTask1.OnTimeOut, null, false);
    PageAsyncTask task2 = new PageAsyncTask(slowTask2.OnBegin, slowTask2.OnEnd, slowTask2.OnTimeOut, null, false);
    PageAsyncTask task3 = new PageAsyncTask(slowTask3.OnBegin, slowTask3.OnEnd, slowTask3.OnTimeOut, null, false);

    RegisterAsyncTask(task1);
    RegisterAsyncTask(task2);
    RegisterAsyncTask(task3);

    Page.ExecuteRegisteredAsyncTasks();

    OutputLabel.Text = slowTask1.GetData() + "
    " + slowTask2.GetData() + "
    " + slowTask3.GetData();

    https://www.codeproject.com/Articles/13948/Practical-NET2-and-C-2-ASP-NET-2-0-Handling-a-page

    Nos puede interesar un PageAsyncTaskManagerWrapper ?
    https://github.com/webformsmvp/webformsmvp/blob/master/WebFormsMvp/WebFormsMvp/Web/PageAsyncTaskManagerWrapper.cs

    o un PageAsyncTaskManager
    https://github.com/chtoucas/Narvalo.NET/blob/077cd471bac68d2331d100c8b586cec02d61e777/src/Narvalo.Mvp.Web/Core/PageAsyncTaskManager.cs


    REAL WORLD samples ? Demos are cool, but it is time to talk about the real world!

    Sería interesante una serie succinctly sobre ello :-)
    The Succinctly series

    This frustration translated into a deep desire to produce a series of concise technical books that would be targeted at developers working on the Microsoft platform.
    We firmly believe, given the background knowledge such developers have, that most topics can be translated into books that are between 50 and 100 pages.
    This is exactly what we resolved to accomplish with the Succinctly series. Isn’t everything wonderful born out of a deep desire to change things for the better?

    ResponderEliminar