.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 的汇总:
一行代码执行进程 Process.RunAndCaptureText[Async]一行代码执行进程(不捕获输出) Process.Run[Async]即发即忘 Process.StartAndForget无死锁输出捕获 Process.ReadAllText/Bytes/Lines[Async]重定向至任意目标 ProcessStartInfo.Standard[Input/Output/Error]HandleSafeFileHandle。受控继承 ProcessStartInfo.InheritedHandles父进程退出时杀死子进程 ProcessStartInfo.KillOnParentExit分离进程 ProcessStartInfo.StartDetached轻量级进程句柄 SafeProcessHandle.Start/WaitForExit/Kill/SignalProcess 即可启动和管理进程。进程退出详情 ProcessExitStatusNull 句柄 File.OpenNullHandle()匿名管道 SafeFileHandle.CreateAnonymousPipe控制台句柄 Console.OpenStandard[Input/Output/Error]Handle()句柄类型检测 SafeFileHandle.Type
其他改进包括:
• 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// * 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在 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重定向标准句柄
限制句柄继承问题的另一种方式,是让用户将标准句柄重定向到任意文件句柄,而无需将其设为可继承。现在可以将标准句柄重定向至任意文件句柄(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=trueProcess 磁盘大小改进(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如你所见,在这个特定的微基准测试和机器上,吞吐量提升了约 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-aBenchmarkDotNet 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-v3Unix 上减少内存分配
我们还将 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行动起来
所有这些改进今天就可以在 .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] #81896: https://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