[c#] 종료를 기다리는 동안 프로세스가 때때로 정지됨

종료를 기다리는 동안 프로세스가 중단 된 이유는 무엇입니까?

이 코드는 내부에서 많은 작업을 수행하는 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 빌드를 실행하기 위해 다시 시도하는 작은 래퍼를 추가했습니다.

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;
}

InternalExecuteScriptIngen의 코드는 어디에 있습니까



답변

관련 게시물에서 허용되는 답변 을 요약 해 보겠습니다 .

문제는 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<EventPattern<...>>하고 Action<Exception>. 관찰 된 이벤트가 발생할 때마다 그 sender와는 args래핑됩니다 EventPattern과를 통해 밀어 Action<EventPattern<...>>. 파이프 라인에서 예외가 발생하면 Action<Exception>이 사용됩니다.

의 단점 중 하나 Event이 유스 케이스 (및 참조 된 게시물의 모든 해결 방법에서)에 명확하게 설명 된 패턴 이벤트 핸들러를 언제 / 구독 취소해야하는지 명확하지 않다는 것입니다.

Rx를 IDisposable사용하면 구독을 할 때 다시 돌아옵니다 . 폐기 할 때 구독을 효과적으로 종료합니다. 의 추가로 DisposeWith(에서 차용 확장 메서드 RxUI ), 우리는 여러 추가 할 수 있습니다 IDisposableA와 S를 CompositeDisposable(이름을 disposables코드 샘플에서). 모두 완료되면 한 번의 호출로 모든 구독을 종료 할 수 있습니다disposables.Dispose() .

확실히, Rx로 할 수있는 것은 아무것도 없으며, 바닐라 .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.Exited이벤트를processExited 처리 관찰 가능 객체에 . 이런 식으로 Do연산자에 대한 입력을 준비 할 수 있습니다 .

코드는 매우 자명하다. exitedSuccessfully프로세스가 정상적으로 종료 된 경우 그렇지 않은 경우 exitedSuccessfully강제 종료해야합니다. 참고 process.Kill()로 비동기 적으로 실행되고, 심판 발언 . 그러나 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;
    }
}


답변

독자 들의 이익 을 위해 이것을 2 개의 섹션으로 나누겠습니다.

섹션 A : 문제 및 유사한 시나리오를 처리하는 방법

섹션 B : 문제 재현 및 해결

섹션 A : 문제

이 문제가 발생하면 프로세스가 작업 관리자에 나타나고 2-3 초가 사라진 후 (정상) 시간 초과를 기다린 다음 예외가 발생합니다.

& 아래 시나리오 4 참조

귀하의 코드에서 :

  1. Process.WaitForExit(ProcessTimeOutMiliseconds); 이것으로 당신이 기다리고있어 Process시간 제한 또는 종료 지금까지 발생, 첫째 .
  2. OutputWaitHandle.WaitOne(ProcessTimeOutMiliseconds)그리고 errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds); 이것으로 당신은 완료를 알리기 위해 OutputData& ErrorData읽기 동작을 기다리고 있습니다.
  3. Process.ExitCode == 0 프로세스가 종료 될 때 상태를 가져옵니다.

다른 설정 및주의 사항 :

  • 시나리오 1 (행복한 경로) : 프로세스가 시간 종료 전에 완료되므로 stdoutput 및 stderror도 완료되기 전에 완료됩니다.
  • 시나리오 2 : 프로세스, OutputWaitHandle 및 ErrorWaitHandle이 시간 종료되었지만 stdoutput & stderror를 계속 읽고 있으며 시간 종료 대기 핸들러 후에 완료됩니다. 이것은 또 다른 예외로 이어집니다ObjectDisposedException()
  • 시나리오 3 : 프로세스 시간 초과 (19 초)이지만 stdout 및 stderror가 작동 중이면 WaitHandler가 시간 초과 (19 초) 될 때까지 대기하여 추가 지연이 +19 초입니다.
  • 시나리오 4 : 프로세스가 시간 초과되고 코드가 조기 쿼리를 시도 Process.ExitCode하여 오류가 발생했습니다 System.InvalidOperationException: Process must exit before requested information can be determined.

이 시나리오를 12 번 이상 테스트했으며 테스트 중에 다음 설정이 사용되었습니다.

  • 약 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
  • 병렬 빌드를 사용하지 않는 경우에도 CMDWaitForExit 를 통해 빌드를 시작 하여 프로세스가 정지되는 것을 막을 수 있으므로 빌드 프로세스에 직접 의존하지 않습니다.

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

너무 많은 MSBuild 노드가 놓여 있지 않기 때문에 두 번째 방법이 선호됩니다.


답변

문제는 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 개 이상의 비디오를하고있었습니다. 불행히도 나는 직접적인 파워 쉘 경험이 없지만 이것이 도움이되기를 바랍니다.


답변

이것이 문제인지 확실하지 않지만 MSDN을 보면 출력을 비동기 적으로 리디렉션 할 때 과부하 된 WaitForExit에 이상이있는 것 같습니다. MSDN 기사에서는 오버로드 된 메서드를 호출 한 후 인수가없는 WaitForExit를 호출하는 것이 좋습니다.

문서 페이지가 여기있습니다. 관련 텍스트 :

표준 출력이 비동기 이벤트 핸들러로 경로 재 지정된 경우이 메소드가 리턴 될 때 출력 처리가 완료되지 않았을 수 있습니다. 비동기 이벤트 처리가 완료되도록하려면이 오버로드에서 true를 수신 한 후 매개 변수가없는 WaitForExit () 오버로드를 호출하십시오. Windows Forms 응용 프로그램에서 Exited 이벤트가 올바르게 처리되도록하려면 SynchronizingObject 속성을 설정하십시오.

코드 수정은 다음과 같습니다.

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


답변