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.
- Start-ThreadJob
https://docs.microsoft.com/en-us/powershell/module/threadjob/start-threadjob - Get-Job
https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/get-job - Receive-Job
https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/receive-job - Stop-Job
https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/stop-job - Remove-Job
https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/remove-job
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 schreibenforeach ($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.
- Interprocess Communication in Powershell
https://gbegerow.wordpress.com/2012/04/09/interprocess-communication-in-powershell/ - Threadaufträge und -variablen
https://docs.microsoft.com/de-de/powershell/module/microsoft.powershell.core/about/about_thread_jobs?view=powershell-7.2#thread-jobs-and-variables - Start-ThreadJob
https://docs.microsoft.com/en-us/powershell/module/threadjob/start-threadjob - about_Thread_Jobs
https://docs.microsoft.com/de-de/powershell/module/microsoft.powershell.core/about/about_thread_jobs - Backgrounding tasks in PowerShell classes using either the PoshRSJob and
ThreadJob module
https://gist.GitHub.com/scrthq/8710f93ce0292bca0a0cd0bf33b0e6f9 - Windows_Tests/ThreadJob.Tests.ps1
https://www.powershellgallery.com/packages/ThreadJob/2.0.0/Content/Windows_Tests%5CThreadJob.Tests.ps1
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.
- PowerShell Mutex
- A Look at Implementing $Using: Support
in PowerShell for PoshRSJob
https://learn-powershell.net/2015/05/04/a-look-at-implementing-using-support-in-powershell-for-poshrsjob/
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
- PowerShell und parallele Verarbeitung
- PowerShell Pipeline
- PowerShell Mutex
- PS Runspace
- Start-ThreadJob
https://docs.microsoft.com/en-us/powershell/module/threadjob/start-threadjob - A Look at Implementing $Using: Support
in PowerShell for PoshRSJob
https://learn-powershell.net/2015/05/04/a-look-at-implementing-using-support-in-powershell-for-poshrsjob/ - A quick show case of Start-ThreadJob
https://scriptingchris.tech/2021/08/01/how-to-use-multithreading-in-powershell-with-custom-modules/