Skip to content
Go back

.NET 11 Process API 全面升级:一行代码告别死锁,苹果 Silicon 快 98 倍

System.Diagnostics.Process 是 .NET 里启动和管理进程的核心类,但它一直存在几个让人头疼的问题:读取进程输出容易死锁、句柄继承行为难以控制、进程生命周期管理缺少原生支持。.NET 11 针对这些痛点做了多年来最大的一次更新。

这篇文章是对原文的中文梳理,覆盖所有新 API 和性能改进,按场景分块,让你快速找到自己关心的部分。

.NET 11 Process API 升级封面:旧方法死锁 vs 新 API 一行搞定

一张表看清所有新 API

功能API说明
一行捕获输出Process.RunAndCaptureText[Async]启动进程、捕获 stdout/stderr、等待退出,一个调用搞定
一行等待退出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()获取标准流的底层 OS 句柄
句柄类型检测SafeFileHandle.Type判断句柄是文件、管道、Socket 等

捕获进程输出为什么会死锁

管道有有限的缓冲区(Windows 通常 4 KB,Unix 通常 64 KB)。当子进程写入量超过缓冲区容量,且父进程没有同时读取时,子进程会被阻塞在写操作上。

下面这段代码看起来合理,但在输出量较大时会死锁:

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

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

调换顺序也没用——ReadToEnd 会一直阻塞到流结束(即子进程关闭管道),所以读 stdout 时,没有人在读 stderr;一旦 stderr 把缓冲区写满,双方就互相等待,卡死了。

根本原因是顺序读取两个流。.NET 11 之前有两种解法,但都不够简洁:

方法一:用异步 API 同时读取

process.Start();

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

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

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

方法二:用事件回调

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();

两种方式都有样板代码,性能也不是最优。

新 API:无死锁的输出读取

ReadAllText 和 ReadAllTextAsync

.NET 11Process 类加了 ReadAllTextReadAllTextAsync 方法,在底层同时排空 stdout 和 stderr:

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();

RunAndCaptureText:真正的一行代码

如果你只需要捕获输出然后等待进程退出(最常见的场景),RunAndCaptureText 把启动、读取、等待打包成一个调用:

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

ProcessTextOutput 包含 StandardOutputStandardErrorExitStatus(含退出码、Unix 信号、是否被取消)和 ProcessId

异步版本同理:

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

不关心输出,只需要等待退出的场景用 Process.Run

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

ReadAllLines:按行读取,区分流

如果你需要逐行区分 stdout 和 stderr,用 ReadAllLinesReadAllLinesAsync,返回 ProcessOutputLine 序列,每条记录携带 ContentStandardError 标志:

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

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

超时和取消

所有新的读取方法都支持 TimeSpan 超时和 CancellationToken。超时或取消时分别抛出 TimeoutExceptionOperationCanceledException,高层的 RunAndCaptureText[Async]Run[Async] 还会自动尝试杀死进程,避免留下僵尸进程。

底层:多路复用 + ArrayPool

同步的 RunAndCaptureTextReadAll[Bytes/Text] 在底层用多路复用(Unix 上用 poll,Windows 上用 WaitForMultipleObjects)单线程同时读取两个流,并配合 ArrayPool 减少内存分配。

Windows 基准数据(1000 行输出):

方法均值线程池工作项分配内存
Events(旧)71.21 ms2006612.58 KB
ReadToEndAsync(旧)70.33 ms2004636.67 KB
RunAndCaptureText(新)68.11 ms132.58 KB
RunAndCaptureTextAsync(新)70.66 ms2004534.09 KB

同步新方法速度略快,内存分配减少约 4.5 倍,并且完全不占用线程池。Linux 上内存节省 2–4 倍。

句柄继承控制

管道 EOF 的触发条件是所有指向写端的句柄都关闭。如果并发启动的兄弟进程或孙子进程意外继承了管道句柄,管道就永远不会到达 EOF,造成另一种死锁。

新的 ProcessStartInfo.InheritedHandles 属性让你精确控制子进程继承哪些句柄:

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

Windows 上设置 InheritedHandles 后,并发启动多个进程时只需要读锁,不会相互阻塞,吞吐量大约翻倍(benchmark 显示 4.014 s → 1.958 s)。

注意:列表中的句柄不应预先设置为可继承,避免被其他并发启动的进程意外拿到。目前支持 SafeFileHandleSafePipeHandle

重定向标准句柄

除了 RedirectStandardOutput / RedirectStandardInput / RedirectStandardError 这套布尔开关,.NET 11 加了三个 SafeFileHandle 属性,让你把标准流重定向到任意文件句柄:

public class ProcessStartInfo
{
    public SafeFileHandle? StandardInputHandle { get; set; }
    public SafeFileHandle? StandardOutputHandle { get; set; }
    public SafeFileHandle? StandardErrorHandle { get; set; }
}

配套引入了几个辅助 API:

// 创建一对匿名管道(可选异步)
SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe,
    bool asyncRead = false, bool asyncWrite = false);

// 打开 null 句柄:写入丢弃,读取返回 EOF
File.OpenNullHandle();

// 获取控制台标准流的 OS 句柄
Console.OpenStandardInputHandle();
Console.OpenStandardOutputHandle();
Console.OpenStandardErrorHandle();

用 C# 实现 ls /usr/bin | grep zip > output.txt 的等价操作:

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
    };

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

    using Process producerProcess = Process.Start(producer)!;
    writePipe.Dispose(); // 释放父进程的写端,让 grep 能感知到 EOF

    using Process consumerProcess = Process.Start(consumer)!;
    readPipe.Dispose();

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

SafeFileHandle 还新增了:

进程生命周期管理

StartAndForget:即发即忘

Process.Dispose 不会杀死进程,只释放相关资源。StartAndForget 明确表达了”我不关心这个进程”的意图:

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

KillOnParentExit:父退子死

子进程在父进程退出后默认继续存活。KillOnParentExit 改变这个行为(支持 Windows 和 Linux/Android):

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

StartDetached:彻底脱离父进程

StartDetached = true 让子进程在父进程退出、收到信号或终端关闭后仍然存活:

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

Windows 内部使用 DETACHED_PROCESS 标志,Unix 使用 setsid。开启后如未指定标准句柄重定向,会自动把标准流重定向到 null 句柄,避免父进程句柄被意外保持打开。

SafeProcessHandle:对裁剪友好的低层 API

当你需要绕过 Process 类(例如需要 P/Invoke CreateProcessAsUser 或自定义 posix_spawn)时,SafeProcessHandle 现在提供了一套完整的操作 API:

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(TimeSpan timeout, out ProcessExitStatus? exitStatus);
    public ProcessExitStatus WaitForExit();
    public Task<ProcessExitStatus> WaitForExitAsync(CancellationToken cancellationToken = default);
    public Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken);
    public ProcessExitStatus WaitForExitOrKillOnTimeout(TimeSpan timeout);
}

示例——先发 SIGTERM 等待,超时再 SIGKILL:

[UnsupportedOSPlatform("windows")]
ProcessExitStatus TerminateProcess(Process process)
{
    process.SafeHandle.Signal(PosixSignal.SIGTERM);
    if (process.SafeHandle.TryWaitForExit(TimeSpan.FromSeconds(3), out ProcessExitStatus? exitStatus))
    {
        return exitStatus;
    }

    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.");
}

NativeAOT 体积比较

类型.NET 版本OS体积(字节)vs .NET 10 Process
Process.NET 10Windows x641,730,048baseline
Process.NET 11Windows x641,389,056-19.7%
SafeProcessHandle.NET 11Windows x641,178,624-31.9%
Process.NET 10Linux x642,113,808baseline
Process.NET 11Linux x642,043,768-3.3%
SafeProcessHandle.NET 11Linux x641,816,504-14.1%

值得关注的性能改进

Windows 并发启动可扩展性提升 1.8x

BeginOutputReadLine / BeginErrorReadLine 过去会在每个进程上阻塞两个线程池线程。.NET 11 中,Windows 上的匿名管道用具名管道实现,读端以异步 IO 打开,写端以同步 IO 打开,彻底消除了线程池阻塞。

300 个进程并发启动(每个输出 1000 行)的对比:

Runtime均值比值
.NET 105.307 s1.00
.NET 112.936 s0.57

Apple Silicon 进程启动快 98 倍

实现 InheritedHandles 促使苹果平台从 fork + exec 切换到 posix_spawn,带来了意外的性能红利:

方法工具链均值比值
Start.NET 11 (posix_spawn)122.0 μs1.00
Start.NET 10 (fork+exec)12,043.2 μs98.86x
StartAndWaitForExit.NET 111,246.5 μs1.00
StartAndWaitForExit.NET 108,945.9 μs7.18x

(Apple M4,macOS Sequoia 15.4.1)

Unix 内存分配减少 30–50%

Apple M2 上的测试数据:

方法.NET 11 分配.NET 10 分配比值
StartAndWaitForExit15.83 KB23.92 KB-34%
Start15.83 KB23.98 KB-34%

小结

.NET 11 的 Process API 更新解决了几个长期存在的实际问题:

所有改进目前已在 .NET 11 Preview 4/5 中可用。如果你有反馈或发现问题,可以在 dotnet/runtime 提 issue。

参考


Tags


Next

Microsoft Agent Framework 1.0:Agent 与 Workflow 分离的生产级 AI 框架正式发布