PS Job

Auf der Seite PS Parallel habe ich verschiedene Ansätze zur Parallelisierung von Aufträgen dargestellt und auf PS Distributed eine Sonderform zur Synchronisierung paralleler Prozesse dargestellt. Ein PowerShell-Skript kann aber auch über "Jobs" bestimmte Aufgaben quasi in den Hintergrund verlagern und danach wieder abrufen. Diese Seite beschreibt diese Funktion und die Fallstricke.

Achtung:
Ein per "Start-Job" oder mit dem Paramter "-AsJob" gestartetes Programm ist kein "Multithreading", sondern starte eine neue Umgebung die CPU-Last und vor allem ca 80-120Mbyte RAM pro Job belegt.

Grundprinzip eines Job

Normalerweise läuft ein PowerShell-Skript streng sequentiell von oben nach unten ab und verzichtet auf Sprünge. Sich wiederholende Sequenzen werden als Funktion ausgelagert. Es findet aber keine echte parallele Verarbeitung innerhalb des Skripts statt. Das ist natürlich störend, wenn mehrere Dinge aus Gründen der Zeitersparnis auch parallel ausgeführt werden könnten oder bestimmte Dinge länger dauern und den weiteren Fortschritt behindern.

Genau hierfür kennt PowerShell die Option einen Skript-Teil oder ein Commandlet als "Job" auszulagern. In beiden Fällen bekommt das aufrufende Skript ein Job-Objekt zurück, über welches es dann den Status und die Rückgaben auswerten kann. Hier mal ein einfaches Beispiel eines künstlich verlangsamten Jobs:

start-job -scriptblock {
   foreach ($count in (1..5)) {
      write-host "Count";
      $count
      start-sleep -seconds 10
   }
}

Dieser Job läuft ca. 5x10 Sekunden und zählt einfach hoch. Der Status des Jobs während der Laufzeit kann mit Get-Job abgefragt werden. Mit Wait-Job kann ich auf das Ende warten.

Beachten Sie aber immer, dass der Job im gleichen Prozessraum des aufrufenden PowerShell-Skripts läuft. Wird das aufrufende Skript beendet, dann werden auch die Jobs einfach abgebrochen. Über Get-Job können Sie mehr Details den Job in Erfahrung bringen:

PS C:\> get-job 2| fl


HasMoreData   : True
StatusMessage :
Location      : localhost
Command       :
                   foreach ($count in (1..5)) {
                      write-host "Count";
                      $count
                      start-sleep -seconds 10
                   }

JobStateInfo  : Completed
Finished      : System.Threading.ManualResetEvent
InstanceId    : 0d5cb38a-d77b-4aa4-adae-7cf886077f8a
Id            : 2
Name          : Job2
ChildJobs     : {Job3}
PSBeginTime   : 09.06.2016 14:32:39
PSEndTime     : 09.06.2016 14:33:35
PSJobTypeName : BackgroundJob
Output        : {}
Error         : {}
Progress      : {}
Verbose       : {}
Debug         : {}
Warning       : {}
State         : Completed

Wenn Sie nun mehrere Jobs starten, dann werden Sie sich vielleicht wundern, dass die Nummerierung immer in Zweierschritten aufsteigt. Das liegt daran, dass PowerShell neben dem eigentlichen Job noch einen zweiten Job als Child startet.

PS C:\> get-job -IncludeChildJob

Id     Name            PSJobTypeName   State         HasMoreData     Location
--     ----            -------------   -----         -----------     --------
2      Job2            BackgroundJob   Completed     True            localhost
3      Job3                            Completed     True            localhost

Die Jobliste können Sie mit "Remove-Job" wieder bereinigen. Verschiedene Commandlets erlauben ihnen den Umgang mit Jobs.

Unterschied zu "-AsJob"

Viele Commandlets können ebenfalls direkt mit dem Parameter "-AsJob" zu einem Hintergrund-Job gewandelt werden. Aber auch hier ist der Einsatzbereich ein etwas anderer. Der Start eines Commandlet mit dem Parameter "-job" ist primäre dafür vorgesehen, den Aufruf mit einer Remote Powershell zu einem Job zu machen. Wer also z.B. eine Exchange Powershell oder Skype for Business PowerShell remote ausführt, der kann mit "-AsJob" den Befehl auf der entfernten Maschine als Job ausführen.

Bis PowerShell 2.0 gibt es wohl den Bug, dass solche Jobs immer ein "HasModedata = True" liefern, auch wenn es keine Rückgabe gibt.

Unterschied zu "Start-Process" und "Invoke-Command"

Sehr nahe neben "Start-Job" finden sie auch die Commandlets "Start-Process" und "Invoke-Command". Auf den ersten Blick scheinen sie ziemlich die gleiche Aufgabe zu erfüllen, Aber es gibt aber wesentliche Unterschiede.

  Start-Process Invoke-Command Start-Job
Anzeige

GUI mit Interaktion möglich

Vordergrund

Hintergrund ohne Interaktion

Returnwerte

Keine Rückgabe an aufrufenden Prozess

Keine Rückgabe an aufrufenden Prozess

Rückgabe über "Receive-Job"

Commandlets

Debug-Process
Get-Process
Start-Process
Stop-Process
Wait-Process

Invoke-Command

Get-Job
Receive-Job
Remove-Job
Resume-Job
Start-Job
Stop-Job
Suspend-Job
Wait-Job

Vereinfach kann man sagen, dass ein "Job" ein Hintergrundprozess ist, den man starten, abfragen, stoppen, pausieren und wiederaufnehmen kann. Der Job wird aber nie "sichtbar" werden. Ein Prozess ist eher der Start eines anderen Skripts oder Programms als eigenständigen Windows-"Prozess".

Prozessumgebung

Interessant ist natürlich, welche "Umgebung" bei dem Prozess ankommt. Ich habe daher einfach aus dem aufrufenden Script eine Azure AD Powershell gestartet und dann im Job die Abfragen ausführen lassen:

Connect-msolservice
$job = Start-job -scriptblock {get-msolUser}
wait-job $job
$result = Receive-Job $job
$result

Die Ausführung liefert folgenden Fehler:

Der mit "Start-Job" im Hintergrund ausgeführte Skriptblock hat nicht die Laufzeitumgebung des ausführenden Scripts, sondern ist komplett "autark". Ein Versuch den Befehl mit "-asJob" in den Hintergrund zu senden geht nicht da z.B. "Get-MSOLUser" diesen Parameter gar nicht anbietet. Sie müssen also im Skriptblock eventuell benötigte Voraussetzungen erst herstellen

Das gilt übrigens auch für Extensions und SnapIns, die ggfls. neu geladen werden müssen. Wenn Sie verschiedene Jobs immer mit der gleichen Startumgebung nutzen wollen, dann ist der Parameter "-Initializationscript" interessant.

Use this parameter to prepare the session in which the job runs. For example, you can use it to add functions, snap-ins, and modules to the session.
Quelle: https://technet.microsoft.com/de-de/library/hh849698.aspx 

Rückgabe durch den Job

An dem einfachen Beispiel haben sie schon gesehen, dass der Job natürlich auch Ausgaben generieren kann. Der Jobstatus zeigt über das Property "HasMoreData" an, dass Daten abgeholt werden können. Dazu dient dann das Commandlet "Receive-Job". Interessant ist dabei die Ausgabe.

Receive-Job holt sowohl die Ausgabe am Bildschirm (Write-Host) als auch die Ausgabe in die Pipeline ab. Wer in dem Job also Daten verarbeitet und die Ergebnisse zurückmelden will, kann z.B. Diagnosefunktionen einfach weiter per Write-Host ausgeben, die dann vom aufrufenden Script per Transcript einfach beim "Receive-Job" erfasst werden können. Ergebnisdaten hingegen sollte der Job in die Ausgabepipeline übergeben, damit Sie mit Receive-Job ausgelesen werden können.

Achtung:
Receive-Job holt nur genau einmal die Daten ab. Danach ist die Pipeline des Jobs geleert. Beim Status sehen Sie auch, dass "HasMoreData" dann auf False steht.

Die Rückgabe einfacher Strings ist ja relativ einfach. Ich wollte natürlich wissen, wie dies mit "komplexeren Objekten" aussieht. Hier meine Tests mit unterschiedlichen Rückgaben:

Scriptblock Ergebnis Bewertung

Ein einfacher String

{
   [string]"string"
}

"string"

Zurück kommt einfach ein String. Das war aber auch nicht anders zu erwarten

Ein einfacher Integer

{[int]5}

5

Auch mit einem Integer funktioniert es

Array mit gemischten Werten

{@([int]1,[string]"string2")}

1
string2

Ein Array wird auch als Array zurück gegeben. Auch die Typen der einzelnen Elemente bleiben erhalten.

Mehrere verschiedene Variablen

{
   @([int]1,[string]"string2")
   [int]3;
   [string]"string4"
}

1
string2
3
string4

Zurück kommt ein Array mit vier Elementen. Wenn als mehrere Variablen im Job ausgegeben werden, dann kann mit Receive-Job ein Array mit den Einzelergebnissen erhalten werden.

WMI-Objekt

{
   get-process
}

Liste der Prozesse als object[]

Auch hier ist ein relativ komplexes Objekt durch den aufrufenden Prozess abrufbar und verwertbar.

Insoweit habe ich keine Einschränkung gefunden. Allerdings habe ich nun nicht genau z.B. den Speicherbedarf analysiert. Die Daten es Jobs müssen ja gepuffert werden und wenn diese mit "Receive-Job" abgeholt werden, könnte PowerShell diese noch mal kopieren oder einfach den Pointer auf die Variable übergeben. Meine "Jobs" sind aber eh eher kurzweilig und kein echtes Multithreading.

Rückgabe zur Laufzeit

Während der Job läuft, kann das aufrufende Skript ja weiter arbeiten. Ich habe dann einen Job gebaut, der alle paar Sekunden eine Ausgabe erzeugt. Noch während der Job aktiv ist, kann ich mit Receive-Job die Ausgaben abholen.

start-job -scriptblock {
   $count = 0;
   while ($True) {
      $count++;
      $count
      start-sleep -seconds 5
   }
}

Der Job läuft so endlos, bis ich ihn mit "Stop-Job" unsanft beende oder die aufrufende Powershell beende. Aber während er läuft, kann ich die Ausgaben einfach auslesen.

Wenn das Skript nichts neues ausgegeben hat, dann liefert "Receive-Job" einfach ein "$NULL. Allerdings ist die Aussage von "HasMoreData" in diesem Fall nicht zuverlässig.

Parameter an dem Job

Allein mit der Rückgabe an einen Job ist es nicht getan. Ich möchte auch Parameter an den Job übergeben. Start-Job sieht dabei den Parameter "-argumentlist" vor. Nebenbei kann ich damit gleich prüfen, ob der ScriptBlock auf eine im aufrufenden Skript definierte Variable auch so zugreifen kann. Das folgende Beispiel soll das erläutern:

$a=[int]1
$b=[string]"string2"

start-job -argumentlist $a `
   -scriptblock {
      param ($joba)

      "JobA=" + $joba
      "VarB=" + $b
      }

Die per ArgumentList übergebene Variable ist angekommen aber auf "$b" konnte der ScriptBlock nicht zugreifen.

Mir ist kein eingebauter Weg bekannt, wie ich während der Laufzeit z.B. Informationen oder Steuerdaten an den Job übergeben kann, die dieser Job dann auswerten kann. Globale Variablen oder "ByRef"-übergebene Parameter sind nicht nutzbar. Insofern müssen Sie für solche Anforderungen dann eine Prozess zu Prozess-Kommunikation per IPC, RPC, TCP, NamePipe o.ä., aufbauen

Übergabe an einen laufenden Jobs / Argumentlist

Dann hat mich interessiert, wie ich Daten an einen laufenden Job übergeben kann. Sicher könnte der Job eine TCP-Verbindung öffnen und über den Kanal angesprochen werden-. Auch wäre eine Datenübergabe über "Dateien" oder eine Pipeline möglich. Zuerst habe ich aber prüfen sollen, ob vielleicht eine Variable möglich ist, die per "[ref]" im Skriptblock definiert wird. Daher habe ich folgenden Code entworfen:

$a=[int]1

$job= start-job `
   -scriptblock {
      param ([ref]$joba)
      while ($True) {
         [string]$joba;
         start-sleep -seconds 5
      }
   } `
-argumentlist $a 

start-sleep -seconds 2
receive-job $job

Allerdings scheint das nicht wirklich zu funktionieren:

Die Übergabe einer Variable als "ByRef" statt "ByVal" mit der Hoffnung, dass ich so vom aufrufenden Programm auch den Inhalt in dem Job nachträglich ändern kann, ist mir nicht gelungen. Hier wird es dann auf lokale Named Pipes hinauslaufen oder über das Netzwerk als WebService, TCP-Connection o.ä., was natürlich auch per LocalHost funktionieren kann. Bei Netzwerkverbindungen ist natürlich die Authentifizierung zu gewährleisten:

Da wohl alle Werte nach dem Parameter "-Argumentlist" als Argumente verstanden werden, sollten sie diesen Parameter als letzten Parameter verwenden.

Performance

Jobs im Hintergrund stören das Hauptprogramm nicht wirklich. Allerdings habe ich schon gemerkt, dass der Start eines Job durchaus dauern kann. Sie können das selbst einmal testen. Zwischen 2-3 Sekunden dauert allein der Start eines "Write-Host".

PS C:\> measure-command {Start-Job -ScriptBlock {write-host "done"}}

Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 3
Milliseconds      : 329
Ticks             : 33299146
TotalDays         : 3,85406782407407E-05
TotalHours        : 0,000924976277777778
TotalMinutes      : 0,0554985766666667
TotalSeconds      : 3,3299146
TotalMilliseconds : 3329,9146


PS C:\> Get-Job
Id     Name            PSJobTypeName   State         HasMoreData     Location
--     ----            -------------   -----         -----------     --------
2      Job2            BackgroundJob   Completed     True            localhost

PS C:\> Get-Job | fl

HasMoreData   : True
StatusMessage :
Location      : localhost
Command       : write-host "done"
JobStateInfo  : Completed
Finished      : System.Threading.ManualResetEvent
InstanceId    : 3ac9e635-b25d-472d-aa3d-b841ba09a06a
Id            : 2
Name          : Job2
ChildJobs     : {Job3}
PSBeginTime   : 11.06.2016 15:22:00
PSEndTime     : 11.06.2016 15:22:06
PSJobTypeName : BackgroundJob
Output        : {}
Error         : {}
Progress      : {}
Verbose       : {}
Debug         : {}
Warning       : {}
State         : Completed

Das ist schon sehr lange aber Start-Job ist dafür einfach zu nutzen. Nach der PSBeginTime und PSEndTime ist der Job selbst sogar 6 Sekunden aktiv. Starte ich dann die Jobs mehrfach, dann wird es aber schneller. Es hat den Eindruck, dass die PowerShell hier vermutlich beim ersten Start erst die entsprechenden Module laden und die Umgebung aufbauen muss.

PS C:\> measure-command {Start-Job -ScriptBlock {write-host "done"}}

Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 0
Milliseconds      : 686
Ticks             : 6865582
TotalDays         : 7,94627546296296E-06
TotalHours        : 0,000190710611111111
TotalMinutes      : 0,0114426366666667
TotalSeconds      : 0,6865582
TotalMilliseconds : 686,5582


PS C:\> get-job 5 | fl

StatusMessage :
HasMoreData   : True
Location      : localhost
Runspace      : System.Management.Automation.RemoteRunspace
Command       : write-host "done"
JobStateInfo  : Completed
Finished      : System.Threading.ManualResetEvent
InstanceId    : f1d145f2-36a5-446a-b1dd-86db4fe5217e
Id            : 5
Name          : Job5
ChildJobs     : {}
PSBeginTime   : 11.06.2016 15:24:34
PSEndTime     : 11.06.2016 15:24:37
PSJobTypeName :
Output        : {}
Error         : {}
Progress      : {}
Verbose       : {}
Debug         : {}
Warning       : {}
State         : Completed

Der Start hat nur 0,6 Sek benötigt und der Job selbst war nach 3 Sekunden komplett fertig. Dennoch gibt es durchaus Optionen, dies bei sehr vielen Jobs effektiver zu machen.

Drosseln von Jobs

Es passiert mit Start-Job sehr schnell, dass die mehrere Jobs in den Hintergrund senden. Dabei sollten Sie aber immer an die Ressourcen ihres Servers achten. Auch wenn es verlockend ist, z.B. ein komplettes Class-C Subnetz mit 255 eigenen Jobs zu scannen, sollten Sie einen Scriptblock erst einmal mit wenigen parallelen Jobs testen. Sehr schnell überlasten Sie den Server und die Ergebnisse sind verzögert oder sogar fehlerhaft. Eine gute Idee ist es vor dem Start eines die Jobs zu zählen, die gerade aktiv sind und neue Jobs erst auf die Reise zu senden, wenn eine selbst gesetzte Zahl nicht überschritten ist. Die Anzahl der aktiven Jobs können Sie einfach wie folgt ermitteln:

(get-job -State running).count

Natürlich sollten Sie die Ergebnisse von Jobs ebenfalls zeitnah einsammeln und die erledigten Jobs dann auch bereinigen. Die Liste der erledigten Jobs, die keine Daten mehr haben ist wie folgt schnell zu erhalten und zu bereinigen:

Get-Job -HasMoreData $false -State completed | remove-job

Ansonsten werden die Jobs mit dem Ende des aufrufenden Powershell-Skripts erledigt.

Weitere Links