×

【译】.NET 11 中的 Process API 改进

独孤求败 独孤求败 发表于2026-05-20 14:39:06 浏览26 评论0

抢沙发发表评论

.NET 11 中的 Process API 改进

作者:Adam Sitnik,高级软件工程师

原文链接:https://devblogs.microsoft.com/dotnet/process-api-improvements-in-dotnet-11/

System.Diagnostics.Process 类是 .NET 中创建和操作进程的主要方式。在 .NET 11 中,我们对其进行了多年来最大规模的一次更新。此次变更新增了高层次 API,让启动进程并捕获输出变得简单而不会死锁;赋予你对句柄继承和标准句柄重定向的完全控制权;引入了 KillOnParentExit 等生命周期管理特性;并提供了基于 SafeProcessHandle 的轻量级 API ,对裁剪(trimmer)更加友好。

概览

以下是 .NET 11 新增 Process API 的汇总:

功能
API
说明
一行代码执行进程Process.RunAndCaptureText[Async]
启动进程、捕获输出/错误、等待退出——一次调用全搞定。
一行代码执行进程(不捕获输出)Process.Run[Async]
启动进程并等待退出,不捕获输出。
即发即忘Process.StartAndForget
启动进程,返回其 PID,并立即释放所有资源。
无死锁输出捕获Process.ReadAllText/Bytes/Lines[Async]
使用多路复用同时读取 stdout 和 stderr,避免管道缓冲区死锁。
重定向至任意目标ProcessStartInfo.Standard[Input/Output/Error]Handle
将标准句柄重定向至文件、管道、null 或任意 SafeFileHandle
受控继承ProcessStartInfo.InheritedHandles
精确指定子进程继承哪些句柄,防止意外泄漏。
父进程退出时杀死子进程ProcessStartInfo.KillOnParentExit
确保父进程退出时子进程被终止(Windows 和 Linux)。
分离进程ProcessStartInfo.StartDetached
启动一个在父进程退出、收到信号或终端关闭后仍能存活的进程。
轻量级进程句柄SafeProcessHandle.Start/WaitForExit/Kill/Signal
对裁剪更友好的低层次 API,无需使用 Process 即可启动和管理进程。
进程退出详情ProcessExitStatus
报告退出码、终止信号(Unix)以及进程是否因超时/取消而被杀死。
Null 句柄File.OpenNullHandle()
打开一个丢弃写入内容、读取时返回 EOF 的句柄。
匿名管道SafeFileHandle.CreateAnonymousPipe
创建一对相连的管道,可选支持异步。
控制台句柄Console.OpenStandard[Input/Output/Error]Handle()
获取标准流的底层操作系统句柄。
句柄类型检测SafeFileHandle.Type
识别句柄是文件、管道、socket 等哪种类型。

其他改进包括:

  • • Windows 上更好的可扩展性BeginOutputReadLine/BeginErrorReadLine 不再阻塞线程池线程——在并行启动多个带重定向输出和错误的进程时,吞吐量显著提升。
  • • 更好的裁剪性:相较 .NET 10,使用 Process 时 NativeAOT 二进制文件最多缩小 20%,使用 SafeProcessHandle 时最多缩小 32%。
  • • Apple 平台上更快的进程创建:切换至 posix_spawn 后,Apple Silicon 上的进程创建速度最高提升 100 倍。
  • • Unix 上减少内存分配:Unix 上启动进程时内存分配减少 30–50%。

以下是对各项特性的深入介绍。

无死锁地捕获进程输出

为什么捕获进程输出可能导致应用挂起

重定向进程的标准输出和错误时,有可能陷入死锁。了解其原因,是理解我们所做改动的关键。让我们构建一个试图读取进程所有输出和错误的 C# 应用。

首先,我们需要将进程的标准输出和错误重定向,才能读取它。做法是将 ProcessStartInfo 的 RedirectStandardOutput 和 RedirectStandardError 属性设置为 true。在进程启动之前,会创建两条专用管道(分别用于标准输出和标准错误),进程以这两条管道的写端作为其标准输出和错误启动。子进程照常向标准输出和错误写入数据,但数据不会输出到控制台,而是写入管道。

ProcessStartInfo startInfo = new("dotnet", "--help")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true
};

using
 Process process = new() { StartInfo = startInfo };

管道的缓冲区大小有限(Windows 上通常为 4KB,Unix 上为 64KB)。当生产者(本例中为子进程)向管道写入数据时,数据会暂存于缓冲区,直到消费者(父进程)将其读出。若生产者写入的数据超过缓冲区大小,而消费者又没有同时从管道读取,生产者就会在写操作上阻塞,等待消费者读取数据、腾出缓冲区空间。

如果消费者在没有读取管道的情况下等待生产者退出(例如调用 WaitForExit),一旦生产者填满缓冲区,消费者就会被阻塞:

process.Start();
process.WaitForExit();

string
 output = process.StandardOutput.ReadToEnd();
string
 error = process.StandardError.ReadToEnd();

调换代码顺序有用吗?

process.Start();

string
 output = process.StandardOutput.ReadToEnd();
string
 error = process.StandardError.ReadToEnd();

process.WaitForExit();

依然不行。ReadToEnd 是阻塞调用——它一直读到流结束(EOF),而 EOF 只有在子进程关闭管道写端时(通常在退出时)才会出现。因此上面的代码会先在标准输出上阻塞,等子进程退出,然后才开始读标准错误。在等待标准输出期间,没有任何代码在消费标准错误。若子进程向 stderr 写入的数据超过管道缓冲区容量,子进程就会阻塞在写入操作上——双方就此僵死。

根本原因在于我们顺序地读取两个流,而不是同时读取。要避免死锁,必须同时排空标准输出和标准错误。此前我们有两种选择,但现有 API 在简洁性和性能上都不够理想,下面展示两种典型模式。

对 StandardOutput 和 StandardError 使用异步读取操作

Process 类暴露了流读取器,可以使用 ReadToEndAsync 等方法进行读取:

process.Start();

// Start both operations to ensure both streams are drained at the same time

Task<string> outputTask = process.StandardOutput.ReadToEndAsync();
Task<string> errorTask = process.StandardError.ReadToEndAsync();

// Wait for both read operations to complete and process to exit

await
 Task.WhenAll(outputTask, errorTask, process.WaitForExitAsync());

string
 output = await outputTask;
string
 error = await errorTask;

使用 OutputDataReceived 和 ErrorDataReceived 事件

Process 类的这两个事件分别在向标准输出和标准错误写入一行时触发:

StringBuilder stdOut = new(), stdErr = new();

process.OutputDataReceived += (sender, e) => stdOut.AppendLine(e.Data);
process.ErrorDataReceived += (sender, e) => stdErr.AppendLine(e.Data);

process.Start();

process.BeginOutputReadLine();
process.BeginErrorReadLine();

process.WaitForExit();

Process.ReadAllText 和 Process.ReadAllTextAsync

我们为 Process 类新增了 ReadAllText 和 ReadAllTextAsync(PR[1])方法,它们会同时排空标准输出和标准错误,帮助我们避免死锁。这两个方法使用 ProcessStartInfo.Standard[Output/Error]Encoding(或默认编码)对输出进行解码,并以字符串形式返回结果(逐行处理的需求由稍后介绍的 API 满足)。

public class Process
{
    public
 (string StandardOutput, string StandardError) ReadAllText(TimeSpan? timeout = default);
    public
 Task<(string StandardOutput, string StandardError)> ReadAllTextAsync(CancellationToken cancellationToken = default);
}

如此一来,读取进程所有输出和错误的代码就简洁多了:

ProcessStartInfo startInfo = new("dotnet", "--help")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true
};

using
 Process process = new() { StartInfo = startInfo };
process.Start();

(string output, string error) = process.ReadAllText();
process.WaitForExit();

Process.RunAndCaptureText 和 Process.RunAndCaptureTextAsync

我们预计捕获输出和错误、然后等待进程退出是非常常见的场景(进程可以在关闭标准句柄后继续运行)。因此我们引入[2]了 RunAndCaptureText 和 RunAndCaptureTextAsync 方法,将启动进程、读取所有输出和错误、等待进程退出合并为一次方法调用:

namespace System.Diagnostics;

public
 sealed class ProcessExitStatus
{
    public ProcessExitStatus(int exitCode, bool canceled, PosixSignal? signal = null);
    public
 int ExitCode { get; }
    public
 PosixSignal? Signal { get; }
    public
 bool Canceled { get; }
}

public
 sealed class ProcessTextOutput
{
    public ProcessTextOutput(ProcessExitStatus exitStatus, string standardOutput, string standardError, int processId);
    public
 ProcessExitStatus ExitStatus { get; }
    public
 string StandardOutput { get; }
    public
 string StandardError { get; }
    public
 int ProcessId { get; }
}

public
 class Process
{
    public static ProcessTextOutput RunAndCaptureText(ProcessStartInfo startInfo, TimeSpan? timeout = default);
    public static ProcessTextOutput RunAndCaptureText(string fileName, IList<string>? arguments = null, System.TimeSpan? timeout = default);

    public static Task<ProcessTextOutput> RunAndCaptureTextAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default);
    public static Task<ProcessTextOutput> RunAndCaptureTextAsync(string fileName, IList<string>? arguments = null, CancellationToken cancellationToken = default);
}

这让启动进程并捕获其输出和错误真正变成了一行代码:

ProcessTextOutput output = Process.RunAndCaptureText("dotnet", ["--help"]);

我们同时提供了 Process.Run 和 Process.RunAsync 方法——在你不关心输出时,仅等待进程退出:

ProcessExitStatus status = Process.Run("dotnet", ["build", "-c", "Release"]);

Process.ReadAllLines 和 Process.ReadAllLinesAsync

如需以行为单位捕获输出和错误,可以使用 ReadAllLines(PR[3])和 ReadAllLinesAsync(PR[4])方法。它们的实现方式与 ReadAllText 系列相同,但返回的是 ProcessOutputLine 的可枚举集合,而非单个字符串:

namespace System.Diagnostics;

public
 readonly struct ProcessOutputLine
{
    public ProcessOutputLine(string content, bool standardError);
    public
 string Content { get; }
    public
 bool StandardError { get; }
}

public
 class Process
{
    public IEnumerable<ProcessOutputLine> ReadAllLines(TimeSpan? timeout = default);
    public IAsyncEnumerable<ProcessOutputLine> ReadAllLinesAsync(CancellationToken cancellationToken = default);
}

下面演示如何在进程产生输出时逐行读取:

using Process process = Process.Start(new ProcessStartInfo("dotnet", "--help")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true
})!;
await
 foreach (ProcessOutputLine line in process.ReadAllLinesAsync())
{
    if
 (line.StandardError)
        Console.ForegroundColor = ConsoleColor.Red;

    Console.WriteLine(line.Content);
    Console.ResetColor();
}

Process.ReadAllBytes 和 Process.ReadAllBytesAsync

如需以字节形式捕获输出和错误,可以使用 ReadAllBytes 方法(该方法在内部被 ReadAllText 所使用,PR[5])。它返回字节数组而非字符串:

public class Process
{
    public
 (byte[] StandardOutput, byte[] StandardError) ReadAllBytes(TimeSpan? timeout = default);
    public
 Task<(byte[] StandardOutput, byte[] StandardError)> ReadAllBytesAsync(CancellationToken cancellationToken = default);
}

超时与取消

上述所有从标准输出和标准错误读取的方法都支持超时和取消。如果在流结束前达到超时或取消令牌被触发,方法将分别抛出 TimeoutException 或 OperationCanceledException。高层次的 RunAndCaptureText[Async] 和 Run[Async] 方法还会尝试杀死进程,以避免其继续运行。

多路复用及其他底层优化

新方法不仅更易用,速度也更快。在底层,同步的 Process.RunAndCaptureText 和 Process.ReadAll[Bytes/Text] 方法使用多路复用(Unix 上用 poll[6],Windows 上用 WaitForMultipleObjects[7]),以单线程同时读取标准输出和标准错误,并实现了一系列其他优化,例如使用 ArrayPool 减少内存分配。异步的 Process.RunAndCaptureTextAsync 和 Process.ReadAllTextAsync 方法则使用异步 I/O 操作,不阻塞任何线程。

using BenchmarkDotNet.Attributes;
using
 BenchmarkDotNet.Running;
using
 System.Diagnostics;
using
 System.Text;

BenchmarkSwitcher.FromAssembly(typeof(CaptureOutputBenchmarks).Assembly).Run(args);

[MemoryDiagnoser, ThreadingDiagnoser]
public
 class CaptureOutputBenchmarks
{
    private
 readonly ProcessStartInfo _processStartInfo = CreateStartInfo();

    private static ProcessStartInfo CreateStartInfo()
    {
        ProcessStartInfo startInfo = OperatingSystem.IsWindows()
            ? new("cmd.exe", "/c for /L %i in (1,1,1000) do @echo Line %i")
            : new("sh", ["-c", "for i in $(seq 1 1000); do echo \"Line $i\"; done"]);

        startInfo.RedirectStandardOutput = true;
        startInfo.RedirectStandardError = true;

        return
 startInfo;
    }

    [Benchmark]
    public int Events()
    {
        using
 Process process = new();
        process.StartInfo = _processStartInfo;

        StringBuilder stdOut = new(), stdErr = new();

        process.OutputDataReceived += (sender, e) => stdOut.AppendLine(e.Data);
        process.ErrorDataReceived += (sender, e) => stdErr.AppendLine(e.Data);

        process.Start();

        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        process.WaitForExit();

        // Other benchmarks materialize the output, so we do it here

        // to ensure it's apples to apples comparison.

        _ = stdOut.ToString();
        _ = stdErr.ToString();

        return
 process.ExitCode;
    }

    [Benchmark]
    public async Task<int> ReadToEndAsync()
    {
        using
 Process process = Process.Start(_processStartInfo)!;

        Task<string> readOutput = process.StandardOutput.ReadToEndAsync();
        Task<string> readError = process.StandardError.ReadToEndAsync();

        _ = await readOutput;
        _ = await readError;

        await
 process.WaitForExitAsync();

        return
 process.ExitCode;
    }

    [Benchmark]
    public int RunAndCaptureText()
    {
        ProcessTextOutput processTextOutput = Process.RunAndCaptureText(_processStartInfo);

        _ = processTextOutput.StandardOutput;
        _ = processTextOutput.StandardError;

        return
 processTextOutput.ExitStatus.ExitCode;
    }

    [Benchmark]
    public async Task<int> RunAndCaptureTextAsync()
    {
        ProcessTextOutput processTextOutput = await Process.RunAndCaptureTextAsync(_processStartInfo);

        _ = processTextOutput.StandardOutput;
        _ = processTextOutput.StandardError;

        return
 processTextOutput.ExitStatus.ExitCode;
    }
}
BenchmarkDotNet v0.16.0-nightly.20260505.517, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2)
AMD Ryzen Threadripper PRO 3945WX 12-Cores 3.99GHz, 1 CPU, 24 logical and 12 physical cores
Memory: 63.86 GB Total, 44.02 GB Available
.NET SDK 11.0.100-preview.5.26255.101
  [Host]
     : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3

方法
均值
完成的工作项
分配内存
Events(旧)
71.21 ms
2006.0000
612.58 KB
ReadToEndAsync(旧)
70.33 ms
2004.0000
636.67 KB
RunAndCaptureText(新)
68.11 ms
132.58 KB
RunAndCaptureTextAsync(新)
70.66 ms
2004.0000
534.09 KB

// * Legends *
  Completed Work Items : The number of work items that have been processed in ThreadPool (per single operation)
  Allocated            : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)

如你所见,在 Windows 上,同步的 RunAndCaptureText 方法比旧方案快约 2–3 ms,内存分配减少约 4.5 倍,并且完全不使用线程池。

BenchmarkDotNet v0.16.0-nightly.20260505.517, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD Ryzen Threadripper PRO 3945WX 12-Cores 3.99GHz, 1 CPU, 24 logical and 12 physical cores
Memory: 31.27 GB Total, 29.69 GB Available
.NET SDK 11.0.100-preview.5.26255.101
  [Host]
     : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3

方法
均值
完成的工作项
分配内存
Events(旧)
4.494 ms
90.8359
178.79 KB
ReadToEndAsync(旧)
4.831 ms
78.0313
108.43 KB
RunAndCaptureText(新)
4.488 ms
48.9 KB
RunAndCaptureTextAsync(新)
4.738 ms
84.1641
81.6 KB

在 Linux 上,新方法的内存分配减少约 2–4 倍,同步方法同样不使用线程池。


句柄继承

对于管道而言,只有当所有指向写端的句柄都被关闭时,才会到达文件末尾(EOF)。Process 在启动子进程后会关闭自己持有的管道写端副本,但为了让子进程能够使用管道,管道必须是可继承的。进程启动时,默认会从父进程继承所有可继承句柄,这为另外两种可能导致死锁的问题敞开了大门:

  • • 并发启动的兄弟进程继承了管道句柄并一直持有;
  • • 孙子进程继承了管道句柄,并在子进程退出后仍持有。

从 .NET 的角度来看,无法阻止孙子进程意外继承句柄,因为子进程可以对它从父进程继承的句柄做任何操作。我们所能做的最佳方案,是提供一个 API 让子进程只继承指定的句柄——这正是我们引入 ProcessStartInfo.InheritedHandles 属性(PR[8])所做的事情:

public class ProcessStartInfo
{
    public
 IList<SafeHandle>? InheritedHandles { get; set; } = null;
}

出于向后兼容性考虑,该新属性默认为 null,即行为与之前相同——所有可继承句柄都会被继承。设置为空列表时,只有标准句柄会被继承。设置为特定句柄列表时,这些句柄连同标准句柄一起被继承。

反馈请求: 我们正在考虑将所有捕获进程输出的新 API 扩展,支持在进程退出但管道仍然打开时停止读取。如果你对这个功能感兴趣,请告知我们。

重要提示:此列表中的句柄事先不应启用继承。 如果已启用,它们可能被其他使用不同 API 并发启动的进程意外继承,从而导致安全或资源管理问题。

注意: 目前仅支持 SafeFileHandle 和 SafePipeHandle,如需更多类型,请告知我们。

性能影响: 当 InheritedHandles 不为 null 时:

简而言之:只要不在旧版 Linux 内核(5.9 以前)上运行,与继承所有句柄的旧行为相比应当没有性能回退。

  • • 在 Windows 上,启动新进程时我们只获取读锁。这意味着并行启动多个设置了 InheritedHandles 的进程时,它们不会像使用全局锁那样在进程创建阶段互相阻塞。
  • • 在 Unix 上,我们使用最优的系统调用确保只有指定句柄被子进程继承:
    • • 在 Apple 平台上,始终使用带 POSIX_SPAWN_CLOEXEC_DEFAULT 标志的 posix_spawn,所有当前 .NET 支持的版本均支持此标志;
    • • 在 Linux 上,如果可用且已启用,使用 close_range 或 __NR_close_range
    • • 在 FreeBSD 上使用 close_range(FreeBSD 12.2 起可用);
    • • 在 Illumos/Solaris 上使用 fdwalk
    • • 若以上均不可用(或未启用),则回退到遍历所有文件描述符并手动设置 FD_CLOEXEC,这种方式代价较高,可能引发严重的性能回退,主要影响 Linux 5.9 以前的内核(已向后移植[9]此功能的 RHEL 8.0 除外)。

我们来对性能影响做个基准测试:

public class GlobalLock
{
    private
 ProcessStartInfo info;

    [Params(true, false)]
    public
 bool SetInheritedHandles { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        info = OperatingSystem.IsWindows()
            ? new("cmd.exe", ["/c", "exit 42"])
            : new("sh", ["-c", "exit 42"]);

        info.InheritedHandles = SetInheritedHandles ? [] : null;
    }

    [Benchmark]
    public ParallelLoopResult Run() => Parallel.For(0, 1_000, (_, _) => _ = Process.Run(info));
}

可以看到,在这台 Windows 机器上,吞吐量提升了一倍:

BenchmarkDotNet v0.16.0-nightly.20260505.517, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2)
AMD Ryzen Threadripper PRO 3945WX 12-Cores 3.99GHz, 1 CPU, 24 logical and 12 physical cores
Memory: 63.86 GB Total, 32.5 GB Available
.NET SDK 11.0.100-preview.5.26255.101
  [Host]
 : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3
  Dry    : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3

InvocationCount
=1  IterationCount=10  LaunchCount=1
UnrollFactor
=1  WarmupCount=1

方法
SetInheritedHandles
均值
Run
False
4.014 s
Run
True
1.958 s


重定向标准句柄

限制句柄继承问题的另一种方式,是让用户将标准句柄重定向到任意文件句柄,而无需将其设为可继承。现在可以将标准句柄重定向至任意文件句柄(PR[10]),开启了以下新场景:

  • • 进程管道(piping)
  • • 重定向到文件;
  • • 重定向到 null 句柄(每次读写操作均报告 0 字节):
    • • 以无输入方式启动进程;
    • • 丢弃输出;
  • • 以异步句柄启动进程(高级、小众场景);
  • • 通过将标准句柄重定向到非父进程句柄来打破继承链。

新 API 还附带了若干新的辅助类型(File.OpenHandle 已存在),方便使用:

namespace System.Diagnostics
{
    public
 class ProcessStartInfo
    {
        public
 SafeFileHandle? StandardInputHandle { get; set; }
        public
 SafeFileHandle? StandardOutputHandle { get; set; }
        public
 SafeFileHandle? StandardErrorHandle { get; set; }
    }
}

namespace
 Microsoft.Win32.SafeHandles
{
    public
 class SafeFileHandle
    {
        public static SafeFileHandle CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe,
            bool
 asyncRead = false, bool asyncWrite = false);
    }
}

namespace
 System.IO
{
    public
 static class File
    {
        public static SafeFileHandle OpenHandle(string path, FileMode mode = FileMode.Open, FileAccess access = FileAccess.Read, FileShare share = FileShare.Read, FileOptions options = FileOptions.None, long preallocationSize = 0);
        public static SafeFileHandle OpenNullHandle();
    }
}

namespace
 System
{
    public
 static class Console
    {
        public static SafeFileHandle OpenStandardInputHandle();
        public static SafeFileHandle OpenStandardOutputHandle();
        public static SafeFileHandle OpenStandardErrorHandle();
    }
}

让我们用 ls /usr/bin 通过管道输入 grep zip,并将输出重定向到文件,以查找与 zip 相关的命令:

ls /usr/bin | grep zip > output.txt

现在,我们用 C# 和新 API 实现同样的效果:

SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe);

using
 (readPipe)
using
 (writePipe)
using
 (SafeFileHandle outputFile = File.OpenHandle("output.txt", FileMode.Create, FileAccess.Write))
{
    ProcessStartInfo producer = new("ls", ["/usr/bin"])
    {
        StandardOutputHandle = writePipe
    };

    // Start consumer with input from the read end of the pipe, writing output to file

    ProcessStartInfo consumer = new("grep", ["zip"])
    {
        StandardInputHandle = readPipe,
        StandardOutputHandle = outputFile,
    };

    using
 Process producerProcess = Process.Start(producer)!;
    // The producer process has its own copy of the write end of the pipe, we need to dispose the parent copy.

    writePipe.Dispose();

    using
 Process consumerProcess = Process.Start(consumer)!;
    // The consumer process has its own copy of the read end of the pipe, we need to dispose the parent copy.

    readPipe.Dispose();

    await
 producerProcess.WaitForExitAsync();
    await
 consumerProcess.WaitForExitAsync();
}

注意: 所有新辅助类型创建的句柄均为不可继承句柄,但 Process 知道如何在启动进程时将其变为可继承,因此你完全不必担心句柄继承问题。

SafeFileHandle 的其他改进

值得一提的是,SafeFileHandle 类也获得了一些新功能,让使用更加便利:

  • • Type 属性,用于检查句柄类型是文件、管道、控制台等(PR[11]);
  • • IsAsync 在 Unix 上仅当句柄启用了 O_NONBLOCK 标志时返回 true
  • • 所有 RandomAccess 的读写方法现在支持非可寻址句柄(PR[12]),例如管道,无需使用 FileStream 即可操作。
namespace System.IO
{
    public
 enum FileHandleType
    {
        Unknown = 0,
        RegularFile,
        Pipe,
        Socket,
        CharacterDevice,
        Directory,
        SymbolicLink,
        BlockDevice,
    }
}

namespace
 Microsoft.Win32.SafeHandles
{
    public
 class SafeFileHandle
    {
        public
 FileHandleType Type { get; }
    }
}

生命周期管理

Process.StartAndForget

有一个常见的误解:当进程被 dispose 时,它也会被杀死。实际上并非如此,Process.Dispose 只会释放与进程关联的资源,不会杀死进程。

为了让启动进程时无需操心 dispose 问题,我们引入了 Process.StartAndForget 方法,它会启动进程、返回其 ID,并立即释放所有关联资源(PR[13]):

public class Process
{
    public static int StartAndForget(ProcessStartInfo startInfo);
    public static int StartAndForget(string fileName, IList<string>? arguments = null);
}

用法非常直观:

int processId = Process.StartAndForget("notepad.exe");

ProcessStartInfo.KillOnParentExit

父进程启动的子进程在父进程退出时不会自动终止,这在许多场景下会导致孤儿进程在后台持续运行。为了解决这个问题,我们引入了 ProcessStartInfo.KillOnParentExit 属性,确保当父进程退出时(包括强制终止和崩溃)子进程也被杀死:

public class ProcessStartInfo
{
    [SupportedOSPlatform("windows")] // introduced in .NET 11 Preview 4
    [SupportedOSPlatform("linux")] // introduced in .NET 11 Preview 5
    [SupportedOSPlatform("android")] // introduced in .NET 11 Preview 5
    public
 bool KillOnParentExit { get; set; }
}

这是通过平台特定功能实现的,例如 Windows 上的 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE(PR[14])和 Linux/Android 上的 PR_SET_PDEATHSIG(PR[15])。与其他 API 不同,此行为在不同平台上略有差异:

  • • 在 Windows 上,我们需要使用 Job 对象来确保父进程退出时子进程被杀死。Job 对象默认由所有子进程继承,因此若子进程又启动了另一个进程(孙子进程),孙子进程在父进程退出时也会被终止。
  • • 在 Linux 和 Android 上,我们使用 PR_SET_PDEATHSIG 指定一个 SIGKILL,当创建进程的线程退出时,内核会向子进程发送此信号。由于线程池线程和用户线程均可在任意时刻被终止,我们维护了一个专用线程,专门用于启动设置了 KillOnParentExit 的进程,以确保父进程退出时子进程被杀死。因此,当有多个进程以 KillOnParentExit 方式启动时,会使用同步机制确保该专用线程一次只启动一个进程。

反馈请求: 我们正在考虑将此 API 扩展,支持在父进程退出时杀死子进程的功能在其他 Unix 平台上也能使用。由于这些平台没有类似的机制,我们只能处理正常退出(atexit)和优雅终止(SIGTERM 等)的情况。如果你对此功能感兴趣,请告知我们。

ProcessStartInfo.StartDetached

ProcessStartInfo.StartDetached 属性允许你启动一个与父进程分离的进程,这意味着即使父进程退出、收到信号或终端关闭,该进程也会继续运行。这通过平台特定功能实现,例如 Windows 上的 DETACHED_PROCESS 标志和 Unix 上的 setsid(PR[16]):

public class ProcessStartInfo
{
    public
 bool StartDetached { get; set; }
}

此外,若 StartDetached 设置为 true 且未指定标准句柄重定向,标准句柄将被重定向至 null 句柄,以避免不必要地保持父进程标准句柄的开启状态。


SafeProcessHandle

有时 Process 无法覆盖你的场景——例如,你可能需要在 Windows 上 P/Invoke CreateProcessAsUser,或在 Unix 上使用自定义的 posix_spawn 配置。在这些情况下,你已经持有一个操作系统进程句柄,但此前 SafeProcessHandle 除构造函数外没有提供任何公开 API。我们为其扩展了一组针对最常见操作的 API:

namespace Microsoft.Win32.SafeHandles
{
    public
 class SafeProcessHandle : SafeHandle
    {
        public
 int ProcessId { get; }
        public void Kill();
        public bool Signal(PosixSignal signal);
        public static SafeProcessHandle Start(ProcessStartInfo startInfo);
        public bool TryWaitForExit(System.TimeSpan timeout, [NotNullWhen(true)] out ProcessExitStatus? exitStatus);
        public ProcessExitStatus WaitForExit();
        public Task<ProcessExitStatus> WaitForExitAsync(CancellationToken cancellationToken = default);
        public Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken);
        public ProcessExitStatus WaitForExitOrKillOnTimeout(TimeSpan timeout);
    }
}

Process 类本身已通过 Process.SafeHandle 属性暴露了 SafeProcessHandle,因此即使使用 Process 类,你也可以使用这些新 API:

[UnsupportedOSPlatform("windows")] // SIGTERM is not supported on Windows
ProcessExitStatus TerminateProcess(Process process)
{
    // First try to terminate the process gracefully with SIGTERM

    process.SafeHandle.Signal(PosixSignal.SIGTERM);
    if
 (process.SafeHandle.TryWaitForExit(TimeSpan.FromSeconds(3), out ProcessExitStatus? exitStatus))
    {
        return
 exitStatus;
    }

    // If the process is still running after the timeout, kill it forcefully with SIGKILL

    process.SafeHandle.Signal(PosixSignal.SIGKILL);
    return
 process.SafeHandle.WaitForExit();
}

或者,如果希望在进程未在指定时间内退出时将其杀死:

using SafeProcessHandle processHandle = SafeProcessHandle.Start(new ProcessStartInfo("myapp.exe"));
ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMinutes(1));
if
 (exitStatus.Canceled)
{
    Console.WriteLine("The process was killed after timeout.");
}

裁剪性

SafeProcessHandle 还提供了更好的裁剪性。我们来发布一个启动进程并等待其退出的 NativeAOT 应用,分别使用 SafeProcessHandle 和 Process

使用 SafeProcessHandle

using SafeProcessHandle handle = SafeProcessHandle.Start(new ProcessStartInfo("whoami"));
handle.WaitForExit();

使用 Process

using Process process = Process.Start(new ProcessStartInfo("whoami"))!;
process.WaitForExit();
dotnet publish -c Release -r win-x64 -p:PublishAot=true
dotnet publish -c Release -r linux-x64 -p:PublishAot=true

类型
.NET 版本
操作系统
大小(字节)
对比 .NET 10 Process
Process
.NET 10
Windows x64
1,730,048
基准
Process
.NET 11
Windows x64
1,389,056
-19.7%
SafeProcessHandle
.NET 11
Windows x64
1,178,624
-31.9%





Process
.NET 10
Linux x64
2,113,808
基准
Process
.NET 11
Linux x64
2,043,768
-3.3%
SafeProcessHandle
.NET 11
Linux x64
1,816,504
-14.1%

Process 磁盘大小改进(PR[17])包含了来自 Red Hat 的社区贡献[18]。值得一提的是,Red Hat 的 Tom Deseyn[19] 通过审查此次发布的新 API 的 Linux 实现,为本次发布贡献良多,在此特别致谢!


值得关注的性能改进

Windows 上的可扩展性改进

此前,Process.BeginOutputReadLine 和 Process.BeginErrorReadLine 都会创建一个后台任务,对输出/错误管道执行阻塞读取。因此,每个以重定向输出和错误方式启动并使用 Begin[Output/Error]ReadLine 方法的进程,都会阻塞两个线程池线程(#81896[20])。多年来,我们一直认为这个问题在 Windows 上无解,因为匿名管道不支持真正的异步 I/O 操作。

但在阅读文档[21]时,我们发现了这样一段话:

"匿名管道是使用带唯一名称的命名管道实现的。因此,你通常可以将匿名管道的句柄传递给需要命名管道句柄的函数。"

我们知道命名管道在 Windows 上支持真正的异步 I/O 操作,结合此前在 .NET 6 文件 I/O 改进[22]中积累的经验,我们意识到可以将命名管道的一端以异步 I/O 模式打开,另一端以同步 I/O 模式打开(99.99% 的应用期望标准句柄以同步 I/O 模式打开)。

我们研究了 CreatePipe 的实现,并确认(PR[23])新方案不会引入任何破坏性变更。从 .NET 11 Preview 4 开始,在 Windows 上,当你以重定向输出和错误的方式启动进程时,管道将以命名管道形式创建,读端以异步 I/O 模式打开,写端以同步 I/O 模式打开。这使我们能够对输出和错误管道使用真正的异步 I/O 操作,而不阻塞任何线程。

我们还暴露了通过 SafeFileHandle.CreateAnonymousPipe 方法创建匿名管道的能力,该方法创建一对相连的管道并返回其句柄,在 Windows 和 Unix 上均可使用,并屏蔽了平台特定的实现细节。

所有这些改动,在 Windows 上并行启动多个带重定向输出和错误的进程时,可扩展性大幅提升,因为我们不再为每个进程阻塞线程池线程了。

public class BeginReadLineBenchmarks
{
    private
 static readonly ProcessStartInfo _processStartInfo = CreateStartInfo();

    private static ProcessStartInfo CreateStartInfo()
    {
        ProcessStartInfo startInfo = OperatingSystem.IsWindows()
            ? new("cmd.exe", "/c for /L %i in (1,1,1000) do @echo Line %i")
            : new("sh", ["-c", "for i in $(seq 1 1000); do echo \"Line $i\"; done"]);

        startInfo.RedirectStandardOutput = true;
        startInfo.RedirectStandardError = true;

        return
 startInfo;
    }

    [Benchmark]
    public ParallelLoopResult Run() => Parallel.For(0, 300, static (_, _) => _ = Events());

    private static int Events()
    {
        using
 Process process = new();
        process.StartInfo = _processStartInfo;

        StringBuilder stdOut = new(), stdErr = new();

        process.OutputDataReceived += (sender, e) => stdOut.AppendLine(e.Data);
        process.ErrorDataReceived += (sender, e) => stdErr.AppendLine(e.Data);

        process.Start();

        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        process.WaitForExit();

        return
 process.ExitCode;
    }
}
BenchmarkDotNet v0.16.0-nightly.20260505.517, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2)
AMD Ryzen Threadripper PRO 3945WX 12-Cores 3.99GHz, 1 CPU, 24 logical and 12 physical cores
Memory: 63.86 GB Total, 39.4 GB Available
.NET SDK 11.0.100-preview.5.26255.101
  [Host]
    : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3
  .NET 10.0 : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3
  .NET 11.0 : .NET 11.0.0 (11.0.0-preview.5.26255.101, 11.0.26.25601), X64 RyuJIT x86-64-v3

方法
运行时
均值
比率
Run
.NET 10.0
5.307 s
1.00
Run
.NET 11.0
2.936 s
0.57

如你所见,在这个特定的微基准测试和机器上,吞吐量提升了约 1.8 倍。当并行启动更多进程时,改善将更为显著,因为我们不再为每个进程阻塞线程池线程了。

Apple 平台上进程创建速度的改进

为了在 Apple 平台(macOS、MacCatalyst)上实现 ProcessStartInfo.InheritedHandles,我们不得不从 fork + exec 切换至 posix_spawn(PR[24])。长话短说,这在 Apple 平台上带来了更好的性能,尤其是在 arm64 架构上。

运行如下基准测试:

[MemoryDiagnoser]
public
 class ProcessStartBenchmarks
{
    private
 static ProcessStartInfo s_startProcessStartInfo = new ProcessStartInfo()
    {
        FileName = "whoami", // exists on both Windows and Unix, and has very short output
        RedirectStandardOutput = true // avoid visible output
    };

    private
 Process? _startedProcess;

    [Benchmark]
    public void Start()
    {
        _startedProcess = Process.Start(s_startProcessStartInfo)!;
    }

    [IterationCleanup(Target = nameof(Start))]
    public void CleanupStart()
    {
        if
 (_startedProcess != null)
        {
            _startedProcess.WaitForExit();
            _startedProcess.Dispose();
            _startedProcess = null;
        }
    }

    [Benchmark]
    public void StartAndWaitForExit()
    {
        using
 (Process p = Process.Start(s_startProcessStartInfo)!)
        {
            p.WaitForExit();
        }
    }
}

我们观察到[25]在 Apple Silicon 上有约 98 倍的惊人提升,在 x64 机器上也有约 4.5 倍的提升:

BenchmarkDotNet v0.15.8, macOS Sequoia 15.4.1 (24E263) [Darwin 24.4.0]
Apple M4, 1 CPU, 10 logical and 10 physical cores
.NET SDK 11.0.100-preview.3.26174.112
  [Host]
     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a

方法
工具链
均值
误差
比率
StartAndWaitForExit
/PR_126063/corerun
1,246.5 us
5.26 us
1.00
StartAndWaitForExit
/main/corerun
8,945.9 us
80.30 us
7.18





Start
/PR_126063/corerun
122.0 us
2.40 us
1.00
Start
/main/corerun
12,043.2 us
116.96 us
98.86

BenchmarkDotNet v0.15.8, macOS Sequoia 15.2 (24C101) [Darwin 24.2.0]
Intel Core i5-8500B CPU 3.00GHz (Coffee Lake), 1 CPU, 6 logical and 6 physical cores
.NET SDK 11.0.100-preview.3.26174.112
  [Host]
     : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3

方法
工具链
均值
比率
StartAndWaitForExit
PR #126063
3,163.3 μs
1.00
StartAndWaitForExit
main
4,981.3 μs
1.58




Start
PR #126063
417.4 μs
1.00
Start
main
1,998.9 μs
4.80

Unix 上减少内存分配

我们还将 Unix 上启动进程时的内存分配减少了[26] 30–50%(PR[27]):

BenchmarkDotNet v0.15.8, macOS Sequoia 15.4.1 (24E263) [Darwin 24.4.0]
Apple M2, 1 CPU, 8 logical and 8 physical cores
.NET SDK 11.0.100-preview.3.26178.103
  [Host]
     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a

方法
工具链
均值
分配内存
分配比率
StartAndWaitForExit
/bfe7a08/corerun
1,570.2 us
15.83 KB
1.00
StartAndWaitForExit
/bfe7a08~1/corerun
1,569.0 us
23.92 KB
1.51





Start
/bfe7a08/corerun
173.0 us
15.83 KB
1.00
Start
/bfe7a08~1/corerun
176.5 us
23.98 KB
1.51


行动起来

所有这些改进今天就可以在 .NET 11 Preview 4[28] 中体验。快去试试,告诉我们你的想法——在这里留下评论,或到 dotnet/runtime[29] 提交 Issue 和功能请求。我们期待你的反馈!

引用链接

[1] PR: https://github.com/dotnet/runtime/pull/126942
[2] 引入: https://github.com/dotnet/runtime/pull/127210
[3] PR: https://github.com/dotnet/runtime/pull/127106
[4] PR: https://github.com/dotnet/runtime/pull/126987
[5] PR: https://github.com/dotnet/runtime/pull/126807
[6] poll: https://man7.org/linux/man-pages/man2/poll.2.html
[7] WaitForMultipleObjects: https://learn.microsoft.com/windows/win32/api/synchapi/nf-synchapi-waitformultipleobjects
[8] PR: https://github.com/dotnet/runtime/pull/126318
[9] 向后移植: https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html/8.4_release_notes/new-features
[10] PR: https://github.com/dotnet/runtime/pull/125848
[11] PR: https://github.com/dotnet/runtime/pull/124561
[12] PR: https://github.com/dotnet/runtime/pull/125512
[13] PR: https://github.com/dotnet/runtime/pull/126078
[14] PR: https://github.com/dotnet/runtime/pull/126699
[15] PR: https://github.com/dotnet/runtime/pull/127112
[16] PR: https://github.com/dotnet/runtime/pull/126632
[17] PR: https://github.com/dotnet/runtime/pull/126338
[18] 贡献: https://github.com/dotnet/runtime/pull/126449
[19] Tom Deseyn: https://github.com/tmds
[20] #81896https://github.com/dotnet/runtime/issues/81896
[21] 文档: https://learn.microsoft.com/windows/win32/api/namedpipeapi/nf-namedpipeapi-createpipe
[22] .NET 6 文件 I/O 改进: https://devblogs.microsoft.com/dotnet/file-io-improvements-in-dotnet-6/
[23] PR: https://github.com/dotnet/runtime/pull/125643
[24] PR: https://github.com/dotnet/runtime/pull/126063
[25] 观察到: https://github.com/EgorBot/Benchmarks/issues/73#issuecomment-4126314293
[26] 内存分配减少了: https://github.com/EgorBot/Benchmarks/issues/81
[27] PR: https://github.com/dotnet/runtime/pull/126201
[28] .NET 11 Preview 4: https://dotnet.microsoft.com/download/dotnet/11.0
[29] dotnet/runtime: https://github.com/dotnet/runtime/issues


群贤毕至

访客