PowerShell und Callback-Funktionen

Die meisten Administratoren schreiben, wie ich auch, seriell ablaufende Skripte. Es gibt einen Startpunkt und ein Ende. Modularisierung erreichen wir mit Funktionen und Modulen und mit Schleifen lassen wir Skripte auch länger laufen aber unterm Strich warten wir auf Ergebnisse um diese dann weiter zu bearbeiten. Diverse Module und Klassen erlauben aber kein "Polling" oder das Warten auf Ergebnisse. Für den Fall gibt es Callback-Funktionen, die von dem Modul in unserem Code aufgerufen werden.

Beispiel Timer

Ein sehr einfaches Beispiel zum Einstieg ist der .NET "Timer". Sie können diesen per PowerShell einfach instanzieren und lassen ihn einfach regelmäßig auslösen.

# Timer instanzieren und parametrisieren
$timer = new-object timers.timer
$timer.interval = 1000    # default sind 100ms
$timer.AutoReset=$true   # immer wieder auslösen
$timer.Start()

Der läuft dann und generiert jede Sekunde einen Event. Das Problem ist aktuell, dass er aber noch nichts macht, weil noch kein Code auf den Event reagiert. Den kann ich nun aber ganz schnell einbinden. Ich definiere eine Aktion mit einem Code-Segment

# Aktion hinterlegen
$timeraction = {write-host "Timer abgelaufen um: $(get-date -Format 'HH:mm:ss')"}
Register-ObjectEvent `
   -InputObject $timer `
   -EventName "Elapsed" `
   -SourceIdentifier  end2endTimer `
   -Action $timeraction

Der Wer für "EventName" hängt von dem Objekt ab, auf das sie lauschen wollen. Bei einem "Timer" wird ein "Elapsed" oder "Stopped" unterstützt. Ich habe auch schon "Triggered" und "EventArrived" gesehen. Letztlich müssen Sie das auslösende Objekte dazu befragen:

Und schon erscheint auf der Console jede Sekunde eine Ausgabe:

Sie können natürlich weiter tippen. Lassen Sie sich nicht durch die Ausgaben zwischendurch irritieren. Dem Spuck können Sie ein Ende machen, indem Sie entweder den Timer anhalten, das Property "AutoReset" auf $false setzen oder den Event deregistrieren.

# Timer beenden
$timer.stop()

# Timerneustart verhindern
$timer.autoreset = $false

# Events deregistieren
Get-EventSubscriber | Unregister-Event

Ehe sie nun diese Übung als trivial abtun, sollten Sie an vergangene Herausforderungen denken. Stellen Sie sich vor ihr Skript läuft so vor sich hin und regelmäßig sollte etwas "passieren". Bislang haben Sie dies umständlich so gelöst, dass Sie sich die Zeit gemerkt haben und immer mal wieder über eine IF-Abfrage geprüft haben, ob das Intervall schon abgelaufen ist und nun ein Stück Code aktiv werden muss. Sie wissen aber ja nicht, wo in der Zwischenzeit ihr Skript unterwegs ist und wie weit es an einer Stelle länger braucht. Mit einem Timer können Sie zumindest zum nächsten Befehl etwas dazwischenschieben.

WMI und andere Events

Der "Timer" ist natürlich eine ganz einfach Anwendung. Die Callback-Funktionen gibt es aber an vielen anderen Stellen. Quasi immer da, wo ihr Skript eine Aktion auslöst aber nicht weiß, wann die Antwort kommt, macht es wenig Sinn das Skript "warten" zu lassen. Es kann ja etwas anderes machen und wenn die Antwort kommt, dann können Sie immer noch darauf reagieren. Das ist insbesondere nützlich, wenn Sie mehrere Abfragen abgesetzt haben und diese nicht sequentiell beendet werden. Verwechseln Sie Callback aber nicht mit Multithreading oder Jobs (Siehe PS Job), bei denen die Skripte wirklich parallel laufen.

Sie können aber z.B. auf WMI-Events reagieren, z.B. wenn ein Performance Counter über ein Limit geht, wenn ein Dienst startet, wenn ein Gerät eingesteckt wird oder ein Eventlog passiert. Es wäre denkbar ungünstig hier per Polling immer wieder nachzufragen oder mit einem blockenden Aufruf zu warten.

Wenn Sie ihre Skripte CPU-schonend pausieren und dennoch schnell auf Event reagieren wollen, dann sollten Sie statt "start-sleep" ein "Wait-Event" nutzen. Das Commandlet "Wait-Event" hält das aktuelle Skript an, bis ein Event auftritt.

Auch andere 3rd Party-Klassen (Siehe z.B. Powershell und MQTT) nutzen Callback. Bei MQTT wird z.B. eine Verbindung aufgebaut und gehalten. Wenn aber eine eingehende Kommunikation ansteht, dann muss das Skript reagieren. Genauso würde es sich z.B. mit Skype for Business verhalten. Sie können nicht wirklich ein "Warte auf neue Conversation" codieren, wenn sie mehrere aktive Konversationen betreiben. Selbst ein "Warte x Sekunden, ansonsten gehe weiter" ist keine Lösung. Callback-Funktionen erlauben hier eine schnellere direkte Reaktion und wenn es gut gemacht ist, dass kann die Funktion ihre Arbeit machen aber dennoch mit dem Hauptprogramm kommunizieren.

Alle Events landen in einer EventQueue und sie können auch selbst mit "New-Event" neue Einträge anlegen. Die Queue ist zwar auf die aktuelle Umgebung beschränkt aber so lässt sich dennoch eine Kommunikation zwischen mehreren Programmteilen herstellen.

Parallelität und Scope

Damit stellt sich natürlich die Frage, wie parallel diese Callback-Funktionen laufen und vor allem in welchem Prozessraum diese unterwegs sind. Um es kurz zu machen

  • Da ist nichts parallel
    Die Callback-Funktion wartet zwar im Hintergrund auf ihren Auftritt aber das Hauptprogramm oder andere Events müssen dies natürlich zulassen. Wenn Sie das Hauptprogramm mit einem "Start-Sleep" in den Schlafmodus setzen, dann kann auch keine Callback-Funktion in der Zeit wach werden. Umgekehrt ist es genau so. Wenn die Callback-Funktion aktiv ist, dann steht das eigentliche Programm. Sie sollten also bei beiden effektiv und "nicht blockend" programmieren, so dass es immer wieder eine Möglichkeit gibt, einen Event zu bearbeiten aber auch das Hauptprogramm weiter laufen zu lassen.
  • Mehrere Events
    Alle Events landen in einer Eventqueue und der Name verrät schon, dass die Abarbeitung sequentiell erfolgt. Wenn Sie also z.B. mehrere Events haben, während beim Hauptprogramm ein Commandlet Längere Zeit beansprucht, dann werden die Events in einer Queue gespeichert und nach dem Ende des aktuellen Commandlets eingeschoben.
  • Scope von Variablen
    Auch nutzt die Callback-Funktion die gleichen Variablen wie das Hauptprogamm. Sie können aus der Callback-Funktion solche vorhandenen Variablen also lesen aber auch schreiben. Ein Timer kann also eine Variable im Hauptprogramm immer wieder hochzählen, was für Zeitmessungen oder "Budget-Verwaltungen" zwecks Throttling effektiv nutzbar ist.

Ich merke mir das einfach so, dass dieser Code in der Aktion im Moment der Aktivierung einfach an der Stelle des Skripts eingefügt und ausgeführt wird, an dem das ablaufende Skript gerade unterbrochen wurde. Da aber keine parallelen Codeteile laufen, kann es auch keine Konflikte beim Zugriff auf Variablen geben. Hier ein Beispiel zur Verdeutlichung. Ein Timer-Event tritt alle 5 Sekunden auf und gibt etwas aus. das Hauptprogramm hingegen ist in einer Endlosschleife gefangen, die immer 10 Sekunden pausiert und dann etwas ausgibt:

# Timer instanzieren und parametrisieren
$timer = new-object timers.timer
$timer.interval = 5000    # default sind 100ms
$timer.AutoReset=$true   # immer wieder auslösen
$timer.Start()


$timeraction = {write-host "Timer abgelaufen um: $(get-date -Format 'HH:mm:ss')"}
Register-ObjectEvent `
   -InputObject $timer `
   -EventName elapsed `
   -SourceIdentifier  end2endTimer `
   -Action $timeraction

while ($true) {write-host "a";start-sleep -seconds 8;write-host "b";start-sleep -seconds 4}

Die Ausgabe zeigt gut, dass der Event nicht aktiv wird, solange "Start-Sleep" blockiert:

Dennoch sollten Sie natürlich zusehen, dass alle Variablen und Befehle, die Sie in einer Callback-Aktion nutzen, vor dem ersten Aufruf definiert wurden. Aber das gehört ja eh zu einer sauberen Programmierung dazu.

Weitere Links