Start Debugging

.NET 11 добавляет захват вывода процессов без взаимных блокировок

.NET 11 Preview 4 представляет новые API System.Diagnostics.Process, которые параллельно вычитывают stdout и stderr, плюс однострочные хелперы запуска с захватом и KillOnParentExit.

Адам Ситник объявил о наборе улучшений System.Diagnostics.Process, которые попали в .NET 11 Preview 4. Главное: ловушка дедлока, в которую хотя бы раз попадает каждый .NET-разработчик, наконец-то решена на уровне BCL, а шаблонный код “запустить процесс, захватить вывод, дождаться завершения” сворачивается в один вызов.

Почему старый шаблон приводит к взаимной блокировке

Классическая форма выглядит безопасной, но это не так:

var psi = new ProcessStartInfo("git", "log")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true,
};
using var p = Process.Start(psi)!;
string stdout = p.StandardOutput.ReadToEnd();
string stderr = p.StandardError.ReadToEnd();
p.WaitForExit();

Если дочерний процесс записывает в stderr больше, чем помещается в буфер канала, до того как родительский процесс доберётся до stderr.ReadToEnd(), дочерний процесс блокируется на заполненном канале, а родительский ожидает закрытия stdout. Оба ждут вечно. Документированным обходным путём была API на событиях с OutputDataReceived, ручными StringBuilder и отдельным ManualResetEvent для каждого потока. Это работает, но никто не пишет такой код правильно с первой попытки.

Новые однострочники

.NET 11 добавляет Process.RunAndCaptureText и Process.RunAndCaptureTextAsync, которые внутри мультиплексируют оба канала:

ProcessTextOutput result = await Process.RunAndCaptureTextAsync(
    "git",
    ["log", "--oneline", "-n", "20"]);

if (result.ExitStatus.ExitCode != 0)
    throw new InvalidOperationException(result.StandardError);

Console.WriteLine(result.StandardOutput);

ProcessTextOutput содержит захваченные stdout, stderr, идентификатор процесса и ProcessExitStatus, который сообщает как код завершения, так и, в Unix, сигнал, прервавший процесс. Есть и родственный Process.ReadAllText для вызывающих, у которых уже есть запущенный Process, плюс ReadAllLines/Async для потоковой выдачи IEnumerable<ProcessOutputLine> с флагом StandardError для каждой строки, и ReadAllBytes/Async для бинарных инструментов. По данным блога, синхронный RunAndCaptureText под Windows аллоцирует примерно в 4.5 раза меньше памяти, чем эквивалентный цикл на событиях.

Время жизни и отвязка

Две давние ловушки тоже получают полноценную поддержку. ProcessStartInfo.KillOnParentExit связывает дочерний процесс с временем жизни родителя в Windows, Linux и Android, поэтому падающий CLI-инструмент больше не оставляет осиротевших воркеров. ProcessStartInfo.StartDetached делает обратное: дочерний процесс переживает родителя и терминал, из которого он был запущен. А Process.StartAndForget возвращает только PID и сразу освобождает handle родителя, что и нужно для запусков по принципу “запустил и забыл”:

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

Работа с handle

Для более низкоуровневых сценариев ProcessStartInfo.StandardInputHandle, StandardOutputHandle и StandardErrorHandle принимают любой SafeFileHandle, включая каналы из нового SafeFileHandle.CreateAnonymousPipe и приёмник-заглушку из File.OpenNullHandle. ProcessStartInfo.InheritedHandles позволяет в белом списке указать, какие именно handle пересекают границу fork, и закрывает реальный источник утечек файловых блокировок в Windows.

Попробуйте на .NET 11 Preview 4 и начинайте удалять обработчики OutputDataReceived.

Comments

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

< Назад