IR
irwinrodriguez.dev
Volver a documentación

Patrones

Los patrones que se usan de verdad con FoxCore.Runtime 3.4.2: el lote, el lote grande, el progreso con cancelación, los handlers, la pizarra, los eventos y el entorno del worker. Y al final, cómo se rompe esto.

1. El lote paralelo: el caso más común

Lanzar N guiones, bombear hasta que no quede ninguno vivo, y recoger. El bucle lo escribe el host: no hay ninguna llamada que bloquee esperando, porque eso es justo la interfaz congelada que este producto evita.

Un lote de 30 guiones: lanzar, bombear y recoger
* lcJwt es TU licencia, y GuionDe() es TU funcion: devuelve el texto
* del guion que va a correr cada tarea. Los dos son tuyos, no del
* producto.
LOCAL loRt, i, lnTotal, lnVivas
LOCAL ARRAY laIds[1]

loRt = CREATEOBJECT("FoxCore.Runtime")
loRt.Activate(lcJwt)
loRt.Attach(_VFP)
loRt.MaxWorkers = 8
loRt.DefaultTimeoutSeconds = 300

lnTotal = 30
DIMENSION laIds[lnTotal]
FOR i = 1 TO lnTotal
    laIds[i] = loRt.RunScript(GuionDe(i), "lote " + TRANSFORM(i))   && el guion corre en OTRO proceso
ENDFOR

DO WHILE .T.
    lnVivas = 0
    FOR i = 1 TO lnTotal
        IF !loRt.IsCompleted(laIds[i])
            lnVivas = lnVivas + 1
        ENDIF
    ENDFOR
    IF lnVivas = 0
        EXIT
    ENDIF
    loRt.Pump()
    DOEVENTS
ENDDO

FOR i = 1 TO lnTotal
    ? laIds[i], loRt.GetState(laIds[i]), loRt.GetResult(laIds[i]), loRt.GetError(laIds[i])
ENDFOR

loRt.Shutdown()

2. El lote de cientos o miles

El bucle de arriba pregunta tarea por tarea. Con 400 tareas son 400 llamadas COM por vuelta sólo para saber quién sigue vivo, y a 0,08 ms cada una, unos 32 ms por vuelta gastados en preguntar. Con lotes de miles no escala. La forma buena es PollCompleted(), que entrega los ids terminados desde la última llamada en UNA sola llamada, y Forget() para liberar cada resultado según se lee.

Lote grande con PollCompleted() y Forget()
LOCAL lcFeed, lnHechas, i, loStats
LOCAL ARRAY laTerm[1]

loRt.KeepCompletedTasks = 5000          && que la purga no se lleve lo que no has leído
lnHechas = 0

DO WHILE lnHechas < lnTotal
    loRt.Pump()
    lcFeed = loRt.PollCompleted()       && UNA llamada, entregue lo que entregue
    IF !EMPTY(lcFeed)
        FOR i = 1 TO ALINES(laTerm, lcFeed, 1, ";")
            * ... GetState / GetResult de laTerm[i] ...
            loRt.Forget(laTerm[i])      && purga explícita, sin carreras
            lnHechas = lnHechas + 1
        ENDFOR
        * GetStats() devuelve JSON como cadena: se parsea con la libreria
        * que uses (aqui, jsonfox). el progreso honesto sale de los contadores de VIDA
        loStats = loJson.Parse(loRt.GetStats())
        THISFORM.lblProgreso.Caption = "Procesando " + ;
            TRANSFORM(loStats.finishedEver) + " de " + TRANSFORM(lnTotal)
    ENDIF
    DOEVENTS
ENDDO
finishedEver, no completed. Son dos familias distintas y se parecen: los *Ever cuentan lo que ha PASADO desde que arrancó el runtime y no bajan nunca; total, completed y compañía son la FOTO del registro ahora mismo, y Forget() y la purga los descuentan. Con este bucle (que llama a Forget() en cada vuelta) la foto se queda en cero, y es correcto. El progreso tiene que salir del histórico.

Y dos trampas más que este bucle evita: con KeepCompletedTasks en su defecto de 200 y un lote de 1.000 con recogida diferida, los primeros 800 resultados desaparecen antes de leerlos y GetState() empieza a contestar NotFound. Y el canal de terminados está acotado a 5.000 ids: si nadie lo drena se pierden los más viejos, y GetStats() lo dice en completedDropped, y no es pérdida de datos, la tarea sigue en el registro.

3. Progreso y cancelación, en un formulario

El sink no te llama desde un hilo de .NET: te llama Pump(), en TU hilo. Por eso un método del sink puede tocar la pantalla sin miedo. Y en un formulario, el sitio del Pump() es un Timer.

Formulario con progreso y botón de cancelar
* oRt y cTaskId son PROPIEDADES que le anades tu al formulario, y
* Terminado() un metodo tuyo. El boceto es del lado del host.
* Init del formulario
THIS.oRt = CREATEOBJECT("FoxCore.Runtime")
THIS.oRt.Activate(lcJwt)
THIS.oRt.Attach(_VFP)
THIS.oRt.SetEventSink(THISFORM)        && THISFORM tiene OnProgress y OnLog
THIS.cTaskId = ""

* Click del boton "Procesar"
THISFORM.cTaskId = THISFORM.oRt.RunScript(lcGuion, "cierre mensual")
THISFORM.timer1.Enabled = .T.

* Timer, Interval = 100
THISFORM.oRt.Pump()
IF !EMPTY(THISFORM.cTaskId) AND THISFORM.oRt.IsCompleted(THISFORM.cTaskId)
    THISFORM.timer1.Enabled = .F.
    THISFORM.Terminado()
ENDIF

* Click del boton "Cancelar"
THISFORM.oRt.Cancel(THISFORM.cTaskId)

* Destroy
THISFORM.oRt.ClearEventSink()          && IMPORTANTE: mientras el runtime lo tenga, el formulario sigue vivo
THISFORM.oRt.Shutdown()

Y en el guion, para que Cancel() sirva de algo:

Guion que atiende a Cancel() y avisa del progreso
SCAN
    IF runtime.IsCancelled()
        EXIT
    ENDIF
    * ... trabajo ...
    IF MOD(RECNO(), 500) = 0
        runtime.Progress(100 * RECNO() / RECCOUNT(), "procesando...")
    ENDIF
ENDSCAN
Un guion que no mira IsCancelled() sólo se para con Kill() (que mata el proceso por PID) o cuando vence su plazo. Es la diferencia entre cancelación cooperativa y dura, y hay que decidirla al diseñar la tarea, no después.

4. El worker te pide algo a ti: handlers

El caso clásico es un correlativo. Registras un objeto tuyo con un nombre, y el guion lo llama con runtime.Call().

CajaDeFolios: el handler registrado en el host
loRt.RegisterHandler("SiguienteFolio", loCaja, "Siguiente")

DEFINE CLASS CajaDeFolios AS Custom
    nUltimo = 0
    FUNCTION Siguiente(tcSerie)          && sin un solo bloqueo, y no hace falta
        THIS.nUltimo = THIS.nUltimo + 1
    RETURN TRANSFORM(tcSerie) + "-" + PADL(THIS.nUltimo, 4, "0")
    ENDFUNC
ENDDEFINE
El guion pide un folio con runtime.Call()
lcFolio = runtime.Call('SiguienteFolio', 'FRA')

* O sin TRY/CATCH, que no lanza nunca:
lcJson = runtime.TryCallJson('SiguienteFolio', 'FRA')
IF 'true' $ LOWER(lcJson)
    * ... parsea el JSON como prefieras ...
ENDIF

Y aquí está la propiedad que importa: Pump() corre en tu hilo y atiende las peticiones de una en una. El objeto del host no lleva ningún bloqueo y aun así ocho workers pidiendo a la vez no producen ni un folio duplicado, verificado con 400 tareas y 410 peticiones. La serialización la da el Pump, no el objeto.

Hay tres handlers integrados siempre disponibles, para probar el canal sin escribir nada: echo, upper y add. Y RegisterHandler() devuelve .F. sin lanzar si el objeto no expone ese método, así que el error sale en el Init del formulario y no seis horas después dentro de un worker.

5. Pasarle datos al worker: la pizarra

Por orden de preferencia: dentro del propio guion (es texto, genéralo con TEXTMERGE); la pizarra; un fichero, para lotes de parámetros grandes; y Call() a un handler, que es el más caro y se reserva para lo que TIENE que resolverse en el host. Nunca por variables PUBLIC ni por cursores: no se heredan.

Publicar un dato en la pizarra y leerlo en el guion
* En el host, antes de lanzar:
loRt.Publish("cRutaDatos", "\\srv\datos")

* En el guion:
lcRuta = runtime.GetValue("cRutaDatos")      && sin coste de cola ni de pump

6. Enterarse sin preguntar: eventos

SetEventSink() es adicional: GetProgress() y GetProgressMessage() siguen funcionando igual sin él. Lo que cambia es que te avisan en vez de tener que preguntar.

EcoDeProgreso: un sink de eventos
DEFINE CLASS EcoDeProgreso AS Custom
    nAvisos = 0
    cUltimo = ""

    *-- OnProgress y OnLog son OBLIGATORIOS: sin los dos, SetEventSink da .F.
    PROCEDURE OnProgress(tcTaskId, tnPercent, tcMessage)
        THIS.nAvisos = THIS.nAvisos + 1
        THIS.cUltimo = TRANSFORM(tnPercent) + "% " + TRANSFORM(tcMessage)
    ENDPROC

    PROCEDURE OnLog(tcTaskId, tcLevel, tcMessage)
    ENDPROC

    *-- Opcionales: se detectan si estan.
    PROCEDURE OnTaskCompleted(tcTaskId, tcState)
    ENDPROC
    PROCEDURE OnTaskFailed(tcTaskId, tcError)
    ENDPROC
ENDDEFINE

7. El entorno del worker

Por defecto MaxTasksPerWorker vale 1: un proceso por tarea, aislamiento total. Subiéndolo, el worker se reutiliza: 91% menos procesos y 70% menos tiempo, medido sobre 120 tareas, y entonces hacen falta los dos guiones: el de inicio monta el entorno una vez, y el de reposición limpia entre tareas.

Reutilizar workers con guiones de inicio y de reposición
loRt.MaxTasksPerWorker = 20
loRt.WorkerInitScript  = "SET PROCEDURE TO miapp.app ADDITIVE" + CHR(13) + CHR(10) + ;
                         "SET PATH TO " + lcRutaDatos      && una vez por worker, antes de su primera tarea
loRt.WorkerResetScript = "CLOSE TABLES ALL"                && entre tareas de un worker reutilizado
loRt.WorkerInitFailurePolicy = "abort"
Nada genérico se limpia entre tareas, y no es pereza: cualquier limpieza lo bastante amplia para borrar la tarea anterior destruiría también el entorno que WorkerInitScript acaba de montar. Para eso está WorkerResetScript. Y un worker vuelve al pool sólo si su tarea acabó Completed: cualquier otra cosa lo quema, porque un guion que falló a medias puede haber dejado un cursor, una transacción o un bloqueo.

Cómo se rompe esto

Las reglas duras, con la consecuencia de saltárselas:

ReglaConsecuencia
Llama a Pump()Los Call() de los workers no se atienden y vencen su plazo. Síntoma: «las tareas no avanzan».
Llama a Shutdown()Quedan procesos vfp9.exe huérfanos ocupando memoria.
Pump() sólo desde el hilo del AttachDesde otro hilo lanza un error controlado.
Nada de DOEVENTS dentro de un handlerReentra en Pump(), que devuelve 0 sin hacer nada. No lanza, pero no atiende.
ClearEventSink() en el DestroyMientras el runtime tenga el sink, el formulario sigue vivo: fuga de referencia.
Un handler VFP sólo falla hacia fuera con THROWERROR "..." y los errores de tiempo de ejecución no cruzan a COM: el método sigue en la línea siguiente y devuelve como si nada.
Y aun con THROW, el worker recibe «Procedure canceled.»VFP no marshala el Message de la excepción al cruzar a COM. Si necesitas explicar por qué falló, guárdalo en una propiedad del objeto del host o devuélvelo como valor.
VFP ignora en silencio los argumentos que le sobranLlamar a un handler de un parámetro con tres no da error: llega el primero.

Antes de paralelizar: mira los datos

Esto es lo que más veces convierte un candidato aparente en un no-candidato:

  • Los workers abren las tablas por su cuenta. Si el trabajo escribe en las mismas tablas desde ocho procesos, hay contención y bloqueos reales, los mismos que tendrías con ocho usuarios. Paraleliza lecturas y escrituras particionadas: cada worker su rango, su fichero de salida, su rama de datos.
  • Una transacción no cruza procesos. No hay transacción distribuida: si el trabajo tiene que ser atómico de principio a fin, o cabe entero dentro de una tarea, o no es candidato.
  • SET EXCLUSIVE, PACK, REINDEX y ZAP necesitan uso exclusivo. Sirven como tarea única en su ventana, nunca como lote paralelo sobre la misma tabla.
  • Cuidado con los ficheros temporales de nombre fijo. Ocho workers escribiendo c:\temp\salida.txt se pisan. Usa SYS(2015) o el TaskId en el nombre.
  • Los correlativos y contadores son justo el caso del handler del host, no de «que cada worker lea y sume».
¿Vienes del motor clásico? Aquí no vas a encontrar RunAll ni WaitFor: no tienen equivalente, y el porqué está explicado en «Lo que ya no existe», al final de la Referencia de API.

Siguiente: Referencia API ->