Compartir a través de


Escritura de Progress en varios subprocesos con Foreach Parallel

Desde PowerShell 7.0, es posible trabajar en varios subprocesos simultáneamente mediante el parámetro Parallel en el cmdlet Foreach-Object. No obstante, la supervisión del progreso de estos subprocesos puede suponer un reto. Normalmente, puede supervisar el progreso de un proceso mediante Write-Progress. Pero, debido a que PowerShell usa un espacio de ejecución independiente para cada subproceso al emplear Parallel, informar del progreso al host no es tan sencillo como el uso normal de Write-Progress.

Uso de una tabla hash sincronizada para realizar un seguimiento del progreso

Al escribir el progreso desde varios subprocesos, el seguimiento resulta difícil porque, al ejecutar procesos paralelos en PowerShell, cada uno tiene su propio espacio de ejecución. Para evitar esto, puede usar una tabla hash sincronizada. Una tabla hash sincronizada es una estructura de datos segura para subprocesos que varios de ellos pueden modificar simultáneamente sin que se produzca un error.

Configurar

Uno de los inconvenientes de este enfoque es que toma una configuración algo compleja para asegurarse de que todo se ejecuta sin errores.

$dataset = @(
    @{
        Id   = 1
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 2
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 3
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 4
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 5
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
)

# Create a hashtable for process.
# Keys should be ID's of the processes
$origin = @{}
$dataset | Foreach-Object {$origin.($_.id) = @{}}

# Create synced hashtable
$sync = [System.Collections.Hashtable]::Synchronized($origin)

En esta sección se crean tres estructuras de datos distintas, para tres propósitos diferentes.

La variable $dataSet almacena una matriz de tablas hash que se usa para coordinar los pasos siguientes sin el riesgo de que se modifiquen. Si se modifica una colección de objetos mientras se recorre en iteración, se producirá un error en PowerShell. Debe mantener la colección de objetos en el bucle independiente de los objetos que se van a modificar. La clave Id de las tablas hash es el identificador de un proceso ficticio. La clave Wait simula la carga de trabajo de los procesos ficticios de los que se realiza un seguimiento.

La variable $origin almacena una tabla hash anidada, en la que cada clave es uno de los identificadores del proceso ficticio. A continuación, se usa para hidratar la tabla hash sincronizada almacenada en la variable $sync. La variable $sync es responsable de notificar el progreso al espacio de ejecución primario, que muestra el progreso.

Ejecución de los procesos

En esta sección se ejecutan los procesos con varios subprocesos y se crean algunos de los resultados que se usan para mostrar el progreso.

$job = $dataset | Foreach-Object -ThrottleLimit 3 -AsJob -Parallel {
    $syncCopy = $using:sync
    $process = $syncCopy.$($PSItem.Id)

    $process.Id = $PSItem.Id
    $process.Activity = "Id $($PSItem.Id) starting"
    $process.Status = "Processing"

    # Fake workload start up that takes x amount of time to complete
    start-sleep -Milliseconds ($PSItem.wait*5)

    # Process. update activity
    $process.Activity = "Id $($PSItem.id) processing"
    foreach ($percent in 1..100)
    {
        # Update process on status
        $process.Status = "Handling $percent/100"
        $process.PercentComplete = (($percent / 100) * 100)

        # Fake workload that takes x amount of time to complete
        Start-Sleep -Milliseconds $PSItem.Wait
    }

    # Mark process as completed
    $process.Completed = $true
}

Los procesos ficticios se envían a Foreach-Object y se inician como trabajos. El valor de ThrottleLimit se establece en 3 para resaltar la ejecución de varios procesos en una cola. Los trabajos se almacenan en la variable $job, que nos permite saber cuándo se completan todos los procesos más adelante.

Al utilizar la instrucción using: para hacer referencia a una variable de ámbito principal en PowerShell, no puede usar expresiones para que sea dinámica. Por ejemplo, si ha intentado crear la variable $process de este modo ($process = $using:sync.$($PSItem.id)), se producirá un error en el que se le indicará que no puede utilizar expresiones en ese lugar. Por lo tanto, creamos la variable $syncCopy para poder hacer referencia a la variable $sync y modificarla sin el riesgo de que se produzca un error.

A continuación, creamos una tabla hash para representar el progreso del proceso actualmente en el bucle con la variable $process. Para ello, hacemos referencia a las claves de la tabla hash sincronizadas. Las claves Activity y Status se utilizan como valores de parámetro para que Write-Progress muestre el estado de un proceso ficticio determinado en la sección siguiente.

El bucle foreach solo es una forma de simular el funcionamiento del proceso y se aleatoriza en función del atributo $dataSet Wait para establecer Start-Sleep usando milisegundos. La forma de calcular el progreso del proceso puede variar.

Vista del progreso de varios procesos

Ahora que los procesos ficticios se están ejecutando como trabajos, podemos empezar a escribir el progreso de los procesos en la ventana de PowerShell.

while($job.State -eq 'Running')
{
    $sync.Keys | Foreach-Object {
        # If key is not defined, ignore
        if(![string]::IsNullOrEmpty($sync.$_.keys))
        {
            # Create parameter hashtable to splat
            $param = $sync.$_

            # Execute Write-Progress
            Write-Progress @param
        }
    }

    # Wait to refresh to not overload gui
    Start-Sleep -Seconds 0.1
}

La variable $job contiene el trabajo principal y tiene un trabajo secundario para cada uno de los procesos ficticios. Mientras haya algún trabajo secundario en ejecución, el valor de State del trabajo principal será "Running". Esto nos permite usar el bucle while para actualizar continuamente el progreso de todos los procesos hasta que finalicen.

Dentro del bucle while, se recorren todas las claves de la variable $sync. Dado que se trata de una tabla hash sincronizada, se actualiza constantemente, pero todavía se puede acceder a ella sin que se produzcan errores.

Se realiza una comprobación para asegurarse de que el proceso del que se está informando se está ejecutando realmente con el método IsNullOrEmpty(). Si el proceso no se ha iniciado, el bucle no informará sobre él y pasará al siguiente hasta que llegue a un proceso que se haya iniciado. Si se inicia el proceso, se usa la tabla hash de la clave actual para expandir los parámetros a Write-Progress.

Ejemplo completo

# Example workload
$dataset = @(
    @{
        Id   = 1
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 2
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 3
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 4
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
    @{
        Id   = 5
        Wait = 3..10 | get-random | Foreach-Object {$_*100}
    }
)

# Create a hashtable for process.
# Keys should be ID's of the processes
$origin = @{}
$dataset | Foreach-Object {$origin.($_.id) = @{}}

# Create synced hashtable
$sync = [System.Collections.Hashtable]::Synchronized($origin)

$job = $dataset | Foreach-Object -ThrottleLimit 3 -AsJob -Parallel {
    $syncCopy = $using:sync
    $process = $syncCopy.$($PSItem.Id)

    $process.Id = $PSItem.Id
    $process.Activity = "Id $($PSItem.Id) starting"
    $process.Status = "Processing"

    # Fake workload start up that takes x amount of time to complete
    start-sleep -Milliseconds ($PSItem.wait*5)

    # Process. update activity
    $process.Activity = "Id $($PSItem.id) processing"
    foreach ($percent in 1..100)
    {
        # Update process on status
        $process.Status = "Handling $percent/100"
        $process.PercentComplete = (($percent / 100) * 100)

        # Fake workload that takes x amount of time to complete
        Start-Sleep -Milliseconds $PSItem.Wait
    }

    # Mark process as completed
    $process.Completed = $true
}

while($job.State -eq 'Running')
{
    $sync.Keys | Foreach-Object {
        # If key is not defined, ignore
        if(![string]::IsNullOrEmpty($sync.$_.keys))
        {
            # Create parameter hashtable to splat
            $param = $sync.$_

            # Execute Write-Progress
            Write-Progress @param
        }
    }

    # Wait to refresh to not overload gui
    Start-Sleep -Seconds 0.1
}