PowerShell Mutex

Das schönste PowerShell Skript kann monatelang seine Aufgabe ausüben, solange es alleine läuft. Was machen Sie aber, wen Sie Skripte wie z.B. DynQuota gegen viele Objekte laufen soll uns Sie daher das Skript einfach mehrfacht starten? Das Skript ist durch die Angabe einer OU dafür ausgelegt auch mehrfach ausgeführt zu werden.

Probleme, die man umgehen will

Am Beispiel von DynQuota kann ich ein Problem einer parallelen Verarbeitung sehr gut aufzeigen: Das Skript enthält die Funktion, alle Aktionen in Logdateien abzulegen. Diese Dateien werden natürlich immer mehr und größer. Daher enthält das Skript ebenfalls einen Code-Teil, der alte Dateien nach einiger Zeit entfernt. Das kann man ganz einfach mit folgenden Zeilen machen

get-item -path "C:\Tasks\dynquota\logs\dynquota*.log" `
   | where {$_.lastwritetime -lt ((get-date).adddays(-30))} `
   | remove-item

Dieser einfache Code funktioniert solange, wie er nur einmal parallel ausgeführt wird. Wenn aber mehrere Instanzen das gleichen Verzeichnis zeitgleich beackern, dann habe ich an gleich zwei Stellen ein Problem:

  • Get-Item
    Ich habe den Anschein, dass Get-Item das Verzeichnis komplett oder in Blöcken liest und dann die Objekte in die Pipeline sendet. Wenn während des Lesens aber ein anderer Prozess die Dateien schon löscht, dann meldet mir Get-Item, dass es die Datei nicht finden kann
  • Remove-Item
    Auch beim Remove-Item kann es passieren, dass eine Datei-Information durch "Get-Item" noch gelesen und übergeben werden konnte, aber ein anderer Prozess die Datei in der Zwischenzeit schon gelöscht hat".

In beiden Fällen wird ein PowerShell Error geworfen und in $Error abgelegt. Ich mag es aber nicht, wenn ich Fehler "unbehandelt" lasse, insbesondere wenn die Skript automatisch ablaufen. Da sollte ich auf jeden Fehler eine "Antwort" haben und zumindest am Ende auf die Variable "$Error" überprüfen, wenn ich nicht schon zwischendrin aus Fehler prüfe. Der Start eines Commandlets mit "-Erroraction continue" oder "-Erroraction SilentlyContinue" versteckt zwar den Fehler in der Ausgabe aber eben nicht nicht $error. Siehe dazu auch PS ErrHandling.

Daher ist es also wichtig, bestimmte sich gegenseitig beeinflussende Bereiche zueinander abzustimmen.

.NET Mutex

Wenn man sich auf einen Computer beschränkt, dann gibt es schon im Betriebssystem entsprechende Funktionen, über die sich Prozesse synchronisieren können. Der einfachste Anwendungsfall ist ein Mutex, den jeder Prozess instanzieren kann und über den sich die Prozesse dann abgleichen. Hier ein Beispiel

write-host "Instanzieren des Mutex ohne gleich den Besitz zu übernehmen"
$mutex = New-Object System.Threading.Mutex($false, "MSXFAQ-Mutexname") 


Write-Host "warte auf Mutex"
$mutex.WaitOne() 
write-host "Mutex bekommen"
start-sleep -seconds 10
# Mutex wieder frei geben
$mutex.ReleaseMutex()
write-host "Mutex freigegeben. Andere können arbeiten"

Natürlich sollte ein Skript in der Phase, in der er den Mutex belegt, keine Warteschleifen durchführen, da er andere Prozesse ebenfalls damit blockiert. Stören könnte es aber auch sein, dass die Methode "WaitOne()" ohne Zeitangabe dann ewig auf die Freigabe wartet. Wenn das Skript die Aufgabe auch später machen kann, dann kann der Code angepasst werden:

write-host "Instanzieren des Mutex ohne gleich den Besitz zu übernehmen"
$mutex = New-Object System.Threading.Mutex($false, "MSXFAQ-Mutexname") 


Write-Host "warte 5 Sek auf Mutex"
if ($mutex.WaitOne(5000)) { 
   write-host "Mutex bekommen"
   start-sleep -seconds 10
   write-host "Mutex wieder frei geben"
   $mutex.ReleaseMutex()
}
else {
   write-host "Mutex nicht erhalten"
}
write-host "Mutex ende"

Schaut man sich so einen Mutex mal an, dann ist es auch nur ein "Handle" und einer Datei ähnlich.

PS C:\> $mutex | fl

Handle         : 1752
SafeWaitHandle : Microsoft.Win32.SafeHandles.SafeWaitHandle

In der Kommandozeile funktioniert das auch alles ganz nett. Aber in einem Skript ist das schon tückischer. Speichern Sie diese Zeilen mal als "Test-mutex.ps1 und rufen Sie es auf dem gleichen PC in zwei Powershell-Boxen auf. Ein Skript arbeitet aber das andere bekommt nie den Mutex, obwohl es diesen auch bekommen sollte

write-host "Instanziere Mutex ohne den Besitz zu übernehmen"
$mutex = New-Object System.Threading.Mutex($false, "MSXFAQ-Mutexname") 

while ($true) {
   Write-Host "warte 5 Sek auf Mutex"
   if ($mutex.WaitOne(5000)) { 
      write-host "+++ Mutex bekommen, warte 10 Sek"
      start-sleep -seconds 10
      write-host "Mutex wieder frei geben, warte 6 Sek"
      $mutex.ReleaseMutex()
      start-sleep -seconds 6
   }
   else {
      write-host "Mutex nicht erhalten"
   }
}
write-host "Mutex ende"

Selbst wenn das laufende Skript dann beendet wird, hat das andere Skript keinen Zugriff auf den Mutex. Irgendwie scheint der Mutex nicht immer "Global" bezogen auf den Computer zu sein. Führt an den gleichen Code "interaktiv" in der Console aus, dann funktioniert er aber. Auf der MSDN Seite habe ich einen Hinweis in die Richtung gefunden:

On a server that is running Terminal Services, a named system mutex can have two levels of visibility. If its name begins with the prefix "Global\", the mutex is visible in all terminal server sessions. If its name begins with the prefix "Local\", the mutex is visible only in the terminal server session where it was created. In that case, a separate mutex with the same name can exist in each of the other terminal server sessions on the server. If you do not specify a prefix when you create a named mutex, it takes the prefix "Local\". Within a terminal server session, two mutexes whose names differ only by their prefixes are separate mutexes, and both are visible to all processes in the terminal server session. That is, the prefix names "Global\" and "Local\" describe the scope of the mutex name relative to terminal server sessions, not relative to processes.
Quelle: https://msdn.microsoft.com/en-us/library/f55ddskf(v=vs.110).aspx 

Richtig zuverlässig hat das bei mir aber doch nicht funktioniert. Zudem bin ich eigentlich davon ausgegangen, dass ein MUTEX keinen "Besitzer" hat, sondern vom Betriebssystem verwaltet wird und mehrere Prozesse auch mit einem "New-Object" nur eine Referenz bekommen.

Wenn ich aber den Prozess beende, der zuerst den "New-Object" gemacht hat, dann werfen die anderen Prozesse einen Fehler.

Das ist sicher alles abzufangen aber macht den Einsatz von MUTEX doch aufwändiger.

File Locking

Früher war es ganz und gäbe, dazu einfach eine "LOCK-Datei" anzulegen und diese durch einen Prozess zu sperren. Wer die Datei erfolgreich gesperrt hat, konnte weiter machen während andere Prozesse eben warten mussten. So ein "Locking" funktioniert auch wunderbar über mehrere Server hinweg und erlaubt eine Synchronisierung über viele Clients. Wenn natürlich kein gemeinsames Dateisystem zur Verfügung steht, dann ist auch eine Synchronisation über Netzwerkprotokolle denkbar. Das würde aber den Rahmen der MSXFAQ sprengen.

Die Steuerung per Datei-Jobs und Locks habe ich übrigens in meiner Diplomarbeit (1991) intensiv genutzt, um auf bis zu 250 PCs mit 286/386 CPUs mit Turbo Pascal unter DOS mit NetWare Servern verteilte Apfelmännchen rechnen zu lassen.

Insofern kommt mir eine Arbeit zu Gute, die ich schon einige Jahre früher gemacht habe und bei meinem Problem einfach recycled habe. Ich nutze wieder mal eine Datei, die ich bei Bedarf anlegen und dann über das Dateisystem exklusiv sperre.

$lockfilename = $reportjobstatuspath+$report.name+".run"
write-host "run-statusweb4:Run-Report:Locking LOCKFile $lockfilename"
$lockfile = $null
try {
   write-host "Create Lockfile"
   "Lockfile" | Out-File -filepath $lockfilename
   write-host "Try to Lock file"
   $lockfile = [System.IO.File]::Open($lockfilename , 'Open', 'Read', 'None')
   write-host "LOCKFile:Successful"
}
catch {
   write-host "LOCKFile is not lockable"
   $lockfile = $null
}
finally {
   $lockfile.close()
}

if ($lockfile -ne $null) {
   write-host "DoJob"
}

$lockfile.close()
remove-item $lockfile.name

Dieses kleine Code-Segment legt eine Datei an und sperrt diese. Wenn dies nicht gelingt, weil sie schon vorhanden oder gesperrt ist, oder nicht angelegt werden kann, dann erkennt das Code und die Variable "Lockfile" ist leer. War das Sperren aber erfolgreich, dann dürfte dieser Job der einzige Prozess sein, der diese Datei nun hält und kann die kritischen Dinge durchführen.

Alle anderen Jobs, die die gleiche Logik durchlaufen, wissen um die Existenz dieses einen Jobs. Am Ende schließt der Prozess den Lock wieder und entfernt die Datei. Die Existenz der Datei selbst ist nicht relevant, da ein Skript ja auch einfach abbrechen kann. Sobald die Powershell-Session aber beendet wird, wird die Sperre automatisch wieder aufgehoben.

PID-File

Betriebssysteme verpassen jedem Prozess eine "ProcessID", Kurz PID. Das gibt es unter Windows und unter Unix und gerade bei den UNIX-Welten gehört es zum guten Ton, eine "PID-Datei" abzulegen, in der die ProzessID des aktuell laufenden Prozesses gespeichert ist. So kann ein anderer Prozess zum einen schnell erkennen, dass der Prozess läuft und welche PID er hat.. In einer PowerShell hilft es nicht viel, wenn ich mit Get-Process auf den Namen prüfe, der aber bei jedem Skript dann "PowerShell" oder "pwsh" ist. Daher sollte ein Script schon selbst eine entsprechende Logik mitbringen, z.B.

$pidfile = "c:\skript\scriptname.pid"
if (test-path -path $pidfile -pathtype leaf) {
   $pid= get-content $scripname+".pid"
   write-verbose "Found PID-File with $($pid)"
   if (Get-Process -id $pid) {
      Write-Verbose "Looks like process is running -skip"
      exit
   }
   else {
      Write-Verbose "orphaned PID-File. Removing"
      remove-item $pidfile
   }
}
else {
   write-verbose "No PID-File found"
}
Write-Verbose "Write PID File $($pidfile)"
$pid | out-file $scripname+".pid"

...

Wenn das Skript eine PID-Datei findet, liest es die PID aus und sucht einen Prozess. Wenn es einen Prozess mit der ID gibt, geht es davon aus, dass das Skript schon läuft und beendet das Skript. Ansonsten löscht es die übergelassene PID-Datei und legt seine PID als neue Datei ein und läuft weiter

Genaugenommen könnte das Skript aber auch schon beendet worden sein und die PID mittlerweile durch ein anderes Programm besetzt sein. Dann wäre ein Locking die bessere Option

Weitere Links