Weisheiten - der Netz-Weise Blog
Powershell parallelisieren mit Runspaces
Dieser Blogpost ist eine kurze Zusammenfassung von Powershell Runspaces am Beispiel eines Massenping. Ich fasse das Thema hier zusammen, weil Runspaces immer sehr mächtig und komplziert wirken, obwohl sie tatsächlich mit wenigen Zeilen Text beschrieben werden können.
Das Problem: Ein Skript soll anhand einer bekannten Adresse (des Routers) herausfinden, in welchem Netzwerk es sich befindet. Dafür müssen auf der Netzwerkkarte mehrere IP-Adressen konfiguriert und dann der Router angepingt werden. In einem einfachen Powershell-Skript kann man das natürlich machen, das kann dann aber je nach Anzahl der IP-Adressen, die geprüft werden müssen, sehr, sehr lange dauern, da Powershell standardmäßig immer nur eine Aufgabe nach der anderen erledigen kann.
Für dieses Problem stellt Powershell verschiedene Lösungen zur Verfügung. Zum einen kann man Powershell-Jobs verwenden. Ein Job startet eine neue Powershell im Hintergrund und führt das Skript dort aus. Da ein neuer Powershell-Prozess (eine komplett eigene Powershell) im Hintergrund gestartet wird, taucht die Ausgabe nicht einfach in der Konsole auf, sondern muß aus dem zweiten Prozess abgeholt werden. Die Cmdlets, um Jobs zu verwalten sind Start-Job, Get-Job und Receive-Job. Get-Job zeigt alle Jobs an, die gestartet wurde, Receive-Job holt die Rückgabe der beendeten Jobs an.
Start-Job -Name GetVM -Scriptblock { Get-Childitem -Path $env:windir -recurse } Get-Job Receive-Job -Name GetVM
Nachdem Start-Job aufgerufen wurde, wird die Konsole sofort für weitere Aufgaben freigegeben, während der Job im Hintergrund seine Arbeit tut. Allerdings sind Jobs sehr resourcenintensiv, weil für jeden Job ein komplett neuer Powershell-Prozess gestartet werden muß.
In Powershell 7 gibt es eine Alternative. Statt eines Jobs kann man Skriptblöcke mit dem Cmdlet Foreach-Object im Hintergrund verarbeiten lassen, indem man den Parameter -parallel verwendet.
Get-ChildItem -Path c:\Windows -Directory | ForEach-Object -Parallel { Get-ChildItem $_.fullname -Recurse }
Runspaces wurden für die Pipeline entwickelt und stehen seit der ersten Version von Powershell zur Verfügung. Um Sie Powershell 5 verwenden zu können, ist allerdings etwas mehr Arbeit notwendig - aber wirklich nicht viel.
Zuerst benötigen wir ein Script, dass wir parallelisieren wollen. Wie im Foreach-Object übergeben wir das als Scriptblock. Dafür erstellen wir den Scriptblock und speichern ihn in einer Variablen:
$Scriptblock = { param( [String]$Path ) Get-Childitem -Path $Path -Recurse }
Um den Codeblock im Hintergrund auszuführen, benötigen wir einen neuen Runspace (Thread), in dem der Code ausgeführt wird. Hierfür erstellen wir ein neues Runspaceobjekt, in dem alle wichtigen Daten zum Prozess gespeichert werden.
$RunSpace = [powershell]::Create() $Null = $RunSpace.AddScript($ScriptBlock).AddArgument('c:\windows') # $Null = $Runspace.AddArgument('C:\Windows') $Handle = $Runspace.BeginInvoke($object,$Object)
In Zeile 1 wird ein neues, leeres Runspace-Objekt erstellt. Mit der Methode AddScript() können wir den auszuführenden Skript-Code hinzufügen. Die Parameter werden als positional (also in der Reihenfolge, in der Sie im Parameter-Block des Skripts angegeben sind) übergeben. Das können Sie entweder direkt mit der Methode AddArgument() anschließen (Zeile 2) oder optional in eigenen Codezeilen (Zeile 3, auskommentiert). Mit BeginInvoke() starten Sie schließlich den Runspace. BeginInvoke() liefert Ihnen ein Handle zurück, also ein Objekt, über das Sie die Rückgabe und den Status des Runspace abfragen können. Da die vollständige Rückgabe erst nach Abschluß des Skripts zur Verfügung steht, können Sie z.B. mit einer While-Schleife den Status des Runspace abfragen. Der Skriptblock ist fertig verarbeitet, wenn die Eigenschaft IsCompleted des Handle True zurückgibt.
While ( $Handle.IsCompleted ) {} $Runspace.EndInvoke($Handle)
Abschließend rufen Sie über die Methode EndInvoke($Handle) die Rückgabe des Runspace ab und schließen ihn. EndInvoke() müssen Sie selbst dann aufrufen, wenn Ihr Runspace keine Rückgabe liefert, da erst Endinvoke den Arbeitsspeicher des Runspace wieder freigibt. Komplett sieht das Skript damit so aus:
$Scriptblock = { param( [String]$Path ) Get-Childitem -Path $Path -Recurse } $RunSpace = [powershell]::Create() $Null = $RunSpace.AddScript($ScriptBlock).AddArgument('c:\windows') $Handle = $Runspace.BeginInvoke() While ( $Handle.IsCompleted ) {} $Runspace.EndInvoke($Handle)
Der reine Runspace ist also in 3 Zeilen Code erzeugt, und mit 1 Zeile Code wieder abgerufen. Den Abruf können Sie sich tatsächlich sogar noch ein wenig vereinfachen, indem Sie beim Aufrufen des Runspace ein PSDataCollectionObject übergeben, dass die Rückgabe aufnimmt.
$Scriptblock = { param( [String]$Path ) Get-Childitem -Path $Path -Recurse } $ReturnValue = New-Object 'System.Management.Automation.PSDataCollection[psobject]' $RunSpace = [powershell]::Create() $Null = $RunSpace.AddScript($ScriptBlock).AddArgument('c:\windows') $Handle = $Runspace.BeginInvoke($ReturnValue,$Returnvalue) While ( $Handle.IsCompleted ) {} $ReturnValue $Runspace.Dispose()
Im Unterschied zur vorigen Version wird hier ein PSDataCollection-Objekt erzeugt und Begininvoke() einmal als In- und einmal als Outputobjekt übergeben. Sie können die Rückgabe des Runspace jetzt jederzeit abfragen, der aktuelle Status ist immer in der Variablen $Returnvalue gespeichert. Da wir nur das endgültige Ergebnis haben, wird aber wieder gewartet, bis der Runspace abgearbeitet ist. Da Endinvoke() nicht mehr aufgerufen wird, um den Runspace freizugeben, muss dass nun mit Dispose() geschehen.
Mehrere Runspaces mit Runspace-Pools parallelisieren
$Scriptblock = { param( [String]$IP ) Test-NetConnection -ComputerName $IP } $RunspacePool = [runspacefactory]::CreateRunspacePool(1, $env:NUMBER_OF_PROCESSORS) $RunspacePool.Open() $Runspaces = @()
In den Zeilen 7 und 8 wird ein neuer RunspacePool mit der Methode CreateRunspacePool() aus der Klasse Runspacefactory erzeugt. Zur Erstellung des Runspace müssen zwei Parameter angeben werden: Die minimale Anzahl der Runspaces, die erzeugt werden sollen, und die maximale Anzahl von Runspaces, die der Runspacepool parallel startet. Die maximale Anzahl von Runspaces ist wichtig, da ein Rechner nur eine bestimmte Anzahl von Prozessen parallel verarbeiten kann, nämlich pro CPU einen. Unterstützen die Rechner Hyperthreading (Intel) bzw. SMT (AMD), verdoppelt sich die Anzahl der logischen Prozessoren. Eine Daumenregel lautet also, nicht mehr Threads parallel zu starten, als die CPU auch verarbeiten kann. Die Anzahl der CPUs wird hier einfach aus den Umgebungsvariablen ausgelesen. Es kann sich trotzdem lohnen, eine größere Anzahl von Threads anzugeben - das hängt von der Komplexität des Prozesses ab. Zeile 9 erzeugt vorab schon einmal ein leeres Array, dass später unsere in Ausführung befindlichen Runspaces speichert.
Der Rest ist bekannt - im folgenden Schritt wird für jeden anzupingenden Rechner mit Hilfe einer Forach-Schleife ein Runspace erzeugt, dem Runspace-Pool hinzugefügt und gestartet:
$IPAdressen = '192.168.1.1','192.168.1.2','192.168.1.3','192.168.1.4' $Runspaces = Foreach ( $IP in $IPAdressen ) { $RunSpace = [powershell]::Create() $null = $RunSpace.AddScript($ScriptBlock).AddArgument($IP) $RunSpace.RunspacePool = $RunspacePool [PSCustomObject]@{ Runspace = $RunSpace Handle = $Runspace.BeginInvoke() } } While ($Handles.IsCompleted -Contains $false ) {} Foreach ( $CompletedScript in $Runspaces ) { $CompletedScript.Runspace.EndInvoke($CompletedScript.Handle) }
Zeile 1 definiert ein Beispiel für eine Liste von anzupingenden IPs. Zeile 4 und 5 sollten Ihnen bekannt vorkommen - hier wird ein Runspace erstellt und der Skriptblock zum Runspace hinzugefügt. Neu ist Zeile 6 - hier wird der Runspace dem Runspacepool zugeordnet. Zeile 7-9 speichern den Handle und den Runspace in einem neuen Objekt. Für einen einzelnen Runspace mußten wir das nicht machen, da der Runspace selber immer in der Variable $Runspace gespeichert war, und das Handle in $Handle. Das Objekt speichern wir dann in Zeile 2 im Array $Runspaces.
In Zeile 13 warten wir mit Hilfe einer While-Schleife, bis alle Runspaces fertig bearbeitet sind, und geben geben die Rückgabe dann mit Endinvoke zurück. Wir bekommen nun für jede IP ein Rückgabeobjekt, das anzeigt, ob der Zielrechner verfügbar ist. Hier noch einmal das komplette Skript:
$Scriptblock = { param( [String]$IP ) Test-NetConnection -ComputerName $IP } $RunspacePool = [runspacefactory]::CreateRunspacePool(1, $env:NUMBER_OF_PROCESSORS) $RunspacePool.Open() $Runspaces = @() $IPAdressen = '192.168.1.1','192.168.1.2','192.168.1.3','192.168.1.4' $Runspaces = Foreach ( $IP in $IPAdressen ) { $RunSpace = [powershell]::Create() $null = $RunSpace.AddScript($ScriptBlock).AddArgument($IP) $RunSpace.RunspacePool = $RunspacePool [PSCustomObject]@{ Runspace = $RunSpace Handle = $Runspace.BeginInvoke() } } While ($Handles.IsCompleted -Contains $false ) {} Foreach ( $CompletedScript in $Runspaces ) { $CompletedScript.Runspace.EndInvoke($CompletedScript.Handle) }
Router anpingen mit Runspaces
Da der Artikel ja eigentlich um das Anpingen von Routern ging, hier noch die Lösung für das eigentliche Problem. Ein Rechner soll testen, in welchem Netzwerk er ist. Dafür muß er erst mal ein IP aus dem jeweiligen Netzwerk bekommen. Das kann mit dem Cmdlet New-NetIPAddress erledigt werden. Da eine Netzwerkkarten auch mehrere IP-Adressen gebunden haben kann, kann man aus Perfomance-Gründen gleich alle IPs in einer Foreach-Schleife binden.
$IPAdressen = '192.168.1.2','192.168.2.2','192.168.3.2','192.168.4.2' foreach ( $Location in $LocationToIP ) { $null = New-NetIPAddress -IPAddress $Location.LocalIP -PrefixLength $Location.PrefixLength -AddressFamily IPv4 -InterfaceIndex $NetAdapter.InterfaceIndex } Start-Sleep 5
Zeile 5 wartet ein paar Sekunden, bis die Netzwerkkarte die IPs gebunden hat und online ist. Im nächsten Schritt bauen wir den Skriptblock. Aus Performance-Gründen verwende ich nicht Test-Netconnection sondern die .Net-Methode Send() aus der Ping-Klasse, da ich hier angeben kann, wie oft/lange gepingt werden soll. Test-Netconnection beherrscht das leider nicht.
$Scriptblock = { param( [string]$IP ) $ping = New-Object -TypeName System.Net.NetworkInformation.Ping $PingResult = $ping.Send($IP,1000) }
Und anschließend wird ein Runspacepool erzeugt. Das komplette Script sieht dann so aus:
$IPAdressen = '192.168.1.2','192.168.2.2','192.168.3.2','192.168.4.2' foreach ( $Location in $LocationToIP ) { $null = New-NetIPAddress -IPAddress $Location.LocalIP -PrefixLength $Location.PrefixLength -AddressFamily IPv4 -InterfaceIndex $NetAdapter.InterfaceIndex } Start-Sleep 5 $Scriptblock = { param( [string]$IP ) $ping = New-Object -TypeName System.Net.NetworkInformation.Ping $ping.Send($IP,1000) } $RunspacePool = [runspacefactory]::CreateRunspacePool(1, $Env:NUMBER_OF_PROCESSORS) $RunspacePool.Open() $Runspaces = @() Foreach ( $IP in $IPAdressen ) { $RunSpace = [powershell]::Create() $RunSpace.RunspacePool = $RunspacePool $null = $RunSpace.AddScript($ScriptBlock).AddArgument($IP) $Runspaces += [PSCustomObject]@{ Runspace = $RunSpace Handle = $Runspace.BeginInvoke() } } # Warten, bis die Runspaces Ihre Arbeit abgeschlossen haben. While ( $Runspaces.Handle.IsCompleted -contains 'False' ) { Start-Sleep -Milliseconds 100 } Foreach ( $CompletedScript in $Runspaces ) { $CompletedScript.Runspace.EndInvoke($CompletedScript.Handle) }
Wer noch tiefer in das Thema Parallelisierung in Powershell einsteigen möchte, dem empfehle ich den sehr ausführlichen Artikel PowerShell Multithreading: A Deep Dive von Tylen Muir.
Kommentare 2
Woher kommt die $Handles Variable in dem letzten Script Zeile 28?
Hi Alex,
Danke für den Hinweis, da hat sich wohl ein Fehler eingeschlichen. Korrekt muss das Array $Runspaces.Handle.IsCompleted geprüft werden. Ich habe den Code korrigiert.