Procesos en background en PHP

Una de las clásicas limitaciones de programar en un entorno web es la limitación del tiempo de espera. Muchas veces necesitamos hacer algún proceso largo que no tengamos que espera a que se haga. Un claro ejemplo es hacer una exportación de datos que sabemos que tarda alrededor de 30 minutos.

Lo primero que se nos viene a la cabeza es aumentar el timeout de Apache, pero realmente no es una solución.

Lo segundo que se nos viene a la mente es un cronjob, pero no es realmente controlado sino que depende de un tiempo para ejecutarse y esto podría descontrolarnos.

Lo tercero, ahora nos avanzamos un poco más e intentamos mediante shell_exec ejecutar diferentes scripts php en background, pero shell_exec es el tipico agujero de seguridad que podemos dejar abierto.

Lo siguiente es lo que trataremos en el artículo: Procesos en background via Requests. Esta solución no es la más elegante pero creo que es la que menos depende del sistema operativo que utilices en el servidor (espero que si has llegado a este blog sea un cualquier *nix) y, acompañado de una base de datos, más sencillo de controlar.

¿Cómo?

El truco de este método es lanzar un Request HTTP a nosotros mismos para que apache ejecute un trozo de código asincrónicamente, por lo que en principio tenemos que encolar tareas a realizar en la base de datos, para luego ir ejecutando nuestro código que vaya consumiendo trabajos de esta cola que no duren más de 30 segundos, para al finalizar los trabajos llamarnos nuevamente y así terminar con la cola de trabajos.

Implementando

Supongamos que realizar cada trabajo nos consume 5 segundos de proceso, por lo que tendríamos que ir consumiendo la cola de trabajos de 5 en 5 para no pasarnos de los 30 segundos y al finalizar los 5 trabajos volver a llamar al proceso que consume los trabajos. Tenemos que tener en cuenta también que el primer paso a llevar es llenar la cola de trabajos (un buffer) y llamar al proceso por primera vez, por lo que crearemos un archivo consumidor.php que será algo parecido a esto:

<?php 
#archivo consumidor.php

include_once "background.php";
include_once "trabajos.php";

$trabajos = GestorTrabajos::obtenerTrabajos(5);

if( count($trabajos) > 0 ){
    #si encuentro trabajos los ejecuto
    foreach( $trabajos as $trabajo ){
        GestorTrabajos::ejecutarTrabajo($trabajo);
    }
    #ejecutar el resto de los trabajos
    Background::ejecutar( $_SERVER['SERVER_NAME'] . "/consumidor.php");
}
else{
    #si no hay más trabajos disponibles informo
    GestorTrabajos::trabajoCompleto();
}

Ahora la implementacion de trabajos.php que se tendrá que ajustar al problema concreto.

<?php 
#archivo trabajos.php

class GestorTrabajos {
 
    public static function obtenerTrabajos($cantidad){
        #esta función extrae de bbdd los N trabajos que tengo que ejecutar y elimina los registros de la bbdd.
    }

    public static function ejecutarTrabajo(){
        #esta función ejecuta el trabajo que sabemos que tarda más o menos 5 minutos
    }

    public static function trabajoCompleto(){
        #esta función informa que el trabajo está funalizado (un flag en alguna tabla)
    }

}

implementación de background.php que es donde está la magia 🙂

<?php 
#archivo background.php

class Background{

    #elegimos un nombre de User-Agent para poder distinguirlo de otros requests
    static $useragent = "BackgroundProcess/1.0";

    static function ejecutar($url,$parametros=''){
        self::requestAsincrono($url,$parametros);
    }

    static function requestAsincrono($url,$parametros=''){

        #ahora se monta un request hacia que se lanzará asincrónicamente.
        $method   = "POST";
        $urlParts = parse_url($url);
        $fp       = fsockopen($urlParts['host']);

        if ($method=='GET' && $parametros != '')
            $urlParts['path'] .= '?'.$parametros;

        $request = "$method "     . $urlParts['path']." HTTP/1.1\r\n";
        $request.= "Host: "       . $urlParts['host']."\r\n";
        $request.= "User-Agent: " . self::$useragent ."\r\n";

        if ($method=='POST'){
            $request.= "Connection: keep-alive\r\n";
            $request.= "Content-Type: application/x-www-form-urlencoded\r\n";
            $request.= "Content-Length: ".strlen($parametros)."\r\n\r\n";
            $request.= $parametros;
        }
        else{
            $request.= "Connection: keep-alive\r\n\r\n";
        }
        #se envía el request
        fwrite($fp, $request);
        #no se espera la respuesta y se cierra la conexión
        fclose($fp);
    }
}

¿Mejoras?

Lo siguiente es optimizar la ejecución de nuestro proceso. Si los trabajos puede paralelizarse (los trabajos no dependen de ellos mismos ni del orden de ejecución) podemos ejecutar más de un consumidor.php al mismo tiempo… dependiendo de cuanta memoria consuman los procesos podremos lanzar varios. Para realizar esto podemos modificar la función “ejecutar” dentro de background.php:

<?php 

class BackgroundParalelo extends Background{

    static $hilos = 10;

    static function ejecutar($url,$parametros=''){
        for($i = 0;$i<self::$hilos;$i++){
            self::requestAsincrono($url,$parametros);
        }
    }
}

Nos hace falta llamar a BackgroundParalelo para llamar de 10 en 10 procesos, lo que hará que se ejecuten 10 hilos al mismo tiempo con 5 trabajos, lo que será un total de 50 trabajos por ciclo. Tenemos que ir revisando que esto no consuma muchos recursos. Los trabajos no se ejecutarán exactamente al mismo tiempo ni tardarán lo mismo en ejecutarse por lo que es importante controlar la atomicidad de las consultas a base de datos con transacciones o con semáforos php.

Con esta base se puede comenzar a trabajar especializar para el caso específico.

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInShare on StumbleUpon
Pablo Oneto

Soy un desarrollador de software web especializado en PHP.

0 Comments

  1. Pingback: Exportar a CSV desde base de datos con PHP - vardump.esvardump.es

Deja un comentario