Netz-Weise Logo

Weisheiten - der Netz-Weise Blog

Hier finden Sie Tipps und Tricks für vor, während und nach der Schulung.
8 Minuten Lesezeit (1693 Worte)

Powershell parallelisieren mit Runspaces

Runspaces mit Powershell Powershell Multithreading

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 } 
Foreach-Parallel ist deutlich effizienter, weil er keine Jobs, sondern Runspaces verwendet. Ein Runspace ist im groben ein Thread (Faden oder Strang). Threads unterscheiden sich von Prozessen dadurch, dass ein Prozess selber mehrere Threads aufmachen kann, die alle im gleichen Prozess laufen - es muß keine neue Powershell-Konsole initiiert werden und es wird deutlich weniger Arbeitsspeicher verbraucht. 

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 

Ein Runspace ist gut, aber viele Runspaces sind besser. Denn für das vorige Beispiel hätte man auf den Runspace auch komplett verzichten können. Im nächsten Beispiel schauen wir uns daher an, wie man eine große Anzahl von Zielrechnern parallel pingen kann. Hierfür verwenden wir wieder Runspaces, fassen diese aber in Pools zusammen. Ein Runspacepool ist ein Objekt, dass mehrere parallele Runspaces für uns verwaltet, so dass wir uns selber nicht mehr um die Ausführung kümmern müssen. Dazu erstellen wir zuerst wieder einen Skriptblock, erzeugen aber dann erst mal einen Runspacepool.
$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)
   } 
Alles in allem sind Runspaces also eigentlich sehr simpel zu implementieren - Skriptblock erstellen, Runspacepool erzeugen, Runspace dem Runspacepool hinzufügen, fertig. Am kompliziertesten gestaltet sich das Abrufen der Daten, was man sich mit PSDataColletionobject aber noch deutlich vereinfachen kann. Wem das trotzdem noch zu kompliziert ist, kann auch auf ein Modul von Boe Prox namens PoshRSJob zurückgreifen, dass man direkt aus der Powershell-Gallery abrufen kann. 

  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.

Wenn das Windows 10 Startmenü mal wieder hakt
E-Mails in Office 365 kommen nicht an - Nachrichte...

Ähnliche Beiträge

 

Kommentare 2

Gäste - Alex am Mittwoch, 19. Oktober 2022 08:58

Woher kommt die $Handles Variable in dem letzten Script Zeile 28?

Woher kommt die $Handles Variable in dem letzten Script Zeile 28?
Holger Voges am Mittwoch, 19. Oktober 2022 12:44

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.

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.
Bereits registriert? Hier einloggen
Donnerstag, 21. November 2024

Sicherheitscode (Captcha)