병렬 실행을 사용하여 성능 최적화

PowerShell은 병렬 호출을 만들기 위한 몇 가지 옵션을 제공합니다.

  • Start-Job 는 각각 PowerShell의 새 인스턴스를 사용하여 개별 프로세스에서 각 작업을 실행합니다. 대부분의 경우 선형 루프가 더 빠릅니다. 또한 직렬화 및 역직렬화는 반환된 개체의 유용성을 제한할 수 있습니다. 이 명령은 모든 버전의 PowerShell에 기본 제공됩니다.
  • Start-ThreadJobThreadJob 모듈에 있는 cmdlet입니다. 이 명령은 PowerShell Runspace를 사용하여 스레드 기반 작업을 만들고 관리합니다. 이러한 작업은 생성된 Start-Job 작업보다 더 가벼우며 교차 프로세스 직렬화 및 역직렬화에 필요한 형식 충실도의 잠재적 손실을 방지합니다. ThreadJob 모듈은 PowerShell 7 이상과 함께 제공됩니다. Windows PowerShell 5.1의 경우 PowerShell 갤러리에서 이 모듈을 설치할 수 있습니다.
  • PowerShell SDK의 System.Management.Automation.Runspaces 네임스페이스를 사용하여 고유한 병렬 논리를 만듭니다. ForEach-Object -ParallelStart-ThreadJob 모두 PowerShell runspaces를 사용하여 코드를 병렬로 실행합니다.
  • 워크플로는 Windows PowerShell 5.1의 기능입니다. PowerShell 7.0 이상에서는 워크플로를 사용할 수 없습니다. 워크플로는 병렬로 실행할 수 있는 특수한 유형의 PowerShell 스크립트입니다. 장기 실행 작업을 위해 설계되었으며 일시 중지하고 다시 시작될 수 있습니다. 워크플로는 새 개발에 권장되지 않습니다. 자세한 내용은 about_Workflows참조하세요.
  • ForEach-Object -Parallel 는 PowerShell 7.0 이상의 기능입니다. 마찬가지로 Start-ThreadJobPowerShell Runspace를 사용하여 스레드 기반 작업을 만들고 관리합니다. 이 명령은 파이프라인에서 사용하도록 설계되었습니다.

실행 동시성 제한

스크립트를 병렬로 실행해도 성능이 향상되는 것은 아닙니다. 예를 들어 다음 시나리오는 병렬 실행의 이점을 활용할 수 있습니다.

  • 다중 코어, 다중 스레드 프로세서에서 계산 집약적인 스크립트
  • 이러한 작업이 서로를 차단하지 않는 한 결과를 기다리거나 파일 작업을 수행하는 데 시간을 소비하는 스크립트입니다.

병렬 실행의 오버헤드와 수행된 작업 유형의 균형을 맞추는 것이 중요합니다. 또한 병렬로 실행할 수 있는 호출 수에 제한이 있습니다.

명령에 Start-ThreadJobForEach-Object -Parallel 한 번에 실행 중인 작업 수를 제한하는 ThrottleLimit 매개 변수가 있습니다. 작업이 더 많이 시작되면 큐에 대기하고, 현재 실행 중인 작업 수가 스로틀 제한 아래로 떨어질 때까지 기다립니다. PowerShell 7.1 ForEach-Object -Parallel 부터 기본적으로 Runspace 풀에서 Runspace를 다시 사용합니다. ThrottleLimit 매개 변수는 Runspace 풀 크기를 설정합니다. 기본 Runspace 풀 크기는 5입니다. UseNewRunspace 스위치를 사용하여 각 반복에 대해 새 Runspace를 만들 수 있습니다.

명령에 Start-JobThrottleLimit 매개 변수가 없습니다. 한 번에 실행 중인 작업 수를 관리해야 합니다.

성능 측정

다음 함수는 Measure-Parallel다음 병렬 실행 방법의 속도를 비교합니다.

  • Start-Job - 백그라운드에서 자식 PowerShell 프로세스를 만듭니다.

  • Start-ThreadJob - 별도의 스레드에서 각 작업을 실행합니다.

  • ForEach-Object -Parallel - 별도의 스레드에서 각 작업을 실행합니다.

  • Start-Process - 외부 프로그램을 비동기적으로 호출합니다.

    비고

    이 방법은 병렬 작업이 PowerShell 코드 블록을 실행하는 것이 아니라 외부 프로그램에 대한 단일 호출로만 구성된 경우에만 의미가 있습니다. 또한 이 방법을 사용하여 출력을 캡처하는 유일한 방법은 파일로 리디렉션하는 것입니다.

function Measure-Parallel {
    [CmdletBinding()]
    param(
        [ValidateRange(2, 2147483647)]
        [int] $BatchSize = 5,

        [ValidateSet('Job', 'ThreadJob', 'Process', 'ForEachParallel', 'All')]
        [string[]] $Approach,

        # pass a higher count to run multiple batches
        [ValidateRange(2, 2147483647)]
        [int] $JobCount = $BatchSize
    )

    $noForEachParallel = $PSVersionTable.PSVersion.Major -lt 7
    $noStartThreadJob = -not (Get-Command -ErrorAction Ignore Start-ThreadJob)

    # Translate the approach arguments into their corresponding hashtable keys (see below).
    if ('All' -eq $Approach) { $Approach = 'Job', 'ThreadJob', 'Process', 'ForEachParallel' }
    $approaches = $Approach.ForEach({
            if ($_ -eq 'ForEachParallel') { 'ForEach-Object -Parallel' }
            else { $_ -replace '^', 'Start-' }
        })

    if ($noStartThreadJob) {
        if ($interactive -or $approaches -contains 'Start-ThreadJob') {
            Write-Warning "Start-ThreadJob is not installed, omitting its test."
            $approaches = $approaches.Where({ $_ -ne 'Start-ThreadJob' })
        }
    }
    if ($noForEachParallel) {
        if ($interactive -or $approaches -contains 'ForEach-Object -Parallel') {
            Write-Warning 'ForEach-Object -Parallel require PowerShell v7+, omitting its test.'
            $approaches = $approaches.Where({ $_ -ne 'ForEach-Object -Parallel' })
        }
    }

    # Simulated input: Create 'f0.zip', 'f1'.zip', ... file names.
    $zipFiles = 0..($JobCount - 1) -replace '^', 'f' -replace '$', '.zip'

    # Sample executables to run - here, the native shell is called to simply
    # echo the argument given.
    $exe = if ($env:OS -eq 'Windows_NT') { 'cmd.exe' } else { 'sh' }

    # The list of its arguments *as a single string* - use '{0}' as the placeholder
    # for where the input object should go.
    $exeArgList = if ($env:OS -eq 'Windows_NT') {
        '/c "echo {0} > NUL:"'
     } else {
        '-c "echo {0} > /dev/null"'
    }

    # A hashtable with script blocks that implement the 3 approaches to parallelism.
    $approachImpl = [ordered] @{}

    # child-process-based job
    $approachImpl['Start-Job'] = {
        param([array] $batch)
        $batch |
            ForEach-Object {
                Start-Job {
                    Invoke-Expression ($using:exe + ' ' + ($using:exeArgList -f $args[0]))
                } -ArgumentList $_
            } |
            Receive-Job -Wait -AutoRemoveJob | Out-Null
    }

    # thread-based job - requires the ThreadJob module
    if (-not $noStartThreadJob) {
        # If Start-ThreadJob is available, add an approach for it.
        $approachImpl['Start-ThreadJob'] = {
            param([array] $batch)
            $batch |
                ForEach-Object {
                    Start-ThreadJob -ThrottleLimit $BatchSize {
                        Invoke-Expression ($using:exe + ' ' + ($using:exeArgList -f $args[0]))
                    } -ArgumentList $_
                } |
                Receive-Job -Wait -AutoRemoveJob | Out-Null
        }
    }

    # ForEach-Object -Parallel job
    if (-not $noForEachParallel) {
        $approachImpl['ForEach-Object -Parallel'] = {
            param([array] $batch)
            $batch | ForEach-Object -ThrottleLimit $BatchSize -Parallel {
                Invoke-Expression ($using:exe + ' ' + ($using:exeArgList -f $_))
            }
        }
    }

    # direct execution of an external program
    $approachImpl['Start-Process'] = {
        param([array] $batch)
        $batch |
            ForEach-Object {
                Start-Process -NoNewWindow -PassThru $exe -ArgumentList ($exeArgList -f $_)
            } |
            Wait-Process
    }

    # Partition the array of all indices into subarrays (batches)
    $batches = @(
        0..([math]::Ceiling($zipFiles.Count / $batchSize) - 1) | ForEach-Object {
            , $zipFiles[($_ * $batchSize)..($_ * $batchSize + $batchSize - 1)]
        }
    )

    $tsTotals = foreach ($appr in $approaches) {
        $i = 0
        $tsTotal = [timespan] 0
        $batches | ForEach-Object {
            Write-Verbose "$batchSize-element '$appr' batch"
            $ts = Measure-Command { & $approachImpl[$appr] $_ | Out-Null }
            $tsTotal += $ts
            if (++$i -eq $batches.Count) {
                # last batch processed.
                if ($batches.Count -gt 1) {
                    Write-Verbose ("'$appr' processing $JobCount items finished in " +
                        "$($tsTotal.TotalSeconds.ToString('N2')) secs.")
                }
                $tsTotal # output the overall timing for this approach
            }
        }
    }

    # Output a result object with the overall timings.
    $oht = [ordered] @{}
    $oht['JobCount'] = $JobCount
    $oht['BatchSize'] = $BatchSize
    $oht['BatchCount'] = $batches.Count
    $i = 0
    foreach ($appr in $approaches) {
        $oht[($appr + ' (secs.)')] = $tsTotals[$i++].TotalSeconds.ToString('N2')
    }
    [pscustomobject] $oht
}

다음 예제에서는 사용 가능한 모든 방법을 사용하여 한 번에 5개, 병렬로 20개의 작업을 실행하는 데 사용합니다 Measure-Parallel .

Measure-Parallel -Approach All -BatchSize 5 -JobCount 20 -Verbose

다음 출력은 PowerShell 7.5.1을 실행하는 Windows 컴퓨터에서 제공됩니다. 타이밍은 여러 요인에 따라 달라질 수 있지만 비율은 상대적인 성능을 제공해야 합니다.

VERBOSE: 5-element 'Start-Job' batch
VERBOSE: 5-element 'Start-Job' batch
VERBOSE: 5-element 'Start-Job' batch
VERBOSE: 5-element 'Start-Job' batch
VERBOSE: 'Start-Job' processing 20 items finished in 7.58 secs.
VERBOSE: 5-element 'Start-ThreadJob' batch
VERBOSE: 5-element 'Start-ThreadJob' batch
VERBOSE: 5-element 'Start-ThreadJob' batch
VERBOSE: 5-element 'Start-ThreadJob' batch
VERBOSE: 'Start-ThreadJob' processing 20 items finished in 2.37 secs.
VERBOSE: 5-element 'Start-Process' batch
VERBOSE: 5-element 'Start-Process' batch
VERBOSE: 5-element 'Start-Process' batch
VERBOSE: 5-element 'Start-Process' batch
VERBOSE: 'Start-Process' processing 20 items finished in 0.26 secs.
VERBOSE: 5-element 'ForEach-Object -Parallel' batch
VERBOSE: 5-element 'ForEach-Object -Parallel' batch
VERBOSE: 5-element 'ForEach-Object -Parallel' batch
VERBOSE: 5-element 'ForEach-Object -Parallel' batch
VERBOSE: 'ForEach-Object -Parallel' processing 20 items finished in 0.79 secs.

JobCount                         : 20
BatchSize                        : 5
BatchCount                       : 4
Start-Job (secs.)                : 7.58
Start-ThreadJob (secs.)          : 2.37
Start-Process (secs.)            : 0.26
ForEach-Object -Parallel (secs.) : 0.79

결론

  • Start-Process 방법은 작업 관리의 오버헤드가 없기 때문에 가장 잘 수행됩니다. 그러나 앞에서 설명한 것처럼 이 접근 방식에는 근본적인 제한 사항이 있습니다.
  • ForEach-Object -Parallel는 가장 적은 오버헤드를 추가하고, 그다음으로 Start-ThreadJob가 추가됩니다.
  • Start-Job 에는 각 작업에 대해 만드는 숨겨진 PowerShell 인스턴스로 인해 오버헤드가 가장 많습니다.

감사의 글

이 문서의 대부분은 이 Stack Overflow 게시물산티아고 스쿼르존mklement0의 답변을 기반으로 합니다.

또한 산티아고 스쿼존에서 만든 PSParallelPipeline 모듈에 관심이 있을 수 있습니다.

추가 읽기