Start-ThreadJob

Die meisten PowerShell-Script laufen sequentiell, auch wenn Sie z.B. mit "Start-Job" durchaus Code in den Hintergrund senden können. Leider wird dabei eine neue Laufzeitumgebung gestartet. Das kostet nicht zur Zeit und Ressourcen sondern auch die aktuelle Umgebung ist nicht vorhanden.

Thread statt Job

Schon länger habe ich mir vorgenommen mal das Thema "Multithreading" anzugehen, d.h. ein Prozess startet keinen komplett neuen weiten Prozess sondern nur einen Thread im gleichen Prozess. Das sollte ja sehr viel sparsamer und schneller sein. Einige Ansätze dazu habe ich auf der Seite PowerShell und parallele Verarbeitung beschrieben. Auch die Verarbeitung über die Pipeline (Siehe PowerShell Pipeline) unterliegt bestimmten Einschränkungen.

Mit PowerShell 7 kommt nun direkt eine Funktion, um For-Schleifen parallel abzuarbeiten und mit PowerShell 6 habe ich das Commandlet "Start-ThreadedJob" gefunden. Das hat mich neugierig gemacht, insbesondere weil Microsoft selbst dazu schreibt:

Start-ThreadJob creates background jobs similar to the Start-Job cmdlet. The main difference is that the jobs which are created run in separate threads within the local process. By default, the jobs use the current working directory of the caller that started the job.
Quelle: Start-ThreadJob https://docs.microsoft.com/en-us/powershell/module/threadjob/start-threadjob

Erste Tests

Zuerst hat mich mal interessiert, wie sich ein Thread von einem Job unterscheiden. Ich habe daher eine ganz kurze Testserie gemacht

Aktion Start-Job Start-ThreadedJob

Eine Variable setzen

$a=2

$a=2

Job im Hintergrund laufen lassen

$job= start-job -ScriptBlock {$a+=1;$a}

$job= Start-ThreadJob -ScriptBlock {$a+=1;$a}

Ausgabe der Variable im aufrufenden Job. Inhalt wurde nicht verändert

$a
2

$a
2

Ausgabe des Jobs. Er hat wohl bei "0" begonnen, d.h. bekam auch keine Kopie der Umgebung

receive-job $job
1

receive-job $job
1

Viele Jobs anlegen

measure-command { `
   1..100  `
   | %{ Start-ThreadJob  `
           -ScriptBlock {$global:b+=1;$global:b} `
      }`
} 
Days : 0
Hours : 0
Minutes : 0
Seconds : 0
Milliseconds : 147
Ticks : 1475901
TotalDays : 1,70821875E-06
TotalHours : 4,099725E-05
TotalMinutes : 0,002459835
TotalSeconds : 0,1475901
TotalMilliseconds : 147,5901

measure-command { `
   1..100  `
   | %{ Start-Job  `
           -ScriptBlock {$global:b+=1;$global:b} `
      } `
}
Days : 0
Hours : 0
Minutes : 0
Seconds : 23
Milliseconds : 201
Ticks : 232011936
TotalDays : 0,000268532333333333
TotalHours : 0,006444776
TotalMinutes : 0,38668656
TotalSeconds : 23,2011936
TotalMilliseconds : 23201,1936

So gesehen gibt es erst erst mal keinen Unterschied. Der Thread ist genau so mit eigenen lokalen Variablen versehen wie der aufrufende Task. Auch der Versuch per "$global:a" den Scope zu erweitern, hat nichts verändert. Allerdings hat das Anlegen der Jobs deutlich weniger Zeit benötigt. Bei 100 Jobs ist der "Start-ThreadedJob" in dem Beispiel 157mal schneller fertig. Meine CPU war beim "Start-Job" auch deutlich zu hören.

Über den PSJobType können Sie auch gut erkennen, wie der Job angelegt wurde.

Auch wenn Sie einen Auftrag mit "Start-ThreadJob" gestartet haben, wird er weiter mit den normalen Commandlets wie "Get-Job, Receive-Job, Remove-Job" etc. verwaltet.

CPU/Speicherbedarf

Ein Job, der ich gleich beendet, ist ja aus Ressourcensicht überschaubar, da Powershell die Umgebung wieder abräumt und nur den Buffer für die Rückgabe und Job-Verwaltung bestehen lässt. Also habe ich einen abgewandelten Job gleich mehrfach gestartet, der etwas CPU-Last und Speicher belegt.

Start-Job Start-ThreadJob

1..10 `
| % {`
   $job= Start-Job  `
            -ScriptBlock {while ($true){start-sleep -seconds 1}}`
}

1..10 `
| % {`
   $job= Start-ThreadJob `
            -ThrottleLimit 10  `
            -ScriptBlock {while ($true){start-sleep -seconds 1}}`
}

Das Job werden viele eigene Instanzen aufgemacht, die allesamt auch einen deutlichen Speicherbedarf haben.

Als Thread komme ich mit deutlich weniger Ressourcen aus.

Das neue "Start-ThreadJob Commandlet ist also wirklich deutlich besser

Throttling (Default 5)

Mit Start-Job musste ich mit noch selbst Gedanken um ein Throttling machen. Jobs sind ja schon aufwändig und ich sollte nicht gerade hunderte Jobs im Hintergrund starten. Also merke ich mir die laufenden Jobs und starte weitere erst, nachdem andere Jobs beendet und aufgeräumt wurden. Auch das ist mit Start-ThreadJob deutlich besser geworden und fällt auf, wenn ich z.B. viele Jobs starte:

1..10 `
| % {`
   $job= Start-ThreadJob -ScriptBlock {while ($true){start-sleep -seconds 1}} `
}

Die Ausgabe von "Get-Job" zeigt dann folgendes Bild:

Die ersten fünf Jobs laufen los, während die anderen Jobs nicht automatisch gestartet werden. Erst wenn ein Job beendet wird, wird der nächste Job automatisch gestartet. Wenn Sie mehr Jobs parallel laufen lassen wollen, dann hilft der Parameter "ThrottleLimit".

1..20 `
| % {`
   $job= Start-ThreadJob  `
            -ThrottleLimit 20 `
            -ScriptBlock {while ($true){start-sleep -seconds 1}} `
}

Damit starten dann bis zu 20 Jobs parallel.

Das Limit ist global für die jeweilige Session, z.B. wenn ich Start-ThreadJob mit einem Throttle-Limit aufrufe, dann passt er das Limit an und startet ggfls. noch wartende Jobs. Es reicht bei einem Aufruf den Wert zu setzen

Aktiv laufende Jobs werden aber nicht mehr angehalten, wenn das Throttle-Limit niedriger gesetzt wird.

Parameter Eingabe und Ausgabe

In der Regel nutze ich Start-ThreadJob, um den gleichen Code mit mehreren Eingabe-Werten ausführen zu lassen, z.B. Computernamen, URLs o.ä. Die muss ich natürlich in den Code reinbringen. Dazu gibt es gleich mehrere Wege:

  • Pipeline
    Das ist der klassische PowerShell-Weg, um Daten an eine Routine zu übergeben und funktioniert auch mit Start-ThreadJob
# Pipeline Übergabe (erzeugt nur einen Job mit zwei sequentiellen Schleifen
"server1","server2" `
| Start-ThreadJob -Scriptblock {
       begin {"begin: Block"}
       process {"Process: $($_)"}
       end {"End: Block"} 
}

# Pipeline Übergabe (erzeugt zwei parallele Threads
"server1","server2" `
| foreach { $_| Start-ThreadJob -Scriptblock {
       begin {"begin: Block"}
       process {"Process: $($_)"}
       end {"End: Block"} 
   }
}
  • Argumentlist
    Wenn sie z.B. nur einen Inhalt einer einfachen Variablen übergeben wollen, kann dieser Code einfacher aussehen
# Übergabe als Argumentlist mit param
foreach ($server in ("server1","server2")) {
   Start-ThreadJob `
      -ArgumentList @($server) `
      -Scriptblock {
         param ([string] $srvname) 
          "Parameter = $srvname"
        }
}
  • Using-Parameter
    Damit können Sie den Wert einer einfache Variablen direkt in den Code schreiben
    foreach ($server in ("server1","server2")) {
       Start-ThreadJob `
          -Scriptblock {
              "Parameter = $using:server"
            }
    }

Wenn der Thread nicht einfach nur etwas macht und sich dann beendet, sondern auch eine Rückmeldung oder gewonnene Daten an das aufrufende Skript liefern soll, dann gibt es mehrere Optionen. Es ist einfacher , wenn Sie das Ende des Jobs abwarten, um alle Daten zu bekommen.

  • Receive-Job
    Damit rufe ich die Daten der Pipeline eines Job ab und leere die Pipelin, wenn ich nicht den Parameter "-keep" verwenden
# Ausgaben einmal abrufen
get-job | wait-job | receive-job
  • Output-Property von get-job
    Das Property enthält auch die Ausgabe im Property "Output". Über den Weg kann ich auch die Properties Error, Warning, Verbose etc. abfragen
# Ausgaben abrufen
get-job | select output
  • Threadsichere globale Variable
    Ich kann eine threadsichere globale Variable anlegen, in die alle Thread schreiben können. Alternativ kann ich die Thread mit einem PowerShell Mutex abstimmen, um eine normale Variable sauber zu behandeln.

#Threadsafe variable, z.B. Dictionary
$Result = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new() 
# Pipeline Übergabe (erzeugt zwei parallele Threads
"server1","server2" `
| foreach { $_| Start-ThreadJob -Scriptblock {
      $dict = $using:result 
      $dict.TryAdd([string]($input), "Done") 
   }
}
$Result

Welcher Weg zum Einsatz kommt, mache ich vom Verwendungszweck abhängig. Wenn ich einen Thread quasi im Hintergrund dauernd aktiv sein lasse, dann könnte die Threadsafe Variable interessant sein, um auch Daten an den Thread zu senden ohne gleich über Pipes zu kommunizieren.

Variablen Scope und Synchronisation

Wenn ich mehrere Code-Teile als Hintergrund-Aufgabe laufen lassen, dann stellt sich natürlich die Frage, wie solche Jobs miteinander kommunizieren können oder ob es gemeinsame Variablen oder Semaphoren gibt. Leider habe ich in PowerShell selbst nichts gefunden und folgendes Beispiel funktioniert anders:

$global:a=2

$job = start-job -ScriptBlock { $using:a }

$a
2

$job = start-job -ScriptBlock { $using:a++ }
At line:1 char:33
+ $job = start-job -ScriptBlock { $using:b++ }
+ ~~~~~~~~
The '++' operator works only on variables or on properties.

Hierbei übernimmt die PowerShell den Inhalt der Variable $a in den Skriptblock. Es ist aber kein Pointer oder ein Verweis. Der Skript-Block kann also den Wert als Kopie nutzen und den Wert in in seinem lokalen Scope auch nicht ändern. Es ist eine Konstante und nur eine etwas einfacherer Ansatz einer Parameter-Übergabe.

In Invoke-Command, the $using: variables are values copied to the remote machine. No data is exchanged between individual target machines.
In the new ForEach-Object -Parallel, $using: is (as you point out in the article) not thread-safe, as the actual reference to the data can and is shared between threads.
Quelle: https://GitHub.com/PowerShell/PowerShell/issues/10499

D.h. bei dem neuen "Foreach"-Funktion ab PowerShell7 zur Parallelisierung von Abschnitten kann man sich hier schon selbst ärgern. Parallele Programmierung erfordert also schon etwas Vorsicht beim Zugriff auf gemeinsame Dateien.

Speicher und Pipeline

Wenn ich nun mehrere Prozesse parallel laufen lasse, und die alle eine Rückmeldung an das aufrufende Programm geben, dann interessierte mich der Speicherbedarf und die Verwaltung. Stellen Sie sich folgendes vor:

  • Ein Skript startet mehrere Threads
  • Jeder Thread erzeugt Ergebnisse und gibt die auf seine eigene Pipeline aus.
    Wie viel Speicher kostet das ?
  • Der aufrufende Prozess liest die Ausgaben per "Receive-Job"
    Wird der Speicher dann wieder frei gegeben?

Also habe ich eine kleine Testserie gemacht, indem ich in einem Thread mit zeitlichem Abstand eine große ISO-Datei (500MB) eingelesen und zurück gegeben habe. Der Code ist einfach und mit "-raw" war das einlesen dann auch schnell.

Start-ThreadJob -ScriptBlock {1..10 | %{Get-Content -raw C:\temp\Porteus-Kiosk-5.0.0-x86_64.iso; Start-Sleep -Seconds 60} }

Ich habe dann noch die folgenden Befehle verwendet:

# Anzeige des Prozess samt des Speicherbedarf
get-process pwsh

# Rückgabe des Jobs auslesen
$result= Receive-job <jobnummer>

# manuelles anstoßen der Garbage Collection, damit man freien Speicher sofort erhält
[System.GC]::Collect()

# Entfernen einer Variablen, dann spart man sich den GC
Remove-Variable result

Receive-Job holt immer alle Ergebnisse ab und wenn ich nicht den Parameter "-keep" verwende, dann wird der Speicherbereich für den Zwischenspeicher auch umgehend gegeben. Das Ergebnis lag dann aber in meiner Variablen "$result". Mit einem "-keep" ist der Speicherbedarf natürlich angestiegen. Wenn ich "$result=$null" mit nachfolgenden [System.GC]::Collect() mache, ist der Speicher auch wieder frei.

Mit der Erfahrung kann man also Jobs/Threads im Hintergrund laufen lassen, wenn man ab und an die Ergebnisse abholt.

Exchange

Meine bisherigen Tests haben ja schon festgestellt, dass auch die Threads eigenständige Umgebungen haben. Dennoch habe ich mal schnell einen Test mit der klassischen Exchange-PowerShell gemacht. Ich habe in einer PowerShell 6 einfach eine Exchange Remote PowerShell eingebunden und einen Get-Mailbox gegen meinen Office 365-Tenant gemacht.

Ich habe dann ein Get-Mailbox mit Start-Job und Start-ThreadJob" gestartet.

Sie sehen aber schon hier, dass ein Code mit Start-Job noch "Start-ThreadJob" auf die Session der bestehenden Powershell zugreifen kann. Ich habe natürlich auch noch mal versucht, die Session zu übergeben und in jedem Modul eigenständig zu übernehmen.

$Session = New-PSSession `
    -ConfigurationName Microsoft.Exchange `
    -ConnectionUri https://outlook.office365.com/powershell-liveid/ `
    -Credential (get-credential) `
    -Authentication Basic `
    -AllowRedirection 

$job1 = start-job -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
$job2 = start-job -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
$job3 = start-threadjob -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
$job4 = start-threadjob -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
Import-PSSession $session

Ich habe dann die Ergebnisse abgefragt. Der Abruf der Ergebnisse von "Job1" und "Job2" im Beispiel funktioniert nicht.

$result1 =receive-job $job1
Cannot bind parameter 'Session'. Cannot convert the "[PSSession]Runspace1" value of 
type "Deserialized.System.Management.Automation.Runspaces.PSSession" to type 
"System.Management.Automation.Runspaces.PSSession".

Der Abruf der JOB3/JOB4 hingegen funktioniert tatsächlich:

PS C:\> $result1 =receive-job $job3
WARNING: The names of some imported commands from the module 'tmp_yxuay5cd.wkf' include unapproved verbs
that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module 
command again with the Verbose parameter. For a list of approved verbs, type Get-Verb.
PS C:\> $result1

Name                Alias     Database           ProhibitSendQuota    ExternalDirectoryObjectId
----          -----     --------           -----------------    -------------------------
fctest1       fctest1   NAMPR20DG007-db027       99 GB (106,300,440,… 17857064-d5b5-458f-b99d-…
fctest2       fctest2   NAMPR20DG006-db017       99 GB (106,300,440,… cafa6404-7d35-43fa-a3f7-…
…

Hier funktioniert die Übergabe von "Session". Die Variable kann sogar von zwei oder mehr Jobs genutzt werden und selbst das aufrufende Hauptprogramm kann sich auch noch mal eine Session verbinden.

Hier verhält sich Start-ThreadJob anders. Sie sollten aber nun nicht viele Sessions mit Exchange starten, denn auch Exchange Server nutzen "Throttling", um Überlastsituationen zu vermeiden. Exchange Online beschränkt den Zugriff auf 3 parallele Sessions pro Tenant.

"To help prevent denial-of-service (DoS) attacks, you're limited to three open remote PowerShell connections to your Exchange Online organization. Quelle:  https://docs.microsoft.com/en-us/powershell/exchange/exchange-online/connect-to-exchange-online-powershell/connect-to-exchange-online-powershell

Weitere Links