【问题标题】:C# Starting Process leaks memory even though Killed and Disposed (on Linux)即使 Killed 和 Disposed(在 Linux 上),C# 启动进程也会泄漏内存
【发布时间】:2026-02-17 20:05:02
【问题描述】:

注意:根据测试(参见下面的编辑),这只发生在 Linux 机器上。

我有一个在 Raspberry Pi 上运行的 ASP.NET Core Blazor 应用程序(使用服务器端托管模型)。应用程序的部分功能是根据上次与系统交互的时间来调暗/调亮屏幕。为此,我每隔 1 秒左右生成一个终端子进程来运行 xprintidle,解析其输出并采取相应措施。

我使用 DataDog 进行监控,我一直有内存泄漏,直到系统崩溃(需要几天时间才能用完所有内存,但最终确实会发生):

我已经指出以下方法是内存泄漏的原因 - 如果我跳过调用它并使用一些恒定的时间跨度,内存不会泄漏: 我有以下代码:

// note this code has some parts that aren't even needed - I was simply trying anything to solve this problem at this point
public async Task<TerminalResult> ExecuteAndWaitAsync(string command, bool asRoot, CancellationToken cancellationToken = default)
{
    using Process prc = CreateNewProcess(command, asRoot);
    // we need to redirect stdstreams to read them
    prc.StartInfo.RedirectStandardOutput = true;
    prc.StartInfo.RedirectStandardError = true;

    // start the process
    _log.LogTrace("Starting the process");
    using Task waitForExitTask = WaitForExitAsync(prc, cancellationToken);
    prc.Start();

    // read streams
    string[] streamResults = await Task.WhenAll(prc.StandardOutput.ReadToEndAsync(), prc.StandardError.ReadToEndAsync()).ConfigureAwait(false);

    // wait till it fully exits, but no longer than half a second
    // this prevents hanging when process has already finished, but takes long time to fully close
    await Task.WhenAny(waitForExitTask, Task.Delay(500, cancellationToken)).ConfigureAwait(false);
    // if process still didn't exit, force kill it
    if (!prc.HasExited)
        prc.Kill(true);  // doing it with a try-catch approach instead of HasExited check gives no difference
    return new TerminalResult(streamResults[0], streamResults[1]);
}

public Task<int> WaitForExitAsync(Process process, CancellationToken cancellationToken = default)
{
    TaskCompletionSource<int> tcs = new TaskCompletionSource<int>();
    IDisposable tokenRegistration = null;
    EventHandler callback = null;
    tokenRegistration = cancellationToken.Register(() =>
    {
        Unregister();
        tcs.TrySetCanceled(cancellationToken);
    });
    callback = (sender, args) =>
    {
        Unregister();
        tcs.TrySetResult(process.ExitCode);
    };
    process.Exited += callback;
    process.EnableRaisingEvents = true;

    void Unregister()
    {
        lock (tcs)
        {
            if (tokenRegistration == null)
                return;
            process.EnableRaisingEvents = false;
            process.Exited -= callback;
            tokenRegistration?.Dispose();
            tokenRegistration = null;
        }
    }

    return tcs.Task;
}

private Process CreateNewProcess(string command, bool asRoot)
{
    _log.LogDebug("Creating process: {Command}", command);
    Process prc = new Process();

    if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
    {
        string escapedCommand = command.Replace("\"", "\\\"");
        // if as root, just sudo it
        if (asRoot)
            prc.StartInfo = new ProcessStartInfo("/bin/bash", $"-c \"sudo {escapedCommand}\"");
        // if not as root, we need to open it as current user
        // this may still run as root if the process is running as root
        else
            prc.StartInfo = new ProcessStartInfo("/bin/bash", $"-c \"{escapedCommand}\"");
    }
    else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        prc.StartInfo = new ProcessStartInfo("CMD.exe", $"/C {command}");
        if (asRoot)
            prc.StartInfo.Verb = "runas";
    }
    else
        throw new PlatformNotSupportedException($"{nameof(ExecuteAndWaitAsync)} is only supported on Windows and Linux platforms.");

    prc.StartInfo.UseShellExecute = false;
    prc.StartInfo.CreateNoWindow = true;

    if (_log.IsEnabled(LogLevel.Trace))
    {
        _log.LogTrace("exec: {FileName} {Args}", prc.StartInfo.FileName, prc.StartInfo.Arguments);
        _log.LogTrace("exec: as root = {AsRoot}", asRoot);
    }

    return prc;
}

我花了很多时间(超过几个月 - 字面意思)尝试各种更改来解决这个问题 - WaitForExitAsync 进行了很多大修,尝试了不同的处理方式。我试图定期调用 GC.Collect() 。还尝试在服务器和工作站 GC 模式下运行应用程序。

正如我之前提到的,我很确定是这段代码泄漏了——如果我不调用ExecuteAndWaitAsync,就没有内存泄漏。结果类也不由调用者存储 - 它只是解析一个值并立即使用它:

public async Task<TimeSpan> GetSystemIdleTimeAsync(CancellationToken cancellationToken = default)
{
    ThrowIfNotLinux();

    const string prc = "xprintidle";
    TerminalResult result = await _terminal.ExecuteAndWaitAsync(prc, false, cancellationToken).ConfigureAwait(false);
    if (result.HasErrors || !int.TryParse(result.Output, out int idleMs))
        throw new InvalidOperationException($"{prc} returned invalid data.");
    return TimeSpan.FromMilliseconds(idleMs);
}

private static void ThrowIfNotLinux()
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        throw new PlatformNotSupportedException($"{nameof(BacklightControl)} is only functional on Linux systems.");
}

我错过了什么吗?是进程类泄漏,还是我读取输出的方式?

编辑:正如 cmets 中的人所问,我创建了最少的可运行代码,基本上在单个类中获取所有相关方法并在循环中执行。该代码可作为要点提供:https://gist.github.com/TehGM/c953b670ad8019b2b2be6af7b14807c2
我在我的 Windows 机器和 Raspberry Pi 上都运行了它。在 Windows 上,内存似乎很稳定,但在 Raspberry Pi 上,它显然正在泄漏。我尝试了xprintidleifconfig 以确保这不是仅xprintidle 的问题。 .NET Core 3.0 和 .NET Core 3.1 都试过了,效果基本一样。

【问题讨论】:

  • 嗨@Orwel - 这是using 关键字的C# 8 方式 - 如果你不使用括号作为范围,范围是直到方法退出,所以当方法返回时发生处置。
  • 你能提供最小可重复的例子吗?例如。 Windows 控制台应用程序不断调用某些进程并在短时间内泄漏内存(等待几个小时进行重现是不方便的)。如果不能在 Windows 上重现,那就制作一些 Linux 应用程序。可重现的示例是在这里获得帮助的最佳方式。
  • @TehGM 那时我不知道。似乎是 Linux .NET 实现中的一个错误。这个想法是避免使用Streams 并尝试OutputDataReceivedErrorDataReceived 事件处理程序。
  • @aepot 感谢您的回复,伙计。我将流更改为您提到的这些事件,但不幸的是,这不起作用 - 我猜不是流有问题。我的代码中是否还有其他任何东西会引发我遗漏的危险信号,或任何其他替代方案?当然,我不指望你解决 Linux/.NET 的 bug,但是这个问题困扰了我很长时间,也许你会注意到一些我没有注意到的地方。
  • @aepot 导致 InvalidOperationException,导致在执行 Shell Execute 时无法重定向标准输出。

标签: c# .net linux .net-core memory-leaks


【解决方案1】:

这可能是由 .NET Core 2.2 和 .NET Core 3.0 之间的回归引起的 显然它将在版本 3.1.7 中修复

刚启动进程会导致linux上的内存泄漏,因为没有释放句柄

已在此处跟踪问题https://github.com/dotnet/runtime/issues/36661

【讨论】:

  • 巧合的是,今天发布了 3.1.7。我更新了,现在运行同样的测试——而且看起来很稳定。希望我早点知道,不会因为这个大声笑掉那么多头发。