Buscar
Social
Ofertas laborales ES
« JavaHispano Podcast - 104 - Estado actual de Android (Entrevista a Android-Spa) | Main | Encuesta: ¿Lees libros electrónicos sobre tecnología? »
martes
ene182011

El "placer" de la programación asíncrona

Una de las razones por las que Java se hizo popular fue por la introducción de primitivas en el propio lenguaje para la gestión de hilos tal y como la palabra clave synchronized.

Todo el mundo sabe que la programación concurrente y en general la gestión de hilos es un tema necesario puesto que el mundo de la programación web es esencialmente concurrente pues varios clientes pueden acceden a la vez, pero a su vez es complicado.

Además de la complejidad propia de la sincronización de recursos compartidos, todo el mundo sabe que la gestión de hilos es muy costosa para el sistema operativo y consume muchos recursos y es raro usar muchos más de 100 hilos, por ejemplo Tomcat 6 por defecto limita el número máximo de hilos a 200.

El problema del mundo web en Java es el estándar servlet de Java que vincula un hilo y sólo uno al request, tal que hasta que no termina el request el hilo siendo usado no queda libre para satisfacer a otro request. Por tanto el máximo número de request concurrentes viene dado por el número de hilos máximo que podemos utilizar.

Para evitar este problema surgieron extensiones propietarias al estándar servlet por parte de los fabricantes de servidores de aplicaciones (Tomcat, Jetty, GlassFish…), todos ellos de una forma u otra (aunque normalmente a través de Java NIO) rompen la asociación 1 request – 1 hilo por lo que el hilo de la request termina sin que termine la request, como no hay esta sincronía request-hilo se dice que la request se procesa de forma “no bloqueante” o “asíncrona”.

En web “normal” en donde los requests terminan lo antes posible los hilos no han supuesto gran problema, el problema surge en Comet long-polling en donde el request puede estar retenido durante largo tiempo, si se usa el estándar servlet esto supone retener (parar) el hilo del request puesto que la request (y sus objetos asociados) sólo pueden usarse dentro del hilo usado en la request. Si un hilo está retenido no puede utilizarse para otros requests por lo que hay que reservar tantos hilos como usuarios Comet.

En las alternativas Java NIO un solo hilo es capaz de procesar múltiples requests evitando el problema de la conmutación de miles de hilos y permitiendo una mayor escalabilidad, es decir un mayor número de usuarios concurrentes.

El problema que introduce la programación “mono-hilo” es que hay que ser consciente, por ejemplo en programación Comet, de que no podemos bloquear con nuestras acciones el hilo NIO, pues si lo bloqueamos los demás requests tendrán que esperar. Un ejemplo de bloqueo es una operación de base de datos, dicha operación es claramente bloqueante pues puede tardar bastante tiempo, tiempo en el que el sistema (el hilo NIO) está “parado” esperando la respuesta del servidor de base de datos, y las demás requests están inútilmente sin procesar.

Para evitar este problema siempre podemos crear un hilo (o utilizar uno de un pool) y delegar la consulta de la base de datos a dicho hilo, dejando libre el hilo NIO. El problema de esta práctica es que introduce de nuevo la programación multihilo con todas sus desventajas en escalabilidad anteriormente enunciadas.

Para solucionar este problema está surgiendo un “nuevo” paradigma que es la “programación asíncrona”, la programación asíncrona consiste en utilizar siempre el mismo hilo para procesar múltiples requests, es decir, de forma secuencial, pero sin que una request totalice el hilo para ello como veremos más adelante las operaciones de la request se ejecutarán “a trocitos”. Para ello hay que evitar ejecutar tareas bloqueantes que producen un paro del hilo, para ello necesitamos una API no bloqueante, por ejemplo una operación de base de datos usando una API no bloqueante “se registra” pero no se ejecuta inmediatamente por lo que el método retorna sin que se haya realizado, en la llamada al mismo tiempo pasamos una callback que será llamada cuando el proceso de base de datos termine, por lo que el flujo continuará a través de la callback de forma “asíncrona” respecto a como habría sido de forma secuencial (la llamada no retorna hasta que termina).

A través de este registro encolamos la tarea, y permitimos que esta cola de tareas vayan ejecutándose por el hilo principal que inspecciona si la tarea bloqueante ha terminado o no utilizando a su vez las APIs asíncronas del sistema operativo, o bien a través de un pool de hilos pues el fin es no bloquear el hilo principal que ejecuta las requests, para que al ejecutar poco a poco las requests podamos ejecutarlas “en paralelo” pero con el mismo hilo.

En el caso de que queramos ejecutar tareas en paralelo en nuestra request, simplemente en el retorno de la llamada seguimos haciendo cosas, cuando termine la tarea en paralelo (que normalmente supone esperar a un dispositivo en operaciones de entrada/salida) el flujo continuará por la callback, pero usando siempre mismo hilo principal. Como se usa siempre el hilo principal para ejecutar código del usuario (las requests), no hay problema de acceso concurrente a los objetos compartidos por lo que te liberas de los problemas de sincronización. Esto no es obviamente posible por ejemplo con JDBC pues su API es bloqueante (aunque nada impide hacer una capa encima que defina APIs asíncronas).

Un exponente de este emergente paradigma de la programación es Node.js , un servidor de aplicaciones web que se programa en JavaScript. Este paradigma encaja muy bien con JavaScript pues este lenguaje no tiene soporte de hilos.

Ejemplo de código Node.js (notar el registro de callbacks)

var fs = require('fs'), sys = require('sys');

fs.readFile('treasure-chamber-report.txt', function(report)
{
  sys.puts("oh,look at all my money: "+report);

});

fs.writeFile('letter-to-princess.txt','...', function()
{

  sys.puts("can't wait to hear back from her!");

});

Para más información sobre Node.js este link es útil 

La API por tanto de Node.js es no bloqueante, porque o bien la tarea no es bloqueante o bien cuando lo es, Node.js evita ese bloqueo y nos permite registrar una callback para cuando termine. Cada llamada a la API de Node.js es una oportunidad para el motor de Node.js de cambiar de request y ejecutar alguna callback pendiente que estaba esperando a una operación bloqueante a que terminara, de esa manera Node.js CONMUTA entre requests usando el mismo hilo por lo que las requests se van ejecutando poco a poco aparentemente a la vez, gracias a que nuestro código en vez de seguir un flujo síncrono o secuencia, lo hemos partido en trocitos gracias a los registros de nuestras callbacks pues dentro de ellas a su vez  se realizan nuevas llamadas a APIs con registro de callbacks, podemos decir que las callbacks son los trocitos de código.

Como se puede comprobar esta estrategia es ¡¡¡SIMPLEMENTE GENIAL!!!

Ahora bien, sólo tiene un problema…

 QUE LLEVA INVENTADA EN TORNO A 40 AÑOS Y QUE ES UNA VERSIÓN MANUAL, INEFICIENTE Y TEDIOSA DE LO QUE HACE UN SCHEDULER DE HILOS

Y como es fácil de suponer ya empiezan a surgir los problemas

Los hilos software son una mera ilusión, los núcleos de nuestros procesadores son prácticamente monohilo (en procesadores Intel comunes), lo que hace un scheduler de hilos es conmutar los registros del procesador/núcleo para seguir ejecutando una zona diferente de código en un stack diferente durante un ratito de tiempo dando lugar a la ilusión de paralelismo que sólo es real cuando hay varios núcleos. Esta conmutación es AUTOMÁTICA y siguiendo una política de tiempo efectivo de uso del procesador y es muy difícil que una alternativa manual sea capaz de superar a un scheduler de hilos (hubo un tiempo en que fue posible en viejos Linux de ahí el proyecto SEDA pero ya no es cierto) pues un scheduler de hilos tiene la posibilidad de quitar el control del procesador/núcleo al hilo software cuando quiera.

Es FALSO que la gestión de hilos sea costosa como se demuestra en este artículo  que seguramente ya conocéis, recientemente he encontrado otro enlace que viene a corroborar lo mismo  aunque aparentemente parezca lo contrario (recomiendo leer mi comentario).

Es FALSO que la gestión de hilos parados sea costosa, el uso de CPU es CERO como se puede comprobar en este ejemplo de 3000 hilos:

public static void main(String[] args)
{
    for(int i = 0; i < 3000; i++)
    {
        Thread thread = new Thread()
        {

            final Object monitor = new Object();

            public void run()

            {

                synchronized(monitor)

                {

                    try { monitor.wait(40000); }

                    catch(InterruptedException ex) {}

                }
            }

        };

        thread.start();
   }
}

Es FALSO que podamos separar tareas bloqueantes de no bloqueantes de forma efectiva y usar al máximo la CPU con un único hilo.

En el ejemplo siguiente se realizan muchos millones de iteraciones e incrementos de una simple variable entera, un ejemplo extremo de tarea no bloqueante, pues curiosamente cuanto más se aumenta el número de hilos con la variable THREADS  (el número de iteraciones e incrementos es el mismo) el tiempo de proceso DISMINUYE a pesar de la creación o inicialización de los hilos (que si fuera un pool no existiría ese coste) , prueba THREADS con el número de núcleos de tu ordenador y luego con 1000 por ejemplo, ¡¡el tiempo es menor con 1000 hilos!! ahora imagina tareas más complicadas aparentemente no bloqueantes.

    public static void main(String[] args) throws Exception

    {

        final int THREADS = 8;

        final long LENGTH = 100000000000L / THREADS;

         long start = System.currentTimeMillis();

         Runnable task = new Runnable()

        {

            public void run()

            {

                long j = 0;

                for(long i = 0; i < LENGTH; i++)

                    j++;               

            }

         };

 

        Thread[] threadList = new Thread[THREADS];

        for(int i = 0; i < threadList.length; i++)

        {

            threadList[i] = new Thread(task);

            threadList[i].start();

        }

        for(int i = 0; i < threadList.length; i++)

            threadList[i].join();      

        long end = System.currentTimeMillis();

         System.out.println("END " + (end - start));

    }

Afortunadamente Servlet 3.0 nos permite decidir el número de hilos que creemos conveniente en nuestra aplicación Comet. Un ejemplo.

Mi experiencia reciente con ItsNat Comet me dice que no será raro que en nuestras aplicaciones Comet tengamos que notificar a TODOS los clientes a la vez, y la forma más efectiva es que cada cliente tenga un hilo asociado.

Por favor paren este absurdo y si es necesario añadan hilos a JavaScript...

-------------------

Actualización:

Debido a algún acertado comentario sobre el segundo test, lo he mejorado para asegurar que hay la mayor concurrencia posible sobre todo en el caso de muchos hilos.

    public static void main(String[] args) throws Exception
    {
        final int THREADS = 8;
        final long LENGTH = 100000000000L / THREADS;  // 100000000000L

        Object[] monitorList = new Object[THREADS];       
        Thread[] threadList = new Thread[THREADS];       
        for(int i = 0; i < threadList.length; i++)
        {
            final Object monitor = new Object();
            monitorList[i] = monitor;
            threadList[i] = new Thread(new Runnable()
                {
                    public void run()
                    {
                        synchronized(monitor)
                        {
                            try { monitor.wait(); }
                            catch(InterruptedException ex) { }
                        }

                        long j = 0;
                        for(long i = 0; i < LENGTH ; i++)
                        {
                            j++;
                        }
                    }
                }
            );
        }

        for(int i = 0; i < threadList.length; i++)
            threadList[i].start();

        // We give some time to threads to get the monitors
        Thread.sleep(5000);
        // Here all threads are stopped, otherwise
        // this test never ends

        System.out.println("READY");
        long start = System.currentTimeMillis();

        for(int i = 0; i < monitorList.length; i++)
        {
            Object monitor = monitorList[i];
            synchronized(monitor) { monitor.notify(); }
        }

        System.out.println("END NOTIFY");

        for(int i = 0; i < threadList.length; i++)
            threadList[i].join();        

        long end = System.currentTimeMillis();

        System.out.println("END " + (end - start));
    }
 ------------------------------------------

 Actualización Segunda

En esta versión se busca que el número de objetos total creado sea el mismo en todos los casos, cada hilo ejecuta exactamente la misma tarea, lo que cambia es el nivel de concurrencia dado CONCURRENT_THREAD

CONCURRENT_THREAD  puede cambiarse desde 1 hilo hasta 1000 hilos (si se quieren estudiar cantidades más elevadas de hilos concurrentes hay que cambiar  TOTAL_THREADS)

Cada hilo crea 1000 instancias temporales para acercarnos más a una situación más real (pero no bloqueante) de proceso por ejemplo de un evento Comet.

El resultado es prácticamente el mismo, por lo menos hasta 1000 hilos se mejora el resultado a mayor número de hilos.

     public static void main(String[] args) throws Exception
    {
        final int CONCURRENT_THREADS = 8;
        
        final int TOTAL_THREADS = 1000;
        final long LENGTH = 1000000L;
        final long NUMOBJECTS = 1000;
        
        long total = 0;

        final Object globalMonitor = new Object();

        for(int block = 0; block < TOTAL_THREADS / CONCURRENT_THREADS; block++)
        {
            Thread[] threadList = new Thread[CONCURRENT_THREADS];
            for(int i = 0; i < threadList.length; i++)
            {
                threadList[i] = new Thread(new Runnable()
                    {
                        public void run()
                        {
                            synchronized(globalMonitor) {
                                try { globalMonitor.wait(); }
                                catch(InterruptedException ex) { }
                            }

                            for(long j = 0; j < LENGTH ; j++)
                            {
                                double k = Math.cos(10);
                                k++;
                            }

                            for(long j = 0; j < NUMOBJECTS; j++)
                                new String("Hello");
                        }
                    }
                );
            }

            for(int i = 0; i < threadList.length; i++)
                threadList[i].start();

            // We give some time to threads to get the monitors
            Thread.sleep(2 * CONCURRENT_THREADS);
            // Here all threads are stopped, otherwise
            // this test never ends

            //System.out.println("READY BLOCK");

            long start = System.currentTimeMillis();

            synchronized(globalMonitor) { globalMonitor.notifyAll(); };

            for(int i = threadList.length - 1; i >= 0; i--)
                threadList[i].join();

            long end = System.currentTimeMillis();

            total += end - start;
        }       

        System.out.println("END millisec " + total);
    }

 

Reader Comments

There are no comments for this journal entry. To create a new comment, use the form below.
Comentarios deshabilitados
Comentarios deshabilitados en esta noticia.