Start Debugging

Process.Run vs Process.Start en .NET 11: ¿cuál deberías usar?

Usa Process.Run cuando inicias una herramienta y solo te importa cómo terminó: una sola llamada, un timeout que mata al proceso hijo y un ProcessExitStatus con la señal. Quédate con Process.Start cuando necesitas el objeto Process: escribir en stdin, leer la salida a medida que llega, UseShellExecute o matar un árbol de procesos completo.

Usa Process.Run (o Process.RunAsync) siempre que inicies un proceso, lo dejes terminar y solo necesites saber cómo terminó. Es una sola llamada, no puede filtrar un handle de Process, su timeout o CancellationToken realmente mata al proceso hijo, y devuelve un ProcessExitStatus que te dice si el hijo terminó normalmente, si lo mató una señal o si lo mataste tú. Quédate con Process.Start cuando necesitas el objeto Process vivo: escribir en stdin, leer la salida línea por línea mientras el proceso se ejecuta, UseShellExecute para abrir una URL o un documento, el evento Exited, o Kill(entireProcessTree: true) para herramientas que lanzan sus propios workers. El rendimiento no es lo que decide: ambos midieron unos 740 microsegundos por lanzamiento en .NET 11 RC 1. Todo lo siguiente se verificó con .NET 11 RC 1 (SDK 11.0.100-rc.1.26425.128, runtime 11.0.0-rc.1.26425.128, C# 15) en macOS 26.6 con un Apple M4.

Las dos APIs lado a lado

Comportamiento (.NET 11 RC 1)Process.Run / RunAsyncProcess.Start + WaitForExit
Disponible en.NET 11+todas las versiones de .NET
DevuelveProcessExitStatusProcess (hay que liberarlo)
stdin/stdout/stderr por defectoheredados del padre (o null con silent: true)heredados del padre
RedirectStandardOutput = trueInvalidOperationException
Escribir en stdin mientras se ejecutano
UseShellExecute = trueInvalidOperationException
Timeoutmata al hijo, Canceled = trueWaitForExit(TimeSpan) devuelve false, el hijo sigue ejecutándose
Cancelación (asíncrona)mata al hijo, sin excepciónWaitForExitAsync(ct) lanza, el hijo sigue ejecutándose
Señal de terminaciónProcessExitStatus.Signaladivinar a partir de ExitCode (137, 143)
Matar nietos en el timeoutno, solo el hijo directoKill(entireProcessTree: true)
ID del procesono se devuelveProcess.Id
Lanzar /usr/bin/true, síncrono737.0 us, 16.56 KB739.4 us, 16.84 KB

Las primeras cinco filas deciden la mayoría de los casos. Si tu código toca StandardInput, BeginOutputReadLine o UseShellExecute, Process.Run directamente no es una opción. Si no toca ninguno de ellos, Process.Run es más corto y tiene valores por defecto más seguros.

Qué hace realmente Process.Run

Process.Run llegó en .NET 11 Preview 4 junto con RunAndCaptureText y StartAndForget, como parte de la renovación de la API de Process que cubrí cuando llegó la captura de salida sin interbloqueos. La superficie en RC 1 es:

// .NET 11 RC 1, System.Diagnostics.Process
public static ProcessExitStatus Run(ProcessStartInfo startInfo, TimeSpan? timeout = default);
public static ProcessExitStatus Run(string fileName, IEnumerable<string>? arguments = null,
    bool silent = false, TimeSpan? timeout = default);

public static Task<ProcessExitStatus> RunAsync(ProcessStartInfo startInfo,
    CancellationToken cancellationToken = default);
public static Task<ProcessExitStatus> RunAsync(string fileName, IEnumerable<string>? arguments = null,
    bool silent = false, CancellationToken cancellationToken = default);

Dos detalles difieren del artículo del blog de Preview 4. Preview 7 cambió arguments de IList<string>? a IEnumerable<string>? (dotnet/runtime#130630), y las sobrecargas con fileName ganaron un flag silent que apunta stdin, stdout y stderr al dispositivo nulo.

La implementación en Process.Scenarios.cs es lo bastante corta como para resumirla con exactitud. Nunca crea un objeto Process. Llama a SafeProcessHandle.Start(startInfo), luego a WaitForExit() o a WaitForExitOrKillOnTimeout(timeout), y libera el handle antes de devolver. RunAsync hace lo mismo con WaitForExitOrKillOnCancellationAsync. Por eso no puede filtrar un handle, y también por eso rechaza todo lo que necesite un Process para manejarlo: los flags RedirectStandard* solo tienen sentido si alguien sostiene Process.StandardOutput y lo vacía.

ProcessExitStatus tiene tres propiedades: ExitCode, Canceled y un PosixSignal? Signal anulable. El mismo tipo es el que devuelven los nuevos métodos Process.WaitForExitStatus de RC 1.

El mismo trabajo escrito de las dos formas

Este es el caso cotidiano: ejecutar dotnet build, dejar que su salida fluya a la consola, fallar si falla y rendirse después de cinco minutos.

// .NET 11 RC 1, C# 15 -- before: Process.Start
using System.Diagnostics;

using Process build = Process.Start("dotnet", ["build", "-c", "Release"]);

if (!build.WaitForExit(TimeSpan.FromMinutes(5)))
{
    build.Kill(entireProcessTree: true);
    build.WaitForExit();
    throw new TimeoutException("dotnet build took longer than 5 minutes");
}

if (build.ExitCode != 0)
    throw new InvalidOperationException($"dotnet build failed with {build.ExitCode}");
// .NET 11 RC 1, C# 15 -- after: Process.Run
using System.Diagnostics;

ProcessExitStatus status = Process.Run(
    "dotnet", ["build", "-c", "Release"],
    timeout: TimeSpan.FromMinutes(5));

if (status.Canceled)
    throw new TimeoutException("dotnet build took longer than 5 minutes");

if (status.ExitCode != 0)
    throw new InvalidOperationException($"dotnet build failed with {status.ExitCode}");

La versión con Process.Start es correcta, pero solo porque recuerda dos cosas que la gente olvida habitualmente: WaitForExit(TimeSpan) no mata nada al vencer el timeout, y el Process tiene que liberarse. La versión con Process.Run no tiene forma de equivocarse en ninguna de las dos. Fíjate, eso sí, en que mantuve entireProcessTree: true en la primera versión a propósito. Más sobre eso en los detalles a tener en cuenta.

Los timeouts y la cancelación se comportan distinto

Esta es la diferencia que muerde a quienes migran con buscar y reemplazar. Ejecuté ambos contra sleep 10 con un margen de 500 ms en .NET 11 RC 1:

// .NET 11 RC 1, C# 15, macOS 26.6
using System.Diagnostics;

using var cts = new CancellationTokenSource(500);
ProcessExitStatus s = await Process.RunAsync("sleep", ["10"], cancellationToken: cts.Token);
Console.WriteLine($"ExitCode={s.ExitCode} Canceled={s.Canceled} Signal={s.Signal}");
// ExitCode=137 Canceled=True Signal=SIGKILL   (returned after 505 ms, no exception)

using var cts2 = new CancellationTokenSource(500);
using Process p = Process.Start("sleep", ["10"]);
try { await p.WaitForExitAsync(cts2.Token); }
catch (TaskCanceledException) { Console.WriteLine($"HasExited={p.HasExited}"); }
// HasExited=False   (the child is still running)

Tres cosas que sacar de esa salida:

  1. RunAsync no lanza excepción al cancelarse. Mata al hijo con SIGKILL (o TerminateProcess en Windows), espera a que termine y devuelve un estado con Canceled = true. El código que lo envuelve en catch (OperationCanceledException) nunca entrará en el bloque catch. Revisa status.Canceled en su lugar.
  2. Process.WaitForExitAsync(ct) solo cancela la espera. Lanza TaskCanceledException y deja el proceso vivo. Si lo querías muerto, llamas a Kill() tú mismo.
  3. Signal elimina las conjeturas sobre el código de salida. En Unix, un proceso matado por SIGKILL reporta el código de salida 137 y uno matado por SIGTERM reporta 143, y un proceso también puede simplemente hacer exit 137. ProcessExitStatus.Signal te dice cuál de los dos ocurrió. Dentro de una llamada a Process.Run, Canceled te dice si la señal de muerte fue tuya.

El Run síncrono recibe un TimeSpan? timeout y ningún token; RunAsync recibe un token y ningún timeout. Para un timeout asíncrono, usa new CancellationTokenSource(TimeSpan) como arriba. Si la cancelación llega desde una solicitud de ASP.NET Core o desde un hosted service, los patrones de cómo cancelar una Task de larga duración sin interbloqueos aplican sin cambios.

Cuándo elegir Process.Run

// .NET 11 RC 1, C# 15
using System.Diagnostics;
using Microsoft.Win32.SafeHandles;

using SafeFileHandle log = File.OpenHandle("build.log", FileMode.Create, FileAccess.Write);

ProcessExitStatus status = Process.Run(new ProcessStartInfo("dotnet", ["build"])
{
    StandardOutputHandle = log,
    StandardErrorHandle = log,
    WorkingDirectory = "/src/app",
});

Si necesitas la salida en memoria en lugar de en un archivo, olvídate de ambas APIs y llama a Process.RunAndCaptureText, que vacía stdout y stderr de forma concurrente para que el clásico interbloqueo por redirección no pueda ocurrir.

Cuándo quedarte con Process.Start

El benchmark

El rendimiento no es motivo para elegir uno u otro, y lo medí para que no tengas que preguntártelo. Entorno: .NET 11 RC 1 (11.0.0-rc.1.26425.128), BenchmarkDotNet 0.15.8 con el toolchain in-process emit (0.15.8 todavía no reconoce net11.0 para ejecuciones fuera de proceso), 3 iteraciones de calentamiento y 15 medidas, macOS 26.6.2, Apple M4 con 10 núcleos. Cada benchmark lanza /usr/bin/true y espera a que termine.

// .NET 11 RC 1, BenchmarkDotNet 0.15.8
[MemoryDiagnoser]
[InProcess, WarmupCount(3), IterationCount(15)]
public class SpawnBenchmarks
{
    private const string Exe = "/usr/bin/true";

    [Benchmark(Baseline = true)]
    public int Start_WaitForExit()
    {
        using Process p = Process.Start(Exe);
        p.WaitForExit();
        return p.ExitCode;
    }

    [Benchmark]
    public int Run() => Process.Run(Exe).ExitCode;

    [Benchmark]
    public async Task<int> Start_WaitForExitAsync()
    {
        using Process p = Process.Start(Exe);
        await p.WaitForExitAsync();
        return p.ExitCode;
    }

    [Benchmark]
    public async Task<int> RunAsync() => (await Process.RunAsync(Exe)).ExitCode;
}
MethodMeanStdDevAllocated
Start_WaitForExit739.4 us6.26 us16.84 KB
Run737.0 us2.76 us16.56 KB
Start_WaitForExitAsync768.5 us13.89 us17.89 KB
RunAsync762.6 us2.56 us17.47 KB

La diferencia está dentro del ruido. Casi todo el costo es el sistema operativo creando el proceso, y ambas APIs comparten la misma ruta de inicio de SafeProcessHandle. Omitir el objeto Process ahorra unos 300 bytes. Las grandes mejoras de .NET 11 (un inicio de procesos 98x más rápido en Apple Silicon, según las mediciones del equipo de .NET) benefician por igual a ambas APIs, porque viven por debajo de esta capa.

Detalles que deciden por ti

Un timeout mata solo al hijo directo. Ejecuté Process.Run("sh", ["-c", "sleep 31; true"], timeout: TimeSpan.FromMilliseconds(500)). Run devolvió Canceled=True Signal=SIGKILL, y pgrep -f "sleep 31" seguía encontrando el proceso sleep después, reasignado a otro padre y en ejecución. La misma prueba con Process.Start y Kill(entireProcessTree: true) no dejó nada atrás. dotnet build, npm run, docker compose y la mayoría de los scripts de shell lanzan hijos, así que si un timeout debe dejar la máquina limpia, Process.Start con una muerte del árbol completo sigue siendo la herramienta correcta. ProcessStartInfo.KillOnParentExit (solo Windows, Linux y Android) no es un sustituto: se dispara cuando tu proceso termina, no cuando vence un timeout.

No hay sobrecarga con argumentos como string. Process.Run("git", "status") no compila, porque string no es IEnumerable<string>. Pasa ["status"], o construye un ProcessStartInfo("git", "status") y usa la sobrecarga Run(ProcessStartInfo).

silent: true también anula stdin. Un hijo que pide datos, como git solicitando credenciales, lee fin de archivo inmediatamente en lugar de quedarse colgado. Eso suele ser lo que quieres en un servicio y resulta sorprendente en un script de desarrollo.

Run(ProcessStartInfo) rechaza tu start info existente. Si migras un ProcessStartInfo que todavía tiene RedirectStandardOutput = true, obtienes InvalidOperationException: The RedirectStandardInput, RedirectStandardOutput, and RedirectStandardError properties cannot be used by SafeProcessHandle.Start or Process.StartAndForget. El mensaje no menciona ni Run ni RunAsync, pero es la misma comprobación. Reemplaza el flag por StandardOutputHandle, o cambia a RunAndCaptureText.

Los fallos al iniciar son idénticos. Un ejecutable inexistente lanza la misma Win32Exception (“No such file or directory”) desde ambas APIs, así que el manejo de errores existente sigue sirviendo.

Ninguno está soportado en iOS ni tvOS. Ambos llevan [UnsupportedOSPlatform("ios")] y [UnsupportedOSPlatform("tvos")], y Mac Catalyst sí está soportado. El analizador de plataformas marca las llamadas en un proyecto MAUI en cualquiera de los dos casos.

La decisión

Para código nuevo de .NET 11 que ejecuta un proceso hasta completarse, usa por defecto Process.Run o Process.RunAsync. Eliminan los tres errores más comunes en el código de procesos (el Process sin liberar, el timeout que no mata, el código de salida que oculta una señal) sin ningún costo medible. Pasa a Process.RunAndCaptureText en el momento en que necesites la salida como string. Recurre a Process.Start solo cuando el proceso tenga que manejarse mientras se ejecuta, abrirse a través del shell o eliminarse como un árbol completo. Ese último caso importa más de lo que parece, porque muchas herramientas que envolverías en Process.Run lanzan hijos en silencio que su timeout no tocará. Si todavía mantienes código antiguo que se bloquea en WaitForExit, los patrones básicos de espera a que termine un proceso son donde suele empezar el código con Process.Start, y vale la pena revisarlos con estos valores por defecto en mente.

Relacionados

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver