Freigeben über


Optimieren der Leistung mithilfe der parallelen Ausführung

PowerShell bietet mehrere Optionen für die Erstellung paralleler Aufrufe.

  • Start-Job führt jeden Auftrag in einem separaten Prozess aus, jeweils mit einer neuen Instanz von PowerShell. In vielen Fällen ist eine lineare Schleife schneller. Außerdem kann die Serialisierung und Deserialisierung die Nützlichkeit der zurückgegebenen Objekte einschränken. Dieser Befehl ist in alle Versionen von PowerShell integriert.
  • Start-ThreadJob ist ein Cmdlet im ThreadJob-Modul . Dieser Befehl verwendet PowerShell-Runspaces zum Erstellen und Verwalten von threadbasierten Aufträgen. Diese Aufträge sind leichter als die durch Start-Job erstellten Aufträge und vermeiden potenzielle Verluste der Typtreue, die bei der prozessübergreifenden Serialisierung und Deserialisierung auftreten können. Das ThreadJob-Modul verfügt über PowerShell 7 und höher. Für Windows PowerShell 5.1 können Sie dieses Modul aus dem PowerShell-Katalog installieren.
  • Verwenden Sie den System.Management.Automation.Runspaces-Namespace aus dem PowerShell SDK, um eine eigene parallele Logik zu erstellen. Beide ForEach-Object -Parallel und Start-ThreadJob verwenden PowerShell-Runspaces, um den Code parallel auszuführen.
  • Workflows sind ein Feature von Windows PowerShell 5.1. Workflows sind in PowerShell 7.0 und höher nicht verfügbar. Workflows sind eine spezielle Art von PowerShell-Skript, die parallel ausgeführt werden kann. Sie sind für lang andauernde Aufgaben konzipiert und können angehalten und fortgesetzt werden. Workflows werden für die neue Entwicklung nicht empfohlen. Weitere Informationen finden Sie unter about_Workflows.
  • ForEach-Object -Parallel ist ein Feature von PowerShell 7.0 und höher. Wie Start-ThreadJob verwendet es PowerShell-Runspaces, um threadbasierte Aufträge zu erstellen und zu verwalten. Dieser Befehl wurde für die Verwendung in einer Pipeline entwickelt.

Einschränken der Parallelität der Ausführung

Das parallele Ausführen von Skripts garantiert keine verbesserte Leistung. Die folgenden Szenarien können beispielsweise von der parallelen Ausführung profitieren:

  • Berechnen intensiver Skripts auf Multithreading-Multi-Core-Prozessoren
  • Skripts, die Zeit verbringen, auf Ergebnisse zu warten oder Dateivorgänge auszuführen, solange diese Vorgänge einander nicht blockieren.

Es ist wichtig, den Aufwand der parallelen Ausführung mit der Art der geleisteten Arbeit auszugleichen. Außerdem gibt es Grenzwerte für die Anzahl der Aufrufe, die parallel ausgeführt werden können.

Die Befehle Start-ThreadJob und ForEach-Object -Parallel haben einen ThrottleLimit-Parameter, um die Anzahl der gleichzeitig ausgeführten Aufträge einzuschränken. Wenn weitere Aufträge gestartet werden, werden sie in die Warteschlange gestellt und warten, bis die aktuelle Anzahl von Aufträgen unter die festgelegte Begrenzung fällt. Ab PowerShell 7.1 ForEach-Object -Parallel werden Runspaces standardmäßig aus einem Runspace-Pool wiederverwendet. Der Parameter ThrottleLimit legt die Größe des Runspacepools fest. Die Standardgröße des Runspacepools ist 5. Sie können weiterhin einen neuen Runspace für jede Iteration mithilfe der UseNewRunspace-Option erstellen.

Der Start-Job Befehl verfügt nicht über einen ThrottleLimit-Parameter . Sie müssen die Anzahl der gleichzeitig ausgeführten Aufträge verwalten.

Messen der Leistung

Die folgende Funktion Measure-Parallelvergleicht die Geschwindigkeit der folgenden parallelen Ausführungsansätze:

  • Start-Job – erstellt hinter den Kulissen einen untergeordneten PowerShell-Prozess.

  • Start-ThreadJob - Führt jeden Auftrag in einem separaten Thread aus

  • ForEach-Object -Parallel - Führt jeden Auftrag in einem separaten Thread aus

  • Start-Process - ruft asynchron ein externes Programm auf

    Hinweis

    Dieser Ansatz ist nur sinnvoll, wenn Ihre parallelen Aufgaben nur aus einem einzigen Aufruf eines externen Programms bestehen, anstatt einen PowerShell-Codeblock auszuführen. Außerdem besteht die einzige Möglichkeit zum Erfassen der Ausgabe mit diesem Ansatz darin, die Ausgabe in eine Datei umzuleiten.

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
}

Im folgenden Beispiel werden mit Measure-Parallel 20 Aufträge parallel ausgeführt, jeweils 5 gleichzeitig, wobei alle verfügbaren Ansätze genutzt werden.

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

Die folgende Ausgabe stammt von einem Windows-Computer mit PowerShell 7.5.1. Ihr Zeitplan kann je nach vielen Faktoren variieren, aber die Verhältnisse sollten einen Eindruck der relativen Leistung vermitteln.

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

Fazit

  • Der Start-Process Ansatz funktioniert am besten, weil er den Aufwand der Auftragsverwaltung nicht hat. Wie bereits erwähnt, hat dieser Ansatz jedoch grundlegende Einschränkungen.
  • Dies ForEach-Object -Parallel fügt den geringsten Aufwand hinzu, gefolgt von Start-ThreadJob.
  • Start-Job hat den größten Aufwand aufgrund der ausgeblendeten PowerShell-Instanzen, die er für jeden Auftrag erstellt.

Danksagungen

Ein Großteil der Informationen basiert auf den Antworten von Santiago Squarzon und Mklement0 in diesem Stack Overflow-Beitrag.

Sie können auch an dem PSParallelPipeline-Modul interessiert sein, das von Santiago Squarzon erstellt wurde.

Weiterführende Lektüre