C# 启动进程会泄漏内存,即使已终止并已处置(在 Linux 上)

C# Starting Process leaks memory even though Killed and Disposed (on Linux)

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

我有一个 ASP.NET Core Blazor 应用程序(使用服务器端托管模型)运行正在 Raspberry Pi 上运行。该应用程序的部分功能是 dim/brighten 根据系统最后一次交互的时间进行屏幕显示。为此,我每隔 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,就没有内存泄漏。结果 class 也没有被调用者存储 - 它只是解析一个值并立即使用它:

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

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

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

可能是.NET Core 2.2和.NET Core 3.0之间的回归导致的 显然它会在版本 3.1.7

中修复

由于未释放句柄

,刚启动进程会导致 linux 内存泄漏

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