PS Runspace

Wenn ich eine PowerShell starte, dann habe ich auch immer eine "Laufzeitumgebung", in der ich mich bewege. Wir wissen alle, dass wir auch weitere PowerShell-Fenster öffnen können oder mit Start-Job oder Start-Thread auch Befehle in den Hintergrund senden. Ich kann aber auch parallel weitere Runspaces instanzieren und Code dort parallel und im Hintergrund ausführen lassen.

Anstatt nun viel Text und Beispielcode zu lesen, können Sie sich das kurze Video von Adam Driscol anschauen, welches sehr gut die Nutzung von PowerShell Runspaces erklärt.

Adan Driscol: Advanced PowerShell - Runspaces
https://www.youtube.com/watch?app=desktop&v=WvxUuru_vhk

Mit PowerShell Core gibt es mit Start-ThreadJob auch eine neue Option zum Start von Prozessen im Hintergrund.

Einfacher Runspace mit Exchange

Eine ganz einfache Variante zeigt, was damit möglich ist und was nicht. Hier ein Code, der einfach ein "Get-Recipient" ausführen soll. Er wurde in einer Exchange PowerShell gestartet.

$PowerShell = [powershell]::Create()
[void]$PowerShell.AddScript({
   Get-Recipient -resultsize 3
})
$PowerShell.Invoke()

Sie können mit AddScript auch mehrere Skripte addieren. Die werden dann einfach nacheinander ausgeführt.

Der "$powershell.invoke()" startet den Code und wartet, bis er fertig ist. Das geht hier schnell und das "$powershell"-Objekt hat folgende Eigenschaften:

[PS] C:\>$PowerShell
Commands            : System.Management.Automation.PSCommand
Streams             : System.Management.Automation.PSDataStreams
InstanceId          : 5966f660-b993-4176-b5cd-a73d5a6b3ec7
InvocationStateInfo : System.Management.Automation.PSInvocationStateInfo
IsNested            : False
HadErrors           : True
Runspace            : System.Management.Automation.Runspaces.LocalRunspace
RunspacePool        :
IsRunspaceOwner     : True
HistoryString       :

Hier sehen Sie die verschiedenen Properties. Da sind "Commands" und "Streams" interessant:

[PS] C:\>$PowerShell.Commands | fl *
 
Commands : {
               Get-recipient -resultsize 3
           }

[PS] C:\>$PowerShell.Streams

Error       : {The term 'Get-recipient' is not recognized as the name of a cmdlet, function, script file, or operable
              program. Check the spelling of the name, or if a path was included, verify that the path is correct and
              try again.}
Progress    : {parent = -1 id = 0 act = Preparing modules for first use. stat =   cur =  pct = -1 sec = -1 type =
              Completed}
Verbose     : {}
Debug       : {}
Warning     : {}
Information : {}

Im Property "Commands" stehen die Befehle, die ich übergeben hatte und auch immer wieder per "Invoke" aufrufen könnte. Das Skript ist aber nicht gelaufen, weswegen hier im ERROR-Stream der Fehlercode steht.

Damit sehe ich aber auch, dass der Runspace eine eigene Shell ist und nicht die Module und Einstellungen der aufrufenden Shell vererbt bekommt.

Die Exchange Commandlets funktionieren so einfach nicht. Ich muss also in dem Runspace ggfls. meine erforderlich Umgebung noch einmal neu schaffen.

Etwas Parallel

Um die Befehle nun parallel auszuführen, kann ich "BeginInvoke()" nutzen. Damit wird das aufrufende Programm nicht angehalten.

# Code definieren
$code = { 
   1..100 | % {
      $(get-date)
      start-sleep -seconds 1
   }
}
#Objekt System.Management.Automation.PowerShell vorbereiten
$newPowerShell = [PowerShell]::Create().AddScript($code)

# Job starten
$job = $newPowerShell.BeginInvoke()

#Warten auf das Ende
#While (-Not $job.IsCompleted) {
#   write-host "
#}

#Ergebnisse abholen. Wartet allerdings bis das Skript beendet ist. Notfalls endlos
$newPowerShell.EndInvoke($job)

# Aufraeumen
$newPowerShell.Dispose()

Die so gestartet Jobs finden Sie nicht mit "Get-Job". Sie sind also gut beraten das Ergebnis des "BeginInvoke" irgendwo zu hinterlegen. Soweit ich gesehen habe, werden aber alle Ausgaben erst mit dem "EndInvoke()" zurück gegeben und auch erst dann, wenn der Code terminiert. Das Beispiel wird also erst nach 100 Sekunden beendet und dann die Ausgabe eingesammelt. Das ist zwar "schnell parallel" aber eine Weiterverarbeitung der Zwischenergebnisse ist so nicht möglich. Auch scheint die Laufzeitumgebung sich nicht zu vererben wenngleich es keinen weiteren "Powershell.exe"-Prozess im Taskmanager gibt. Es sieht wirklich nach einem eigenen Thread aus.

Parameterübergabe

Wer ein PowerShell-Skript startet, weiß um die Funktion der Parameter.

$Global:Runspace1 = [PowerShell]::Create()
$Runspace1.Runspace.AddArgument("argument1")

$Runspace1.AddScript({
                      param($arg1)
                         Write-host "Runspace1:Variable1 $($arg1)"
                      })
$Runspace1´.AddArgument(arg1,"Wert1")
$jobHandle = $Runspace1.BeginInvoke()

Die lassen sich natürlich einfach übergeben.

Übergabe mit SetVariable

Ein paar PowerShell-Befehle an einen anderen Prozess zur Ausführung zu senden und am Ende die Ausgabe abzurufen, ist ja einfach. Interessanter ist hier, wie man z.B. bestimmte Informationen schon vorab füllt und am Ende wieder abruft. Auch hierfür gibt es Wege, z.B. kann ich Variablen direkt am Anfang setzen:

$Global:Runspace1 = [PowerShell]::Create()
$Runspace1.Runspace.SessionStateProxy.SetVariable("var1","1")

$Runspace1.AddScript({
   start-transcript ".\runspace1.txt"
   1..10 | %{
      Write-host "Runspace1:Variable1 $($var1)"
      $var1++
      start-sleep -seconds 2
   }
   stop-transcript

})
$jobHandle = $Runspace1.BeginInvoke()

start-sleep -seconds 5
Write-host "Host:Variable1 $($Runspace1.Runspace.SessionStateProxy.GetVariable("var1"))"
$Runspace1.Runspace.SessionStateProxy.SetVariable("var1",100)
Write-host "Host:Variable1 $($Runspace1.Runspace.SessionStateProxy.GetVariable("var1"))"
start-sleep -seconds 5
Write-host "Host:Variable1 $($Runspace1.Runspace.SessionStateProxy.GetVariable("var1"))"

Bei dem Skript habe ich zwei Fehler bzw. Verhaltensweisen erkennen können:

  • Start-Transcript ist Global
    Alle Ausgaben des Hosts wurden protokolliert aber nicht die des Runspace. Ein "Transcript" im Runspace funktioniert nicht
  • Keine Variablen bei laufendem Runspace
    Wenn der Runspace mit "BeginInvoke()" im Hintergrund läuft sind die Properties ReadOnly

Ich kann also nur vorher die Variablen füllen und hinterher die verschiedenen Pipelines auswerten

Rückgabe mit GetVariable

Wenn das Skript im Runspace zum Ende gekommen ist, dann wird es zwar nicht mehr ausgeführt aber der PowerShell Host ist weiterhin vorhanden und auch alle Variablen sind noch instanziert und belegen Speicher. Diese Variablen kann ich auch auslesen

$Global:Runspace1 = [PowerShell]::Create()
$Runspace1.Runspace.SessionStateProxy.SetVariable("var1","1")

$Runspace1.AddScript({
                        Write-host "Runspace1:Variable1 $($var1)"
                        $Var1=2
                     })
$jobHandle = $Runspace1.BeginInvoke()

Start-Sleep -seconds 5
Write-host "Host:Variable1 $($Runspace1.Runspace.SessionStateProxy.GetVariable("var1"))"
2

Das Beispiel setzt erst die Variabel vor dem Start des Runspace auf "1", das Script im Runspace setzt die Variable auf 2 und nach dem Ende des Runspace kann ich den Wert auch auslesen.

Rückgaben über Pipeline

Ein zweiter Weg sind natürlich die Pipelines. Beim normalen "Invoke" kommen die Daten direkt über die Pipeline. Beim asynchronen Aufruf mit BeginInvoke muss ich die Variablen mit übergeben.

Ich sende erst einmal keine Eingabe aber erwarte eine Ausgabe

$Global:Runspace1 = [PowerShell]::Create()
$Runspace1.Runspace.SessionStateProxy.SetVariable("var1","1")

$null= $Runspace1.AddScript({
                      1..5 | foreach {
                            $Var1=$_
                            Write-Output "Runspace1:Variable1 $($var1)"
                            Start-Sleep -seconds 1
                         }
                     })

$jobHandle = $Runspace1.BeginInvoke()

While (!$jobHandle.IsCompleted) {
   Write-host "Host: Wait for completion"
   Start-Sleep -Seconds 1
}
$outputStream = $Runspace1.EndInvoke($jobHandle)
$outputstream

Sie sollten sehen, dass das Hauptprogramm wartet und wenn der Hintergrundprozess zu Ende ist, dann hole ich die Output-Pipeline ab.

EXIT-Code?

Eigentlich alle Skripte können am Ende einen Fehlercode zurückgeben. Das habe ich mit einem Runspace auch versucht.

$PowerShell = [powershell]::Create()
$PowerShell.AddScript({Write-Verbose "Start" -verbose ; exit 1})
$PowerShell.Invoke()

Allerdings habe ich keinen Weg gefunden, dieses "EXIT 1" auch abzufragen. Der Aufruf von "$PowerShell.Invoke()" liefert immer "$null". Nur beim Aufruf von "BeginInvoke()" erhalte ich einen Handle zu dem Thread und auch das "PowerShell"-Objekt kennt zwar Streams, und hier auch die Ausgabe von Write-Verbose, aber der Error-Stream ist leer und auch "InvocationStateInfo" liefert nichts mit.

Ich habe mir dann damit geholfen, dass ich den Exit-Code einfach in eine Variable geschrieben habe

$PowerShell = [powershell]::Create()
$PowerShell.AddScript({Write-Verbose "Start" -Verbose ; $exit=1})
$PowerShell.Invoke()
$PowerShell.Runspace.SessionStateProxy.GetVariable("EXIT")
1

Schön ist das nicht aber $LASTEXITCODE konnte ich nicht schreiben und nicht auslesen. Eine andere Option wäre es gewesen, den Exitcode einfach and STDOUT oder STRERR zu schreiben und dort abzurufen

Wenn sie hier eine Lösung zum sauberen lesen des "EXIT <code" kennen, dann gerne her damit.

Sonstige Kommunikation

Das das Setzen und Lesen von Variablen geht natürlich vor dem Start oder nach dem Ende des PowerShell Runspace. Die Kommunikation mit Pipelines in der einfachen Form funktioniert auch nicht während der Laufzeit. Das geht dann über entsprechende Handler, die bei neuen Ausgaben aufgerufen werden.

Wer ansonsten eine Kommunikation zwischen dem Host und dem Runspace benötigt, kann natürlich eine klassische Kommunikation zwischen Prozessen verwenden, z.B. per TCP/IP, Sockets, Windows Pipelines oder das Dateisystem.

Runspace beenden

Ein gutes Programm endet, nachdem es seinen Dienste getan hat. Das gilt so zwar nicht für das eigentliche Hauptprogramm aber ein Runspace hat meist eine begrenzte Lebensdauer. Dennoch kann es passieren, dass ein Runspace länger läuft als erwartet oder sogar festklemmt. Das steuernde Hauptprogramm sollte sich also nicht nur merken, welchen Runspace es aktiviert hat, sondern auch wann das passiert ist und vielleicht Langläufer behandeln. Das geht mit einem "dispose()" recht einfache

$Global:Runspace1 = [PowerShell]::Create()

$null= $Runspace1.AddScript({
                             Write-Verbose "ThreadID: $([appdomain]::GetCurrentThreadId())" -Verbose
                             start-sleep -seconds 10000
                     })

$jobHandle = $Runspace1.BeginInvoke()

Start-Sleep -Seconds 10

$runspace1.dispose()

Die bisherigen Ausgaben, z.B. durch Write-Verbose können wir natürlich immer noch abrufen

Runspace Pool

Die ganze Zeit habe ich eine PowerShell-Konsole genutzt, in der ich genau einen Runspace im Hintergrund aktiviert habe. Wenn ich nun mehrere Runspaces parallel laufen lassen will, dann kann ich diese nacheinander instanzieren und z.B. in einem Array oder Hashtable o.ä. speicher. Ich werde ich nicht nicht die Variablen von Hand und statisch in "$runspace1, $runspace2, $runspace3" durchnummerieren.

Allerdings bringt hier PowerShell oder das .NET Framework eine eigene Klasse "RunspaceFactory" mit, über die elegant mehrere Runspaces koordinieren können. Der große Vorteil dabei ist, dass ich mich nicht ums "Throttling" kümmern muss. Ich kann mehr Threads einstellen während die RunspaceFactory dafür sorgt, dass nur die angegebene Anzahl parallel läuft. Natürlich könnte ich das auch selbst koordinieren aber so ist es einfacher.

$pool = [RunspaceFactory]::CreateRunspacePool(1, [int]$env:NUMBER_OF_PROCESSORS + 1)
$pool.ApartmentState = "MTA"
$pool.Open()
$runspaces = New-Object System.Collections.ArrayList 

1..10 | foreach {

   $runspace = [PowerShell]::Create();
   $null = $runspace.AddScript({
                                   Write-Verbose "ThreadID: $([appdomain]::GetCurrentThreadId())" -Verbose
                                   1..100 | % {Write-Verbose $_; Start-Sleep -seconds 1}
                               });
   # Wir setzen dann das Property "RunspacePool" des Runspace auf den Pool
   $runspace.RunspacePool = $pool
   $runspaces += [PSCustomObject]@{ Pipe = $runspace; handle = $runspace.BeginInvoke() }
}

while ($Runspaces.Handle.IsCompleted){}
# hier dann die Ergebnisse einsammeln

In der Liste "Runspaces" sind dann alle Instanzen über das Property "handle" erreichbar.

Ich kann problemlos mehr Prozesse einstellen und starten aber es laufen maximal die angegeben Anzahl an Prozessen gleichzeitig. Wie viele Runspaces noch verfügbar sind, erhalten wir mit:

$pool.GetAvailableRunspaces()

Zudem gibt es einen "Cleanup"-Prozess, der ungenutzte Runspaces wegwirft, was natürlich Speicher spart

Im Windows Taskmanager sehe ich übrigens nur einen PWSH-Prozess. Erst mit dem SysInternals ProcessExplorer finde ich bei dem Prozess die ganzen Threads

Weitere Links