Tiefe Einblicke in CLR
Beschleunigen des Anwendungsstarts
Claudio Caldato

Inhalt
Das Warten auf den Start einer Anwendung frustriert viele Benutzer. Wenn Sie sich daher auf die Startleistung einer Clientanwendung konzentrieren, verbessert sich der erste und bleibende Eindruck Ihrer Kunden von Ihrer Arbeit möglicherweise deutlich. Da die Startleistung für Benutzer eine große Rolle spielt, lohnt es sich, die Faktoren zu untersuchen, die sich darauf auswirken, sodass Sie die häufigsten Fehler vermeiden können.
Anwendungsstarts werden in der Regel als Kalt- oder Warmstarts klassifiziert. Im Kontext einer verwalteten Anwendung bedeutet ein Kaltstart, dass der Speicher weder die Microsoft® .NET Framework-Systemassemblys noch den Code der Anwendung enthält und diese deshalb vom Datenträger abgerufen werden müssen. Ein Warmstart bezeichnet den anschließenden Start einer Anwendung oder den Start der Anwendung, wenn der Großteil des Systemcodes bereits im Speicher enthalten ist, weil er vorher von einer anderen verwalteten Anwendung verwendet wurde.
Kaltstart
In den meisten Fällen ist ein Kaltstart E/A-gebunden. Anders ausgedrückt wird mehr Zeit darauf verwendet, auf Daten zu warten, als darauf, Anweisungen zu verarbeiten. Die für den Anwendungsstart erforderliche Zeit entspricht der Zeit, die das Betriebssystem zum Abrufen des Codes vom Datenträger benötigt. Außerdem muss die Zeit für die Durchführung zusätzlicher Verarbeitung dazugerechnet werden, z. B. die JIT-Anpassung (Just-In-Time) des IL-Codes sowie andere Initialisierungen, die während des Starts der Anwendung durchgeführt werden. Da bei einem Kaltstart die Verarbeitung in der Regel keinen Engpass darstellt, ist das erste Ziel einer Untersuchung der Startleistung, den Datenträgerzugriff durch Reduzieren des Umfangs des geladenen Codes zu verringern.
Die Weise, in der der Anwendungscode geschrieben ist, hat ebenfalls eine wichtige Auswirkung auf den Kaltstart. Es ist also wichtig herauszufinden, ob die Anwendung zum Beispiel zusätzliche Dateien öffnet oder andere Prozesse startet, die beim Start auf E/A-Ressourcen zugreifen könnten.
Der Kaltstart stellt ein an E/A-Vorgänge gebundenes Szenario dar. Die Verwendung eines herkömmlichen CPU-Profilers (unabhängig davon, ob auf Instrumentation oder auf Sampling basierend) wird also bei der Untersuchung nicht viel helfen. Instrumentationsbasierte Profiler melden die Zeit, die beim Warten auf E/A-Vorgänge vergeht, als blockierte Zeit. Das Problem besteht darin, dass die blockierte Zeit, selbst wenn sie einer spezifischen Aufrufliste zugeschrieben werden kann, nur einmal gezählt wird. Alle folgenden Datenträger-E/A-Vorgänge werden nicht berücksichtigt, was ein unvollständiges Bild über den eigentlichen Beitrag der Datenträger-E/A-Vorgänge zur gesamten Ausführungszeit ergibt.
Bei samplingbasierten Profilern können die Informationen sogar irreführend sein. Dabei wird nämlich die CPU-Nutzung nachverfolgt, nicht E/A. Das heißt, die gesamte für E/A-Vorgänge benötigte Zeit wird in den Berichten des Profilers nicht aufgeführt.
Sie können in Abbildung 1 feststellen, dass ein Kaltstart E/A-gebunden ist, indem Sie die Anwendung zweimal hintereinander starten. Der erste Start wird wahrscheinlich viel langsamer erfolgen als der zweite (bei dem sich der Großteil des Codes, der für die Ausführung erforderlich ist, bereits aufgrund des ersten Starts im Speicher befindet. Der reduzierte Datenträgerzugriff spart somit Zeit). Um sicherzustellen, dass der erste Start tatsächlich ein Kaltstart ist, starten Sie zuvor Ihren Computer neu, und stellen Sie sicher, dass der Startordner keine verwalteten Anwendungen enthält und dass während der Benutzeranmeldung kein Windows®-Dienst ausgeführt wird, der verwalteten Code verwendet.
Abbildung 1 Datenträgerlesezeit und CPU-Zeit beim Kaltstart
Beachten Sie, dass für einen idealen Test des Kaltstarts der SuperFetch-Dienst deaktiviert werden sollte, der andernfalls einen Teil des von der Anwendung benötigten Codes vorab lädt und dadurch ein wärmeres Startszenario bewirkt. Der Vorteil beim Messen mit dem ausgeschalteten SuperFetch-Dienst besteht darin, dass Sie sich auf die Tatsache verlassen können, dass der gesamte von der Anwendung benötigte Code beim Start der Anwendung in den Speicher geladen wurde. Deshalb können Sie das Ausmaß der E/A-Vorgänge genauer messen. Sie sollten aber bedenken, dass das, was Sie messen, nicht unbedingt die eigentliche Benutzerfunktionalität darstellt. Ziehen Sie also aufgrund der Daten, die Sie mit ausgeschaltetem SuperFetch erfassen, keine konkreten Schlussfolgerungen über die eigentliche Leistung der Anwendung.
Zwei Leistungsindikatoren zum Ermitteln der Auswirkungen eines Kaltstarts auf E/A-Vorgänge sind Prozessorzeit (%) und Lesezeit (%). Wenn E/A-Vorgänge den Kaltstart dominieren, was der Normalfall ist, sollte ein großer Unterschied zwischen Prozessorzeit (%) und Lesezeit (%) zu sehen sein. Leistungsindikatoren können mithilfe von PerfMon gesammelt werden (weitere Informationen finden Sie in der Randleiste „Startleistungsressourcen“).
In Abbildung 1 stellt die rote Zeile die Lesezeit (%) dar, und die grüne Zeile stellt die Prozessorzeit (%) dar. Bei einem Kaltstart sehen Sie, dass die CPU-Nutzung relativ niedrig ist, verglichen mit der Zeit, die für das Lesen des Datenträgers benötigt wird.
Der zweite Start der Anwendung stellt ein warmes Startszenario dar, sodass der Leistungsindikator ein anderes Bild ergibt. In Abbildung 2 ist das Szenario CPU-gebunden, und wie Sie sehen, ist die Lesezeit (%) sehr niedrig verglichen mit der Prozessorzeit (%).
Abbildung 2 Kürzere Zeiten beim Warmstart
Der Warmstart ist CPU-gebunden, weil der Code sich bereits im Speicher befindet (es besteht also kein Bedarf für zusätzliche E/A-Vorgänge). Der Code muss jedoch einer JIT-Anpassung unterzogen werden, bevor die Anwendung ausgeführt werden kann. Im heutigen .NET Framework wird der von JIT generierte systemeigene Code von einer Anwendungsausführung zur nächsten nicht gespeichert.
Wenn Sie feststellen, dass der Warmstart nicht deutlich kürzer ist als der Kaltstart, müssen Sie herausfinden, was die CPU-Zyklen verbraucht (denn der meiste Code wird bei einem Warmstart vorab geladen, und es ist unwahrscheinlich, dass er E/A-gebunden ist). In Frage kommen dann große Mengen an Code, die einer JIT-Anpassung unterzogen bzw. komplexe Berechnungen, die von der Anwendung durchgeführt werden müssen.
Um festzustellen, ob JIT das Problem ist, können Sie die Zeit des Leistungsindikators „.NET CLR JIT\%“ in JIT überprüfen. Wenn der Wert nicht hoch ist (zum Beispiel mehr als 30-40 % für die meisten Startzeiten), stellt JIT wahrscheinlich keinen wesentlichen Faktor dar, und Sie sollten einen Profiler verwenden, um zu bestimmen, welche Funktionen in Ihrer Anwendung die meiste CPU-Zeit in Anspruch nehmen. Bedenken Sie, dass der Zähler nur dann aktualisiert wird, wenn die Methoden tatsächlich eine JIT-Anpassung erfahren. Das heißt, dass nach der JIT-Anpassung der letzten Methode der Zähler weiterhin den letzten Wert berichtet und nicht auf Null zurückgeht. Verfolgen Sie den Zähler deshalb nur in den ersten wenigen Sekunden des Anwendungsstarts. In dieser Zeit werden Sie mit großer Wahrscheinlichkeit feststellen, dass der Zähler schnell zunimmt, was anzeigt, dass die Spitze in der CPU-Nutzung vom JIT-Compiler verursacht wird.
Denken Sie auch daran, dass jede Anwendung, die bei der Benutzeranmeldung geladen wird, zusammen mit anderen Diensten und Anwendungen auf E/A-Vorgänge zugreifen muss, was die Startzeit noch verlängert. Vermeiden Sie es deshalb möglichst, der Startgruppe Anwendungen hinzuzufügen (ein gutes Tool, mit dem Sie feststellen können, welche Anwendungen für eine Ausführung beim Computerstart festgelegt sind, ist AutoRuns, das unter
microsoft.com/technet/sysinternals/Security/Autoruns.mspx verfügbar ist).
Identifizieren von Code, der vom Datenträger geladen wird
Der nächste Schritt besteht darin festzustellen, was vom Datenträger geladen wird und ob Code vorhanden ist, der unabsichtlich geladen wird. Am schnellsten lässt sich der in den Speicher geladene Code mit dem VADump-Tool bestimmen (Sie finden es im Windows Plattform-SDK). In Abbildung 3 wird ein Ausschnitt des Berichts gezeigt, der durch Ausführen des folgenden Befehls generiert wurde:

Figure 3 VADump-Ausgabe
Category Total Private Shareable Shared
Pages KBytes KBytes KBytes KBytes
Page Table Pages 177 708 708 0 0
Other System 39 156 156 0 0
Code/StaticData 8169 32676 2160 8336 22180
Heap 14042 56168 56168 0 0
Stack 0 0 0 0 0
Teb 0 0 0 0 0
Mapped Data 8 32 0 4 28
Other Data 1 4 4 0 0
Total Modules 8169 32676 2160 8336 22180
Total Dynamic Data 14051 56204 56172 4 28
Total System 216 864 864 0 0
Grand Total Working Set 22436 89744 59196 8340 22208
Module Working Set Contributions in pages
Total Private Shareable Shared Module
72 2 70 0 HeadTrax - HeadTrax.exe
107 7 0 100 ntdll.dll
37 4 6 27 mscoree.dll
77 3 0 74 KERNEL32.dll
6 2 0 4 LPK.DLL
27 4 0 23 USP10.dll
116 4 0 112 comctl32.dll
878 23 79 776 mscorwks.dll
Heap Working Set Contributions
0 pages from Process Heap (class 0x00000000)
0 pages from Process Heap (class 0x00000000)
9332 pages from Process Heap (class 0x00000000)
0x0255850F - 0xC255350F 9332 pages
0 pages from Process Heap (class 0x00000000)
0 pages from Process Heap (class 0x00000000)
4710 pages from Process Heap (class 0x00000000)
0x00040000 - 0x10040000 4710 pages
0 pages from Process Heap (class 0x00000000)
Stack Working Set Contributions
0 pages from stack for thread 00001018
0 pages from stack for thread 000017EC
0 pages from stack for thread 0000187C
Ein wichtiger Punkt muss beachtet werden: VADump zeigt nur das an, was bei der Ausführung des Tools in den Speicher geladen wird. Deshalb kann es sein, dass Module übersehen werden, die nur eine kurze Zeitspanne in den Speicher geladen werden. Außerdem wird der Teil der Anwendung (entweder Code oder Daten) nicht angezeigt, der auf den Datenträger ausgelagert wurde. Ziel ist es, den VADump-Bericht daraufhin zu überprüfen, ob ein Laden aller Module in der Liste wirklich sinnvoll ist. Wenn Ihre Anwendung zum Beispiel kein XML verwendet und Sie feststellen, dass System.Xml geladen wurde, müssen Sie dem nachgehen.
Sie können herausfinden, wodurch eine Assembly geladen wurde, indem Sie den Befehl „sxe“ im Windows-Debugger (windbg) verwenden. Der Befehl „sxe ld:<dll name>“ veranlasst ein Unterbrechen des Debuggers, wenn die angegebene DLL geladen wird. Sie können dann die Aufrufliste prüfen, um herauszufinden, durch welche Funktion die DLL in den Speicher geladen wurde. Dieser Aspekt der Untersuchung sollte nicht unterschätzt werden. Es ist sehr leicht, den Überblick darüber zu verlieren, was die Anwendung tatsächlich in den Speicher lädt.
Systemassemblys und andere Prozesse
Wenn Sie das Laden aller unnötigen Assemblys beim Start unterbunden haben (zur weiteren Verbesserung können Sie auch den Anwendungscode ändern, um einen Teil der Initialisierungsarbeit, die beim Start erfolgt, zu verzögern), muss als Nächstes der Umfang des Codes verringert werden, der von Systemassemblys geladen wird. Leider ist mir kein Tool bekannt, mit dem sich feststellen lässt, wie viel Code bei Verwendung einer System-API abgerufen wird. Das wäre sehr nützlich, da der Entwickler APIs im Startcode verwenden könnte, die beim Laden durch die Systemassemblys weniger Code benötigen. Bis solche Tools verfügbar werden, können Sie die ungefähren Kosten einer API-Seite mithilfe eines auf Instrumentation basierten Profilers (zum Beispiel Visual Studio®-Leistungstools) ermitteln.
Durch Untersuchen der Profildaten können Sie versuchen, APIs zu vermeiden, die Systemaufrufe mit großen Aufrufstrukturen erfordern (Aufrufe mit großer Struktur und tiefer Verschachtelung bedeuten, dass der Code für jeden Aufruf einer Methode vom Datenträger stammt, daher ist dies eine Möglichkeit, die Codeverwendung des Aufrufs annähernd zu bestimmen). Wenn Sie die gleiche Funktionalität durch Aufrufen einer API implementieren können, die keine tiefe Systemaufrufstruktur aufweist, sparen Sie Zeit. Dies ist kein wissenschaftlicher Ansatz, da nicht leicht zu bestimmen ist, wie viel Code durch die Reduzierung einer Aufrufstruktur eingespart werden kann. In der Regel kann jedoch der folgende logische Schluss gezogen werden: je größer die Aufrufstruktur, desto größer der Umfang des Codes, der vom Datenträger geladen wird.
In einigen Fällen kann es vorkommen, dass die Anwendung beim Start explizit oder implizit andere Prozesse startet. Diese Prozesse lassen sich problemlos mithilfe der Option „–o“ im Windows-Debugger (windbg) feststellen. Durch die Option „–o“ wird der Debugger an einen untergeordneten Prozess angefügt. Ein typisches Beispiel für einen Prozess, der implizit durch die Anwendung gestartet wird, besteht darin, dass die Anwendung die XML-Serialisierung verwendet, die Serialisierungsklassen aber nicht vorkompiliert (mithilfe des Sgen-Dienstprogramms). Wenn dies geschieht, wird zur Kompilierung der C#-Compiler gestartet. Das Starten anderer Prozesse ist gewöhnlich ein sehr aufwändiger Vorgang, der sich wesentlich auf den Startvorgang auswirken kann.
Leistung von NGen
Das Generieren eines systemeigenen Abbilds (Native Image Generation, NGen) ist bei der Verbesserung des Warmstarts immer hilfreich, da die Kosten der JIT-Anpassung eingespart werden. NGen kann auch bei Kaltstartszenarios nützlich sein, wenn „mscorjit.dll“ nicht geladen werden muss, da der von der Anwendung verwendete Code bereits mithilfe von NGen vorkompiliert ist. Wenn jedoch nur eines der Module nicht über ein entsprechendes systemeigenes Abbild verfügt, wird die mscorjit.dll-Datei trotzdem geladen. Dann wird nicht nur der Code einer JIT-Anpassung unterzogen, was CPU-Zyklen verbraucht, sondern es sind auch viele Seiten in den NGen-Abbildern davon betroffen, weil der JIT-Compiler Metadaten lesen muss. Dies führt dazu, dass sich die Startzeit noch verlängert. Aus diesem Grund wird empfohlen, jeglichen Code zu entfernen, der beim Start eine JIT-Anpassung verursachen könnte. Selbstverständlich kann die Entscheidung, ob dieser Ansatz gewählt werden soll, erst dann getroffen werden, nachdem die Leistung des Kaltstarts mit und ohne generierte systemeigene Abbilder festgestellt wurde, da der eigentliche Vorteil von NGen für den Kaltstart vom Code und der Größe der Anwendung abhängt. NGen ist also keine Garantie für eine wesentliche Startverbesserung, selbst wenn beim Start kein JIT erfolgt.
Eine Möglichkeit zu bestimmen, ob und wann JIT ausgeführt wird, ist das Verwenden des Assistenten für verwaltetes Debuggen (Managed Debugging Assistant, MDA). JIT MDA ermöglicht Ihnen entweder das Unterbrechen des Debuggers oder das Drucken von Debuginformationen, wenn bei einer Methode JIT ausgeführt wird. Der MDA kann durch Einstellen einer Umgebungsvariablen aktiviert werden:
COMPLUS_MDA=JitCompilationStart
Die Anwendung unterbricht den Debugger, wenn der Code einem JIT unterzogen wird. Der MDA kann zudem über die Registrierungs- oder die .config-Datei der Anwendung eingestellt werden. Weitere Informationen zum Verwenden des MDA erhalten Sie in der Randleiste „Startleistungsressourcen“.
Überprüfen Sie Folgendes, um zu gewährleisten, dass NGen die Leistung des Kaltstarts verbessert:
- Auf die gesamte Anwendung wurde NGen angewendet.
- Es gibt keine neuen Basisadressen. Das Zuordnen neuer Basisadressen ist ein sehr aufwändiger Vorgang, und Code mit neuen Basisadressen kann nicht freigegeben werden. Weitere Informationen zum Festlegen der Basisadresse erhalten Sie unter msdn.microsoft.com/msdnmag/issues/06/05/CLRInsideOut.
- Assemblys sind im globalen Assemblycache (Global Assembly Cache, GAC) installiert. Das Überprüfen starker Namen wird für die gesamte Datei vorgenommen, jedoch für alle im GAC installierten Assemblys ausgelassen.
Überprüfen durch Authenticode
Assemblys können mithilfe des Signcode-Tools eine Authenticode-Signatur erhalten. Das Überprüfen durch Authenticode wirkt sich immer negativ auf die Startzeit aus, weil von Authenticode signierte Assemblys durch eine Zertifizierungsstelle überprüft werden müssen. Bei diesem Vorgang muss die zur Signierung der Assemblys eingesetzte Zertifizierungsstelle validiert werden, was ein sehr aufwändiger Vorgang ist, der Netzwerkzugriff erfordert (wenn die Zertifizierungsstelle nicht lokal auf demselben Computer installiert ist).
Im Idealfall sollte die Authenticode-Signierung von Assemblys vermieden und stattdessen Signaturen mit starken Namen verwendet werden. Wenn sich die Authenticode-Signierung nicht vermeiden lässt, kann die Überprüfung in .NET Framework 3.5 mithilfe der folgenden Konfigurationsoption übersprungen werden:
<configuration>
<runtime>
<generatePublisherEvidence enabled="false"/>
</runtime>
</configuration>
Beachten Sie jedoch Folgendes: Auch wenn die Authenticode-Signierung erforderlich ist, kann der Großteil der für die Authentifizierung benötigten Zeit immer noch eingespart werden, wenn das Zertifikat der Zertifizierungsstelle auf dem Clientcomputer installiert wird.
Zusammenfassung
Eine gute Kaltstartleistung lässt sich am besten dadurch erreichen, dass beim Start ein Minimum an Code ausgeführt wird. Jegliche nicht unbedingt erforderliche Initialisierung sollte daher aufgeschoben und alle Verweise darauf überprüft werden, dass sie nicht zu früh geladen werden. Außerdem sollten möglichst Klassen und Methoden eingesetzt werden, für die nicht viel Code geladen werden muss. Ziel ist es, den Datenträgerzugriff einzuschränken. Dies ist keine leichte Aufgabe. Xperf, ein neues nützliches Tool, das im bevorstehenden Windows Server® 2008-SDK enthalten ist, verwendet die Ereignisablaufverfolgung für Windows (Event Tracing for Windows, ETW), um geladene Module, Kontextwechsel und andere Ereignisse nachzuverfolgen und zu bestimmen, was während des Starts der Anwendung geschieht. Mit XPerf wird es möglich sein, äußerst präzise Metriken zur Anwendungsstartzeit zu erfassen. Die Randleiste „Startleistungsressourcen“ enthält viele weitere nützliche Verweise.
Senden Sie Ihre Fragen und Kommentare (in englischer Sprache) an clrinout@microsoft.com.
Claudio Caldato ist Programmmanager für Leistung und Garbage Collector im CLR-Team bei Microsoft.