Drücke „Enter”, um zum Inhalt zu springen.

Asynchrone Prozessaufrufe aus der Powershell

Jan 0

Beim Erstellen der Version stolpere ich immer wieder über das Problem, dass ich einen Prozess im Build anstarte, der dann mit einer Fehlermeldung stehen bleibt. Die Aufrufe erfolgen über die Powershell. Nach etwas Recherche habe ich mir ein Skript geschrieben, dass asynchrone Prozessaufrufe aus der Powershell startet, überwacht und gegebenenfalls beendet.

Das Szenario ist ziemlich simpel. Ich habe eine Liste von Projekten, die das Skript abarbeiten soll. Dazu lese ich in Powershell eine Textdatei mit dieser Liste ein. Das Skript arbeitet darauf hin die Liste ab. Das Problem ist, wenn ein Projekt ein Fenster öffnet, welches eine Interaktion erfordert. Denn dann bleibt das komplette Skript stehen und der Build läuft in einen Timeout. Dabei könnte das Skript doch einfach weiter arbeiten? Dazu betreten wir den Dschungel wie man asynchrone Prozessaufrufe aus der Powershell behandelt.

Prozessaufruf

Mein Beispielskript besteht aus zwei Teilen. Der Hauptteil übernimmt nichts weiter, als in einer Endlosschleife den Prozess aufzurufen und den Job zu starten, der den Prozess überwacht. Dazu wird in dem Beispiel eine zufällige Wartezeit gewählt. Beim Befehl „timeout“ geht eine Kommandozeile auf, welche die Zeit runterzählt. Ist die Wartezeit größer als der Schwellwert, geht die Kommandozeile zu, noch bevor die Zeit abgelaufen ist.

Der später folgende Skriptblock wird mit Start-Job gestartet. Nachdem was ich darüber gelesen habe, wird kein eigener Prozess gestartet, aber auch kein Thread in dem Sinne, da die beiden nicht miteinander interagieren können. Ein weiterer Nachteil von Start-Job ist, dass man zwar Variablen übergeben kann, aber Objekte, da diese bei der Übergabe serialisiert werden.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$StartTime = (Get-Date)
$TryAgain = $true

while ($TryAgain) {
    # Die Wartezeit muss natürlich nicht Random sein, sondern soll
    # hier nur die Abarbeitung des Prozesses simulieren
    [int]$WarteZeit = Get-Random -Maximum 10
    Write-Host "Working $WarteZeit seconds..."
    $job = Start-Job -ScriptBlock $script
    # An dieser Stelle einen zeitintensiven Prozess anstarten
    Start-Process timeout -ArgumentList "/T $WarteZeit /nobreak" -Wait
    $job | Receive-Job
    $job | Remove-Job -Force
}

Mit dem Parameter -wait wartet die Hauptroutine auf das Beenden des Prozesses, was entweder durch erfolgreiche Beendigung oder durch Abbruch geschieht. Das anschließende Receive-Job holt alle Ausgaben des beendeten Jobs ab und zeigt erst dann an. D.h. wenn ich einen logischen Fehler im Subskript habe, suche ich lange. Remove-Job sorgt abschließend dafür, dass beim Abbruch verbleibende Reste entfernt werden und der Job aus der Liste der Jobs entfernt wird.

Prozessüberwachung

Für die Überwachung ist ein eigener Skriptblock im Skript zuständig. Der Skriptblock wartet 5 Sekunden und schießt im Zweifelsfall den Prozess ab. In meinem Beispiel die timeout.exe.

Auf Arbeit steuere ich damit über einen Automation Server, der mir aber im ungünstigsten Fall mit einer Messagebox stehen bleibt. Da die Abarbeitung des Skripts in einem Dienst erfolgt, kann man nicht mal ansatzweise die Meldung wegklicken. Also hilft nur eins, abwarten und Prozess schließen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$script = {
    $StartTime = (Get-Date)
    $TryAgain = $true
    while ($TryAgain)
    {
        Start-Sleep -Seconds 1
        $CurrentTime = (Get-Date)
        $TimeDiff = New-TimeSpan –Start $StartTime –End $CurrentTime
        [int]$TimeDiffSeconds = $TimeDiff.TotalSeconds
        if ($TimeDiffSeconds -ge 5)
        {
            Write-Host "`tTime is over!"
            # An dieser Stelle den zeitintensiven Prozess stoppen
            # Es gibt eigentlich Stop-Job oder Remove-Job, aber beide laufen auch nur in einen Timeout und beenden nicht wirklich.
            Get-WmiObject win32_process | where {($_.CommandLine -like "*timeout.exe*")} | % { $_.Terminate() } | Out-Null
            $TryAgain = $false
        }
        else
        {
            Write-Host "`tWaiting for $TimeDiffSeconds seconds..."
        }
    }
}

Wie man sieht, wartet das Skript eine 1 Sekunde, um dann die Differenz zum Startzeitpunkt zu ermitteln. Hat das Skript zu lang gewartet, wird der stockende Prozess aus der Prozessliste ermittelt und beendet. Das hat den unangenehmen Beigeschmack, dass natürlich alle laufenden gleichnamigen Prozesse (hier die timeout.exe) zum Opfer fallen. Wer also vor hat, diese Nebenläufigkeit auch noch mehrfach auf einem Rechner laufen zu lassen, sollte mit unangenehmen Nebenwirkungen rechnen.

Ein zweiter Nachteil ist die geschätzte Wartezeit. In meinem Beispiel habe ich die Wartezeit auf fünf Sekunden gesetzt. Das funktioniert aber nur, wenn ich abschätzen kann, wie lange mein Prozess braucht und diese Zeit relativ konstant ist. Wenn sie nicht konstant, sondern in Abhängigkeit zur angeforderten Tätigkeit steht, müsste ich diese Stelle mit Parametern versehen.

Das komplette Skript

Selbst unbedarfte Nutzer können sich mal (z.B. in Windows 10) die Windows Powershell ISE starten, das Skript kopieren, in den Skriptbereich einfügen und dann mal starten. Schön kann man sehen, wie die Zeit in der Kommandozeile runterläuft. Sollte die zufällige Zeit länger als fünf Sekunden sein, kann man wunderbar beobachten, wie das Fenster geschlossen wird, bevor die Zeit abgelaufen ist. Parallel sieht man im Ausgabefenster, wie nach Schließen des Fenster die Ausgaben mit den Wartezeiten erscheinen. Natürlich kann man asynchrone Prozessaufrufe aus der Powershell auch einfacher haben. Aber unter Berücksichtigung, dass der aufgerufene Prozess auch hängen bleiben kann, ist mir noch keine andere Lösung eingefallen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$script = {
    $StartTime = (Get-Date)
    $TryAgain = $true
    while ($TryAgain)
    {
        Start-Sleep -Seconds 1
        $CurrentTime = (Get-Date)
        $TimeDiff = New-TimeSpan –Start $StartTime –End $CurrentTime
        [int]$TimeDiffSeconds = $TimeDiff.TotalSeconds
        if ($TimeDiffSeconds -ge 5)
        {
            Write-Host "`tTime is over!"
            # An dieser Stelle den zeitintensiven Prozess stoppen
            # Es gibt eigentlich Stop-Job oder Remove-Job, aber beide laufen auch nur in einen Timeout und beenden nicht wirklich.
            Get-WmiObject win32_process | where {($_.CommandLine -like "*timeout.exe*")} | % { $_.Terminate() } | Out-Null
            $TryAgain = $false
        }
        else
        {
            Write-Host "`tWaiting for $TimeDiffSeconds seconds..."
        }
    }
}

$StartTime = (Get-Date)
$TryAgain = $true

while ($TryAgain) {
    # Die Wartezeit muss natürlich nicht Random sein, sondern soll
    # hier nur die Abarbeitung des Prozesses simulieren
    [int]$WarteZeit = Get-Random -Maximum 10
    Write-Host "Working $WarteZeit seconds..."
    $job = Start-Job -ScriptBlock $script
    # An dieser Stelle einen zeitintensiven Prozess anstarten
    Start-Process timeout -ArgumentList "/T $WarteZeit /nobreak" -Wait
    $job | Receive-Job
    $job | Remove-Job -Force
}

    Schreibe einen Kommentar

    Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.