- PowerShell ofrece múltiples formas de iterar (foreach, ForEach-Object, método ForEach, for, while, do-while, do-until) con diferentes implicaciones de rendimiento y memoria.
- ForEach-Object -Parallel en PowerShell 7 permite procesar elementos del pipeline en paralelo, controlando la concurrencia mediante ThrottleLimit y sacrificando el orden de salida.
- Start-Job, Start-ThreadJob, runspaces y antiguos Workflows con foreach -Parallel proporcionan otros modelos de paralelismo con distintas sobrecargas y niveles de aislamiento.
- Medir el rendimiento, evitar errores típicos en foreach y aplicar buenas prácticas de control de flujo y manejo de errores es clave para construir pipelines potentes y estables.
Cuando empiezas a trabajar con PowerShell, tarde o temprano te topas con el mismo problema: tienes muchos objetos que procesar en bucle y el rendimiento se desploma. Buzones de Exchange, ficheros de un servidor, procesos en múltiples equipos, por ejemplo para gestionar múltiples PCs y servidores… todo pasa por el pipeline y el clásico ForEach-Object. Pero, claro, ese bucle va elemento a elemento, de forma secuencial, y llega un momento en el que se queda corto.
Con la llegada de PowerShell 7 el panorama cambió bastante. Ahora tenemos a nuestra disposición pipelining paralelo con ForEach-Object -Parallel y otros enfoques de concurrencia que permiten exprimir mejor los núcleos de la CPU y el tiempo de espera de operaciones de red o disco. El truco está en entender bien qué hace cada opción, cuándo conviene usarla y cómo limitar la simultaneidad para no cargarnos el rendimiento en lugar de mejorarlo.
Bucles en PowerShell: el punto de partida
Antes de meternos de lleno en los pipelines paralelos, conviene tener claro qué son los bucles y cómo se comportan las diferentes construcciones de PowerShell. En esencia, un bucle es una forma de repetir una o varias instrucciones múltiples veces, ya sea un número de iteraciones conocido o mientras se cumple una condición. En administración de sistemas esto es el pan de cada día: desde aplicar permisos a todos los usuarios de un grupo, hasta recorrer miles de ficheros en un directorio o procesar eventos con Get-WinEvent.
PowerShell ofrece varias estructuras de control clásicas como for, foreach, while, do-while y do-until, además de cmdlets y métodos que también permiten iterar, como ForEach-Object o el método .ForEach() en colecciones. Todas ellas forman la base sobre la que luego podremos construir lógicas más avanzadas, incluyendo la ejecución en paralelo.
foreach vs ForEach-Object vs método ForEach
En PowerShell hay cierta confusión porque tenemos tres formas distintas de hacer “foreach” y no se comportan igual. A nivel sintáctico se parecen, pero internamente la cosa cambia bastante, sobre todo en lo que respecta al uso de memoria y al pipeline.
Palabra clave foreach (foreach statement)
La palabra clave foreach es la construcción de bucle “clásica” del lenguaje. Carga la colección completa en memoria y recorre cada elemento secuencialmente. Es cómoda y muy rápida cuando ya tienes un array u otra colección en memoria y su tamaño es razonable. El procesamiento sigue estos pasos: primero PowerShell determina cuántos elementos hay, luego asigna cada elemento, uno por uno, a una variable (por ejemplo $item) y ejecuta el bloque de código para cada iteración.
Este enfoque es ideal cuando trabajas con colecciones en memoria bien acotadas, como una lista de procesos, un conjunto de rutas o un rango de números. Sin embargo, no está pensado para leer directamente desde el pipeline, y si la colección es muy grande puede consumir bastante memoria.
Cmdlet ForEach-Object en el pipeline
El cmdlet ForEach-Object funciona de manera distinta: está diseñado para procesar un objeto cada vez según va llegando por el pipeline. Es decir, no necesita tener toda la colección cargada desde el principio. Esto lo hace muy eficiente para escenarios donde el origen de datos puede generar muchos elementos (por ejemplo, Get-ChildItem sobre un árbol de carpetas enorme) o cuando no quieres o no puedes guardar toda la colección en memoria, o al usar herramientas externas como Git desde PowerShell.
A cambio, tiene una ligera sobrecarga por el propio mecanismo del pipeline y suele ser algo más lento que la palabra clave foreach cuando ambos trabajan sobre la misma colección en memoria. Sin embargo, gana puntos en escalabilidad y memoria, lo que en entornos de producción suele pesar más que exprimir unas milésimas de velocidad por objeto.
Método ForEach() en colecciones
Desde PowerShell 5, muchas colecciones como arrays y listas exponen el método .ForEach(), que permite aplicar un scriptblock a cada elemento de forma muy concisa. Por ejemplo, $array.ForEach({ ... }). Este método es orientado a objetos y viene muy bien cuando quieres encadenar operaciones y trabajar de forma fluida sobre colecciones ya creadas.
En términos de rendimiento, el método .ForEach() suele ser más eficiente que el cmdlet ForEach-Object cuando todo está ya en memoria, porque evitas parte de la mecánica del pipeline. Aun así, comparte la misma limitación básica: trabaja sobre colecciones que ya han sido materializadas, por lo que no es la herramienta adecuada para procesamientos “streaming” de grandes volúmenes de datos.
Diferencias prácticas entre foreach y ForEach-Object
En la práctica del día a día aparecen preguntas como: “¿por qué este script usa Get-Mailbox ... | ForEach-Object { ... } y otro hace primero $Mailboxes = Get-Mailbox ... y luego un foreach ($Mailbox in $Mailboxes)?”. La diferencia principal es que en el primer caso todo va a través de pipeline, procesando cada buzón a medida que llega, mientras que en el segundo se almacena toda la colección en la variable $Mailboxes y después se recorre.
La elección no es solo cuestión de estilo: si manejas miles de elementos, el uso de memoria y la forma de gestionar los errores pueden variar bastante, y ahí el pipeline suele ser más amigable con el consumo, a costa de un ligero coste de rendimiento por objeto.
Otros tipos de bucles: for, while, do-while, do-until
Además del ecosistema foreach, PowerShell incluye otros bucles tradicionales que siguen siendo muy útiles. El bucle for permite iterar sobre un rango de números, incrementando o decrementando un índice hasta que se deja de cumplir una condición. Es perfecto para recorrer arrays por posición, saltar elementos o acceder de forma inversa.
Los bucles do-while y do-until ejecutan el cuerpo al menos una vez y luego comprueban la condición: en do-while se repite mientras se cumpla, y en do-until se repite hasta que se cumpla. Son muy útiles cuando necesitas que el bloque se ejecute siempre al menos una vez, por ejemplo al pedir datos al usuario o al probar un recurso hasta que responda correctamente.
El bucle while evalúa la condición justo al principio; si no se cumple, no entra en el cuerpo. Es el clásico “mientras esto siga siendo verdad” que todos conocemos de otros lenguajes. En tareas de automatización puede servir para vigilar un servicio, un valor numérico, o el estado de un recurso hasta alcanzar un objetivo.
Control fino del flujo: break, continue y return
Para controlar mejor lo que ocurre dentro de los bucles tenemos tres palabras clave clave: break, continue y return. Aunque a veces se pasan por alto, marcan bastante la diferencia en scripts complejos.
La instrucción break permite salir inmediatamente del bucle actual. Es muy útil cuando recorres un array o una lista buscando algo en concreto: en cuanto lo encuentras, haces break y te ahorras seguir iterando sin necesidad.
La palabra continue salta al siguiente ciclo del bucle, ignorando el resto de instrucciones de la iteración actual. Se usa mucho para “filtrar” elementos: si algo no cumple una condición, haces continue y solo procesas de verdad lo que te interesa.
Por último, return no solo rompe el bucle, sino que además devuelve un valor desde una función o un script. Es como decir “ya tengo lo que buscaba, aquí lo tienes, y me voy”. Usado con cabeza, simplifica bastante la lógica de los scripts.
ForEach-Object -Parallel en PowerShell 7
Con PowerShell 7 se introdujo una novedad muy potente: el parámetro -Parallel del cmdlet ForEach-Object. Esta funcionalidad, añadida inicialmente en la beta 3 de la versión 7.0, permite procesar varios elementos del pipeline a la vez, sin tener que recurrir a módulos externos o a código complejo con runspaces.
La idea es sencilla: en lugar de tratar un objeto tras otro de forma secuencial, el cmdlet puede lanzar múltiples iteraciones en paralelo, cada una ejecutando el bloque de script sobre un elemento distinto de la colección. De esta manera, si tienes trabajos independientes entre sí (como varias peticiones HTTP, múltiples copias de ficheros a diferentes destinos o consultas contra muchos buzones), puedes reducir el tiempo total de ejecución de forma significativa.
Comportamiento de ForEach-Object antes y después de -Parallel
Sin el parámetro -Parallel, ForEach-Object procesa los elementos en orden, uno a uno, esperando a que cada bloque termine antes de pasar al siguiente. Si haces una prueba simple con 10 objetos y una pequeña pausa artificial, verás que el tiempo total es prácticamente la suma de los 10 tiempos individuales.
Activando -Parallel el mismo código puede llegar a ejecutarse varias veces más rápido, porque se reparten las iteraciones entre varios subprocesos. Un ejemplo típico: lo que tarda unos 10 segundos en modo secuencial puede caer a menos de 3 segundos en paralelo, según el número de tareas simultáneas y el tipo de trabajo que hagas dentro del bloque.
Orden de salida y número de operaciones simultáneas
Hay un detalle importante: cuando usas ForEach-Object -Parallel, el orden de los resultados ya no está garantizado. Los elementos se devuelven según van terminando las tareas, no siguiendo el orden original de entrada. Si tu lógica depende del orden, tendrás que reordenar después o incluir información de índice para reconstruirlo.
Por defecto, PowerShell 7 ejecuta cinco operaciones simultáneas. Si necesitas ajustar esto, usas el parámetro ThrottleLimit, que define cuántos elementos como máximo se procesan al mismo tiempo. Configurarlo correctamente es clave para encontrar el equilibrio entre velocidad y consumo de recursos.
Cuándo no conviene paralelizar
No todos los problemas se resuelven metiendo paralelismo a saco. En algunos escenarios, como transferencias de ficheros pesadas a través de una red limitada, lanzar demasiadas operaciones en paralelo puede saturar el ancho de banda y provocar que todo vaya, paradójicamente, más lento. Lo mismo ocurre con APIs o servicios remotos que tienen límites de peticiones.
En otros casos, como operaciones que dependen estrictamente del orden de ejecución o que modifican un mismo recurso compartido, el paralelismo puede introducir condiciones de carrera o inconsistencias. En estos contextos suele ser mejor seguir con un enfoque secuencial o aplicar paralelismo más granular y controlado.
Otras opciones para ejecutar en paralelo en PowerShell
Además de ForEach-Object -Parallel, PowerShell ofrece varias formas de trabajar en paralelo, tanto en versiones antiguas como en la rama 7.x. Cada una tiene pros y contras, y conviene conocerlas para escoger bien la herramienta.
Start-Job: trabajos en procesos independientes
El cmdlet Start-Job existe en todas las versiones de PowerShell y lanza trabajos en procesos separados, cada uno con su propia instancia de PowerShell. Esto proporciona un aislamiento fuerte, pero tiene mucha sobrecarga: crear procesos nuevos, serializar y deserializar los objetos de vuelta, etc.
En muchos escenarios, un bucle secuencial puede resultar más rápido que abusar de Start-Job, especialmente cuando las tareas son cortas o se devuelven muchos datos complejos. Además, Start-Job no incluye de serie un ThrottleLimit, por lo que tienes que gestionar tú mismo cuántos trabajos se ejecutan de forma simultánea.
Start-ThreadJob: trabajos basados en subprocesos
El módulo ThreadJob proporciona el cmdlet Start-ThreadJob, que utiliza runspaces (espacios de ejecución) para crear trabajos más ligeros que los de Start-Job. Al compartir proceso, se evitan muchas de las pérdidas de información de tipos que provoca la serialización entre procesos.
En PowerShell 7 y versiones posteriores, el módulo ThreadJob ya viene incluido, mientras que en Windows PowerShell 5.1 se puede instalar desde la PowerShell Gallery. Start-ThreadJob soporta el parámetro ThrottleLimit, con lo que puedes controlar cuántos trabajos de este tipo se ejecutan a la vez, evitando agotar recursos del sistema.
Runspaces y SDK de PowerShell
Para escenarios avanzados, puedes trabajar directamente con el espacio de nombres System.Management.Automation.Runspaces del SDK de PowerShell. Esto permite crear tu propia lógica de paralelismo muy fina, controlando la gestión de runspaces, pools, sincronización, etc. De hecho, tanto ForEach-Object -Parallel como Start-ThreadJob se apoyan internamente en runspaces.
Este enfoque ofrece máxima flexibilidad y rendimiento potencial, pero también implica más complejidad y código “de plumbing”. En muchos casos, con los cmdlets integrados (ForEach-Object -Parallel y Start-ThreadJob) se cubren la mayoría de necesidades y no hace falta complicarse tanto.
Workflows y foreach -Parallel en Windows PowerShell
En Windows PowerShell 5.1 existía la funcionalidad de Workflows, un tipo especial de script pensado para tareas de larga duración, con capacidad de pausado, reanudación y ejecución en paralelo. Dentro de un workflow se podía usar la construcción foreach -Parallel, que ejecuta el bloque de script una vez por cada elemento de una colección al mismo tiempo.
La sintaxis básica de foreach -Parallel es algo así como foreach -Parallel ($item in $collection) { ... }, y solo es válida dentro de un flujo de trabajo. Los elementos de la colección se procesan en paralelo, mientras que los comandos dentro del bloque se ejecutan de forma secuencial por cada elemento (aunque también se puede combinar con bloques parallel { ... } internos).
Los workflows, sin embargo, no están disponibles en PowerShell 7 ni en versiones posteriores, y tampoco se recomiendan para desarrollos nuevos. Aun así, es interesante conocer la sintaxis y el concepto si mantienes scripts heredados que usan foreach -Parallel, ya que su comportamiento difiere tanto del foreach normal como de ForEach-Object -Parallel.
Limitar concurrencia con ThrottleLimit
El hecho de que algo pueda ejecutarse en paralelo no significa que vaya a ser más rápido. De hecho, aplicaciones muy I/O intensivas o scripts ligeros pueden perder rendimiento si hay demasiadas tareas simultáneas, ya sea por saturar CPU, disco, red o simplemente por la sobrecarga de gestionar tantos hilos a la vez.
Para equilibrar este aspecto, Start-ThreadJob y ForEach-Object -Parallel ofrecen el parámetro ThrottleLimit, que establece el máximo de trabajos o iteraciones que se pueden ejecutar a la vez. Si se intenta lanzar más trabajos de los permitidos, estos se quedan en cola hasta que alguno finaliza y libera hueco.
Desde PowerShell 7.1, ForEach-Object -Parallel reutiliza runspaces de un pool por defecto, y el valor de ThrottleLimit marca el tamaño de ese pool. El valor predeterminado es 5, que suele ser un buen punto de partida. También existe un modificador para crear un runspace nuevo por cada iteración (UseNewRunspace), pero solo compensa cuando necesitas un aislamiento muy fuerte y aceptas más sobrecarga.
En cambio, el cmdlet Start-Job carece de ThrottleLimit, por lo que, si no lo controlas manualmente, puedes acabar con decenas o cientos de procesos de PowerShell corriendo a la vez, con el impacto correspondiente en rendimiento y memoria.
Medir el rendimiento de los diferentes enfoques
Una forma sensata de decidir qué modelo de paralelismo usar es medir tiempos de ejecución reales para tu caso de uso. Para ello se puede utilizar una función como Measure-Parallel, que compara la velocidad de varios enfoques: Start-Job, Start-ThreadJob, ForEach-Object -Parallel y Start-Process.
Esta función crea una colección simulada de entradas (por ejemplo nombres de ficheros ZIP), genera varios “batches” o tandas de trabajo y, para cada enfoque, mide cuánto tarda en procesar una cantidad dada de elementos, con un tamaño de lote configurado mediante BatchSize y un total de trabajos JobCount. También adapta los enfoques disponibles según la versión de PowerShell y la presencia o no de Start-ThreadJob.
En el interior, Measure-Parallel configura un ejecutable (como cmd.exe o sh) con una lista de argumentos, y define un hashtable con implementaciones específicas para cada aproximación. Con Measure-Command calcula el tiempo total de procesar todos los lotes para cada alternativa, permite ver mensajes detallados con -Verbose y, al final, devuelve un objeto con el resumen de tiempos para poder compararlos fácilmente.
Ejecutando algo como Measure-Parallel -Approach All -BatchSize 5 -JobCount 20 -Verbose en un equipo Windows con PowerShell 7.5.1 se observa que, en ese caso concreto, Start-Process consigue el menor tiempo, seguido de cerca por ForEach-Object -Parallel y Start-ThreadJob, mientras que Start-Job resulta ser la opción más lenta debido a la gran sobrecarga que implica lanzar procesos hijos de PowerShell.
Paralelismo y bucles foreach “tradicionales”
Algo que suele ocurrir en la práctica es que, a medida que migras workflows y scripts antiguos a PowerShell 7, empiezas a sustituir runspaces manuales, módulos de terceros como PoshRSJob o Workflows por las herramientas ya integradas, como Start-ThreadJob y ForEach-Object -Parallel. Esto simplifica el código y reduce la cantidad de dependencias externas. También es habitual integrar soluciones de automatización como PowerShell DSC.
Hay administradores que veían que venían usando pools de runspaces manuales pero empezaron a sufrir comportamientos inestables (por ejemplo, scripts que terminaban de forma aleatoria en entornos productivos) y, ante la dificultad de depurar estos problemas, han optado por reducir la complejidad y apoyarse en funcionalidades paralelas integradas que ya están probadas y soportadas por el propio equipo de PowerShell.
Técnicas avanzadas con foreach: anidación, filtrado y manejo de errores
Más allá del paralelismo, los bucles foreach permiten construir lógicas muy ricas cuando combinas sentencias if, anidación de bucles y manejo de excepciones.
Dentro de un foreach es habitual usar condicionales if para procesar los elementos de forma distinta según sus propiedades. Por ejemplo, recorrer todos los archivos de un directorio y solo actuar sobre los que superan determinado tamaño, copiarlos a otra ubicación, comprimirlos o eliminarlos si cumplen ciertas reglas de antigüedad.
También puedes anidar bucles foreach para generar combinaciones de elementos de varias colecciones: por ejemplo, listas de parámetros de prueba, productos cartesianos entre números y letras, o recorridos de datos bidimensionales. En este caso es clave no reutilizar el mismo nombre de variable en bucles interno y externo, para evitar confusiones y errores sutiles.
Respecto al manejo de errores, si un foreach se topa con una excepción sin tratar, es posible que el script completo se detenga, lo que no siempre es deseable. La forma habitual de robustecer el bucle es envolver la lógica interna en un bloque try { ... } catch { ... }, de modo que, si algo falla para un elemento concreto, puedas registrar el error y seguir con el resto de la colección sin parar toda la ejecución.
Errores frecuentes y buenas prácticas con foreach
Hay algunos tropiezos típicos al trabajar con bucles foreach en PowerShell. Uno de ellos es usar el mismo nombre de variable en bucles anidados, lo que provoca que el valor interno “pise” al externo y genere resultados impredecibles. La solución es sencilla: utilizar nombres distintos para cada nivel de anidación.
Otro fallo habitual es abusar de break sin pensarlo bien, provocando que el bucle termine antes de procesar toda la colección, cuando en realidad lo que se quería era simplemente saltar ciertos elementos. En muchos casos continue es más apropiado, porque permite omitir iteraciones concretas sin abandonar el bucle por completo.
También hay que andarse con ojo con los bucles infinitos, sobre todo cuando se modifica la misma colección que se está recorriendo. Si durante la iteración añadimos elementos al mismo array sin ningún tipo de control, es fácil acabar con un bucle que nunca termina. Una técnica muy útil es iterar sobre una copia de la colección original, haciendo las modificaciones necesarias sobre la versión “real”.
En cuanto a mejores prácticas generales, suele ser recomendable usar ForEach-Object para datos que llegan por pipeline, manteniendo el cuerpo de los bucles lo más legible posible a base de funciones auxiliares, y aplicando una indentación y comentarios consistentes para facilitar el mantenimiento del script con el paso del tiempo.
Dominar las distintas variantes de foreach y sus opciones de paralelismo en PowerShell, desde ForEach-Object -Parallel y Start-ThreadJob hasta los antiguos foreach -Parallel en Workflows, te permite diseñar scripts mucho más rápidos y robustos, eligiendo en cada caso si te interesa priorizar rendimiento, memoria, orden de salida o simplicidad de código, en lugar de limitarte siempre al bucle secuencial de toda la vida.
Redactor apasionado del mundo de los bytes y la tecnología en general. Me encanta compartir mis conocimientos a través de la escritura, y eso es lo que haré en este blog, mostrarte todo lo más interesante sobre gadgets, software, hardware, tendencias tecnológicas, y más. Mi objetivo es ayudarte a navegar por el mundo digital de forma sencilla y entretenida.