Cómo ejecutar PowerShell desde C# (CSharp) y recibir los resultados como objetos

· Actualizado el: · · C#, CSharp, PowerShell, Windows, .NET, Automatización, Aprovechamiento de activos existentes

Ejecutar PowerShell desde C# es una necesidad habitual en aplicaciones de negocio y herramientas internas. Por ejemplo, en procesos como los siguientes:

  • Obtener la lista de servicios de Windows
  • Consultar procesos o el registro de eventos
  • Invocar scripts de PowerShell existentes desde una aplicación C#
  • Ejecutar comandos de PowerShell desde una pequeña herramienta GUI para administradores
  • Incorporar poco a poco a una aplicación .NET los activos de automatización que ya existen en PowerShell

Si solo se trata de ejecutarlo de forma sencilla, basta con iniciar powershell.exe o pwsh.exe como un proceso externo y leer la salida estándar como texto. Sin embargo, con ese método se pierde una de las grandes ventajas de PowerShell: la «canalización de objetos» (object pipeline).

Los resultados de PowerShell no son, en realidad, simple texto. El resultado de Get-Process es un objeto de proceso, y el de Get-Service es un objeto de servicio. Si desde C# se puede recibir esa estructura tal cual, sin perderla, ya no hace falta analizar cadenas de texto y el procesamiento resulta bastante más seguro.

En este artículo se explican los fundamentos de cómo ejecutar PowerShell desde C# y recibir los resultados como PSObject.

El código que aparece en este artículo está publicado en GitHub como un conjunto de ejemplos completo, que se puede compilar y ejecutar (una biblioteca con el wrapper de ejecución y el procesamiento de conversión, una demo de consola que muestra cada capítulo del artículo, y pruebas unitarias que verifican la recepción de PSObject y el manejo de errores).

csharp-run-powershell-receive-objects - komurasoft-blog-samples (GitHub)

1. Usar el PowerShell SDK en lugar de iniciar un proceso externo

Las formas de invocar PowerShell desde C# se pueden dividir, a grandes rasgos, en dos.

Método Características Casos adecuados
Iniciar powershell.exe / pwsh.exe con ProcessStartInfo Lee la salida estándar y el error estándar como texto Ejecución simple de scripts por lotes existentes, procesos que solo dejan un registro
Usar System.Management.Automation.PowerShell Permite recibir el resultado como PSObject Procesamiento que transforma el resultado en C#, herramientas de administración, aplicaciones de negocio

En este artículo se trata la segunda opción. Con System.Management.Automation.PowerShell se puede construir y ejecutar una canalización de PowerShell desde código C#. Lo importante es que el valor de retorno no es una cadena de texto, sino básicamente una Collection<PSObject>.

Es decir, la idea general es la siguiente.

Ejecutar el comando de PowerShell
  ↓
Recibir el resultado como una colección de PSObject
  ↓
Extraer los valores desde BaseObject o Properties
  ↓
Convertir a un DTO / record / class de C# si es necesario

La clave está en tratar la salida de PowerShell como un objeto desde el principio, en lugar de descomponerla como texto.

2. Entorno previo

En este artículo se usa como ejemplo una aplicación de consola de .NET 8. Como el PowerShell SDK apunta a una versión de .NET distinta según su versión, hay que elegirlo de acuerdo con el framework de destino del proyecto.

En junio de 2026, resulta claro pensarlo, por ejemplo, de la siguiente manera.

Destino de la aplicación C# PowerShell SDK de ejemplo Notas
.NET 8 Serie 7.4 de Microsoft.PowerShell.SDK Fácil de usar en aplicaciones .NET 8
.NET 10 Serie 7.6 de Microsoft.PowerShell.SDK Candidato cuando se quiere usar un PowerShell SDK más reciente
.NET Framework Microsoft.PowerShell.5.1.ReferenceAssemblies Para Windows PowerShell 5.1. En desarrollos nuevos, conviene confirmar el requisito

Aquí, como ejemplo de .NET 8, se usa Microsoft.PowerShell.SDK 7.4.16.

dotnet new console -n PowerShellObjectSample
cd PowerShellObjectSample
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16

El .csproj quedaría, por ejemplo, así.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.PowerShell.SDK" Version="7.4.16" />
  </ItemGroup>

</Project>

Se recomienda fijar la versión. El PowerShell SDK es cómodo, pero está sujeto a la compatibilidad entre el entorno de ejecución de la aplicación, el .NET de destino y los módulos de PowerShell. En una aplicación de negocio es más seguro dejar explícita la versión que se ha verificado, en lugar de usar sin más «la última versión que funcionó en el entorno de desarrollo».

Qué cambia al escribir para .NET Framework

También se da el caso de mantener una aplicación Windows Forms o WPF en .NET Framework desde la que se quiere invocar PowerShell. Los ejemplos de código de este artículo parten del .NET actual, pero la forma de escribir el código en C# es prácticamente la misma. Lo que cambia es lo siguiente.

Aspecto .NET actual + Microsoft.PowerShell.SDK .NET Framework + Microsoft.PowerShell.5.1.ReferenceAssemblies
PowerShell que se ejecuta La serie PowerShell 7 incluida en el paquete Windows PowerShell 5.1 que viene con Windows
Rol del paquete NuGet Incluye el binario real Solo ensamblados de referencia. El ensamblado en tiempo de ejecución lo aporta el sistema operativo
Sintaxis y cmdlets disponibles El alcance de la serie PowerShell 7 El alcance de 5.1. No se pueden usar ForEach-Object -Parallel, ??, el operador ternario, etc.
Ruta de búsqueda de módulos El $env:PSModulePath de PowerShell 7 El $env:PSModulePath de Windows PowerShell
Ejecución asíncrona Se puede usar InvokeAsync BeginInvoke / EndInvoke, o envolver Invoke() con Task.Run
Forma de escribir en C# PowerShell.Create(), AddCommand, AddParameter, Invoke(), Collection<PSObject> Igual
Tamaño de distribución Grande, porque incluye todo el SDK Pequeño, porque usa lo que aporta el sistema operativo

El .csproj quedaría, por ejemplo, así.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.PowerShell.5.1.ReferenceAssemblies" Version="1.0.0" />
  </ItemGroup>

</Project>

En la práctica, lo que más pesa es que lo que se ejecuta es Windows PowerShell 5.1. Si se incorpora un script de PowerShell existente que fue escrito para PowerShell 7, puede dar error de sintaxis en 5.1. A la inversa, también se elige esta opción cuando se necesita usar un módulo antiguo que solo funciona en Windows PowerShell 5.1.

Cabe señalar que el código de los capítulos 3 a 12 de este artículo no usa InvokeAsync, por lo que funciona igual en .NET Framework. Lo relacionado con la asincronía se trata aparte en el capítulo 13.

3. Código mínimo: ejecutar PowerShell y recibir un PSObject

Primero, vamos a obtener desde PowerShell el proceso de la propia aplicación C# actual.

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Management.Automation;

int currentProcessId = Environment.ProcessId;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> results = ps
    .AddCommand("Get-Process")
    .AddParameter("Id", currentProcessId)
    .Invoke();

foreach (PSObject item in results)
{
    Console.WriteLine($"PSObject type: {item.GetType().FullName}");
    Console.WriteLine($"BaseObject type: {item.BaseObject.GetType().FullName}");

    if (item.BaseObject is Process process)
    {
        Console.WriteLine($"Id: {process.Id}");
        Console.WriteLine($"Name: {process.ProcessName}");
        Console.WriteLine($"Memory: {process.WorkingSet64:N0} bytes");
    }
}

Hay tres puntos que conviene retener aquí: que PowerShell.Create() crea el objeto de ejecución de PowerShell, que AddCommand("Get-Process") y AddParameter("Id", currentProcessId) construyen el comando y el parámetro, y que el valor de retorno de Invoke() es una Collection<PSObject>.

PSObject es un envoltorio (wrapper) que contiene el valor que emite PowerShell. Si se quiere ver el objeto .NET original que hay dentro, se consulta BaseObject. En este ejemplo, el contenido del resultado de Get-Process se puede extraer como System.Diagnostics.Process.

4. Cuándo usar BaseObject y cuándo Properties

Al manejar en C# los resultados de PowerShell, lo primero que suele generar dudas son estas dos formas:

item.BaseObject
item.Properties["Name"]?.Value

La pauta para decidir cuál usar es la siguiente.

Forma de extracción Cuándo usarla
BaseObject Cuando se quiere usar tal cual el objeto .NET original que devolvió PowerShell
Properties["..."] Cuando se quiere extraer una columna creada con Select-Object o [pscustomobject]

Cuando se ejecuta directamente un comando como Get-Process, BaseObject suele contener el objeto .NET original. En cambio, cuando en PowerShell se usa Select-Object para dar forma a las columnas, el resultado suele volver como un objeto personalizado de PowerShell. En ese caso, es más natural obtener el valor por nombre de columna desde Properties.

5. Leer en C# el resultado de Select-Object

En la práctica, rara vez se necesitan todas las propiedades que devuelve PowerShell. Cuando solo se quiere pasar a C# las columnas necesarias, se usa Select-Object en la canalización de PowerShell.

using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> rows = ps
    .AddCommand("Get-Process")
    .AddCommand("Sort-Object")
        .AddParameter("Property", "CPU")
        .AddParameter("Descending", true)
    .AddCommand("Select-Object")
        .AddParameter("First", 10)
        .AddParameter("Property", new[] { "Name", "Id", "CPU", "WorkingSet" })
    .Invoke();

foreach (PSObject row in rows)
{
    string name = Convert.ToString(row.Properties["Name"]?.Value, CultureInfo.InvariantCulture) ?? "";
    int id = Convert.ToInt32(row.Properties["Id"]?.Value, CultureInfo.InvariantCulture);
    double? cpu = row.Properties["CPU"]?.Value is null
        ? null
        : Convert.ToDouble(row.Properties["CPU"]!.Value, CultureInfo.InvariantCulture);
    long workingSet = Convert.ToInt64(row.Properties["WorkingSet"]?.Value, CultureInfo.InvariantCulture);

    Console.WriteLine($"{id}: {name}, CPU={cpu}, WorkingSet={workingSet:N0}");
}

Este código equivale, en PowerShell, a la siguiente canalización.

Get-Process |
  Sort-Object -Property CPU -Descending |
  Select-Object -First 10 -Property Name, Id, CPU, WorkingSet

Desde el punto de vista de C#, al encadenar llamadas a AddCommand se construye la canalización de PowerShell.

.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddCommand("Select-Object")

Escrito así, la salida del comando anterior se pasa al siguiente comando.

Después de restringir las columnas con Select-Object, se extrae el valor por nombre de columna, como en row.Properties["Name"]?.Value.

6. Convertir a un record de C#

Si se pasa el PSObject tal cual por toda la aplicación, el código posterior termina dependiendo demasiado de PowerShell. Si se va a usar para mostrar en pantalla o en la lógica de negocio, resulta más manejable convertirlo a un tipo propio de C#.

Por ejemplo, se convierte la información del proceso al siguiente record.

public sealed record ProcessSummary(
    string Name,
    int Id,
    double? Cpu,
    long WorkingSet);

Separar el procesamiento de conversión de la siguiente manera facilita la lectura.

using System.Globalization;
using System.Management.Automation;

static ProcessSummary ToProcessSummary(PSObject row)
{
    string name = GetString(row, "Name");
    int id = GetInt32(row, "Id");
    double? cpu = GetNullableDouble(row, "CPU");
    long workingSet = GetInt64(row, "WorkingSet");

    return new ProcessSummary(name, id, cpu, workingSet);
}

static string GetString(PSObject row, string propertyName)
{
    return Convert.ToString(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture) ?? "";
}

static int GetInt32(PSObject row, string propertyName)
{
    return Convert.ToInt32(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}

static long GetInt64(PSObject row, string propertyName)
{
    return Convert.ToInt64(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}

static double? GetNullableDouble(PSObject row, string propertyName)
{
    object? value = row.Properties[propertyName]?.Value;
    return value is null ? null : Convert.ToDouble(value, CultureInfo.InvariantCulture);
}

El lado que lo consume queda así.

List<ProcessSummary> processes = rows
    .Select(ToProcessSummary)
    .ToList();

foreach (ProcessSummary process in processes)
{
    Console.WriteLine($"{process.Id}: {process.Name}");
}

El PSObject se maneja en la frontera con PowerShell, y dentro de la aplicación se convierte a un tipo normal de C#, como ProcessSummary.

Con esta separación, si más adelante se cambia el comando de PowerShell, el alcance del impacto se mantiene reducido.

7. Devolver un PSCustomObject facilita el manejo en C#

Cuando desde PowerShell se quieren devolver varios valores agrupados, resulta cómodo usar [pscustomobject].

using System.Collections.ObjectModel;
using System.Management.Automation;

string script = @"
[pscustomobject]@{
    MachineName       = [System.Environment]::MachineName
    PowerShellVersion = $PSVersionTable.PSVersion.ToString()
    CurrentDirectory  = (Get-Location).Path
}
";

using PowerShell ps = PowerShell.Create();

Collection<PSObject> rows = ps
    .AddScript(script, useLocalScope: true)
    .Invoke();

foreach (PSObject row in rows)
{
    Console.WriteLine($"MachineName: {row.Properties["MachineName"]?.Value}");
    Console.WriteLine($"PowerShell:  {row.Properties["PowerShellVersion"]?.Value}");
    Console.WriteLine($"Directory:   {row.Properties["CurrentDirectory"]?.Value}");
}

Si el script de PowerShell devuelve [pscustomobject] al final, en C# se puede extraer el valor por nombre desde Properties. Esto es bastante más seguro que devolver una cadena compleja y dividirla en el lado de C#.

Un ejemplo que conviene evitar es una salida como la siguiente.

"$MachineName,$PowerShellVersion,$CurrentDirectory"

Este método parece sencillo a primera vista, pero se rompe si el valor contiene comas o saltos de línea.

En PowerShell se devuelve un objeto, y en C# se lee como propiedad. Manteniendo esta forma, resulta más fácil adaptarse si más adelante se añaden columnas.

8. No insertar entrada de usuario directamente en AddScript

Incluso usando el PowerShell SDK, es peligroso construir el script como una cadena de texto. Por ejemplo, conviene evitar código como el siguiente.

// Ejemplo que se debe evitar
string userInputPath = GetPathFromUser();
string script = $"Get-ChildItem -Path '{userInputPath}'";

using PowerShell ps = PowerShell.Create();
ps.AddScript(script).Invoke();

Con esta forma de escribirlo, existe la posibilidad de que la entrada del usuario se interprete como código de PowerShell. Para pasar valores a un comando de PowerShell, conviene usar AddCommand y AddParameter en la medida de lo posible.

string userInputPath = GetPathFromUser();

using PowerShell ps = PowerShell.Create();

Collection<PSObject> files = ps
    .AddCommand("Get-ChildItem")
    .AddParameter("Path", userInputPath)
    .AddParameter("File", true)
    .Invoke();

El valor que se pasa con AddParameter no se concatena como cadena de código de PowerShell, sino que se trata como un valor de parámetro.

En la práctica, es prudente distinguir el uso de la siguiente manera.

Forma de escribirlo Cuándo usarla
AddCommand / AddParameter Cuando se quiere construir el comando de forma segura desde C#
AddScript Cuando se ejecuta un script corto y fijo, o cuando se carga un script existente
AddScript con concatenación de cadenas Evitarlo en principio. Si se usa, hay que validar y escapar el valor de entrada con mucho cuidado

Al incorporar PowerShell en C#, la aplicación gana la capacidad de realizar operaciones muy potentes. Es cómodo, pero hay una línea que no se debe cruzar: no convertir la entrada del usuario directamente en script.

9. Format-Table es solo para la presentación final en pantalla; no usarlo antes de pasar a C#

Cuando se quiere recibir en C# el resultado de PowerShell como objeto, en principio no se usan Format-Table ni Format-List.

Por ejemplo, un PowerShell como el siguiente es cómodo para que una persona lo vea en pantalla.

Get-Service | Format-Table Name, Status

Sin embargo, si se usa Format-Table antes de recibir el resultado en C#, este deja de ser el objeto del servicio y pasa a ser información de formato para mostrar en pantalla. Si se quiere manejar en C#, se usa Select-Object.

Get-Service | Select-Object Name, Status

Escrito desde C#, quedaría así.

using PowerShell ps = PowerShell.Create();

Collection<PSObject> services = ps
    .AddCommand("Get-Service")
    .AddCommand("Select-Object")
        .AddParameter("Property", new[] { "Name", "Status" })
    .Invoke();

La idea es sencilla.

Solo para verlo cómodamente en pantalla → Format-Table / Format-List
Para usarlo en el procesamiento posterior en C# → Select-Object / PSCustomObject

Esto también se aplica cuando se usa PowerShell por sí solo, pero cobra especial importancia al integrarlo con C#.

10. Recibir errores

En PowerShell, la salida y los errores son flujos (streams) distintos. Si solo se observa el valor de retorno de Invoke(), se pueden pasar por alto los errores. La forma básica es la siguiente.

using System.Management.Automation;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> output = ps
    .AddCommand("Get-Item")
    .AddParameter("Path", @"C:\no-such-file.txt")
    .Invoke();

if (ps.HadErrors)
{
    foreach (ErrorRecord error in ps.Streams.Error)
    {
        Console.WriteLine($"Error: {error.Exception.Message}");
        Console.WriteLine($"Category: {error.CategoryInfo.Category}");
        Console.WriteLine($"Target: {error.TargetObject}");
    }
}

Los cmdlets de PowerShell tienen errores que detienen el proceso y errores que permiten continuar. Si se quiere tratarlos como excepción en C#, existe la opción de especificar Stop en ErrorAction.

using System.Management.Automation;

try
{
    using PowerShell ps = PowerShell.Create();

    Collection<PSObject> output = ps
        .AddCommand("Get-Item")
        .AddParameter("Path", @"C:\no-such-file.txt")
        .AddParameter("ErrorAction", "Stop")
        .Invoke();
}
catch (RuntimeException ex)
{
    Console.WriteLine($"PowerShell failed: {ex.Message}");
}

Cuál es mejor depende de la naturaleza de la aplicación. En una herramienta de administración donde se quiere «mostrar la lista aunque parte de ella falle», conviene recoger el flujo de errores y mostrarlo en pantalla; en cambio, cuando se quiere «detener todo el proceso si algo falla», resulta más claro tratarlo como excepción con ErrorAction Stop.

11. Crear un pequeño wrapper de ejecución

En una aplicación que invoca PowerShell muchas veces, escribir el mismo manejo de errores cada vez desordena el código. Conviene preparar un wrapper sencillo.

using System.Management.Automation;

public sealed record PowerShellRunResult(
    IReadOnlyList<PSObject> Output,
    IReadOnlyList<ErrorRecord> Errors);

public static class PowerShellRunner
{
    public static PowerShellRunResult Run(Action<PowerShell> build)
    {
        using PowerShell ps = PowerShell.Create();

        build(ps);

        List<PSObject> output;

        try
        {
            output = ps.Invoke().ToList();
        }
        catch (RuntimeException ex)
        {
            throw new InvalidOperationException($"PowerShell execution failed: {ex.Message}", ex);
        }

        return new PowerShellRunResult(
            Output: output,
            Errors: ps.Streams.Error.ToList());
    }
}

El lado que lo usa puede concentrarse solo en construir el comando.

PowerShellRunResult result = PowerShellRunner.Run(ps => ps
    .AddCommand("Get-Service")
    .AddCommand("Where-Object")
        .AddParameter("Property", "Status")
        .AddParameter("EQ", "Running")
    .AddCommand("Select-Object")
        .AddParameter("First", 10)
        .AddParameter("Property", new[] { "Name", "DisplayName", "Status" }));

foreach (PSObject row in result.Output)
{
    Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}

foreach (ErrorRecord error in result.Errors)
{
    Console.Error.WriteLine(error.Exception.Message);
}

Sin embargo, como ocurre con Where-Object en este ejemplo, construir desde C# una condición propia de PowerShell puede resultar algo difícil de leer. Para comandos y parámetros simples basta con AddCommand / AddParameter, pero para filtros o agregaciones complejas, a veces resulta más legible prepararlos como un script de PowerShell fijo. Incluso en ese caso, se mantiene el principio de no concatenar directamente la entrada externa en la cadena del script.

12. Dar forma de objeto en PowerShell a los procesos complejos

Al combinar C# y PowerShell, resulta más fácil de diseñar si se separa claramente qué se hace en cada lado.

El reparto que se recomienda es el siguiente.

Responsable Qué hace
PowerShell Operaciones cercanas a Windows y a los módulos, scripts existentes, ejecución de comandos de administración
C# Interfaz de usuario, validación de entrada, conversión de tipos, lógica de negocio, almacenamiento, integración con API

En el lado de PowerShell, se da forma a la salida final como [pscustomobject].

Get-Service |
  Where-Object Status -eq 'Running' |
  Select-Object Name, DisplayName, Status

O bien, se crea explícitamente un [pscustomobject].

$services = Get-Service | Where-Object Status -eq 'Running'

[pscustomobject]@{
    Count = $services.Count
    Names = $services.Name
}

En el lado de C#, se leen las propiedades del PSObject recibido y se convierten al tipo propio de la aplicación.

Manteniendo esta forma, se evita que los detalles de implementación de PowerShell se filtren en exceso hacia el lado de C#.

13. Precauciones habituales en la práctica

Al ejecutar PowerShell desde C#, no basta con que el código funcione. En la práctica, conviene confirmar cuanto antes los siguientes puntos.

Permisos del usuario de ejecución

PowerShell se ejecuta con los permisos del usuario que ejecuta la aplicación C#. Los comandos que requieren permisos de administrador fallan si se ejecutan como usuario normal. En operaciones de servicios, el registro de eventos, certificados, el registro de Windows, Hyper-V o módulos de administración de Microsoft 365, es necesario diferenciar los permisos con cuidado.

Diferencias entre 32 bits y 64 bits

En Windows, el registro y los módulos que se ven pueden variar entre un proceso de 32 bits y uno de 64 bits. Si se está construyendo una herramienta de administración de Windows, en general se reducen los problemas si se parte de la base de ejecutarla en x64.

Si el módulo está presente en el entorno de ejecución

Aunque se incluya el PowerShell SDK en la aplicación C#, no todos los módulos de PowerShell se instalan automáticamente. Por ejemplo, si se va a usar un módulo de administración de un producto específico o un módulo interno de la empresa, hay que confirmar si ese módulo existe en el entorno de ejecución y desde qué ruta se carga.

No bloquear el hilo de interfaz en aplicaciones con pantalla

Al ejecutar PowerShell desde WinForms o WPF, si se ejecuta directamente en el hilo de interfaz un proceso pesado, la pantalla se congela. En ese caso, hay que diseñarlo para que se ejecute como proceso en segundo plano y actualizar la interfaz una vez que termine.

El PowerShell SDK cuenta con una API de ejecución asíncrona, y usarla es lo más directo. PowerShell.InvokeAsync devuelve un Task<PSDataCollection<PSObject>>, así que se puede aplicar await directamente.

Primero, se extrae la parte que invoca PowerShell como un método que no toca la interfaz de usuario.

using System.Management.Automation;

// Se asume que los dos métodos siguientes se colocan dentro de la clase
// de la ventana o del formulario.
//
// Siempre se recibe un cancellationToken. Si la instancia de PowerShell se deja
// oculta como variable local de este método, quien lo llama no tiene forma de
// invocar Stop(). Si el comando se cuelga o el usuario cierra la ventana, el
// controlador que está en await se queda esperando con el botón deshabilitado
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesAsync(
    CancellationToken cancellationToken)
{
    // No se usa using, porque hace falta esperar a que termine la detención antes de liberar la instancia (se explica más abajo)
    PowerShell ps = PowerShell.Create();
    Task? stopping = null;

    try
    {
        // `-EQ` es un switch que no recibe valor; el valor con el que se compara
        // se pasa en `-Value` (la sintaxis simplificada es
        // `-Property <String> -EQ -Value <Object>`).
        // Escribir AddParameter("EQ", "Running") falla en la asignación de parámetros
        ps.AddCommand("Get-Service")
          .AddCommand("Where-Object")
              .AddParameter("Property", "Status")
              .AddParameter("EQ")
              .AddParameter("Value", "Running")
          .AddCommand("Select-Object")
              .AddParameter("Property", new[] { "Name", "DisplayName", "Status" });

        // No registrar antes de iniciar. Register ejecuta la retrollamada en el acto
        // si el token que se le pasa ya está cancelado. Eso hace que BeginStop se
        // dispare en el vacío contra una canalización que todavía no ha empezado, y
        // después InvokeAsync inicia el comando con la señal de detención ya consumida:
        // el resultado es un comando que sigue corriendo aunque se cierre la ventana.
        // Primero se comprueba la cancelación, después se inicia, y solo entonces se
        // añade el punto para detenerlo
        cancellationToken.ThrowIfCancellationRequested();

        Task<PSDataCollection<PSObject>> running = ps.InvokeAsync();

        // Si se cancela, se detiene la canalización. Stop() no vuelve hasta que la
        // detención termina, y como la cancelación puede llegar desde el hilo de
        // interfaz, se usa la versión asíncrona. La propia detención se recibe como
        // Task y se espera su finalización más abajo, en el finally
        using (cancellationToken.Register(
            state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
        {
            PSDataCollection<PSObject> output = await running;
            return output.ToList();
        }
    }
    finally
    {
        // Si se solicitó la detención, se espera a que termine antes de liberar.
        // Que await running vuelva y que la detención termine son dos cosas que
        // ocurren por separado; si aquí se hace Dispose sin esperar, el EndStop que
        // se ejecuta después toca una instancia de PowerShell ya liberada. Y como eso
        // ocurre en una retrollamada del thread pool, no hay ningún try que recoja la
        // excepción que se lance (el proceso entero se cae)
        Task? pending = Volatile.Read(ref stopping);
        if (pending is not null)
        {
            try { await pending; }
            catch { /* No dejar que un fallo en la detención tape el resultado o la excepción originales */ }
        }

        ps.Dispose();
    }
}

// Se envuelven BeginStop / EndStop en un Task. Si se llama a EndStop
// directamente dentro de la retrollamada, su excepción se escapa del thread pool
private static Task StopAsync(PowerShell ps) =>
    Task.Factory.FromAsync(ps.BeginStop, ps.EndStop, null);

Quien lo llama es el controlador de eventos de la ventana o el formulario. Aquí puede ser async void sin problema. Los controladores de eventos son uno de los pocos lugares donde se permite async void.

// Se asume que esto va en la clase de una ventana WPF.
// Para WinForms, sustituya RoutedEventArgs por EventArgs,
// IsEnabled por Enabled, e ItemsSource por DataSource.
// Se usa para cancelar la ejecución en curso: tanto desde el botón de
// cancelar como al cerrar la ventana
private CancellationTokenSource? _running;

private async void RunButton_Click(object sender, RoutedEventArgs e)
{
    RunButton.IsEnabled = false;

    using var cts = new CancellationTokenSource();
    _running = cts;

    try
    {
        IReadOnlyList<PSObject> services = await GetRunningServicesAsync(cts.Token);

        ResultList.ItemsSource = services
            .Select(row => row.Properties["Name"]?.Value?.ToString() ?? "")
            .ToList();
    }
    catch (PipelineStoppedException)
    {
        // Ruta normal cuando se pulsa el botón de cancelar o se cierra la ventana. No se muestra nada
    }
    catch (RuntimeException ex)
    {
        MessageBox.Show($"PowerShell failed: {ex.Message}");
    }
    finally
    {
        _running = null;
        RunButton.IsEnabled = true;
    }
}

private void CancelButton_Click(object sender, RoutedEventArgs e) => _running?.Cancel();

protected override void OnClosed(EventArgs e)
{
    _running?.Cancel();   // Para no dejar la canalización corriendo en segundo plano después de cerrar
    base.OnClosed(e);
}

Hay cuatro puntos que conviene retener.

  1. El punto al que se vuelve después de await es el hilo de interfaz, tanto en WinForms como en WPF. Como el contexto de sincronización se encarga de devolvernos allí, no hacen falta Invoke ni Dispatcher.Invoke.
  2. Se deshabilita el botón mientras se ejecuta. Si no se deshabilita, se termina lanzando el mismo proceso por duplicado.
  3. Las excepciones se reciben con RuntimeException. Si no se ha puesto ErrorAction en Stop, también hay que revisar el flujo de errores (capítulo 10).
  4. Hay que dejar siempre un punto para detener. Si la instancia de PowerShell se deja oculta como variable local del método, quien lo llama no puede invocar Stop(). Cuando el comando se cuelga o el usuario cierra la ventana, la pantalla se queda esperando indefinidamente con el botón deshabilitado.

Al detenerse, el InvokeAsync que está esperando lanza PipelineStoppedException. Como en el ejemplo anterior, hay que capturarla como una ruta normal y no como un fallo. La API de detención de PowerShell incluye el Stop() síncrono, el BeginStop / EndStop asíncrono y StopAsync (véase la sección de referencias al final). Como la detención puede llegar desde el hilo de interfaz, se elige la versión asíncrona, que no lo hace esperar.

Y si se detuvo de forma asíncrona, hay que esperar a que termine antes de liberar la instancia. Que await running vuelva y que la detención iniciada con BeginStop termine ocurren por separado. Si se sale manteniendo using PowerShell ps, después del Dispose se ejecuta EndStop y toca una instancia ya liberada. Además, eso ocurre en una retrollamada del thread pool, donde no hay ningún try que recoja la excepción que se lance: la aplicación se cae por una causa desconocida justo al cerrarse. Por eso, en el código anterior se abandona using en favor de try / finally, y la detención se guarda como Task antes de aplicarle await. Al envolverla con Task.Factory.FromAsync, la excepción de EndStop también queda dentro de ese Task y deja de escaparse del thread pool.

Si se está escribiendo para Windows PowerShell 5.1 y no se puede usar InvokeAsync, se separa en BeginInvoke / EndInvoke. También existe la opción de envolver la versión síncrona Invoke() con Task.Run, pero eso solo vale cuando se renuncia a la cancelación. Task.Run únicamente libera el hilo de interfaz; nadie toca la canalización que está corriendo. Aunque el usuario pulse «Cancelar», el comando colgado sigue corriendo en segundo plano.

using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;

private static async Task<IReadOnlyList<PSObject>> GetRunningServicesLegacyAsync(
    CancellationToken cancellationToken)
{
    PowerShell ps = PowerShell.Create();
    Task? stopping = null;

    try
    {
        ps.AddCommand("Get-Service")
          .AddCommand("Select-Object")
              .AddParameter("Property", new[] { "Name", "Status" });

        // El orden y la limpieza son exactamente iguales al ejemplo de InvokeAsync
        // de arriba. Primero se comprueba la cancelación, después se inicia, y solo
        // entonces se añade el punto para detener.
        // Con el Invoke() síncrono no se puede escribir «iniciar y después registrar»,
        // así que hace falta separarlo en BeginInvoke / EndInvoke
        cancellationToken.ThrowIfCancellationRequested();

        // Se envuelven BeginInvoke / EndInvoke en un Task. A diferencia de Task.Run,
        // que mantiene ocupado un hilo del thread pool a la espera, aquí es
        // PowerShell quien notifica la finalización
        Task<PSDataCollection<PSObject>> running =
            Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);

        using (cancellationToken.Register(
            state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
        {
            // Si se detiene, se lanza PipelineStoppedException
            PSDataCollection<PSObject> output = await running;
            return output.ToList();
        }
    }
    finally
    {
        Task? pending = Volatile.Read(ref stopping);
        if (pending is not null)
        {
            try { await pending; } catch { }
        }

        ps.Dispose();
    }
}

No adopte la forma de envolver con Task.Run y luego llamar a Register. Si dentro de Task.Run se escribe «comprobar cancelación → registrar → Invoke()», la cancelación que llegue entre el registro y el inicio se pierde en el vacío. BeginStop se dispara contra una canalización que todavía no ha empezado, y después Invoke() inicia el comando con la señal de detención ya consumida: se reproduce exactamente la misma trampa que se evitó en el ejemplo de InvokeAsync de arriba.

En cualquiera de los dos casos, el principio que hay que respetar es el mismo: no ejecutar un Invoke() largo en el hilo de interfaz, no tocar la interfaz directamente desde el método que invoca PowerShell, y abrir el punto que permite detenerlo desde fuera después de haber iniciado la canalización. Son tres reglas.

Ejecución concurrente y el coste del primer arranque

En las herramientas de administración enseguida aparece la necesidad de «procesar en paralelo porque ir equipo por equipo es lento». Antes de nada, veamos los puntos donde es fácil atascarse.

Primero, el primer Invoke() es lento por naturaleza, porque la inicialización del Runspace y la búsqueda de módulos ocurren en la primera ejecución. Que solo la primera vez, justo después de arrancar, tarde más y que a partir de la segunda vaya rápido no es una anomalía. Si se va a medir, hay que comparar excluyendo la primera vez. En una aplicación que se invoca desde pantalla, también cabe la opción de precalentar ejecutando un comando ligero una vez al iniciar.

Después, hay que no compartir una misma instancia de PowerShell entre varios hilos. Si se vuelve a llamar a Invoke o InvokeAsync sobre una instancia que ya está en ejecución, se produce un InvalidOperationException porque «el comando ya se ha iniciado». Si se quiere ejecutar en paralelo, se llama a PowerShell.Create() por cada operación.

Ahora bien, si se llama a PowerShell.Create() en cada operación, se crea un Runspace cada vez. Si el número aumenta, conviene preparar un RunspacePool y reutilizarlo desde ahí.

using System.Management.Automation;
using System.Management.Automation.Runspaces;

static async Task<IReadOnlyList<PSObject>> GetServiceAsync(
    RunspacePool pool,
    string serviceName)
{
    using PowerShell ps = PowerShell.Create();
    ps.RunspacePool = pool;

    ps.AddCommand("Get-Service")
      .AddParameter("Name", serviceName)
      .AddCommand("Select-Object")
          .AddParameter("Property", new[] { "Name", "Status" });

    PSDataCollection<PSObject> output = await ps.InvokeAsync();

    return output.ToList();
}

Del lado que lo usa, se abre el pool y se lanzan las llamadas en paralelo.

using System.Management.Automation;
using System.Management.Automation.Runspaces;

string[] serviceNames = { "Spooler", "W32Time", "EventLog" };

using RunspacePool pool = RunspaceFactory.CreateRunspacePool(1, 4);
pool.Open();

IReadOnlyList<PSObject>[] results = await Task.WhenAll(
    serviceNames.Select(name => GetServiceAsync(pool, name)));

foreach (IReadOnlyList<PSObject> rows in results)
{
    foreach (PSObject row in rows)
    {
        Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
    }
}

Aquí se usa Get-Service con fines explicativos, pero en la práctica imagine un comando que tarda para cada objetivo. Los puntos clave son asignar ps.RunspacePool en lugar de ps.Runspace, y crear y liberar la propia instancia de PowerShell en cada operación.

Por último, aumentar el tamaño del pool no siempre lo hace más rápido. El límite real lo determinan la carga del servidor de destino, el proceso de autenticación, la red y la tolerancia del módulo a la ejecución concurrente. Empiece con un valor pequeño, mídalo y auméntelo después según los resultados.

Tamaño al distribuir la aplicación

Microsoft.PowerShell.SDK es cómodo, pero también aumenta las dependencias que incluye la aplicación. Aunque sea aceptable para una utilidad pequeña, según el formato o el método de actualización, el tamaño puede llegar a preocupar. Da tranquilidad verificarlo cuanto antes con el método de distribución real: ClickOnce, MSIX, un exe único, una herramienta de distribución interna, etc.

14. Ventajas de recibir un objeto en lugar de una cadena de texto

Para terminar, por qué se insiste tanto en PSObject. El método de ejecutar PowerShell como un proceso externo y leer la salida estándar es sencillo.

La salida de PowerShell
  ↓
Cadena de texto
  ↓
Split / expresión regular / Substring
  ↓
Valor en C#

Sin embargo, este método depende del formato de presentación. Se vuelve frágil ante el ancho de las columnas, la configuración regional, los saltos de línea, los espacios, los mensajes de error o los caracteres separadores que aparezcan dentro de los valores.

En cambio, al usar el PowerShell SDK el flujo es el siguiente.

La salida de PowerShell
  ↓
PSObject
  ↓
Properties / BaseObject
  ↓
Tipo de C#

Con este enfoque, los valores se extraen a partir de la estructura de datos, no del formato de presentación. En aplicaciones de negocio y herramientas de administración, este segundo enfoque resulta más fácil de mantener.

15. Resumen

Si se va a ejecutar PowerShell desde C# y manejar sus resultados, vale la pena considerar el uso del PowerShell SDK, en lugar de limitarse a iniciar powershell.exe y leer la salida estándar.

El flujo básico es el siguiente.

Añadir Microsoft.PowerShell.SDK
  ↓
Crear el objeto de ejecución con PowerShell.Create()
  ↓
Construir el proceso con AddCommand / AddParameter / AddScript
  ↓
Ejecutar con Invoke()
  ↓
Recibirlo como Collection<PSObject>
  ↓
Extraer el valor desde BaseObject o Properties
  ↓
Convertir a un DTO / record / class de C#

En la práctica, los tres puntos especialmente importantes son los siguientes.

  • Si se va a usar en el procesamiento posterior en C#, usar Select-Object o [pscustomobject] en lugar de Format-Table
  • No insertar la entrada del usuario directamente en la cadena de AddScript; pasarla mediante AddParameter en la medida de lo posible
  • Manejar PSObject en la frontera, y convertirlo a un tipo de C# dentro de la aplicación

PowerShell es fuerte en la administración de Windows y en el aprovechamiento de activos existentes, mientras que C# lo es en convertir todo en aplicación, en interfaz de pantalla y en un procesamiento de negocio con seguridad de tipos. Si se conectan bien ambos, se puede ir consolidando poco a poco una aplicación .NET sin descartar los scripts de PowerShell existentes.

Referencias

  • El conjunto completo de código de ejemplo de este artículo (biblioteca, demo, pruebas unitarias) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-run-powershell-receive-objects
  • Microsoft Learn: Inicio rápido del host de Windows PowerShell
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/windows-powershell-host-quickstart
  • Microsoft Learn: Agregar y llamar a comandos
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/adding-and-invoking-commands
  • Microsoft Learn: PowerShell Class
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell
  • Microsoft Learn: PSObject Class
    https://learn.microsoft.com/ja-jp/dotnet/api/system.management.automation.psobject
  • Microsoft Learn: PowerShell.InvokeAsync Method
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.invokeasync
  • Microsoft Learn: PowerShell.BeginStop Method (detiene de forma asíncrona un comando en ejecución; el IAsyncResult que devuelve se recoge con EndStop)
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.beginstop
  • Microsoft Learn: PowerShell.Stop Method (versión síncrona; no vuelve hasta que termina de detenerse)
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.stop
  • Microsoft Learn: Crear varios espacios de ejecución
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/creating-multiple-runspaces
  • NuGet Gallery: Microsoft.PowerShell.SDK
    https://www.nuget.org/packages/Microsoft.PowerShell.SDK/
  • NuGet Gallery: Microsoft.PowerShell.5.1.ReferenceAssemblies
    https://www.nuget.org/packages/Microsoft.PowerShell.5.1.ReferenceAssemblies/

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.

¿Qué método conviene usar para ejecutar PowerShell desde C#?
En términos generales existen dos opciones: iniciar powershell.exe/pwsh.exe como un proceso externo con ProcessStartInfo, o usar System.Management.Automation.PowerShell (el PowerShell SDK). Si solo se necesita leer la salida estándar como texto, la primera opción funciona, pero en herramientas de administración o aplicaciones de negocio donde C# procesa el resultado, es más adecuada la segunda, ya que permite recibirlo como una colección de PSObject. Así se elimina la necesidad de analizar cadenas de texto y se puede escribir un procesamiento seguro que no depende del formato de presentación.
¿Cómo se distingue el uso de BaseObject y Properties de PSObject?
BaseObject se usa cuando se quiere trabajar directamente con el objeto .NET original que devolvió PowerShell. Por ejemplo, si se ejecuta Get-Process tal cual, el resultado se puede extraer como System.Diagnostics.Process. Por otro lado, cuando el resultado se ha formado con Select-Object o [pscustomobject], suele volver como un objeto personalizado de PowerShell, así que resulta más natural extraerlo indicando el nombre de columna con Properties["Nombre"]?.Value.
¿En qué hay que fijarse al pasar entrada de usuario desde C# a PowerShell?
Hay que evitar insertar la entrada del usuario en el script de AddScript mediante concatenación de cadenas, porque existe el riesgo de que esa entrada se interprete como código de PowerShell. Cuando se necesita pasar un valor, usar AddCommand junto con AddParameter hace que el valor se trate como valor de parámetro y no como cadena de código. Lo más prudente es limitar AddScript a scripts cortos y fijos, o a la carga de scripts ya existentes.
¿Por qué no se debe usar Format-Table al recibir resultados de PowerShell en C#?
Porque al pasar por Format-Table o Format-List, el resultado deja de ser el objeto original y se convierte en información de formato pensada para mostrarse en pantalla, de modo que en C# ya no se pueden extraer los valores como propiedades. Cuando el resultado se va a usar en un procesamiento posterior en C#, hay que restringir las columnas con Select-Object o dar forma al resultado con [pscustomobject] en el lado de PowerShell. La distinción es sencilla: «para verlo en pantalla, la familia Format; para pasarlo a C#, Select-Object».

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