Procesamiento paralelo en PowerShell — Cuándo usar ForEach-Object -Parallel y cuándo usar jobs

· Actualizado el: · · PowerShell, Windows, Procesamiento paralelo, Mejora del rendimiento, Automatización, Mejora operativa, Scripts, Jobs

«Un script que hace ping a 200 equipos para comprobar si están vivos tarda 15 minutos en completar una vuelta» o «el cálculo de hash de los 100 000 archivos de una carpeta compartida no termina nunca» ── cuando la automatización con PowerShell madura hasta cierto punto, siempre se acaba topando con el muro del tiempo de ejecución. Y, en la mayoría de estos procesos, lo que domina es el tiempo de espera. La CPU está ociosa, pero el proceso es lento porque espera, uno por uno y en orden, la respuesta de la red o la E/S del disco. Aquí es donde entra en juego la paralelización.

Con PowerShell 7 se puede usar ForEach-Object -Parallel, y el listón para el procesamiento paralelo bajó drásticamente. Sin embargo, tiene una complicación: si se paraleliza de forma descuidada, el proceso no solo no se acelera, sino que se vuelve más lento, y además los resultados salen dañados. «Actualicé una variable compartida y el valor desapareció», «el orden de la salida cambia cada vez» o «no encuentra la función que definí en el entorno de llamada» son síntomas típicos de usar el procesamiento paralelo sin conocer su mecanismo.

En este artículo se ordenan los mecanismos de paralelización disponibles en PowerShell y se resume, de forma que se pueda decidir en la práctica, el uso de ForEach-Object -Parallel y sus trampas, cuándo usar Start-ThreadJob frente a Start-Job, y hasta los casos en los que no se debe paralelizar.

1. Conclusiones principales

  • ForEach-Object -Parallel se añadió en PowerShell 7.0. No existe en Windows PowerShell 5.1.1
  • Cada bloque de script se ejecuta en un runspace (entorno de ejecución) distinto. Las variables y funciones del entorno de llamada no son visibles tal cual; las variables se pasan con el calificador de ámbito $using:.1
  • El valor predeterminado de -ThrottleLimit es 5. Desde PowerShell 7.1 el grupo de runspaces (runspace pool) se reutiliza, y ThrottleLimit pasa a ser el tamaño de ese grupo. Si necesita crear uno nuevo en cada ejecución, use -UseNewRunspace.1
  • Lo que pasa $using: es una “referencia”. Leerla es seguro, pero para actualizarla hay que usar un tipo seguro para hilos de System.Collections.Concurrent. Actualizar de forma concurrente una Hashtable o una List normales las corrompe.1
  • La paralelización no es una medida que “siempre” acelere. La propia documentación oficial señala que paralelizar un proceso trivial puede acabar siendo mucho más lento de lo habitual. Funciona en procesos con esperas largas y en cálculos que tienen sentido repartir entre varios núcleos.1
  • El orden tanto de la salida como de los errores no está garantizado. El orden de escritura en el flujo de errores también es aleatorio, y un error de terminación dentro del bloque de script solo detiene esa iteración, informándose como PSTaskException.1
  • Con -AsJob se puede lanzar como job. Pero -ThrottleLimit es el grado de paralelismo por cada job, así que si crea varios jobs, el número de ejecuciones simultáneas se multiplica.1
  • Para un paralelismo ligero use Start-ThreadJob; si necesita aislamiento, use Start-Job. Start-Job se ejecuta en un proceso separado, por lo que su sobrecarga es grande, y los resultados vuelven serializados, habiendo perdido sus métodos.23
  • Si va a aplicar el mismo proceso a varios equipos Windows, la ejecución paralela implícita de Invoke-Command es el camino más corto. De forma predeterminada, ejecuta simultáneamente hasta 32 equipos.4

2. Las cuatro opciones de paralelización

2.1 Conocimientos previos — Qué es un runspace

Antes de nada, conviene fijar el concepto de «runspace», que aparece repetidamente en este artículo. Un runspace es, literalmente, el “entorno de ejecución” con el que PowerShell ejecuta un script. Variables, funciones, módulos ya cargados, el directorio actual y, en general, todo el conjunto de estado de la sesión, pertenecen a ese runspace.

En relación con los procesos y los hilos, la situación es la siguiente.

Capa Qué es Significado en este artículo
Proceso Unidad de ejecución vista por el SO (un pwsh.exe) Un proceso puede contener varios runspaces
Hilo El flujo de ejecución que realmente hace correr el código Un runspace se ejecuta sobre un hilo
Runspace El contenedor del estado de PowerShell (variables, funciones, módulos) Si esto está separado, ni las variables ni las funciones se comparten

Como imagen aproximada, sirve pensar que «dentro de un mismo proceso se pueden levantar varias sesiones de PowerShell, cada una con sus propias variables y funciones». ForEach-Object -Parallel es un mecanismo que prepara varios de estos runspaces y ejecuta cada bloque de script en un hilo distinto.1

Casi todos los quebraderos de cabeza de este artículo vienen de aquí. Las variables y funciones del entorno de llamada pertenecen al runspace de ese entorno, así que no son visibles desde otro runspace. Por eso las variables hay que pasarlas explícitamente con $using:, y las funciones hay que empaquetarlas en un módulo y cargarlas en cada runspace. Por otro lado, como el proceso es el mismo, los objetos en memoria sí pueden llegar a compartirse a nivel de instancia. Esto es lo que, más adelante, se traduce en el problema de la seguridad de hilos.

«No es visible, pero sí se comparte» ── esta propiedad, aparentemente contradictoria, es la causa de los accidentes que ocurren al paralelizar sin entender los runspaces.

2.2 Las cuatro alternativas

Antes de nada, tengamos un mapa general. Los mecanismos para «ejecutar de forma simultánea» en PowerShell son, a grandes rasgos, cuatro.

Mecanismo Unidad de ejecución Versiones compatibles Uso recomendado
ForEach-Object -Parallel Hilo (runspace) PowerShell 7.0+1 Aplicar el mismo proceso a cada elemento de una colección. Primera opción
Start-ThreadJob Hilo (runspace) Incluido en PowerShell 7 / en 5.1 se instala desde la Gallery2 Lanzar unos pocos procesos y recogerlos más tarde
Start-Job Proceso separado Estándar tanto en 5.1 como en 73 Procesos que requieren aislamiento o que deben ir en otro proceso
Invoke-Command -ComputerName Cada PC remoto Estándar tanto en 5.1 como en 74 Distribuir el mismo proceso a varios equipos Windows

En la práctica, la decisión es sencilla. Si va a aplicar el mismo proceso a una gran cantidad de objetivos, use ForEach-Object -Parallel; si el objetivo son “varios PC remotos”, la ejecución remota va primero. Esta última ejecuta de forma predeterminada hasta 32 equipos simultáneamente sobre las computadoras que se indiquen, por lo que no hace falta escribir la paralelización a mano.4 Para la configuración de la ejecución remota en sí, consulte «PowerShell Remoting (WinRM): introducción».

Start-Job es pesado porque el job en segundo plano se inicia como un proceso independiente. Además del coste de arrancar el proceso, el resultado vuelve serializado, así que el objeto recibido acaba siendo una «copia deserializada» sin métodos.3 En cambio, Start-ThreadJob se ejecuta en un hilo dentro del mismo proceso, por lo que resulta mucho más ligero.2

3. Fundamentos de ForEach-Object -Parallel

La forma mínima es esta. $_ es el objeto de entrada actual, y las variables externas se referencian anteponiendo $using:.1

$timeout = 2   # Variable del entorno de llamada

$result = $computers | ForEach-Object -Parallel {
    $name = $_
    # Las variables externas no son visibles si no se les antepone $using:
    $ok = Test-Connection -TargetName $name -Count 1 -TimeoutSeconds $using:timeout -Quiet

    # La forma básica es "emitir" el resultado, no actualizar una variable compartida para agregarlo
    [pscustomobject]@{
        Computer = $name
        Alive    = $ok
        CheckedAt = Get-Date
    }
} -ThrottleLimit 20

$result | Where-Object { -not $_.Alive } | Format-Table

Aquí hay un principio de diseño que conviene fijar. En lugar de actualizar una variable compartida, cada bloque de script “emite” su resultado y el entorno de llamada lo recibe todo junto. Si se mantiene esta forma, el problema de la seguridad de hilos ni siquiera llega a plantearse. El código que da problemas en el procesamiento paralelo casi siempre es el que intenta modificar un estado compartido.

Si de verdad necesita recopilar los resultados en una colección compartida, use un tipo seguro para hilos.1

# Patrón que muestra la documentación oficial: ConcurrentDictionary se puede actualizar de forma segura desde varios hilos
$safeDict = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()

Get-Process | ForEach-Object -Parallel {
    $dict = $using:safeDict
    # TryAdd() devuelve un booleano. Si no se descarta, se mezcla True/False con la salida
    $null = $dict.TryAdd($_.ProcessName, $_)
}

# 【Peligro】No es seguro pasar una Hashtable o una List<T> normales con $using: y actualizarlas
# $shared = @{}          # La actualización concurrente corrompe la estructura interna: hay excepciones o pérdida de valores

Aquí precisemos el significado exacto de $using:. La documentación oficial afirma, sobre ForEach-Object -Parallel, que «el calificador Using: pasa la referencia de la variable desde el hilo que invocó el cmdlet hasta el hilo de cada bloque de script en ejecución». Lo que se pasa es una referencia al mismo objeto dentro del mismo proceso. Por eso, leer algo que no cambia es seguro, y en cambio, si se va a reescribir el estado, hace falta un tipo seguro para hilos.1

Y esta propiedad de “pasar una referencia” se limita a la paralelización que se ejecuta dentro del mismo proceso. Start-Job se ejecuta en un proceso distinto, e Invoke-Command -ComputerName en otra máquina, así que el valor pasado con $using: llega como una copia serializada. Modificarlo del otro lado no se refleja en el entorno de llamada, y el objeto que vuelve también es una copia deserializada sin métodos.3 La sintaxis $using: es la misma, pero su significado es distinto, así que no traslade a Start-Job la intuición que tiene de ForEach-Object -Parallel.

4. Cinco trampas habituales

(1) Las funciones del entorno de llamada no son visibles

Como el bloque de script paralelo se ejecuta en un runspace distinto, no se puede llamar tal cual a una función definida en el entorno de llamada. El enfoque correcto es empaquetar el procesamiento común en un módulo e importarlo al principio del bloque de script.

$items | ForEach-Object -Parallel {
    Import-Module 'D:\Scripts\Modules\KsOps' -ErrorAction Stop   # Se carga en cada runspace
    Convert-KsRecord -Input $_
} -ThrottleLimit 8

Sin embargo, el coste de cargar el módulo en cada runspace se produce una vez por cada grado de paralelismo. En procesos que usan un módulo pesado, esta puede ser la causa principal de que, aunque se aumente el paralelismo, se llegue a un techo. Las buenas prácticas para modularizar están recogidas en «Diseño de parámetros y modularización en PowerShell».

(2) Los runspaces se reutilizan, por lo que el estado puede persistir

En PowerShell 7.0 se creaba un runspace nuevo en cada iteración, pero desde 7.1, de forma predeterminada, se reutilizan a partir de un grupo de runspaces (runspace pool).1 Es una mejora importante de rendimiento, pero significa que los cambios de variables de entorno o de directorio actual, y el estado de los módulos cargados en una iteración, pueden ser visibles en iteraciones posteriores que usen el mismo runspace. Si necesita una independencia completa entre iteraciones, especifique -UseNewRunspace (a costa de ser más lento).1

(3) El orden de la salida y de los errores no está garantizado

El orden de la ejecución paralela no es determinista. El orden en que se escribe en el flujo de errores también es aleatorio, y lo mismo ocurre con los flujos de advertencia, detalle e información.1

1..5 | ForEach-Object -Parallel {
    if ($_ -eq 3) { throw "Terminating Error: $_" }   # Solo esta iteración se detiene
    "Output: $_"
}
# Output: 1 / Output: 4 / Output: 2 / Output: 5 (sin orden fijo), Output: 3 no aparece

Un error de terminación dentro del bloque de script solo termina esa iteración, y las demás ejecuciones paralelas continúan. El error se escribe en el flujo de errores como un ErrorRecord cuyo FullyQualifiedErrorId es PSTaskException. Este no es un comportamiento de «si falla uno, se detiene todo», así que hay que agregar uno mismo el resultado de éxito o fracaso de todos los elementos.1

$results = $files | ForEach-Object -Parallel {
    # Dentro del bloque catch, $_ pasa a ser un ErrorRecord,
    # así que el objeto de entrada siempre debe guardarse antes en otra variable
    $file = $_

    try {
        $hash = Get-FileHash -Path $file.FullName -Algorithm SHA256 -ErrorAction Stop
        [pscustomobject]@{ Path = $file.FullName; Hash = $hash.Hash; Error = $null }
    }
    catch {
        # El fallo también se devuelve como "salida", y se clasifica en el entorno de llamada
        [pscustomobject]@{ Path = $file.FullName; Hash = $null; Error = $_.Exception.Message }
    }
} -ThrottleLimit 8

$failed = $results | Where-Object Error
if ($failed) { Write-Warning "Fallaron $($failed.Count) elemento(s)" }

(4) PipelineVariable no se puede usar

El parámetro común -PipelineVariable no es compatible con los escenarios paralelos, ni siquiera anteponiendo $using:.1 Es un punto en el que se suele tropezar al migrar código desde el procesamiento secuencial.

(5) El progreso y los registros se mezclan

Si varios hilos escriben Write-Progress o un registro propio al mismo tiempo, las líneas se mezclan o los archivos entran en conflicto. Lo seguro es no anexar directamente a un archivo de registro dentro del bloque de script, sino devolverlo como un objeto de resultado y escribirlo desde un único punto en el entorno de llamada. El diseño de los flujos de salida se trata en «Diseño de flujos de salida y registro en PowerShell».

5. Cómo determinar ThrottleLimit

-ThrottleLimit es el número de bloques de script que se ejecutan simultáneamente, y su valor predeterminado es 5.1 Desde 7.1, este valor pasa a ser directamente el tamaño del grupo de runspaces.1 El funcionamiento, representado en un diagrama, es el siguiente.

Grupo de runspaces con ThrottleLimit200 elementos de entrada esperan en una cola hasta que se libera un hueco en un grupo de 5 runspaces (ThrottleLimit = 5); cada runspace libre se reutiliza para procesar el siguiente elemento de la cola, y la salida final llega en un orden no determinadoal terminar un elemento,se reutiliza el mismo runspaceGrupo de runspaces ThrottleLimit = 5Runspace 1Runspace 2Runspace 3Runspace 4Runspace 5Entrada: 200 elementosCola de esperaespera hasta que se libera un huecoSalidaorden no determinado

El punto clave es que no se crean 200 runspaces para 200 elementos de entrada, sino que se reutiliza el número de runspaces que indique -ThrottleLimit. De aquí se derivan dos consecuencias. Una, que cuanto más se sube -ThrottleLimit, más aumentan el consumo de memoria y el coste de inicialización. La otra, que, precisamente por reutilizarse, puede persistir el estado de la iteración anterior (apartado (2) del capítulo 4).

Las pautas para decidir el valor se dividen según la naturaleza del proceso.

Naturaleza del proceso Pauta Razón
Espera de red (comprobación de conectividad, llamadas a API) Puede ser mayor que el número de núcleos (probar desde unos 20-50) La CPU está casi ociosa. La limitación está en el otro lado
Espera de E/S de disco Probar desde unos 8-16 Subirlo demasiado aumenta el acceso aleatorio y resulta contraproducente. La diferencia entre SSD y HDD es grande
Cálculo de CPU (hash, compresión, conversión) Aproximadamente el número de núcleos lógicos Más allá de eso solo se desperdicia en cambios de contexto
El destino es un servidor de producción o una API El límite es la capacidad que tolere el otro lado Superar el límite de tasa o de conexiones simultáneas convierte a este script en el causante de una incidencia

La última fila es la más importante en la práctica. Tirar un servidor de producción con tal de acelerar el propio script es poner el carro delante de los bueyes, así que, al tratar con una API interna o un servidor de archivos, hay que fijar el grado de paralelismo propio dentro del «rango que el otro lado pueda soportar». Para el tratamiento de la limitación de tasa de una API, consulte «Integración con una API REST desde PowerShell».

La documentación oficial también advierte sobre el uso de -AsJob. ThrottleLimit es el límite por cada ejecución de ForEach-Object -Parallel, así que si se crean 10 jobs, se ejecutan simultáneamente «10 × ThrottleLimit».1

Un punto más: aunque el nombre -ThrottleLimit sea el mismo, lo que cuenta varía según el comando. Confundirlos lleva a calcular mal el grado de paralelismo, así que aquí se comparan los dos que aparecen en este artículo.

Comando Qué cuenta -ThrottleLimit Valor predeterminado
ForEach-Object -Parallel El número de bloques de script que se ejecutan simultáneamente (= tamaño del grupo de runspaces) 51
Invoke-Command -ComputerName El número de equipos a los que se conecta simultáneamente 324

Es decir, Invoke-Command -ThrottleLimit 32 significa «conectarse simultáneamente a 32 equipos», no «procesar con 32 hilos paralelos en cada equipo». Si se combinan ambos (ejecutar ForEach-Object -Parallel en el destino remoto), tenga en cuenta también que la carga total sobre el otro lado será número de equipos × grado de paralelismo.

6. Casos en los que no conviene paralelizar

La documentación oficial no se anda con rodeos: un runspace nuevo tiene una sobrecarga considerable frente al procesamiento secuencial, y si el script paralelo hace un trabajo trivial, puede acabar siendo mucho más lento de lo habitual; hay que probarlo de verdad y encontrar dónde surte efecto.1 De hecho, hasta hay un ejemplo oficial explícitamente etiquetado como «un uso ineficiente de la paralelización».

El criterio de decisión es simple.

  • El tiempo de proceso por elemento es corto (del orden de milisegundos) → No paralelizar. Cosas como el procesamiento de cadenas o las búsquedas en una tabla hash son más rápidas de forma secuencial
  • El número de elementos es bajo (unas pocas decenas) → No paralelizar. La sobrecarga es relativamente grande
  • Hay una espera de varios cientos de milisegundos o más por elemento → La paralelización merece la pena
  • Hay un cálculo que usa la CPU durante un tiempo prolongado → Surte efecto en varios núcleos

Y, en cualquier caso, hay que medir antes de decidir. Comparar la versión secuencial y la paralela con Measure-Command da la respuesta en cuestión de segundos. Las buenas prácticas de medición y la optimización general de scripts de PowerShell están recogidas en «Dónde mirar cuando un script de PowerShell va lento».

# Comparar la versión secuencial y la paralela con la misma entrada (ejecutar varias veces para ver un valor estable)
# El lado paralelo se ejecuta en otro runspace, por lo que las funciones definidas en el entorno de llamada no son visibles.
# Para que la comparación sea válida, hay que cargar el módulo o escribir el proceso directamente
$seq = Measure-Command {
    $items | ForEach-Object { Invoke-Work $_ }
}
$par = Measure-Command {
    $items | ForEach-Object -Parallel {
        Import-Module 'D:\Scripts\Modules\KsOps'   # ← Sin esto, el comando no se encuentra
        Invoke-Work $_
    } -ThrottleLimit 16
}
'{0:N1} s → {1:N1} s' -f $seq.TotalSeconds, $par.TotalSeconds

7. Cuándo usar Start-ThreadJob y cuándo Start-Job

ForEach-Object -Parallel encaja en la forma de «aplicar el mismo proceso a muchas entradas», pero cuando se quiere ejecutar simultáneamente procesos de naturaleza distinta y recogerlos después, lo adecuado son los jobs.

# Ejecutar procesos distintos al mismo tiempo y esperarlos todos juntos (thread job = ligero)
# El thread job también se ejecuta en otro runspace, así que las funciones del entorno de llamada no son visibles.
# Cargar el módulo al principio de cada job (o usar un bloque de script autocontenido)
$jobs = @(
    Start-ThreadJob -Name 'InventarioUsuariosAD' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsAdUserReport
    }
    Start-ThreadJob -Name 'CapacidadServidorArchivos' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsShareUsage
    }
    Start-ThreadJob -Name 'ResumenLicencias' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsLicenseSummary
    }
)

# Esperar a que terminen y recoger los resultados. Los errores siempre se reciben con -ErrorVariable
$reports = $jobs | Receive-Job -Wait -ErrorAction SilentlyContinue -ErrorVariable jobErrors

# El estado solo pasa a Failed cuando el bloque de script cae con un "error de terminación".
# Un job que termina con un error no terminante, como Write-Error, se queda en Completed,
# así que si solo se mira el estado, "hubo un error pero se cuenta como éxito"
$failed = @($jobs | Where-Object { $_.State -eq 'Failed' })
$jobs | Remove-Job -Force

if ($failed.Count -gt 0 -or @($jobErrors).Count -gt 0) {
    throw "Se produjeron errores en la ejecución paralela: $(($jobErrors | ForEach-Object { $_.Exception.Message }) -join ' / ')"
}

No se usa -AutoRemoveJob aquí porque, en cuanto se recoge el resultado, el job desaparece y ya no se puede comprobar cuál falló. Los errores de Receive-Job son, de forma predeterminada, errores no terminantes, así que si no se hace nada al respecto, se produce la situación más problemática: «parte falló, pero solo vuelve la parte que tuvo éxito, y parece que todo se completó». En la automatización de inventarios o informes, esta pérdida silenciosa acaba quedando como un número incorrecto.

Se elige Start-Job (basado en aislamiento de procesos) en casos como los siguientes.3

  • Procesos que no deben poder afectar al proceso del entorno de llamada (por ejemplo, invocar una DLL nativa que pueda bloquearse)
  • Procesos que necesitan un entorno de proceso distinto (ejecutarse con otra configuración regional o variables de entorno)
  • Procesos en los que se quiere separar el propio entorno de ejecución, como 32 bits frente a 64 bits

A la inversa, si ninguno de estos casos aplica, Start-Job sale perdiendo por la sobrecarga y las restricciones de la serialización. El hecho de que el objeto deserializado no tenga métodos también repercute en el procesamiento posterior.3

8. En entornos con solo Windows PowerShell 5.1

En 5.1 no se puede usar -Parallel. Las opciones realistas son tres.

  1. Start-ThreadJob (instalar el módulo ThreadJob de PowerShell Gallery) ── permite un paralelismo ligero incluso en 5.1. Para la distribución interna, consulte «Distribución y actualización interna de módulos de PowerShell»2
  2. Invoke-Command -ComputerName ── si el objetivo son varios equipos Windows, esto por sí solo ya proporciona paralelismo (32 equipos simultáneos de forma predeterminada)4
  3. Instalar PowerShell 7 ── como 5.1 y 7 pueden coexistir, también es realista optar por ejecutar solo los procesos pesados en 7

La instalación de la opción 1 es la siguiente. En un 5.1 sin modificar, ejecutar Install-Module tal cual puede fallar, así que se indican también los dos puntos donde se suele tropezar.

# 【1】PowerShell Gallery exige TLS 1.2 o posterior.
#      En Windows PowerShell 5.1 no está habilitado de forma predeterminada,
#      y puede fallar con un mensaje como "Se cerró la conexión subyacente".
#      Habilitar TLS 1.2 solo para esta sesión antes de ejecutar
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# 【2】Instalación. Con CurrentUser no hace falta ser administrador
Install-Module ThreadJob -Scope CurrentUser

# 【3】Comprobación
Import-Module ThreadJob
Get-Command -Module ThreadJob     # Si aparece Start-ThreadJob, la instalación es correcta

Los dos puntos donde se suele tropezar son estos.

  • TLS 1.2 ── desde abril de 2020, PowerShell Gallery da por hecho una conexión con TLS 1.2 o posterior. En 5.1 puede ser necesaria la línea anterior5
  • Confianza del repositorio y directiva de ejecución ── de forma predeterminada, PSGallery se trata como un «repositorio no confiable», por lo que Install-Module pide confirmación. Para instalarlo sin intervención use -Force; si va a usarlo de forma habitual, márquelo como confiable de antemano con Set-PSRepository -Name PSGallery -InstallationPolicy Trusted. Además, si la directiva de ejecución es Restricted (el valor predeterminado en un cliente Windows), la carga de scripts se detiene, por lo que hace falta una configuración equivalente a Set-ExecutionPolicy -Scope CurrentUser RemoteSigned. Evite dejarla en Unrestricted de forma permanente6

En la práctica, la opción 3 suele acabar siendo la más económica, y en nuestra empresa también recomendamos «ejecutar solo ese proceso en 7» antes que «escribir código elaborado en 5.1 para paralelizar». El enfoque de coexistencia y migración está recogido en «Diferencias entre Windows PowerShell 5.1 y PowerShell 7».

9. Reglas prácticas (tabla de decisión)

Qué se quiere hacer Elección Notas
Aplicar el mismo proceso a muchos objetivos (comprobación de conectividad, cálculo de hash, llamadas a API) ForEach-Object -Parallel Exclusivo de PowerShell 7. Diseñar el resultado para que se devuelva como salida1
El mismo proceso en varios equipos Windows Invoke-Command -ComputerName 32 equipos simultáneos de forma predeterminada. No hace falta paralelizar a mano4
Ejecutar simultáneamente procesos de naturaleza distinta Start-ThreadJob Ligero. Se recoge con Receive-Job -Wait2
Se necesita aislamiento de procesos Start-Job Pesado. El resultado se deserializa3
Se quiere reunir el resultado en un solo lugar Devolverlo como salida / ConcurrentDictionary No es posible actualizar de forma concurrente una Hashtable o una List normales1
Elemento ligero, pocos elementos No paralelizar La sobrecarga lo vuelve más lento1
El destino es un servidor de producción o una API Fijar ThrottleLimit según el criterio del otro lado El límite de tasa o de conexiones simultáneas es, de hecho, el tope
Se necesita independencia total entre iteraciones -UseNewRunspace Evita el arrastre de estado por la reutilización (más lento)1

10. Resumen

  • ForEach-Object -Parallel es una función de PowerShell 7.0 en adelante, y ejecuta cada bloque de script en un runspace distinto. La forma básica es pasar las variables del entorno de llamada con $using: y cargar las funciones empaquetadas en un módulo.
  • En lugar de actualizar una variable compartida, si se diseña de forma que cada iteración emita su resultado y el entorno de llamada lo agregue, el problema de la seguridad de hilos se evita casi por completo. Si de verdad hace falta compartir algo, use los tipos de la familia Concurrent.
  • El ThrottleLimit predeterminado es 5. Para procesos dominados por la espera puede ser grande, para procesos de CPU conviene un valor cercano al número de núcleos, y para procesos con un destino externo el límite lo marca la capacidad de ese destino.
  • El orden de la salida y de los errores no está garantizado, y un error de terminación solo detiene esa iteración. Agregue usted mismo el resultado de éxito o fracaso de todos los elementos.
  • La paralelización no es una solución universal. En procesos ligeros o con pocos elementos, la sobrecarga la vuelve más lenta. Compare siempre con la versión secuencial usando Measure-Command antes de adoptarla.
  • En entornos 5.1, use Start-ThreadJob; si es remoto, Invoke-Command; y si aun así no basta, lo realista es plantearse instalar PowerShell 7.

Descarga del código de ejemplo

El código tratado en este artículo se distribuye empaquetado, listo para ejecutarse tal cual. Incluye plantillas de uso práctico de ForEach-Object -Parallel y Start-ThreadJob.

Descargar el código de ejemplo (zip)

Los ejemplos de este artículo se han ejecutado y verificado realmente con PowerShell 7.6 (8 pruebas de Pester). Si ejecuta Invoke-SampleTests.ps1, incluido en el zip, puede reproducir la misma verificación en su propio equipo.

# Análisis de sintaxis + análisis estático + pruebas de Pester
./Invoke-SampleTests.ps1

Los valores de configuración (rutas, nombres de servidor, ID de inquilino, etc.) son solo ejemplos. No los ejecute tal cual en un entorno de producción; adáptelos al entorno de su propia organización.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se encarga de acelerar scripts operativos que tardan demasiado, revisar diseños de automatización que incluyen procesamiento paralelo, e investigar problemas del tipo «al paralelizar, el resultado salió mal».

Referencias

  1. Microsoft Learn, ForEach-Object. Sobre la adición del conjunto de parámetros -Parallel en PowerShell 7.0, la ejecución de cada bloque de script en un runspace nuevo, el paso de variables mediante el calificador de ámbito $using:, el valor predeterminado de -ThrottleLimit (5) como tamaño del grupo de runspaces, la reutilización de runspaces desde 7.1 y la creación de uno nuevo con -UseNewRunspace, el comportamiento de -AsJob y -TimeoutSeconds, la necesidad de un tipo seguro para hilos como System.Collections.Concurrent para actualizar una referencia pasada con $using:, el orden no determinado de la salida de errores no terminantes, el hecho de que un error de terminación solo finaliza la instancia paralela individual con FullyQualifiedErrorId igual a PSTaskException, la falta de compatibilidad de PipelineVariable en escenarios paralelos, la sobrecarga considerable de un runspace nuevo que puede volver más lento un proceso trivial, y que -ThrottleLimit es un límite por job al usar -AsJob.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26

  2. Microsoft Learn, Start-ThreadJob. Sobre el hecho de que Start-ThreadJob ejecuta el bloque de script en un hilo independiente dentro del mismo proceso, en lugar de en un proceso distinto, siendo más ligero que Start-Job; que se puede manejar con los cmdlets de job estándar (Receive-Job, etc.); y el control del número de ejecuciones simultáneas mediante -ThrottleLimit.  2 3 4 5

  3. Microsoft Learn, about_Jobs. Sobre el hecho de que un job en segundo plano ejecuta el comando de forma asíncrona en un proceso nuevo, el manejo de jobs con Start-Job, Get-Job, Receive-Job y Wait-Job, y que el resultado del job vuelve tras pasar por un proceso de serialización.  2 3 4 5 6 7

  4. Microsoft Learn, Invoke-Command. Sobre la posibilidad de especificar varios equipos en -ComputerName para ejecutar el mismo comando, que -ThrottleLimit limita el número de conexiones simultáneas con un valor predeterminado de 32, y la ejecución en segundo plano mediante -AsJob.  2 3 4 5 6

  5. Microsoft PowerShell Team Blog, PowerShell Gallery TLS Support. Sobre el hecho de que, desde abril de 2020, PowerShell Gallery usa TLS 1.2 de forma predeterminada y exige que el cliente también se conecte con TLS 1.2 o posterior, y sobre la solución recomendada para Windows PowerShell 5.1 de establecer Tls12 en [Net.ServicePointManager]::SecurityProtocol antes de conectarse. 

  6. Microsoft Learn, about_Execution_Policies. Sobre el hecho de que la directiva de ejecución es el mecanismo que controla las condiciones para cargar scripts; que, si no está definida en ningún ámbito, la directiva efectiva es Restricted en un cliente Windows y RemoteSigned en Windows Server; que con Restricted no se pueden ejecutar archivos de script, incluidos .ps1 y .psm1; y que se puede especificar -Scope para aplicarla solo al usuario actual o al proceso. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Se puede usar ForEach-Object -Parallel en Windows PowerShell 5.1?
No se puede. -Parallel es un conjunto de parámetros añadido en PowerShell 7.0 y, sencillamente, no existe en 5.1. Si necesita paralelizar en 5.1, las opciones son Start-ThreadJob del módulo ThreadJob (instalable desde PowerShell Gallery), el Start-Job estándar (basado en procesos separados y, por tanto, más pesado), o construir usted mismo un RunspacePool. Si el objetivo es acelerar scripts internos, en lugar de escribir un paralelismo elaborado en 5.1 resulta más ventajoso, también en términos de mantenibilidad, instalar PowerShell 7 y usar -Parallel.
Paralelicé el proceso, pero se volvió más lento. ¿Por qué?
Porque la sobrecarga de la ejecución paralela supera al propio procesamiento. ForEach-Object -Parallel ejecuta cada bloque de script en un runspace distinto, lo que implica una sobrecarga considerable frente al procesamiento secuencial, y la documentación oficial señala explícitamente que, si el script paralelo realiza un trabajo trivial, puede acabar siendo mucho más lento de lo habitual. Cuando cada elemento se procesa en unos pocos milisegundos, o cuando solo hay unas pocas decenas de elementos, lo normal es que no paralelizar resulte más rápido. El paralelismo da resultado en procesos con esperas largas de red o de E/S de archivos, o en cálculos que tienen sentido repartir entre varios núcleos.
¿Se puede escribir un valor, desde dentro de un bloque de script paralelo, en una variable pasada con $using:?
Leer el valor referenciado es seguro, pero escribir en él no lo es, salvo que el tipo de destino sea seguro para hilos (thread-safe). $using: es un mecanismo que pasa la referencia de la variable desde el hilo que hace la llamada hasta el hilo de cada bloque de script, y como varios hilos la tocan a la vez, actualizar una Hashtable o una List normales acaba corrompiéndolas. Si necesita recopilar resultados agregados, use tipos seguros para hilos del espacio de nombres System.Collections.Concurrent, como ConcurrentDictionary o ConcurrentBag, o bien diseñe el proceso para que cada bloque de script simplemente emita su valor como salida y sea el entorno de llamada quien lo recoja.
¿Cuál es el valor correcto para ThrottleLimit?
Depende de la naturaleza del proceso. El valor predeterminado es 5. En procesos dominados por la espera de red o de E/S de archivos (comprobaciones de conectividad con servidores, llamadas a API, etc.), incluso un valor mayor que el número de núcleos de CPU da resultado. En cambio, en cálculos que saturan la CPU, superar aproximadamente el número de núcleos solo genera contención y ralentiza el proceso. Cuando el destino es un servidor de producción o una API, el límite no depende solo de sus propias necesidades, sino también del límite de conexiones simultáneas o de la limitación de tasa (rate limit) del otro lado. El enfoque seguro es medir primero con el valor predeterminado de 5 y luego duplicarlo para comprobar si se obtiene alguna mejora.
No puedo llamar a una función propia desde dentro de un bloque de script paralelo.
Los bloques de script paralelos se ejecutan en un runspace distinto al del entorno de llamada, por lo que las funciones y variables definidas en el ámbito de dicho entorno no son visibles tal cual. Hay dos soluciones: convertir el procesamiento común en un módulo (.psm1) e importarlo con Import-Module al principio del bloque de script, o pasar la definición de la función como texto mediante $using: y redefinirla dentro del bloque de script. Desde el punto de vista de la mantenibilidad, se recomienda la primera opción. Sin embargo, tenga en cuenta que cargar el módulo en cada runspace tiene un coste, así que si el módulo es pesado, aumentar el grado de paralelismo puede llegar a un techo por esta razón.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog