Otimizar o desempenho usando a execução paralela

O PowerShell fornece várias opções para a criação de invocações paralelas.

  • Start-Job executa cada trabalho em um processo separado, cada um com uma nova instância do PowerShell. Em muitos casos, um loop linear é mais rápido. Além disso, a serialização e a desserialização podem limitar a utilidade dos objetos retornados. Esse comando é integrado a todas as versões do PowerShell.
  • Start-ThreadJob é um cmdlet encontrado no módulo ThreadJob . Esse comando usa runspaces do PowerShell para criar e gerenciar trabalhos baseados em thread. Esses trabalhos são mais leves do que os trabalhos criados por Start-Job e evitam a perda potencial de fidelidade de tipo necessária para a serialização e desserialização entre processos. O módulo ThreadJob vem com o PowerShell 7 e superior. Para o Windows PowerShell 5.1, você pode instalar este módulo na Galeria do PowerShell.
  • Use o namespace System.Management.Automation.Runspaces do SDK do PowerShell para criar sua própria lógica paralela. ForEach-Object -Parallel e Start-ThreadJob usam runspaces do PowerShell para executar o código em paralelo.
  • Fluxos de trabalho são um recurso do Windows PowerShell 5.1. Os fluxos de trabalho não estão disponíveis no PowerShell 7.0 e superiores. Fluxos de trabalho são um tipo especial de script do PowerShell que pode ser executado em paralelo. Eles são projetados para tarefas de execução longa e podem ser pausados e retomados. Fluxos de trabalho não são recomendados para o novo desenvolvimento. Para obter mais informações, consulte about_Workflows.
  • ForEach-Object -Parallel é um recurso do PowerShell 7.0 e superior. Assim como Start-ThreadJob, ele usa runspaces do PowerShell para criar e gerenciar trabalhos baseados em thread. Esse comando foi projetado para uso em um pipeline.

Limitar simultaneidade de execução

Executar scripts em paralelo não garante um desempenho aprimorado. Por exemplo, os seguintes cenários podem se beneficiar da execução paralela:

  • Computar scripts intensivos em processadores multi-core com vários threads
  • Scripts que gastam tempo aguardando resultados ou fazendo operações de arquivo, desde que essas operações não bloqueiem umas às outras.

É importante equilibrar a sobrecarga da execução paralela com o tipo de trabalho feito. Além disso, há limites para o número de invocações que podem ser executadas em paralelo.

Os comandos Start-ThreadJob e ForEach-Object -Parallel têm um parâmetro ThrottleLimit para limitar o número de trabalhos em execução ao mesmo tempo. À medida que mais trabalhos são iniciados, eles são enfileirados e esperam até que o número atual de trabalhos caia abaixo do limite de limitação. A partir do PowerShell 7.1, ForEach-Object -Parallel reutiliza runspaces de um pool de runspaces por padrão. O parâmetro ThrottleLimit define o tamanho do pool de runspaces. O tamanho padrão do pool de runspaces é 5. Você ainda pode criar um novo runspace para cada iteração usando a opção UseNewRunspace.

O Start-Job comando não tem um parâmetro ThrottleLimit . Você precisa gerenciar o número de trabalhos em execução ao mesmo tempo.

Medir o desempenho

A função Measure-Parallela seguir compara a velocidade das seguintes abordagens de execução paralela:

  • Start-Job – cria um processo filho do PowerShell em segundo plano

  • Start-ThreadJob – executa cada trabalho em um thread separado

  • ForEach-Object -Parallel – executa cada trabalho em um thread separado

  • Start-Process - invoca um programa externo de forma assíncrona

    Observação

    Essa abordagem só fará sentido se suas tarefas paralelas consistirem apenas em uma única chamada para um programa externo, em vez de executar um bloco de código do PowerShell. Além disso, a única maneira de capturar a saída com essa abordagem é redirecionando para um arquivo.

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
}

O exemplo a seguir usa Measure-Parallel para executar 20 tarefas em paralelo, 5 de cada vez, usando todas as abordagens disponíveis.

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

A saída a seguir vem de um computador Windows que executa o PowerShell 7.5.1. Seu tempo pode variar com base em muitos fatores, mas as proporções devem oferecer uma ideia de desempenho relativo.

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

Conclusões

  • A Start-Process abordagem tem o melhor desempenho porque não tem a sobrecarga do gerenciamento de trabalhos. No entanto, como observado anteriormente, essa abordagem tem limitações fundamentais.
  • O ForEach-Object -Parallel acrescenta a menor sobrecarga, seguido por Start-ThreadJob.
  • Start-Job tem a maior sobrecarga devido às instâncias ocultas do PowerShell que ele cria para cada trabalho.

Confirmações

Grande parte das informações neste artigo é baseada nas respostas de Santiago Squarzon e mklement0 neste post Stack Overflow.

Você também pode estar interessado no módulo PSParallelPipeline criado por Santiago Squarzon.

Leitura adicional