PS Performance

Powershell ist das dafür berühmt, mit einem "Einzeiler" große Aufgaben zu lösen. Aber mit Powershell können auch ganz umfangreiche Programme entwickelt werden. Wenn Umgebungen aber größer werden, dann ist Performance mehr und mehr ein Thema. Es gibt je nach eingesetztem Befehl durchaus mächtige Unterschiede.

Measure-Command

Erster Schritt ist natürlich das "Messen" der Laufzeit. Dazu kann das Commandlet "Measure-Command" dienen, welches die Laufzeit eines Anweisungsblocks ermittelt. Allerdings führt das Commandlet den Block so erst mal nur "einmal" aus und zudem werden die Ausgaben des Codes an die Pipeline unterdrückt, was wieder Verfälschungen mit sich bringen kann. Für einfache Aufgaben ist es aber ausreichbar.

PS C:\> Measure-Command {Get-ChildItem}

Days  : 0
Hours : 0
Minutes           : 0
Seconds           : 0
Milliseconds      : 4
Ticks : 41882
TotalDays         : 4,8474537037037E-08
TotalHours        : 1,16338888888889E-06
TotalMinutes      : 6,98033333333333E-05
TotalSeconds      : 0,0041882
TotalMilliseconds : 4,1882

Der Befehlsblock in geschweiften Klammern kann auch durchaus mehrere Codezeilen enthalten. Allerdings ist der Code gekapselt, d.h. Ausgaben, die hier bei "Get-Childitem" natürlich vorhanden sind, gehen unter. Variable-Zuweisungen in dem Block sind aber auch außerhalb des Blocks verwendbar. Das folgende geht also schon

$laufzeit = measure-command { $recipientlist = get-recipient}
write-host "Laufzeit $laufzeit"
forach ($recipient in $recipientlist) {
    write-host "Recipient:"$recipient.identity
}

Wer damit misst, sollte die Befehle immer mehrfach aufrufen, denn die Laufzeit schwankt. Oft dauert gerade der erste Start länger und Folgeaufrufe sind zügiger. Sobald Abhängigkeiten mit anderen Diensten, z.B. LDAP-Abfragen, Webservices, im Spiel sind, müssen die Ergebnisse relativiert betrachtet werden.

Gerade auch kurze Befehle sollten über eine Schleife einfach mehrfach gestartet werden, z.B.

measure-command {
    1..100000 | %{
        get-date "01.01.1900"
    }
}

Wer mehr oder mehrere Punkte im Code messen möchte, kann dies mit GET-DATE tun, um die aktuelle Zeit sehr genau zu erhalten und von vorher ermittelten Werten abzuziehen.

Beispiele

Um die Auswirkungen anschaulich zu machen, habe ich drei Aufgaben heran gezogen, die das Potential aufzeigen sollen:

Schauen wir uns die Ergebnisse im einzelnen an:

Beispiel 1: Exchange Empfängerabfrage / AD Abfrage

Ich denke für Exchange Administratoren ist die Abfrage der Exchange Empfänger ein häufiger Prozess, der auf mehrere Wege durchgeführt werden kann.

Ich habe alle drei Optionen einmal gegeneinander gestellt, um die Performance in einer etwas größeren Umgebung zu demonstrieren. Das Beispiel fragt ca. 30.000 Einträge ab. Um die Ausgabe der Objekte zu ersparen, wird einfach nur die Anzahl der Objekte zurück gegeben. Dennoch sind die Zeiten natürlich nur eine Momentaufnahme meiner Umgebung. Aber es ist offensichtlich, das "höherwertigen" Commandlets und speziell die Exchange remote Powershell deutlich langsamer. Insofern muss man speziell für aufwändige Auswertungen mit vielen Empfängern überlegen, ob bei der Suche schon gefiltert oder ein schnellerer Weg genutzt wird.

Die Dauer gibt eine Momentaufnahme meiner VMs wieder und ist nicht repräsentativ. Es gibt Varianten bei denen die Initialisierung länger dauert aber beim Zugriff dann schneller sind während andere sofort aber dann nicht so schnell Daten liefern. Auch kann über die Pagesize ein Tuning erfolgen.

Option und Code Laufzeit

Exchange Powershell-Befehl

measure-command { `
    write-host "total:"(get-recipient -resultsize unlimited).count
}
349 Sek

Abfrage per Windows 2008 Get-ADObject, Erfordert aber einen Windows 2008 DC mit ADWS

measure-command { `
    write-host "total:"(get-adobject `
   -ldapfilter "(mailnickname=*)" `
   -ResultSetSize $null `
   -server "server:3268").count 
}
733 Sek

Klassische Abfrage per ADSI.

measure-command {
   $root = [system.directoryservices.activedirectory.forest]::getcurrentforest().rootdomain.name
   $adsearch= New-Object System.DirectoryServices.DirectorySearcher([ADSI]"GC://$root")
   $adsearch.Propertiestoload.add("distinguishedname") | out-null
   $adsearch.Propertiestoload.add("mail") | out-null
   $adsearch.Propertiestoload.add("ProxyAddresses") | out-null
   $adsearch.pagesize = 100
   $adsearch.filter = "(mailnickname=*)"
   $adsearch.filter = "(objectclass=*)"
   $adresult =$adsearch.findall()
   write-host $adresult.count
}

# Ausgabe dann per FOR-Schleife
ForEach ($strResult In $adresult) {
    $strDN = $strResult.Properties.Item("distinguishedName")
    Write-Host "DN:"$strDN
}
30 Sek

Klassische Abfrage per ADO (COM-Objekt)

measure-command {
    $adoConnection = New-Object -comObject "ADODB.Connection"
    $adoConnection.Open("Provider=ADsDSOObject;")
    $adoCommand = New-Object -comObject "ADODB.Command"
    $adoCommand.ActiveConnection = $adoConnection
    $adoCommand.Properties.Item("Page Size") = 100
    $adoCommand.Properties.Item("Timeout") = 30
    $adoCommand.Properties.Item("Cache Results") = $False

    $root = [system.directoryservices.activedirectory.forest]::getcurrentforest().rootdomain.name
    $strbase="<GC://$root>"
    $strFilter = "(mail=*)"
    $strAttributes = "distinguishedName,mail,proxyaddresses"
    $strScope = "subtree"
    $strQuery = "$strbase;$strFilter;$strAttributes;$strScope"

    $adoCommand.CommandText = $strQuery
    $adoRecordset = $adoCommand.Execute()
    write-host "Items Found:"$adoRecordset.recordcount
}

# Ausgabe hier dann mit FindFirst ud FindNext
Do {
    $adoRecordset.Fields.Item("distinguishedName") | Select-Object Value
    $adoRecordset.MoveNext()
} Until ($adoRecordset.EOF)
$adoRecordset.Close()
$adoConnection.Close()
26 Sek

Außer Konkurrenz habe ich die Liste einfach mal mit CSVDE in eine Datei exportiert.

measure-command {
    CSVDE -f Benutzer.csv -r "(mailnickname=*)" -l "mail,proxyaddresses" -s msxfaq.net -t 3268
}

Obwohl die Daten also noch in eine Datei geschrieben werden, ist CSVDE sehr schnell unterwegs.

7 Sek

Allerdings muss man hier natürlich zugeben, dass die Exchange Powershell für den Get-Recipient einige Fehler mehr aus dem AD auslesen muss, um das Ausgabeobjekte zu befüllen. Beim ADSI-Query kann ich genau angeben, welche Felder ich benötige und damit natürlich Zeit sparen.

Beispiel 2: Variablen zuweisen

Diese Aufgabe ist eine kleine Vorarbeit für Beispiel 3. Stellen Sie sic vor ich initialisiere eine Datenstruktur und habe mehrere Felder um ein Datum zu speichern. Um später zu erkennen, welche Felder gar nicht beschrieben wurden, wird ein Startdatum weit in der Vergangenheit gesetzt. So funktionieren "Vergleiche" weiterhin. Stellen sie sich nun vor, Sie müssen eine umfangreiche Liste mit vielen Objekten aufbauen und alle Datumfelder so setzen. Dann ist das schon ein Zeitfaktor. Also starten wir mit der Generierung eines Datums, welches drei Variablen füllt:

Option und Code Laufzeit

Klassischer Weg über Get-Date Commandlet

measure-command {
   1..10000 | %{
      $a=get-date "01.01.1900";
      $b=get-date "01.01.1900";
      $c=get-date "01.01.1900"
   }
}

5,7 Sek

Alternativer Weg mit Konvertierung

measure-command {
   1..10000 | %{
      $a=[datetime]"01.01.1900";
      $b=[datetime]"01.01.1900";
      $c=[datetime]"01.01.1900"
   }
}

1,8 Sek

Es geht sogar noch schneller. Da ein DATETIME kein komplexes Objekt, sondern ein Basistyp ist, ist eine Zuweisung keine Referenz, sondern eine Kopie (Clone)

measure-command {
   $t=get-date "01.01.1900";
   1..10000 | %{
      $a=$t;
      $b=$t;
      $c=$t
   }
}

1,2 Sek

Es kann also auch in Powershell sinnvoll sein, eine Start-Wert, der häufiger verwendet wird, schon vorzubereiten.

Beispiel 3: Objekte für Listenaufbau belegen

Viele meiner Skipte erstellen Berichte und Ausgaben, die im Code schon als CSV-Datei exportiert werden sollen. Diese Ergebnisse eines Datensatzes speichert man natürlich nicht in vielen einzelnen Variablen, sondern fasst diese als "PSCustomObjekt" zusammen. Bei der Initialisierung muss man natürlich darauf achten, dass jedes Ergebnis wieder mit einem neu initialisierten Objekt anfängt. Ein "$obj1=$obj2" ist bei PSCustomObjekts schlecht, da dabei nicht das Objekt kopiert wird, sondern nur eine Referenz erstellt wird. Ändert man dann $obj1, dann ändert sich auch $obj2. Folgendes Beispiel verdeutlicht das.

Das wirkt sogar noch nach, wenn das Objekt genutzt wird, um es in ein Dictionary oder eine Arrayliste zu addieren. Also muss ich das Objekt neu aufbauen, oder einen "Clone" erstellen. Leider kann man PSCustomObjects nicht einfach Clonen, so dass ich es einfach neu aufbaue. Und dazu gibt es drei Optionen.

Option und Code Laufzeit

Option1: Klassischer Weg ein PSCustomObjekt zu instanzieren und mit Werten zu beladen

measure-command {
   1..10000 | %{	
    $newpso =new-object psObject
    $newpso | Add-Member -MemberType noteproperty -Name "mailaddress" -Value "notset"
    $newpso | Add-Member -MemberType noteproperty -Name "PrimarySmtpAddress" -Value "notset"
    $newpso | Add-Member -MemberType noteproperty -Name "TotalItem" -Value 0
    $newpso | Add-Member -MemberType noteproperty -Name "date1" -Value (get-date 01.01.1900)
    $newpso | Add-Member -MemberType noteproperty -Name "date2" -Value (get-date 01.01.1900)
    $newpso | Add-Member -MemberType noteproperty -Name "date3" -Value (get-date 01.01.1900)
   }
}
24 Sek

Option2: Man kann aber auch bei der Instanzierung des Objekts schon Properties mitgeben und füllen, das schon deutlich schneller geht.

Measure-Command {
   1..10000 | %{
      $newpso = New-Object PSObject -Property @{
         mailaddress = "NotSet"
         PrimarySmtpAddress = "NotSet"
         totalItem = 0
         date1 = (Get-Date 01.01.1900)
         date2 = (Get-Date 01.01.1900)
         date3 = (Get-Date 01.01.1900)
      }
   }
}
7 Sek

Option3: Alternativer Weg ein PSCustomObjekt zu instanzieren und mit Werten zu beladen

measure-command {
   1..10000 | %{
      $newpso = ("" | select mailaddress,PrimarySmtpAddress,TotalItem,Date1,date2,date3)
      $newpso.mailaddress="NotSet"
      $newpso.PrimarySmtpAddress="NotSet"
      $newpso.totalItem=0
      $newpso.date1=(get-date 01.01.1900)
      $newpso.date2=(get-date 01.01.1900)
      $newpso.date3=(get-date 01.01.1900)
   }
}
8 Sek

Option4: Zuletzt noch ein Beispiel eines Lesers mit C# als Basisklasse. (Tipp kam von Claudio Spizzi)

Add-Type @"
    namespace Test {
        public class MyObject {
            public string mailaddress;
            public string PrimarySmtpAddress;
            public int totalItem;
            public System.DateTime date1;
            public System.DateTime date2;
            public System.DateTime date3;
        }
    }
"@

(Measure-Command {
   1..10000 | %{
    $newpso5 = New-Object Test.MyObject
    $newpso5.mailaddress="NotSet"
    $newpso5.PrimarySmtpAddress="NotSet"
    $newpso5.totalItem=0
    $newpso5.date1=(Get-Date 01.01.1900)
    $newpso5.date2=(Get-Date 01.01.1900)
    $newpso5.date3=(Get-Date 01.01.1900)
   }
}).TotalSeconds

Diese Version ist noch mal ein klein wenig schneller als die Option3.

7 Sek

Option5: Alternativ könnte man natürlich ein Masterobjekt ablegen, welche einfach kopiert wird.

$newpso = ("" | select mailaddress,PrimarySmtpAddress,TotalItem,Date1,date2,date3)
$newpso.mailaddress="NotSet"
$newpso.PrimarySmtpAddress="NotSet"
$newpso.totalItem=0
$newpso.date1=(get-date 01.01.1900)
$newpso.date2=(get-date 01.01.1900)
$newpso.date3=(get-date 01.01.1900)

measure-command {
   1..10000 | %{
      $newpso2 = ($newpso |select *)
   }
}

Dieser Wert ist natürlich optimistisch. Wenn Sie die Defaultwerte ändern wollten, dann muss nach dem Copy natürlich noch Zeit für die Zuweisung eingeplant werden.

3 Sek

Es ist gut zu sehen, dass der Trick mit dem "select" deutlich schneller ist als der klassische Ansatz oder der Versuch ein Objekt zu kopieren. Beim klassischen Ansatz kostet es einfach zu viel Zeit immer wieder "Add-Member" aufzurufen.

Beispiel 4: Das "Count-Property"

Es ist so schön einfach, wenn ein Skript per ADO eine Datenbankabfrage oder per ADSI eine Suche im Active Directory durchgeführt hat und voller Stoltz ist das erste, was ein Programmierer als nächste Zeile einbaut ein

write-host "Gefundene Elemente:" $resultset.count

Unschön dabei ist, dass sowohl ADO als auch ADSI erst dann einen "Count" zurück geben können, wenn Sie die ganzen Daten einmal ausgelesen haben. Sie verlieren also kostbare Zeit. Wenn die Anzahl der Ergebnisse im Skript nicht für etwas anderes erforderlich ist, z.B. eine Fortschrittsanzeige auszugeben oder die Gültigkeit der Rückgabe zu prüfen, dann kann man das einfach sein lassen und direkt zur Verarbeitung der Ergebnisse übergehen.

Es spricht ja nichts dagegen bei der Verarbeitung einige nützlichere Counter mit hochzuzählen und auszugeben.

Beispiel 5: "Write-host" und Start-Transcript

Zugegeben ich schreibe viele Skripte und natürlich möchte ich wissen, was die Skripte genau tun. Ich kann ja nicht jedes Skript permanent im Debugger laufen lassen. Also machte ich, was auch viele andere Administratoren, Consultants und Entwickler machen: Sie schreiben "Debugausgaben" in das Script. Und nichts ist einfacher als ein "Write-Host" und wenn ich das auch mit protokollieren will, dann addiere ich noch ein Start-Transcript am Anfang.

Aber genau die Ausgaben auf dem Bildschirm machen die Skripte langsam, da die Ausgabe auf dem Bildschirm gescrollt werden muss etc. Nicht umsonst habe ich mir eine eigene Routine gebaut, die "Write-Host" ersetzt und die Ausgaben direkt in eine Datei umlenkt und einige Mehrwertfunktionen bietet. (Siehe MSXFAQLogger).

Kleiner Tipp: Wenn Sie viele Daten verarbeiten und einen Statusupdate erhalten wollen ohne dass der Bildschirm allzu viel scrollt, dann zeigen Sie doch den Fortschritt mit einer nummer auf, die nur all 100 oder 1000 Änderungen aktualisiert wird.

if (!($count%1000)) {write-host "`rUsers:"$count -nonewline}

Write-Host ist also nicht das schnellste Modul, aber geht es schneller ?. Ja, z.B. über [console]

Option und Code Laufzeit

Option1: Ausgabe von 1000 "Textzeilen" mit write-host.

measure-command {
   1..100000 | %{
      write-host "hallo"
   }
}
1,1 Sec/1000
101 Sek/100000

Option2: Ausgabe von 1000 "Textzeilen" mit [console]

measure-command {
   1..100000 | %{
      [console]::Write("hallo`n")
   }
}
0,4 Sec/1000
36 Sek/100000

Option3: Ausgabe von 1000 "Textzeilen" mit "out-host". Natürlich nur nutzbar, wenn Sie STDOUT als Pipelineausgabe nicht anderweitig benötigen. Zudem schummelt das Script bei Measure-command, da keine Ausgabe erzeugt wird

$start=get-date
   1..100000 | %{
      "hallo"
   }
write-host "dauer" ((get-date)-$start).totalseconds
2 Sek/1000
66 Sek/100000

Spart man sich aber die Ausgabe auf den Bildschirm und lässt die Daten in eine Datei schreiben, dann wird es noch langsamer, da das Anhängen an die Datei scheinbar viel Zeit braucht.

measure-command {
   1..1000 | %{
      if (!($_ % 100)) {write-host $_}
      "hallo" | out-file .\debug.txt -append
   }
}
11 Sec/1000

Dann doch besser die Ausgaben erst mal im Speicher aufaddieren und am Ende schreiben, was aber das Risiko hat, dass beim Abbruch des Skript gar nicht geschrieben wurde. Es sei denn sie fangen das mit einem TRAP ab um dann noch mal zu schreiben. Und es ist gut zu sehe, dass die Verarbeitung von Zeile zu Zeile langsamer wird, egal ob $result nun ein String oder Array.

measure-command {
   Trap {"Error: $_"; $result | out-file .\debug.txt ;Break;}
   $result=@()  # oder [string]$result=""
   1..100000 | %{
      if (!($_ % 100)) {write-host $_}
      $result+="hallo"
   }
   $result | out-file .\debug.txt
}
0,1Sek/1000
137Sek/100000

Dass speziell das Schreiben in Dateien noch schneller gehen kann beweist wieder mal, dass eine direkte Nutzung der .NET-Klassen einen großen Vorteil haben kann. Der Streamwriter/TextWriter nutzt intern einen Buffer um nicht jede Ausgabe sofort zu schreiben, sondern in Blöcken.

measure-command {
   $debugfile = New-Object System.IO.StreamWriter "c:\temp\debug.txt"
   1..100000 | %{
      if (!($_ % 100)) {write-host $_}
      $debugfile.WriteLine("hallo")
   }
   $debugfile.Close();
}

Analog könnte man noch einen "System.IO.StringWriter" nutzen, der aber gleich schnell ist. Allerdings sollten Sie ein Skript dann nicht mit "CTRL-C" abbrechen, da dann die Handle nicht geschlossen werden. Erst ein Beenden der Powershell gibt die Handles wieder frei.

0,1Sek/1000
10Sek/100000

Der StreamWriter ist also bezüglich "schreiben" durch nichts zu übertreffen und damit die erste Wahl, um Debugfiles wegzuschreiben. Sie sehen schon die Laufzeitunterschiede einer einfachen Ausgabe.

Beispiel 6: Listen erweitern

Natürlich lebt Powershell auch von der Pipeline. Aber manchmal möchte ich die Ergebnisse doch erst in einer Liste sammeln und danach weiter verarbeiten oder z.B. mit Export-CSV innerhalb des Programms in eine Datei exportieren. Also nehme ich die Werte und "addiere" diese. Am Beispiel mit String könnte das so aussehen:

[String]$ergebnis = ""

foreach ($repeat in 0..9) {
    $start = get-date
    foreach ($zeile in 0..999) {
        $ergebnis += "Zeile $repeat $zeile"
    }
write-host "Repeat $repeat  Dauer: "((get-date) - $start).totalseconds
}

Ausgabe:
Repeat:0  Dauer:  0,0210012
Repeat:1  Dauer:  0,0150009
Repeat:2  Dauer:  0,0220013
Repeat:3  Dauer:  0,053003
Repeat:4  Dauer:  0,297017
Repeat:5  Dauer:  0,3610207
Repeat:6  Dauer:  0,5040289
Repeat:7  Dauer:  0,5900338
Repeat:8  Dauer:  0,6060346
Repeat:9  Dauer:  0,6610378

Ich habe noch nicht hinter die Kulissen geschaut aber anscheinend kopiert Powershell (oder .NET) intern die Variable beim Erweitern um statt dynamische mit Pointern zu arbeiten. Es ist also vielleicht nicht die beste Idee, so eine Liste aufzubauen. Das gilt insbesondere, wenn sie ganz große Strukturen über Strings aufbauen wollen.

Deutlich besser schlagen sich Hashtabellen:

[hashtable]$ergebnis = @{}
foreach ($repeat in 0..9) {
	$start = get-date
	foreach ($zeile in 0..999) {
		$ergebnis.add( ([string]$repeat+[string]$zeile),"Zeile $repeat $zeile")
	}
	write-host "Repeat:$repeat  Dauer: "((get-date) - $start).totalseconds
}

Repeat:0  Dauer:  0,0370021
Repeat:1  Dauer:  0,0130008
Repeat:2  Dauer:  0,0100006
Repeat:3  Dauer:  0,0100005
Repeat:4  Dauer:  0,0150008
Repeat:5  Dauer:  0,0090005
Repeat:6  Dauer:  0,0090005
Repeat:7  Dauer:  0,0130007
Repeat:8  Dauer:  0,0100006
Repeat:9  Dauer:  0,0120007

Im Gegensatz dazu schlagen sich Arrays sogar noch schlechter:

[Array]$ergebnis = @()
foreach ($repeat in 0..9) {
	$start = get-date
	foreach ($zeile in 0..999) {
		$ergebnis += "Zeile $repeat $zeile"
	}
	write-host "Repeat:$repeat  Dauer: "((get-date) - $start).totalseconds
}

Repeat:0  Dauer:  0,0740042
Repeat:1  Dauer:  0,1830105
Repeat:2  Dauer:  0,2580147
Repeat:3  Dauer:  0,3360192
Repeat:4  Dauer:  0,4260243
Repeat:5  Dauer:  0,52503
Repeat:6  Dauer:  0,6060346
Repeat:7  Dauer:  0,70004
Repeat:8  Dauer:  0,8160467
Repeat:9  Dauer:  1,1250643

Weitere Variablen habe ich aber nicht untersucht

Haben Sie eine bessere Idee für ein Aufbau einfacher Listen ?

Beispiel 7: Funktionen einbinden

Aus Gründen der Modularisierung und Wiederverwendung von Code bietet es sich an, geeignete Codeabschnitte als Funktion in eigene PS1-Dateien oder PS1M-Module auszulagern und im Hauptskript dann einzubinden. Auch dazu gibt es unterschiedliche Ansätze, die sich auf die Performance niederschlagen können.

Achtung:
Die Beispiele hier zeigen rein den Overhead der jeweiligen Einbindung. Da der Code aber nicht "aktiv tut", sind die Zahlen viel drastischer aber dennoch nicht zu unterschätzen.

Das Muster besteht aus einem MAIN-Skript, welches in zwei Fällen auf eine ausgelagerte Funktion in einem SUP.PS1 zurück greift.

Version Main.ps1 Sub.ps1 Laufzeit Bemerkung
Einfache Schleife
$sum=0
foreach ($item in 1..1000) {
$sum += $item 
}write-host "Main End sum: $sum"
Entfällt 9msec Nicht modular
Unterroutine in der Schleife
 
$sum=0
foreach ($item in 1..1000) {
$sum += . .\sub.ps1  #
}write-host "Main End sum: $sum"
$item
1680 msec Die Unterroutine wird immer wieder aufgerufen, was letztlich sehr langsam ist.
Einbindung als Funktion
function add-item {
                $item
}
 
$sum=0
foreach ($item in 1..1000) {
$sum += add-item
}write-host "Main End sum: $sum"
Entfällt 120msec Die Auslagerung in eine Funktion kostet schon Performance im Vergleich zu den 9 msec der direkten Nutzung. Modular ist das aber nicht
Einbindung als externe Funktion über DOT Sourcing
. .\sub.ps1   # einbinden
$sum=0
foreach ($item in 1..1000) {
$sum += add-item
}
write-host "Main End sum: $sum"
#sub.ps1
function add-item {
                $item
}
120 msec Lagert man die Funktion au saber bindet sie einmal ein, dann ist es vergleichbar schnell. So kann Modularität funktionieren.

Interessant ist schon, dass allein die Auslagerung in eine Funktion innerhalb der gleichen PS1-Datei schon die Ausführung derart verlangsamt. Wird aber die Funktion in jeder Schleife neu aus einer anderen PS1-Datei bezogen, dann leidet die Performance stark. Bindet man die Funktion aber vorher über DOT-Sourcing mit ein, dann ist man nicht schlechter dran, als wenn man die Funktion im gleichen Skript definiert.

Beispiel 8: Textdateien lesen

Textdateien sind eigentlich was schönes, da man sie einfach lesen und schreiben kann und in fast jedem Editor auch anzeigen und bearbeiten kann. Zugegeben kommt Notepad auch unter Windows 8 mit großen Dateien immer noch an seine Grenzen, so dass man gut beraten ist, alternative Editoren (z.B. Notepad++, Textpad) zu verwenden oder gleich die Informationen per Skript zu extrahieren. So bin also auch ich immer mal genötigt, Textdateien schnell zu parsen und mittlerweile ist natürlich Powershell mein Mittel der Wahl. Als ich aber einmal ein 50 Megabyte großes Lync Client Tracefile parsen wollte, ist mir die Langsamkeit aufgefallen und das wollte ich nicht ganz wahr haben. Daraufhin habe ich eine kleine Mess-Serie aufgestellt, wie man eine große Textdatei am besten zeilenweise schnell einliest. Powershell eröffnet dabei doch einige Optionen. Alle Befehle verwendeten die gleiche Powershell und ich habe die Befehle immer zweimal gestartet, um etwaige Verfälschungen durch Caching zu eliminieren. Es kann also sein, dass die Zeiten "zu gut" sind, weil Daten noch in einem Cache gelegen haben. Virenscanner war aktiv aber es war mein Notebook. Hyperschnelle Raid-Controller mit eigenen Caches gibt es also nicht. Die Zeiten wurden mit Measure-command gemessen.

Methode Zeit Sek Beschreibung
COPY <datei> <datei2> 0,1 ich habe einfach mal einen COPY gemacht, der die 50 MB gelesen aber auch wieder geschrieben hat. Was Windows da mi Caches tut, ist mir suspekt aber 50 MB von einer 2,5" IDE-Disk in 0,1 Sek zu lesen und zu schreiben ist mir suspekt.
type <date> -> <datei2> 256 Ein klassische DOS-Type üer Pipeline in eine Datei ist sehr langsam. Das liegt aber wohl eher am "Append"-Schreiben denn am Lesen.
type <datei> -> nul 0,1 Die Gegenprobe mit einem Ausgeben nach NUL war dann auch deutlich schneller
get-content <datei> | out-null 52 Get-Content liest die Datei zeilenweise ein und damit kann man sicher kein Performanceblumentopf gewinnen. Aber scheinbar liest das Comandlet wirklich sehr langsam die Dateien ein.
select-string datei -pattern * | out-null 37 Auch das Commandlet "Select-String" kann direkt Informationen aus Dateien auslesen. Damit es aber alle Zeilen liefert, muss man den Filter auf ".*" setzen. Trotz erforderlicher Auswertung jeder einzelner Zeile mit dem regulären Ausdruck ist Select-String deutlich schneller als Get-Content.
import-csv datei -delimiter | out-null 36 Auch das "Import-CSV"-Commandlet kann man für den Import einer Textdatei missbrauchen. Allerdings sollte man den Feldtrenner so vorgeben, dass eben nichts getrennt wird. Dann steht im ersten Feld der Pipeline einfach die komplette Zeile
System.File.IO  ReadLines 1,5

Das Einlesen mit folgendem Code ist sehr schnell

foreach ($line in [System.IO.File]::ReadLines($pwd.path+"\Lync-UccApi-0.UccApilog"))  {
#  $line
}

System.File.IO  ReadAllLines 1,3

Mit ReadAllLines geht es sogar noch ein bisschen schneller.

foreach ($line in [System.IO.File]::ReadAllLines($pwd.path+"\Lync-UccApi-0.UccApilog"))  {
#  $line
}

Wenn ich mir das so anschaue, dann ist es schon erschreckend, wie langsam der ein oder andere Code ist. Ich kann aber nicht genau verstehen, warum z.B. "Type" so viel schneller sein soll, so es doch auch nur ein "alias" für "Get-Content" ist. 

Weitere Themen

Und das waren nun nur ein paar Beispiele. Es gibt noch weitere Themen, die knifflig sein können, z.B.:

Insofern kann es sich schon mal lohnen zu schauen, wo ein Code seine Zeit verbringt.

Zusammenfassung

Auch wenn Powershell relative einfach ist und passende Commandlets gerade im Bereich Exchange einen Zugriff auf Objekte deutlich unterstützen, so muss zumindest in größeren Umgebungen geprüft werden, ob die einfachen aber ressourcenintensiven Commandlets die Anforderungen an die Laufzeit erfüllen. Ich merke dies immer in Umgebungen, wo ich mit Powershell Skripte zur Auswertung erstelle. Wenn Sie z.B. per Powershell die Adressbücher von zwei Mailsystemen auslesen, um diese dann zu "vergleichen", dann ist es nicht lustig, wenn eine Auswertung 10 Minuten und länger läuft.

Weitere Links

Keywords:Powershell Performance