【问题标题】:Process sometimes hangs while waiting for Exit等待退出时进程有时会挂起
【发布时间】:2020-02-13 13:11:24
【问题描述】:

我的进程在等待退出时挂起的原因可能是什么?

此代码必须启动内部执行许多操作的 powershell 脚本,例如通过 MSBuild 开始重新编译代码,但问题可能是它生成过多的输出,并且即使在 power shell 脚本正确执行后,此代码在等待退出时也会卡住

这有点“奇怪”,因为有时这段代码运行良好,但有时却卡住了。

代码挂在:

process.WaitForExit(ProcessTimeOutMiliseconds);

Powershell 脚本在 1-2 秒内执行,而超时时间为 19 秒。

public static (bool Success, string Logs) ExecuteScript(string path, int ProcessTimeOutMiliseconds, params string[] args)
{
    StringBuilder output = new StringBuilder();
    StringBuilder error = new StringBuilder();

    using (var outputWaitHandle = new AutoResetEvent(false))
    using (var errorWaitHandle = new AutoResetEvent(false))
    {
        try
        {
            using (var process = new Process())
            {
                process.StartInfo = new ProcessStartInfo
                {
                    WindowStyle = ProcessWindowStyle.Hidden,
                    FileName = "powershell.exe",
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    Arguments = $"-ExecutionPolicy Bypass -File \"{path}\"",
                    WorkingDirectory = Path.GetDirectoryName(path)
                };

                if (args.Length > 0)
                {
                    var arguments = string.Join(" ", args.Select(x => $"\"{x}\""));
                    process.StartInfo.Arguments += $" {arguments}";
                }

                output.AppendLine($"args:'{process.StartInfo.Arguments}'");

                process.OutputDataReceived += (sender, e) =>
                {
                    if (e.Data == null)
                    {
                        outputWaitHandle.Set();
                    }
                    else
                    {
                        output.AppendLine(e.Data);
                    }
                };
                process.ErrorDataReceived += (sender, e) =>
                {
                    if (e.Data == null)
                    {
                        errorWaitHandle.Set();
                    }
                    else
                    {
                        error.AppendLine(e.Data);
                    }
                };

                process.Start();

                process.BeginOutputReadLine();
                process.BeginErrorReadLine();

                process.WaitForExit(ProcessTimeOutMiliseconds);

                var logs = output + Environment.NewLine + error;

                return process.ExitCode == 0 ? (true, logs) : (false, logs);
            }
        }
        finally
        {
            outputWaitHandle.WaitOne(ProcessTimeOutMiliseconds);
            errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds);
        }
    }
}

脚本:

start-process $args[0] App.csproj -Wait -NoNewWindow

[string]$sourceDirectory  = "\bin\Debug\*"
[int]$count = (dir $sourceDirectory | measure).Count;

If ($count -eq 0)
{
    exit 1;
}
Else
{
    exit 0;
}

在哪里

$args[0] = "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe"

编辑

在@ingen 的解决方案中,我添加了一个小包装器,它会重试执行挂起的 MS Build

public static void ExecuteScriptRx(string path, int processTimeOutMilliseconds, out string logs, out bool success, params string[] args)
{
    var current = 0;
    int attempts_count = 5;
    bool _local_success = false;
    string _local_logs = "";

    while (attempts_count > 0 && _local_success == false)
    {
        Console.WriteLine($"Attempt: {++current}");
        InternalExecuteScript(path, processTimeOutMilliseconds, out _local_logs, out _local_success, args);
        attempts_count--;
    }

    success = _local_success;
    logs = _local_logs;
}

InternalExecuteScript 是 ingen 的代码

【问题讨论】:

  • 实际进程在哪一行挂起?并更多地介绍您的代码
  • @Mr.AF 你是对的 - 完成了。
  • Powershell 的实际调用是一回事,但是您没有提供的是您在 Powershell 中尝试处理的脚本的实际其余部分。调用 powershell 本身不是问题,而是在您尝试做的事情之内。编辑您的帖子并放置您尝试执行的显式调用/命令。
  • 我尝试复制错误真的很奇怪。它在 20 次尝试时随机发生了两次,我无法再次触发它。
  • @Joelty,哦,很有趣,你是说Rx 方法有效(因为它没有超时),即使流浪的 MSBuild 进程存在导致无限期等待?有兴趣了解如何处理

标签: c#


【解决方案1】:

问题在于,如果您重定向 StandardOutput 和/或 StandardError,内部缓冲区可能会变满。

要解决上述问题,您可以在单独的线程中运行该进程。我不使用 WaitForExit,我使用进程退出事件,该事件将异步返回进程的 ExitCode,以确保它已完成。

public async Task<int> RunProcessAsync(params string[] args)
    {
        try
        {
            var tcs = new TaskCompletionSource<int>();

            var process = new Process
            {
                StartInfo = {
                    FileName = 'file path',
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    Arguments = "shell command",
                    UseShellExecute = false,
                    CreateNoWindow = true
                },
                EnableRaisingEvents = true
            };


            process.Exited += (sender, args) =>
            {
                tcs.SetResult(process.ExitCode);
                process.Dispose();
            };

            process.Start();
            // Use asynchronous read operations on at least one of the streams.
            // Reading both streams synchronously would generate another deadlock.
            process.BeginOutputReadLine();
            string tmpErrorOut = await process.StandardError.ReadToEndAsync();
            //process.WaitForExit();


            return await tcs.Task;
        }
        catch (Exception ee) {
            Console.WriteLine(ee.Message);
        }
        return -1;
    }

以上代码经过实战测试,使用命令行参数调用 FFMPEG.exe。我正在将 mp4 文件转换为 mp3 文件,一次播放 1000 多个视频而没有失败。不幸的是,我没有直接的 power shell 经验,但希望这会有所帮助。

【讨论】:

  • 这段代码很奇怪,类似于其他解决方案在第一次尝试时失败(卡住),然后似乎工作正常(像其他 5 次尝试一样,我将对其进行更多测试)。顺便说一句,你为什么要执行BegingOutputReadline,然后在StandardError 上执行ReadToEndAsync
  • OP 已经在异步读取,因此控制台缓冲区的死锁不太可能是这里的问题。
【解决方案2】:

让我们先回顾一下相关帖子中的the accepted answer

问题在于,如果您重定向 StandardOutput 和/或 StandardError,内部缓冲区可能会变满。无论您使用什么顺序,都可能出现问题:

  • 如果您在读取 ​​StandardOutput 之前等待进程退出,则进程可能会阻止尝试写入它,因此进程永远不会结束。
  • 如果您使用 ReadToEnd 从 StandardOutput 读取数据,那么如果进程从不关闭 StandardOutput(例如,如果它从不终止,或者如果它被阻止写入 StandardError),则您的进程可能会阻塞。

然而,即使是公认的答案,在某些情况下也难以确定执行顺序。

编辑:如果发生超时,请参阅下面的答案以了解如何避免 ObjectDisposedException

正是在这种情况下,您想要协调多个事件,Rx 才真正大放异彩。

请注意,Rx 的 .NET 实现可作为 System.Reactive NuGet 包使用。

让我们深入了解 Rx 如何促进处理事件。

// Subscribe to OutputData
Observable.FromEventPattern<DataReceivedEventArgs>(process, nameof(Process.OutputDataReceived))
    .Subscribe(
        eventPattern => output.AppendLine(eventPattern.EventArgs.Data),
        exception => error.AppendLine(exception.Message)
    ).DisposeWith(disposables);

FromEventPattern 允许我们将不同发生的事件映射到统一的流(也称为可观察的)。这使我们能够处理管道中的事件(使用类似 LINQ 的语义)。这里使用的Subscribe 重载带有Action&lt;EventPattern&lt;...&gt;&gt;Action&lt;Exception&gt;。每当引发观察到的事件时,其senderargs 将被EventPattern 包裹并通过Action&lt;EventPattern&lt;...&gt;&gt; 推送。当管道中出现异常时,使用Action&lt;Exception&gt;

Event 模式的一个缺点在此用例中(以及引用帖子中的所有变通方法)清楚地说明了,即在何时/何地取消订阅事件处理程序并不明显。

通过 Rx,我们在订阅时会收到 IDisposable。当我们处理它时,我们有效地结束了订阅。通过添加DisposeWith 扩展方法(借用自RxUI),我们可以将多个IDisposables 添加到CompositeDisposable(在代码示例中命名为disposables)。全部完成后,我们可以通过拨打disposables.Dispose() 结束所有订阅。

可以肯定的是,我们无法用 Rx 做任何事情,而我们无法用 vanilla .NET 做这些事情。一旦您适应了函数式思维方式,生成的代码就更容易推理了。

public static void ExecuteScriptRx(string path, int processTimeOutMilliseconds, out string logs, out bool success, params string[] args)
{
    StringBuilder output = new StringBuilder();
    StringBuilder error = new StringBuilder();

    using (var process = new Process())
    using (var disposables = new CompositeDisposable())
    {
        process.StartInfo = new ProcessStartInfo
        {
            WindowStyle = ProcessWindowStyle.Hidden,
            FileName = "powershell.exe",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            Arguments = $"-ExecutionPolicy Bypass -File \"{path}\"",
            WorkingDirectory = Path.GetDirectoryName(path)
        };

        if (args.Length > 0)
        {
            var arguments = string.Join(" ", args.Select(x => $"\"{x}\""));
            process.StartInfo.Arguments += $" {arguments}";
        }

        output.AppendLine($"args:'{process.StartInfo.Arguments}'");

        // Raise the Process.Exited event when the process terminates.
        process.EnableRaisingEvents = true;

        // Subscribe to OutputData
        Observable.FromEventPattern<DataReceivedEventArgs>(process, nameof(Process.OutputDataReceived))
            .Subscribe(
                eventPattern => output.AppendLine(eventPattern.EventArgs.Data),
                exception => error.AppendLine(exception.Message)
            ).DisposeWith(disposables);

        // Subscribe to ErrorData
        Observable.FromEventPattern<DataReceivedEventArgs>(process, nameof(Process.ErrorDataReceived))
            .Subscribe(
                eventPattern => error.AppendLine(eventPattern.EventArgs.Data),
                exception => error.AppendLine(exception.Message)
            ).DisposeWith(disposables);

        var processExited =
            // Observable will tick when the process has gracefully exited.
            Observable.FromEventPattern<EventArgs>(process, nameof(Process.Exited))
                // First two lines to tick true when the process has gracefully exited and false when it has timed out.
                .Select(_ => true)
                .Timeout(TimeSpan.FromMilliseconds(processTimeOutMilliseconds), Observable.Return(false))
                // Force termination when the process timed out
                .Do(exitedSuccessfully => { if (!exitedSuccessfully) { try { process.Kill(); } catch {} } } );

        // Subscribe to the Process.Exited event.
        processExited
            .Subscribe()
            .DisposeWith(disposables);

        // Start process(ing)
        process.Start();

        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        // Wait for the process to terminate (gracefully or forced)
        processExited.Take(1).Wait();

        logs = output + Environment.NewLine + error;
        success = process.ExitCode == 0;
    }
}

我们已经讨论了第一部分,我们将事件映射到可观察对象,因此我们可以直接跳到内容丰富的部分。在这里,我们将 observable 分配给 processExited 变量,因为我们想多次使用它。

首先,当我们激活它时,调用Subscribe。稍后当我们想要“等待”它的第一个值时。

var processExited =
    // Observable will tick when the process has gracefully exited.
    Observable.FromEventPattern<EventArgs>(process, nameof(Process.Exited))
        // First two lines to tick true when the process has gracefully exited and false when it has timed out.
        .Select(_ => true)
        .Timeout(TimeSpan.FromMilliseconds(processTimeOutMilliseconds), Observable.Return(false))
        // Force termination when the process timed out
        .Do(exitedSuccessfully => { if (!exitedSuccessfully) { try { process.Kill(); } catch {} } } );

// Subscribe to the Process.Exited event.
processExited
    .Subscribe()
    .DisposeWith(disposables);

// Start process(ing)
...

// Wait for the process to terminate (gracefully or forced)
processExited.Take(1).Wait();

OP 的一个问题是它假定process.WaitForExit(processTimeOutMiliseconds) 将在超时时终止进程。来自MSDN

指示Process 组件等待指定的毫秒数以等待关联进程退出。

相反,当它超时时,它只是将控制权返回给当前线程(即停止阻塞)。当进程超时时,您需要手动强制终止。要知道何时发生超时,我们可以将Process.Exited 事件映射到processExited observable 进行处理。这样我们就可以为Do 运算符准备输入。

代码是不言自明的。如果exitedSuccessfully 进程将正常终止。如果不是exitedSuccessfully,则需要强制终止。注意process.Kill() 是异步执行的,参考remarks。但是,之后立即调用process.WaitForExit() 将再次打开死锁的可能性。因此,即使在强制终止的情况下,最好在 using 范围结束时清理所有可处置对象,因为无论如何都可以认为输出被中断/损坏。

try catch 构造保留用于将processTimeOutMilliseconds 与流程完成所需的实际时间对齐的例外情况(没有双关语)。换句话说,在Process.Exited 事件和计时器之间发生了竞争条件。 process.Kill() 的异步特性再次放大了这种情况发生的可能性。我在测试的时候遇到过一次。


为了完整起见,DisposeWith 扩展方法。

/// <summary>
/// Extension methods associated with the IDisposable interface.
/// </summary>
public static class DisposableExtensions
{
    /// <summary>
    /// Ensures the provided disposable is disposed with the specified <see cref="CompositeDisposable"/>.
    /// </summary>
    public static T DisposeWith<T>(this T item, CompositeDisposable compositeDisposable)
        where T : IDisposable
    {
        if (compositeDisposable == null)
        {
            throw new ArgumentNullException(nameof(compositeDisposable));
        }

        compositeDisposable.Add(item);
        return item;
    }
}

【讨论】:

  • 恕我直言,绝对值得赏金。很好的答案,以及很好的 RX 主题介绍。
  • 谢谢!!!您的ExecuteScriptRx 完美处理hangs。不幸的是挂起仍然发生,但我只是在你的ExecuteScriptRx 上添加了一个小包装器,它执行Retry,然后它执行得很好。 MSBUILD 挂起的原因可能是@Clint 答案。 PS:那段代码让我觉得自己很傻 这是我第一次看到System.Reactive.Linq;
【解决方案3】:

为了读者的利益,我将把它分成 2 个部分

A 部分:问题以及如何处理类似情况

B 部分:问题重现和解决方案

A 部分:问题

当这个问题发生时 - 进程出现在任务管理器中,然后 2-3秒后消失(很好),然后等待超时,然后 抛出异常 System.InvalidOperationException: Process must 在确定请求的信息之前退出。

& 参见下面的场景 4

在您的代码中:

  1. Process.WaitForExit(ProcessTimeOutMiliseconds); 有了这个你正在等待Process 超时退出,这会发生在第一个。强>
  2. OutputWaitHandle.WaitOne(ProcessTimeOutMiliseconds)anderrorWaitHandle.WaitOne(ProcessTimeOutMiliseconds); 有了这个,你正在等待OutputData & ErrorData 流读取操作发出完成的信号
  3. Process.ExitCode == 0 获取进程退出时的状态

不同的设置及其注意事项:

  • 场景 1(快乐路径):进程在超时之前完成,因此您的 stdoutput 和 stderror 也会在它之前完成,一切都很好。
  • 场景 2:进程、OutputWaitHandle 和 ErrorWaitHandle 超时,但是 stdoutput 和 stderror 仍在被读取并在 WaitHandlers 超时后完成。这导致另一个异常ObjectDisposedException()
  • 场景 3:首先处理超时(19 秒),但 stdout 和 stderror 正在运行,您等待 WaitHandler 超时(19 秒),导致额外的延迟 + 19 秒。
  • 场景 4:进程超时并且代码尝试过早地查询 Process.ExitCode 导致错误 System.InvalidOperationException: Process must exit before requested information can be determined

我已经对这个场景进行了十几次测试并且工作正常,测试时使用了以下设置

  • 通过启动大约 2-15 个项目的构建,输出流的大小从 5KB 到 198KB 不等
  • 过早超时和进程在超时窗口内退出


更新代码

.
.
.
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    //First waiting for ReadOperations to Timeout and then check Process to Timeout
    if (!outputWaitHandle.WaitOne(ProcessTimeOutMiliseconds) && !errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds)
        && !process.WaitForExit(ProcessTimeOutMiliseconds)  )
    {
        //To cancel the Read operation if the process is stil reading after the timeout this will prevent ObjectDisposeException
        process.CancelOutputRead();
        process.CancelErrorRead();

        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine("Timed Out");
        Logs = output + Environment.NewLine + error;
       //To release allocated resource for the Process
        process.Close();
        return  (false, logs);
    }

    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine("Completed On Time");
    Logs = output + Environment.NewLine + error;
    ExitCode = process.ExitCode.ToString();
    // Close frees the memory allocated to the exited process
    process.Close();

    //ExitCode now accessible
    return process.ExitCode == 0 ? (true, logs) : (false, logs);
    }
}
finally{}

编辑:

在玩了几个小时的 MSBuild 之后,我终于能够在我的系统上重现该问题


B 部分:问题重现和解决方案

MSBuild-m[:number] 开关 用于指定要使用的最大并发进程数 构建时。

启用此功能后,MSBuild 会生成许多节点 即使在构建完成之后。现在, Process.WaitForExit(milliseconds) 将等待永远不会退出并且 最终超时

我可以通过几种方式解决这个问题

  • 通过 CMD 间接生成 MSBuild 进程

    $path1 = """C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe"" ""C:\Users\John\source\repos\Test\Test.sln"" -maxcpucount:3"
    $cmdOutput = cmd.exe /c $path1  '2>&1'
    $cmdOutput
    
  • 继续使用 MSBuild 但一定要将 nodeReuse 设置为 False

    $filepath = "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe"
    $arg1 = "C:\Users\John\source\repos\Test\Test.sln"
    $arg2 = "-m:3"
    $arg3 = "-nr:False"
    
    Start-Process -FilePath $filepath -ArgumentList $arg1,$arg2,$arg3 -Wait -NoNewWindow
    
  • 即使未启用并行构建,您仍然可以通过 CMD 启动构建来阻止您的进程挂在WaitForExit,因此您不会创建对构建过程的直接依赖

    $path1 = """C:\....\15.0\Bin\MSBuild.exe"" ""C:\Users\John\source\Test.sln"""
    $cmdOutput = cmd.exe /c $path1  '2>&1'
    $cmdOutput
    

首选第二种方法,因为您不希望周围有太多的 MSBuild 节点。

【讨论】:

  • 所以,正如我上面所说,谢谢,这个"-nr:False","-m:3" 似乎修复了 MSBuild 挂起行为,Rx solution 使整个过程有点可靠(时间会证明)。我希望我能接受两个答案或提供两个赏金
  • @Joelty 我只是想知道其他解决方案中的Rx 方法是否能够在不应用-nr:False" ,"-m:3" 的情况下解决问题。据我了解,它可以处理我在第 1 节中介绍的死锁和其他问题的无限期等待。我认为第 2 节中的根本原因是您所面临问题的根本原因;)我可能错了,这就是为什么我问,只有时间会证明......干杯!!
【解决方案4】:

不确定这是否是您的问题,但查看 MSDN,当您异步重定向输出时,重载的 WaitForExit 似乎有些奇怪。 MSDN 文章建议在调用重载方法后调用不带参数的 WaitForExit。

文档页面位于here. 相关文字:

当标准输出被重定向到异步事件处理程序时,当此方法返回时,输出处理可能尚未完成。为确保异步事件处理已完成,请在从该重载接收到 true 后调用不带参数的 WaitForExit() 重载。为帮助确保在 Windows 窗体应用程序中正确处理 Exited 事件,请设置 SynchronizingObject 属性。

代码修改可能如下所示:

if (process.WaitForExit(ProcessTimeOutMiliseconds))
{
  process.WaitForExit();
}

【讨论】:

  • process.WaitForExit() 的使用有一些复杂性,正如 this answer 的 cmets 所指出的那样。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-09-23
  • 2023-03-13
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多