Wextensible

Simulando multitarea con generadores

Figura
Figura. Multitarea donde los procesos A, B y C comparten un mismo procesador durante un tiempo limitado.

La ejecución en multitarea significa que sólo tenemos un procesador y podemos llevar a cabo la ejecución de múltiples procesos compartiendo el tiempo de uso de ese procesador. Supongamos que tenemos los procesos A, B y C. Ejecutamos A durante un tiempo, lo paramos y ejecutamos B durante otro tiempo y así hasta que todos los procesos finalicen su tarea respectiva. Aparentará que los tres procesos se están ejecutando a la vez, pero realmente estaremos realizando alternativamente un trozo de ejecución de cada uno. Para llevar a cabo multitarea hemos de tener en cuenta los siguientes puntos:

JavaScript se ejecuta en un único hilo con una única tarea. Pero es posible simular multitarea en JavaScript pues está conducido por eventos. Sólo hay que encolar tareas alternativas. Y los generadores nos pueden aliviar mucho trabajo, especialmente con el punto capacidad de ser suspendido y en lo que se refiere a guardar el estado de su ejecución.

Vamos a intentarlo con el siguiente ejemplo interactivo:

Ejemplo: Multitarea con generadores

Canvas no soportado
No iniciado.
ProcesoIteracionesTramosFinalizada
sumar-multitask
multiplicar-multitask
fractal-multitask
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

El ejemplo podría contemplar otros requerimientos, como pausar una tarea específica o reanudar una tarea o todas las que estén pausadas. Pero a efectos de explicar como se implementa con generadores, lo anterior es más que suficiente. Además no costaría mucho agregar esas mejoras. Lo importante es ver que las tres tareas van progresando en su ejecución y completándose cada una cuando le toque. Al mismo tiempo no bloqueamos la interfaz de usuario, lo que nos permite pulsar el botón para detenerlas.

Código JavaScript para implementar multitarea con generadores

Veámos primero el generador, cuyo objetivo principal es suspender un proceso cuando se alcanza el plazo asignado. Recibimos en los argumentos una función que ejecutará un proceso y su plazo. En el primer yield obtenemos los datos iniciales para ese proceso. Ejecutamos el proceso con esos datos y ese plazo. Nos devolverá una condición de si está hecho y un resultado. Si está hecho hacemos un return, que como sabemos, finalizará el generador. Si no está hecho lo suspendemos con yield devolviendo el resultado alcanzado hasta ese momento.

El código del generador es único. Es independiente de la forma en que se ejecuten las tareas. Por cada tarea se ejecutará una instancia del generador, por lo que en cada iteración, cuando se suspende el generador antes de enviar el resultado parcial en el yield, tenemos el estado de variables salvado en datos. Cuando se reanude alimentará al proceso en la siguiente iteración.

function* procesar(proceso, plazo){
    let datos = yield;
    while (true){
        let [hecho, resultado] = proceso(datos, plazo);
        if (hecho){
            return resultado;
        } else {
            datos = resultado;
            yield resultado;
        }
    }
}
    

Un proceso se particulariza para lo que deseemos obtener. Supongamos que partimos de una variable con valor cero y le vamos incrementando uno hasta llegar a diez millones. Este proceso sumar() es uno de los tres del ejemplo. Tiene poca utilidad, pero nos servirá para entender el funcionamiento.

function sumar({num, iteraciones} = datos, plazo){
    let hecho = false, vencido = false;
    let inicio = Date.now();
    while (!vencido && !hecho){
        iteraciones++;
        num = num + 1;
        hecho = (num > Math.pow(10, 7));
        vencido = (Date.now() - inicio) > plazo;
    }
    return [hecho, {num: num, iteraciones: iteraciones}];
}
    

Le pasamos los datos haciendo uso del desestructurado de parámetros. Por lo tanto según la función particularizada, en la parte izquierda relacionamos todas las variables que forman parte del estado del proceso. Hay dos indicadores para ver si está hecho y si ha vencido. Cualquiera de ellos romple el bucle y devuelve un Array con el indicador de si está hecho y los datos. Veáse que hay que clonar el objeto para salvaguardarlo por valor. Ese Array es el que recogemos en el generador usando también destructuring de Array.

La ejecución se inicia preparando un Array de tareas que contiene la información necesaria para gestionar la multitarea. El siguiente código es lo principal del ejemplo interactivo, incluyendo sólo dos de las tres tareas para simplificar la exposición. Pero en el Array de tareas podríamos poner cuantas fueran necesarias:

//Inicio de tareas
stopMultitask = false;
//Inicio de tarea A
let datosA = {num: 0, iteraciones: 0};
let plazoA = 10;
let iterA = procesar(sumar, plazoA); iterA.next();
//Inicio de tarea B
let datosB = {num: 1, iteraciones: 0};
let plazoB = 20;
let iterB = procesar(multiplicar, plazoB); iterB.next();
//Prepara el Array de tareas para ejecutar primer tramo
let arrTareas = [
    {
        name: "sumar-multitask", 
        iter: iterA, 
        res: iterA.next(datosA), 
        end: false, 
        procesarTramo: procesarNumeros
    },
    {
        name: "multiplicar-multitask", 
        iter: iterB, 
        res: iterB.next(datosB), 
        end: false, 
        procesarTramo: procesarNumeros
    }
];
ejecutarTramo(arrTareas, procesarStop, procesarEnd);
    

La variable global stopMultitask nos pemitirá parar todos los procesos a voluntad. Luego veremos que las tareas se encolan, así que entre tramos comprobaremos esa condición para parar la multitarea. Fijamos los datos iniciales y el plazo para cada proceso. Ejecutamos una instancia del generador procesar() y lo iniciamos con un next() vacío. Luego componemos el Array de tareas pasando los siguientes datos:

Por último ejecutamos el primer tramo con ejecutarTramo(), función que encolará las tareas y que comentaremos luego. Le pasamos el Array de tareas y dos funciones más. Una para procesar los resultados cuando la multitarea haya sido detenida con stopMultitask. Y otra para el caso de que finalicen todas. Veáse que no es necesario procesar resultados parciales, pues también podríamos procesar resultados finales o tras la detención. En cualquier caso procesarResultados(), procesarStop() y procesarEnd() son opcionales, todo dependerá de la particularización de los procesos que vamos a ejecutar.

El generador es código genérico que no hay que modificar pues sirve para ejecutar cualquier proceso con estructura similar al anterior sumar(). Otra función genérica que necesitaremos es la que encolará las tareas. Se trata de ejecutar un tramo de la multitarea:

//Para detener todos los procesos
let stopMultitask = false;
//Para ejecutar un tramo
function ejecutarTramo(arrTareas, procesarStop, procesarEnd){
    window.setTimeout(function(){
        let hecho = true;
        for (let tarea of arrTareas){
            if (!tarea.res.done) {
                tarea.res = tarea.iter.next();
            }
            if (!tarea.end && tarea.procesarTramo) {
                tarea.procesarTramo(tarea.name, tarea.res.value);
            }
            tarea.end = tarea.res.done;
            hecho = hecho && tarea.res.done;
        }
        if (!hecho && !stopMultitask) {
            ejecutarTramo(arrTareas, procesarStop, procesarEnd);
        }
        if (stopMultitask && procesarStop) {
            procesarStop(arrTareas);
        }
        if (hecho && procesarEnd) {
            procesarEnd(arrTareas);
        }
    }, 0);
}
    

En la propiedad res del Array de tareas tenemos un objeto resultado del next() del generador. Su propiedad done nos dirá si el generador finalizó. Su propiedad value traerá los resultados parciales. Si el generador no ha finalizado volvemos a enviarle los datos anteriores con tarea.iter.next(tarea.res.value).

La propiedad end se actualizará cuando el generador haya finalizado. Por lo tanto si no ha finalizado y hay una función para procesar ese tramo se ejecutará. Necesitamos ejecutar procesarTramo() antes de asignar tarea.end = tarea.res.done, pues si un proceso finaliza antes de vencer el plazo en el primer tramo, entonces tarea.res.done será verdadero y no captaremos que sea falso para ejecutar procesarTramo().

Comprobamos si todas las tareas han finalizado con la variable hecho. Si no están hechas todas y no hay parada de la multitarea volvemos a ejecutar otro tramo llamando a la misma función. Si están hechas o hay parada ejecutamos procesarStop() y procesarEnd() si fueron definidas.

Consideraciones sobre plazos de ejecución en multitarea

Cuando un proceso se detenga por cumplir su plazo procesaremos resultados parciales. Si esa ejecución conlleva actualizar elementos de la página, ese refresco de pantalla no será ejecutado hasta que se completen todas las tareas en ese tramo y salgamos del setTimeout para llamar a un nuevo tramo o al finalizar todas las tareas. Por lo tanto cuanto menores sean los plazos más rápidamente refrescamos la pantalla.

Elegir los tiempos asignados a cada tarea puede ser un problema a resolver que dependerá de cada particularización. Supongamos que tenemos un proceso A con plazo 10 ms y un proceso B con plazo 20 ms. Un tramo dura lo que diga el mayor plazo de todas las tareas. Así los tramos serán de 20 ms mientras la tarea B siga en ejecución. Por lo tanto en cada tramo la tarea A estará 10 ms suspendida.

Por otro lado si le ponemos a todos el mismo plazo, cada proceso ejecutará su tarea en diferente tiempo. En el ejemplo con 10 ms para cada tarea observará que la de multiplicar finaliza antes con 21 tramos, pues llega más rápido a su condición final. A continuación finaliza la del fractal, mientras que la de la suma aún seguirá hasta 135 tramos. En la tabla resumen que ofrece el ejemplo con proesarEnd() obtuve esos resultados en una ejecución:

ProcesoIteracionesTramosFinalizada
sumar-multitask10.000.001135true
multiplicar-multitask1.611.80821true
fractal-multitask360.00034true

Intentando con algunas combinaciones de valores, con plazos de 70 ms para sumar, 10 ms para multiplicar y 17 ms para el fractal obtuve estos resultados en una de las ejecuciones. Se observa que todas van ejecutándose al mismo ritmo en torno a 20 tramos por tarea:

ProcesoIteracionesTramosFinalizada
sumar-multitask10.000.00118true
multiplicar-multitask1.611.87020true
fractal-multitask360.00019true

Poniéndole un plazo de cero a cada tarea podremos deducir algunas cosas. En una ejecución obtuve estos valores:

ProcesoIteracionesTramosFinalizada
sumar-multitask10.000.0013041true
multiplicar-multitask1.610.565290true
fractal-multitask360.000302true

Se observa que las dos últimas tareas se ejecutan en una cantidad de tramos similar. Pero la primera necesita muchos más. No hay que olvidar un par de cosas. Por un lado que el tiempo declarado en un setTimeout no significa que se vaya a ejecutar en ese tiempo necesariamente, pues la tarea se encola y se llevará a cabo cuando el tiempo corresponda y le toque su turno en la cola de tareas. Por lo tanto si le ponemos cero se llevará a cabo inmediatamente después de las tareas que previamente ya estén encoladas.

En resumen, un setTimeout a 10 ms nunca se ejecuta antes de 10 ms, pero quizás podría ejecutarse en un tiempo muy superior si hay mucha cola. Así que un setTimeout a cero nunca se ejecuta inmediatamente en cero milisegundos.

Además cada iteración de un proceso consumirá su particular tiempo. Aunque le pongamos que el plazo vence en cero milisegundos, el proceso finalizará al menos una iteración, pues la comprobación del vencimiento se realiza al final de cada iteración. Si un proceso tien un plazo de 10 ms y cada iteración consume 20 ms, al menos una primera iteración siempre será consumida.

Veáse que en esa ejecución para la suma en cada tramo se realizan unas 3.333 iteraciones, pues ese es el resultado aproximado de 10.000.000 / 3.041. Mientras que para el fractal, que tiene que iterar 360.000 veces para cada uno de los puntos del canvas de 600×600, sólo necesita 302 tramos. Por lo tanto ejecutará aproximadamente 360.000 / 300 = 1.200 iteraciones por tramo. Una columna de píxeles del canvas tiene 600 píxeles de alto, por lo que en cada tramos pintará en torno a dos columnas de píxeles.

En definitiva, la elección de los plazos dependerá de las tareas que vamos a ejecutar.