Leistungsüberlegungen für Run-Time Technologien im .NET Framework

 

Emmanuel Schanzer
Microsoft Corporation

August 2001

Zusammenfassung: Dieser Artikel enthält eine Umfrage verschiedener Technologien in der verwalteten Welt und eine technische Erläuterung, wie sie sich auf die Leistung auswirken. Erfahren Sie mehr über die Funktionsweise von Garbage Collection, jiT, Remoting, ValueTypes, Sicherheit und mehr. (27 gedruckte Seiten)

Contents

Übersicht
Automatische Speicherbereinigung
Threadpool
The JIT
AppDomains
Sicherheit
Remoting
ValueTypes
Weitere Ressourcen
Anhang: Hosten der Serverlaufzeit

Übersicht

Die .NET-Laufzeit führt mehrere erweiterte Technologien ein, die auf Sicherheit, Einfache Entwicklung und Leistung abzielen. Als Entwickler ist es wichtig, jede der Technologien zu verstehen und effektiv in Ihrem Code zu verwenden. Die erweiterten Tools, die von der Laufzeit bereitgestellt werden, erleichtern das Erstellen einer robusten Anwendung, aber diese Anwendung schnell zu fliegen, ist (und immer) die Verantwortung des Entwicklers.

Dieses Whitepaper sollte Ihnen ein tieferes Verständnis der Technologien bei der Arbeit in .NET bieten und Ihnen dabei helfen, Ihren Code für die Geschwindigkeit zu optimieren. Hinweis: Dies ist kein Spezifikationsblatt. Es gibt bereits viele solide technische Informationen. Ziel ist es hier, die Informationen mit einer starken Neigung zur Leistung bereitzustellen und möglicherweise nicht jede technische Frage zu beantworten, die Sie haben. Ich empfehle, weiter in der MSDN Online Library zu suchen, wenn Sie die hier gesuchten Antworten nicht finden.

Ich werde die folgenden Technologien abdecken, eine allgemeine Übersicht über ihren Zweck und warum sie sich auf die Leistung auswirken. Anschließend werde ich mich mit einigen Details zur Implementierung auf niedrigerer Ebene befassen und Beispielcode verwenden, um die Möglichkeiten zu veranschaulichen, um die Geschwindigkeit der einzelnen Technologien zu verbessern.

Automatische Speicherbereinigung

Die Grundlagen

Garbage Collection (GC) befreit den Programmierer von häufigen und schwierig zu debuggenden Fehlern, indem Speicher für Objekte freigegeben wird, die nicht mehr verwendet werden. Der allgemeine Pfad, der für die Lebensdauer eines Objekts gefolgt ist, lautet wie folgt in verwaltetem und systemeigenem Code:

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object   
delete a;               // Tear down the state of the object, clean up
                        // and free the memory for that object

Im systemeigenen Code müssen Sie all diese Dinge selbst erledigen. Fehlende Zuordnungs- oder Bereinigungsphasen können zu völlig unvorhersehbarem Verhalten führen, das schwierig zu debuggen ist, und das Vergessen der freizugebenden Objekte kann zu Speicherlecks führen. Der Pfad für die Speicherzuweisung in der Common Language Runtime (CLR) ist sehr nah am Pfad, den wir gerade behandelt haben. Wenn wir die GC-spezifischen Informationen hinzufügen, enden wir mit etwas, das sehr ähnlich aussieht.

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object (it is strongly reachable)
a = null;               // A becomes unreachable (out of scope, nulled, etc)
                        // Eventually a collection occurs, and a's resources
                        // are torn down and the memory is freed

Bis das Objekt frei gemacht werden kann, werden die gleichen Schritte in beiden Welten ausgeführt. Im systemeigenen Code müssen Sie sich daran erinnern, das Objekt frei zu geben, wenn Sie damit fertig sind. Sobald das Objekt nicht mehr erreichbar ist, kann der GC es erfassen. Wenn Ihre Ressource natürlich besondere Aufmerksamkeit erfordert, um frei zu sein (z. B. schließen eines Sockets), muss der GC möglicherweise helfen, es richtig zu schließen. Der Code, den Sie zuvor geschrieben haben, um eine Ressource zu bereinigen, bevor es noch gilt, in Form von Dispose() und Finalize() Methoden. Ich werde über die Unterschiede zwischen diesen beiden später sprechen.

Wenn Sie einen Zeiger auf eine Ressource beibehalten, hat die GC keine Möglichkeit zu wissen, ob Sie ihn in Zukunft verwenden möchten. Dies bedeutet, dass alle Regeln, die Sie im systemeigenen Code zum expliziten Freigeben von Objekten verwendet haben, weiterhin gelten, aber die meiste Zeit behandelt der GC alles für Sie. Anstatt sich gedanken über die Speicherverwaltung um einhundert Prozent der Zeit zu machen, müssen Sie sich nur um etwa fünf Prozent der Zeit sorgen.

Der CLR Garbage Collector ist ein generationaler, mark-and-compact Collector. Es folgt mehreren Grundsätzen, die es ermöglichen, eine hervorragende Leistung zu erzielen. Erstens gibt es die Vorstellung, dass Objekte, die kurzlebig sind, kleiner sind und häufig darauf zugegriffen werden. Der GC unterteilt das Zuordnungsdiagramm in mehrere Unterdiagramme, sogenannte Generationen, die es ermöglichen, so wenig Zeit wie möglich zu sammeln*.* Gen 0 enthält junge, häufig verwendete Objekte. Dies ist auch die kleinste, und es dauert etwa 10 Millisekunden, um zu sammeln. Da der GC die anderen Generationen während dieser Sammlung ignorieren kann, bietet es viel höhere Leistung. G1 und G2 sind für größere, ältere Objekte und werden weniger häufig gesammelt. Wenn eine G1-Sammlung auftritt, wird auch G0 gesammelt. Eine G2-Auflistung ist eine vollständige Sammlung und ist das einzige Mal, wenn der GC das gesamte Diagramm durchläuft. Außerdem wird die intelligente Verwendung der CPU-Caches verwendet, die das Speicher-Subsystem für den spezifischen Prozessor optimieren können, auf dem sie ausgeführt wird. Dies ist eine Optimierung, die in der nativen Zuordnung nicht leicht verfügbar ist, und kann Ihrer Anwendung helfen, die Leistung zu verbessern.

Wann geschieht eine Sammlung?

Wenn eine Zeitzuweisung erfolgt, überprüft der GC, ob eine Sammlung erforderlich ist. Es untersucht die Größe der Sammlung, die Menge des verbleibenden Arbeitsspeichers und die Größen jeder Generation und verwendet dann eine Heuristik, um die Entscheidung zu treffen. Bis eine Auflistung auftritt, ist die Geschwindigkeit der Objektzuweisung in der Regel so schnell (oder schneller) als C oder C++.

Was geschieht, wenn eine Auflistung auftritt?

Gehen wir durch die Schritte, die ein Garbage Collector während einer Sammlung ausführt. Die GC unterhält eine Liste der Wurzeln, die auf den GC-Heap verweisen. Wenn ein Objekt live ist, gibt es einen Stamm an seiner Position im Heap. Objekte im Heap können auch aufeinander zeigen. Dieses Diagramm von Zeigern ist das, was der GC durchsuchen muss, um Platz freizugeben. Die Reihenfolge der Ereignisse lautet wie folgt:

  1. Der verwaltete Heap behält den gesamten Zuordnungsraum in einem zusammenhängenden Block bei, und wenn dieser Block kleiner als der angeforderte Betrag ist, wird der GC aufgerufen.

  2. Der GC folgt jedem Stamm und allen folgenden Zeigern, wobei eine Liste der Objekte beibehalten wird, die nicht erreichbar sind.

  3. Jedes Objekt, das nicht von einem Stamm aus erreichbar ist, gilt als sammelbar und wird für die Auflistung markiert.

    Abbildung 1. Vor der Sammlung: Beachten Sie, dass nicht alle Blöcke aus Wurzeln erreichbar sind!

  4. Durch das Entfernen von Objekten aus dem Reichweitendiagramm können die meisten Objekte gesammelt werden. Einige Ressourcen müssen jedoch speziell behandelt werden. Wenn Sie ein Objekt definieren, haben Sie die Möglichkeit, eine Dispose() -Methode oder eine Finalize() -Methode (oder beides) zu schreiben. Ich werde über die Unterschiede zwischen den beiden sprechen und wann sie später verwendet werden sollen.

  5. Der letzte Schritt in einer Sammlung ist die Komprimierungsphase. Alle verwendeten Objekte werden in einen zusammenhängenden Block verschoben, und alle Zeiger und Wurzeln werden aktualisiert.

  6. Durch die Komprimierung der Liveobjekte und die Aktualisierung der Startadresse des freien Raums behält der GC bei, dass alle freien Räume zusammenhängend sind. Wenn genügend Platz zum Zuweisen des Objekts vorhanden ist, gibt der GC die Steuerung an das Programm zurück. Wenn nicht, löst es eine OutOfMemoryException.

    Abbildung 2. Nach der Auflistung: Die erreichbaren Blöcke wurden komprimiert. Mehr freien Platz!

Weitere technische Informationen zur Speicherverwaltung finden Sie in Kapitel 3 der Programmieranwendungen für Microsoft Windows von Jeffrey Richter (Microsoft Press, 1999).

Objektbereinigung

Einige Objekte erfordern eine spezielle Behandlung, bevor ihre Ressourcen zurückgegeben werden können. Einige Beispiele für solche Ressourcen sind Dateien, Netzwerk sockets oder Datenbankverbindungen. Das Freigeben des Speichers auf dem Heap ist nicht ausreichend, da diese Ressourcen ordnungsgemäß geschlossen werden sollen. Zum Durchführen der Objektbereinigung können Sie eine Dispose()- Methode, eine Finalize() -Methode oder beides schreiben.

Eine Finalize() -Methode:

  • Wird vom GC aufgerufen
  • Ist nicht garantiert, dass sie in einer beliebigen Reihenfolge oder zu einer vorhersehbaren Zeit aufgerufen werden
  • Nach dem Aufrufen wird Speicher nach dem nächsten GC frei
  • Alle untergeordneten Objekte werden bis zum nächsten GC live

Eine Dispose() -Methode:

  • Wird vom Programmierer aufgerufen
  • Wird vom Programmierer bestellt und geplant
  • Gibt Ressourcen nach Abschluss der Methode zurück.

Verwaltete Objekte, die nur verwaltete Ressourcen enthalten, erfordern diese Methoden nicht. Ihr Programm verwendet wahrscheinlich nur wenige komplexe Ressourcen, und Die Chancen sind, dass Sie wissen, was sie sind und wann Sie sie benötigen. Wenn Sie beide Dinge kennen, gibt es keinen Grund, sich auf Finalizer zu verlassen, da Sie die Bereinigung manuell ausführen können. Es gibt mehrere Gründe, die Sie tun möchten, und sie müssen alle mit der Finalizer-Warteschlange tun.

Wenn ein Objekt, das über einen Finalizer verfügt, in der GC erfasst wird, wird es und alle Objekte, auf die er verweist, in einer speziellen Warteschlange platziert. Ein separater Thread führt diese Warteschlange durch, indem die Finalize() -Methode jedes Elements in der Warteschlange aufgerufen wird. Der Programmierer hat keine Kontrolle über diesen Thread oder die Reihenfolge der Elemente, die in der Warteschlange platziert wurden. Die GC kann die Steuerung an das Programm zurückgeben, ohne dass objekte in der Warteschlange abgeschlossen wurden. Diese Objekte bleiben möglicherweise im Arbeitsspeicher, die lange in der Warteschlange ausgeblendet sind. Aufrufe zur Fertigstellung werden automatisch ausgeführt, und es gibt keine direkten Leistungsbeeinträchtigungen von aufrufen selbst. Allerdings kann das nicht deterministische Modell für die Finalisierung definitiv andere indirekte Folgen haben:

  • In einem Szenario, in dem Ressourcen vorhanden sind, die zu einem bestimmten Zeitpunkt freigegeben werden müssen, verlieren Sie die Kontrolle mit Finalizern. Angenommen, Sie haben eine Datei geöffnet, und sie muss aus Sicherheitsgründen geschlossen werden. Auch wenn Sie das Objekt auf Null festlegen und sofort eine GC erzwingen, bleibt die Datei geöffnet, bis die Finalize() -Methode aufgerufen wird, und Sie haben keine Vorstellung davon, wann dies geschehen könnte.
  • N-Objekte, die eine Entsorgung in einer bestimmten Reihenfolge erfordern, werden möglicherweise nicht ordnungsgemäß behandelt.
  • Ein riesiges Objekt und seine Kinder können viel zu viel Arbeitsspeicher aufnehmen, zusätzliche Sammlungen erfordern und die Leistung beeinträchtigen. Diese Objekte werden möglicherweise nicht lange gesammelt.
  • Ein kleines Objekt, das abgeschlossen werden soll, kann Zeiger auf große Ressourcen haben, die jederzeit freizugeben sind. Diese Objekte werden erst freigegeben, wenn das zu fertig stellende Objekt erledigt wird, wodurch unnötiger Speicherdruck entsteht und häufige Auflistungen erzwungen werden.

Das Zustandsdiagramm in Abbildung 3 veranschaulicht die verschiedenen Pfade, die Ihr Objekt in Bezug auf die Fertigstellung oder Entsorgung übernehmen kann.

Abbildung 3. Entsorgungs- und Endisierungspfade, die ein Objekt übernehmen kann

Wie Sie sehen können, fügt die Fertigstellung mehrere Schritte zur Lebensdauer des Objekts hinzu. Wenn Sie ein Objekt selbst entsorgen, kann das Objekt gesammelt werden und der Speicher, der an Sie in der nächsten GC zurückgegeben wird. Wenn die Fertigstellung erfolgen muss, müssen Sie warten, bis die tatsächliche Methode aufgerufen wird. Da Sie keine Garantien dafür erhalten, wann dies der Fall ist, können Sie viel Arbeitsspeicher gebunden haben und der Endisierungswarteschlange zustehen. Dies kann äußerst problematisch sein, wenn Ihr Objekt mit einer ganzen Struktur von Objekten verbunden ist, und sie sitzen alle im Arbeitsspeicher, bis die Fertigstellung erfolgt.

Auswählen der zu verwendenden Garbage Collector

Der CLR verfügt über zwei verschiedene GCs: Workstation (mscorwks.dll) und Server (mscorsvr.dll). Wenn sie im Arbeitsstationsmodus ausgeführt werden, ist die Latenz wichtiger als Platz oder Effizienz. Ein Server mit mehreren Prozessoren und Clients, die über ein Netzwerk verbunden sind, kann sich eine Latenz leisten, aber der Durchsatz ist jetzt eine oberste Priorität. Anstatt beide Szenarien in einem einzigen GC-Schema zu verhornen, enthält Microsoft zwei Garbage Collectors, die auf jede Situation zugeschnitten sind.

Server GC:

  • Multiprocessor (MP) Skalierbarer, paralleler Prozess
  • Ein GC-Thread pro CPU
  • Programm während der Markierung angehalten

Workstation GC:

  • Minimiert Pausen, indem sie gleichzeitig während vollständiger Auflistungen ausgeführt werden

Der Server GC ist für den maximalen Durchsatz ausgelegt und skaliert mit sehr hoher Leistung. Die Speicherfragmentierung auf Servern ist ein viel schwereres Problem als auf Arbeitsstationen, wodurch die Garbage Collection zu einem attraktiven Angebot wird. In einem Uniprocessor-Szenario funktionieren beide Sammler genauso: Arbeitsstationsmodus ohne gleichzeitige Sammlung. Auf einem MP-Computer verwendet die Workstation GC den zweiten Prozessor, um die Sammlung gleichzeitig auszuführen, wodurch Verzögerungen minimiert werden, während der Durchsatz verringert wird. Der Server GC verwendet mehrere Heaps und Sammlungsthreads, um den Durchsatz zu maximieren und besser zu skalieren.

Sie können auswählen, welche GC beim Hosten der Laufzeit verwendet werden soll. Wenn Sie die Laufzeit in einen Prozess laden, geben Sie an, welche Sammler verwendet werden sollen. Das Laden der API wird im .NET Framework Entwicklerhandbuch erläutert. Ein Beispiel für ein einfaches Programm, das die Laufzeit hostet und den Server GC auswählt, sehen Sie sich den Anhang an.

Mythen: Garbage Collection ist immer langsamer als hand

Eigentlich, bis eine Sammlung aufgerufen wird, ist die GC viel schneller als die Hand in C. Dies überrascht viele Leute, daher lohnt es sich etwas zu erklären. Beachten Sie zunächst, dass das Auffinden von Freien Speicherplatz in konstanter Zeit auftritt. Da der gesamte freie Platz zusammenhängend ist, folgt der GC einfach dem Zeiger und überprüft, ob genügend Platz vorhanden ist. In C führt ein Aufruf von Malloc() in der Regel zu einer Suche nach einer verknüpften Liste mit kostenlosen Blöcken. Dies kann zeitaufwendig sein, insbesondere, wenn Ihr Heap schlecht fragmentiert ist. Um dies noch schlimmer zu machen, sperren mehrere Implementierungen des C-Laufzeit-Heaps während dieses Verfahrens. Sobald der Speicher zugewiesen oder verwendet wird, muss die Liste aktualisiert werden. In einer gesammelten Garbage-Collection-Umgebung ist die Zuordnung frei, und der Speicher wird während der Sammlung freigegeben. Erweiterte Programmierer reservieren große Speicherblöcke und behandeln die Zuordnung innerhalb dieses Blocks selbst. Das Problem mit diesem Ansatz besteht darin, dass die Speicherfragmentierung für Programmierer zu einem riesigen Problem wird, und sie erzwingen, eine Menge Speicherbehandlungslogik zu ihren Anwendungen hinzuzufügen. Am Ende fügt ein Garbage Collector keinen großen Aufwand hinzu. Die Zuordnung ist so schnell oder schneller, und die Komprimierung wird automatisch verarbeitet – die Programmierer können sich auf ihre Anwendungen konzentrieren.

In Zukunft könnten Müllsammler andere Optimierungen durchführen, die es noch schneller machen. Hot Spot-Identifizierung und bessere Cachenutzung sind möglich und können enorme Geschwindigkeitsunterschiede erzielen. Eine intelligentere GC könnte Seiten effizienter packen, wodurch die Anzahl der Seitenabrufe minimiert wird, die während der Ausführung auftreten. All dies könnte eine garbage-gesammelte Umgebung schneller machen als die Hand.

Einige Personen fragen sich möglicherweise, warum GC in anderen Umgebungen wie C oder C++ nicht verfügbar ist. Die Antwort ist Typen. Diese Sprachen ermöglichen das Umwandeln von Zeigern auf jeden Typ, wodurch es äußerst schwierig ist, zu wissen, auf was ein Zeiger verweist. In einer verwalteten Umgebung wie der CLR können wir ausreichend über die Zeiger garantieren, um GC möglich zu machen. Die verwaltete Welt ist auch der einzige Ort, an dem wir die Threadausführung sicher beenden können, um eine GC auszuführen: In C++ sind diese Vorgänge entweder unsicher oder sehr begrenzt.

Optimierung für Geschwindigkeit

Die größte Sorge für ein Programm in der verwalteten Welt ist die Speicheraufbewahrung. Einige der Probleme, die Sie in nicht verwalteten Umgebungen finden, sind kein Problem in der verwalteten Welt: Speicherlecks und Danglingze sind hier nicht viel von einem Problem. Stattdessen müssen Programmierer vorsichtig sein, um Ressourcen zu verlassen, die verbunden sind, wenn sie sie nicht mehr benötigen.

Die wichtigste Heuristik für die Leistung ist auch der einfachste, um Programmierer zu lernen, die zum Schreiben nativen Codes verwendet werden: Verfolgen Sie die zu tätigenden Zuordnungen, und geben Sie sie frei, wenn Sie fertig sind. Die GC hat keine Möglichkeit zu wissen, dass Sie keine 20 KB-Zeichenfolge verwenden werden, die Sie erstellt haben, wenn es Teil eines Objekts ist, das aufbewahrt wird. Angenommen, Sie haben dieses Objekt in einem Vektor irgendwo ausgeblendet, und Sie möchten diese Zeichenfolge niemals erneut verwenden. Wenn Sie das Feld auf NULL festlegen, kann das GC diese 20 KB später sammeln, auch wenn Sie das Objekt für andere Zwecke noch benötigen. Wenn Sie das Objekt nicht mehr benötigen, stellen Sie sicher, dass Sie keine Verweise darauf behalten. (Genau wie im systemeigenen Code.) Für kleinere Objekte ist dies weniger ein Problem. Alle Programmierer, die mit der Speicherverwaltung im systemeigenen Code vertraut sind, haben hier kein Problem: Alle gleichen Common Sense-Regeln gelten. Sie müssen einfach nicht so paranoid sein.

Die zweite wichtige Leistungsbereinigung befasst sich mit der Objektbereinigung. Wie ich bereits erwähnt habe, hat die Finalisierung tiefgreifende Auswirkungen auf die Leistung. Das häufigste Beispiel ist das eines verwalteten Handlers für eine nicht verwaltete Ressource: Sie müssen eine Art von Bereinigungsmethode implementieren, und dies ist der Ort, an dem die Leistung zu einem Problem wird. Wenn Sie von der Fertigstellung abhängen, öffnen Sie sich selbst für die zuvor aufgeführten Leistungsprobleme. Etwas anderes zu beachten ist, dass der GC in der nativen Welt weitgehend nicht über den Arbeitsspeicherdruck informiert ist, sodass Sie möglicherweise eine Menge nicht verwalteter Ressourcen verwenden, indem Sie nur einen Zeiger in der verwalteten Heap behalten. Ein einzelner Zeiger nimmt keine Menge Arbeitsspeicher auf, sodass es eine Weile dauern könnte, bevor eine Sammlung benötigt wird. Um diese Leistungsprobleme zu umgehen, während sie bei der Speicheraufbewahrung weiterhin sicher wiedergegeben werden, sollten Sie ein Entwurfsmuster auswählen, mit dem alle Objekte verwendet werden können, die eine spezielle Bereinigung erfordern.

Der Programmierer verfügt über vier Optionen beim Umgang mit der Objektbereinigung:

  1. Implementieren von beiden

    Dies ist das empfohlene Design für die Objektbereinigung. Dies ist ein Objekt mit einer Mischung aus nicht verwalteten und verwalteten Ressourcen. Ein Beispiel wäre "System.Windows". Forms.Control. Dies verfügt über eine nicht verwaltete Ressource (HWND) und potenziell verwaltete Ressourcen (DataConnection usw.). Wenn Sie sich nicht sicher sind, wenn Sie nicht verwaltete Ressourcen verwenden, können Sie das Manifest für Ihr Programm öffnen ILDASM`` und auf Verweise auf native Bibliotheken überprüfen. Eine weitere Alternative besteht darin, vadump.exe zu sehen, welche Ressourcen zusammen mit Ihrem Programm geladen werden. Beide können Ihnen Einblicke geben, welche Art von nativen Ressourcen Sie verwenden.

    Das folgende Muster bietet Benutzern eine einzige empfohlene Möglichkeit, anstatt bereinigungslogik außer Kraft zu setzen ( Überschreiben von Dispose(bool)). Dies bietet maximale Flexibilität sowie catch-all just in case Dispose() wird nie aufgerufen. Die Kombination aus maximaler Geschwindigkeit und Flexibilität sowie dem Sicherheitsnetzansatz machen dies zum besten Design.

    Beispiel:

    public class MyClass : IDisposable {
      public void Dispose() {
        Dispose(true);
        GC.SuppressFinalizer(this);
      }
      protected virtual void Dispose(bool disposing) {
        if (disposing) {
          ...
        }
          ...
      }
      ~MyClass() {
        Dispose(false);
      }
    }
    
  2. Nur entsorgen() implementieren

    Dies ist der Fall, wenn ein Objekt nur verwaltete Ressourcen hat, und Sie sicherstellen möchten, dass seine Bereinigung deterministisch ist. Ein Beispiel für ein solches Objekt ist System.Web.UI.Control.

    Beispiel:

    public class MyClass : IDisposable {
      public virtual void Dispose() {
        ...
      }
    
  3. Nur Finalize() implementieren

    Dies ist in extrem seltenen Situationen erforderlich, und ich empfehlen es dringend. Die Implication eines Finalize() -Objekts besteht darin, dass der Programmierer keine Idee hat, wenn das Objekt gesammelt werden soll, aber eine Ressource verwendet, die komplex genug ist, um eine spezielle Bereinigung zu erfordern. Diese Situation sollte niemals in einem gut gestalteten Projekt auftreten, und wenn Sie sich selbst darin finden, sollten Sie zurückkehren und herausfinden, was falsch war.

    Beispiel:

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. Implementieren Sie keines

    Dies ist für ein verwaltetes Objekt, das nur auf andere verwaltete Objekte verweist, die nicht verfügbar sind oder abgeschlossen werden sollen.

Empfehlung

Die Empfehlungen für den Umgang mit der Speicherverwaltung sollten vertraut sein: Freigeben von Objekten, wenn Sie mit ihnen fertig sind, und achten Sie auf das Verlassen von Zeigern auf Objekte. Bei der Objektbereinigung implementieren Sie sowohl eine Finalize() als auch eine Dispose() -Methode für Objekte mit nicht verwalteten Ressourcen. Dies verhindert später unerwartetes Verhalten und erzwingt gute Programmiermethoden

Die unten aufgeführte Seite besteht darin, dass Sie Personen erzwingen müssen, "Dispose()" aufzurufen. Hier gibt es keinen Leistungsverlust, aber einige Menschen finden es frustrierend, um sich über das Entsorgen ihrer Objekte gedanken zu müssen. Ich denke jedoch, dass es die Verschärfung wert ist, ein Modell zu verwenden, das sinnvoll ist. Darüber hinaus erzwingt dies, dass die Personen aufmerksamer an die Objekte sind, die sie zuweisen, da sie nicht blind vertrauen können, dass der GC immer darauf achten kann. Für Programmierer, die aus einem C- oder C++-Hintergrund stammen, wird es wahrscheinlich hilfreich sein, einen Aufruf zu Dispose() zu erzwingen, da es sich um die Art von Dingen handelt, mit denen sie vertraut sind.

Dispose() sollte für Objekte unterstützt werden, die an nicht verwalteten Ressourcen an einer beliebigen Stelle in der Struktur von Objekten darunter halten; Finalize() muss jedoch nur auf diesen Objekten platziert werden, die speziell an diese Ressourcen halten, z. B. ein Os Handle oder eine nicht verwaltete Speicherzuweisung. Ich schlägt vor, kleine verwaltete Objekte als "Wrapper" zu erstellen, um Finalize() zusätzlich zur Unterstützung von Dispose() zu implementieren, die vom Übergeordneten Objekt "Dispose()," aufgerufen wird. Da die übergeordneten Objekte keinen Finalizer haben, überleben die gesamte Struktur von Objekten keine Auflistung, unabhängig davon, ob "Dispose() aufgerufen wurde".

Eine gute Daumenregel für Finalizer besteht darin, sie nur auf dem grundtypensten Objekt zu verwenden, das die Endisierung erfordert . Angenommen, ich habe eine große verwaltete Ressource, die eine Datenbankverbindung enthält: Ich würde es ermöglichen, dass die Verbindung selbst abgeschlossen wird, aber den Rest des Objekts einfügbar macht. Auf diese Weise kann ich Dispose () aufrufen und die verwalteten Teile des Objekts sofort freigeben, ohne auf die Abgeschlossenkeit der Verbindung zu warten. Denken Sie daran: Verwenden Sie Finalize() nur , wo Sie müssen, wenn Sie müssen.

Hinweis C- und C++-Programmer: Die Semantik destructor in C# erstellt einen Finalizer, keine Entsorgungsmethode!

Threadpool

Die Grundlagen

Der Threadpool des CLR ähnelt dem NT-Threadpool in vielen Arten und erfordert fast kein neues Verständnis für den Teil des Programmierers. Es verfügt über einen Wartenthread, der die Blöcke für andere Threads verarbeitet und benachrichtigt, wenn sie zurückgegeben werden müssen, indem sie sie freizugeben, um andere Arbeiten zu erledigen. Es kann neue Threads abrufen und andere blockieren, um die CPU-Auslastung zur Laufzeit zu optimieren, und garantiert, dass die größte Menge nützlicher Arbeit durchgeführt wird. Es wird auch Threads wiederverwendet, wenn sie fertig sind, sie wieder starten, ohne dass der Aufwand für das Töten und Das Spawnen neuer. Dies ist eine erhebliche Leistungssteigerung beim manuellen Behandeln von Threads, aber es ist kein Fangen-All. Das Wissen, wann der Threadpool verwendet werden soll, ist beim Optimieren einer Threadanwendung unerlässlich.

Was Sie aus dem NT-Threadpool wissen:

  • Der Threadpool behandelt die Threaderstellung und -bereinigung.
  • Es bietet einen Abschlussport für I/O-Threads (nur NT-Plattformen).
  • Rückruf kann an Dateien oder andere Systemressourcen gebunden werden.
  • Timer- und Wait-APIs sind verfügbar.
  • Der Threadpool bestimmt, wie viele Threads mithilfe von Heuristiken wie Verzögerung seit der letzten Injektion, Anzahl der aktuellen Threads und Größe der Warteschlange aktiv werden sollen.
  • Threadsfeed aus einer freigegebenen Warteschlange.

Was unterscheidet sich in .NET:

  • Es ist bekannt, dass Threads, die im verwalteten Code blockiert werden (z. B. aufgrund von Garbage Collection, verwalteter Wartezeit), und können die Thread-Injektionslogik entsprechend anpassen.
  • Für einzelne Threads gibt es keine Garantie für den Dienst.

Wenn Sie Threads selbst behandeln möchten

Die Effektive Verwendung des Threadpools ist eng mit dem Wissen verbunden, was Sie von Ihren Threads benötigen. Wenn Sie eine Garantie für den Dienst benötigen, müssen Sie sie selbst verwalten. Für die meisten Fälle bietet die Verwendung des Pools Ihnen die optimale Leistung. Wenn Sie harte Einschränkungen haben und eine enge Kontrolle über Ihre Threads benötigen, ist es wahrscheinlich sinnvoller, native Threads trotzdem zu verwenden, daher sollten Sie darauf achten, verwaltete Threads selbst zu behandeln. Wenn Sie sich entscheiden, verwalteten Code zu schreiben und die Threaderstellung selbst zu behandeln, stellen Sie sicher, dass Sie keine Threads pro Verbindung spawnen: Dies beeinträchtigt nur die Leistung. Als Faustregel sollten Sie sich nur entscheiden, Threads selbst in sehr bestimmten Szenarien zu behandeln, in denen es eine große, zeitintensive Aufgabe gibt, die selten ausgeführt wird. Ein Beispiel kann das Ausfüllen eines großen Caches im Hintergrund sein oder eine große Datei auf dem Datenträger schreiben.

Optimieren für Geschwindigkeit

Der Threadpool legt einen Grenzwert fest, wie viele Threads aktiv sein sollen, und wenn viele davon blockieren, wird der Pool gehungert. Idealerweise sollten Sie den Threadpool für kurzlebige, nicht blockierende Threads verwenden. In Serveranwendungen möchten Sie jede Anforderung schnell und effizient beantworten. Wenn Sie einen neuen Thread für jede Anforderung einrichten, arbeiten Sie mit viel Aufwand zusammen. Die Lösung besteht darin, Ihre Threads wiederzuverwenden, um den Zustand jedes Threads nach Abschluss zu bereinigen und zurückzugeben. Dies sind die Szenarien, in denen der Threadpool ein wichtiger Leistungs- und Entwurfsgewinn ist und wo Sie die Technologie gut nutzen sollten. Der Threadpool behandelt die Zustandsbereinigung für Sie und stellt sicher, dass die optimale Anzahl von Threads zu einem bestimmten Zeitpunkt verwendet wird. In anderen Situationen kann es sinnvoller sein, threading auf eigene Weise zu behandeln.

Während die CLR die Typsicherheit verwenden kann, um Garantien über Prozesse zu machen, um sicherzustellen, dass AppDomains denselben Prozess freigeben können, ist keine solche Garantie mit Threads vorhanden. Der Programmierer ist verantwortlich für das Schreiben von gut verhaltenen Threads, und alle Ihre Kenntnisse aus nativem Code gelten weiterhin.

Unten haben wir ein Beispiel für eine einfache Anwendung, die den Threadpool nutzt. Es erstellt eine Reihe von Arbeitsthreads, und hat sie dann eine einfache Aufgabe, bevor sie geschlossen werden. Ich habe einige Fehlerüberprüfungen durchgeführt, aber dies ist der gleiche Code, der im Framework SDK-Ordner unter "Samples\Threading\Threadpool" gefunden werden kann. In diesem Beispiel haben wir einen Code, der ein einfaches Arbeitselement erstellt, und verwendet den Threadpool, um mehrere Threads behandeln zu können, ohne dass der Programmierer sie verwalten muss. Weitere Informationen finden Sie in der ReadMe.html Datei.

using System;
using System.Threading;

public class SomeState{
  public int Cookie;
  public SomeState(int iCookie){
    Cookie = iCookie;
  }
};


public class Alpha{
  public int [] HashCount;
  public ManualResetEvent eventX;
  public static int iCount = 0;
  public static int iMaxCount = 0;
  public Alpha(int MaxCount) {
    HashCount = new int[30];
    iMaxCount = MaxCount;
  }


   //   The method that will be called when the Work Item is serviced
   //   on the Thread Pool
   public void Beta(Object state){
     Console.WriteLine(" {0} {1} :", 
               Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
     Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);

     //   Do some busy work
     int iX = 10000;
     while (iX > 0){ iX--;}
     if (Interlocked.Increment(ref iCount) == iMaxCount) {
       Console.WriteLine("Setting EventX ");
       eventX.Set();
     }
  }
};

public class SimplePool{
  public static int Main(String[] args)   {
    Console.WriteLine("Thread Simple Thread Pool Sample");
    int MaxCount = 1000;
    ManualResetEvent eventX = new ManualResetEvent(false);
    Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
    Alpha oAlpha = new Alpha(MaxCount);
    oAlpha.eventX = eventX;
    Console.WriteLine("Queue to Thread Pool 0");
    ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
       for (int iItem=1;iItem < MaxCount;iItem++){
         Console.WriteLine("Queue to Thread Pool {0}", iItem);
         ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
                                   new SomeState(iItem));
       }
    Console.WriteLine("Waiting for Thread Pool to drain");
    eventX.WaitOne(Timeout.Infinite,true);
    Console.WriteLine("Thread Pool has been drained (Event fired)");
    Console.WriteLine("Load across threads");
    for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
      Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
    }
    return 0;
  }
}

Das JIT

Die Grundlagen

Wie bei jedem virtuellen Computer benötigt der CLR eine Möglichkeit, die Zwischensprache nach unten auf nativen Code zu kompilieren. Wenn Sie ein Programm kompilieren, das in der CLR ausgeführt werden soll, nimmt Ihr Compiler Ihre Quelle von einer hohen Sprache auf eine Kombination aus MSIL (Microsoft Intermediate Language) und Metadaten ab. Diese werden in eine PE-Datei zusammengeführt, die dann auf jedem CLR-fähigen Computer ausgeführt werden kann. Wenn Sie diese ausführbare Datei ausführen, beginnt das JIT mit der Kompilierung des IL-Codes mit systemeigenem Code und dem Ausführen dieses Codes auf dem echten Computer. Dies erfolgt auf einer Basis pro Methode, sodass die Verzögerung für JITing nur so lange dauert, wie für den Code, den Sie ausführen möchten.

Das JIT ist ziemlich schnell und generiert sehr guten Code. Einige der Optimierungen, die sie ausführt (und einige Erläuterungen jeder) werden unten erläutert. Beachten Sie, dass die meisten dieser Optimierungen Beschränkungen haben, um sicherzustellen, dass das JIT nicht zu viel Zeit verbringt.

  • Konstantenfaltigkeit – Berechnen von Konstantenwerten zur Kompilierungszeit.

    Vor Danach
    x = 5 + 7 x = 12
  • Konstanten- und Kopieren-Verteilung – Ersetzen Sie rückwärts zu freien Variablen zuvor.

    Vor Danach
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • Methode Inlining – Ersetzen Sie Args durch Werte, die zur Aufrufzeit übergeben werden, und entfernen Sie den Aufruf. Viele andere Optimierungen können dann ausgeführt werden, um toten Code auszuschneiden. Aus Geschwindigkeitsgründen hat das aktuelle JIT mehrere Grenzen für das, was er inline kann. Beispielsweise sind nur kleine Methoden inlined (IL-Größe kleiner als 32), und die Flusssteuerungsanalyse ist ziemlich grundtypisch.

    Vor Danach
    ...

    x=foo(4, true);

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

    ...

    x = 9

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

  • Codeaufzug und Dominatoren – Entfernen Sie Code aus Innenschleifen, wenn sie außerhalb dupliziert wird. Das nachstehende Beispiel "before" ist tatsächlich das, was auf IL-Ebene generiert wird, da alle Arrayindizes überprüft werden müssen.

    Vor Danach
    for(i=0; i< a.length;i++){

    if(i < a.length()){

    a[i] = null

    } else {

    raise IndexOutOfBounds;

    }

    }

    for(int i=0; i<a.length; i++){

    a[i] = null;

    }

  • Loop Aufheben der Registrierung – Der Aufwand der Inkrementierungsindikatoren und die Ausführung des Tests können entfernt werden, und der Code der Schleife kann wiederholt werden. Für extrem enge Schleifen führt dies zu einem Leistungsgewinn.

    Vor Danach
    for(i=0; i< 3; i++){

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • Allgemeine SubExpression-Eliminierung – Wenn eine Livevariable weiterhin die neu berechneten Informationen enthält, verwenden Sie sie stattdessen.

    Vor Danach
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • Registrierung – Es ist nicht nützlich, hier ein Codebeispiel anzugeben, daher muss eine Erklärung ausreichen. Diese Optimierung kann Zeit verbringen, um zu sehen, wie Lokale und Temps in einer Funktion verwendet werden, und versuchen, die Registrierung der Zuordnung so effizient wie möglich zu behandeln. Dies kann eine extrem teure Optimierung sein, und der aktuelle CLR JIT betrachtet nur maximal 64 lokale Variablen für die Registrierung. Variablen, die nicht berücksichtigt werden, werden im Stapelrahmen platziert. Dies ist ein klassisches Beispiel für die Einschränkungen von JITing: Während dies gut 99 % der Zeit ist, werden sehr ungewöhnliche Funktionen, die über 100+ Lokale verfügen, besser mithilfe herkömmlicher, zeitaufwändiger Vorkompilierung optimiert.

  • Misc – Andere einfache Optimierungen werden ausgeführt, aber die obige Liste ist ein gutes Beispiel. Der JIT wird auch für Totcode- und andere Peephole-Optimierungen übergeben.

Wann erhält Code JITed?

Hier ist der Pfad, den Ihr Code durchläuft, wenn er ausgeführt wird:

  1. Ihr Programm wird geladen, und eine Funktionstabelle wird mit Zeigern initialisiert, die auf die IL verweisen.
  2. Die Main-Methode wird in systemeigenem Code ausgeführt, der dann ausgeführt wird. Aufrufe von Funktionen werden in indirekten Funktionsaufrufen über die Tabelle kompiliert.
  3. Wenn eine andere Methode aufgerufen wird, wird die Laufzeit in der Tabelle angezeigt, um festzustellen, ob sie in JITed-Code verweist.
    1. Wenn er (vielleicht von einer anderen Anrufwebsite aufgerufen wurde oder vorkompiliert wurde), wird der Kontrollfluss fortgesetzt.
    2. Wenn nicht, ist die Methode JITed, und die Tabelle wird aktualisiert.
  4. Wie sie aufgerufen werden, werden immer mehr Methoden in systemeigenem Code kompiliert, und mehr Einträge im Tabellenpunkt in den wachsenden Pool von x86-Anweisungen.
  5. Während das Programm ausgeführt wird, wird der JIT immer weniger aufgerufen, bis alles kompiliert wird.
  6. Eine Methode ist erst dann JITed, wenn sie aufgerufen wird, und dann wird sie während der Ausführung des Programms nie wieder JITed. Sie zahlen nur für das, was Sie verwenden.

Mythen: JITed-Programme führen langsamer als vorkompilierte Programme aus

Dies ist selten der Fall. Der aufwand, der mit JITing verknüpft ist, ist geringfügig im Vergleich zu der Zeit, die das Lesen in einigen Seiten von Datenträgern verbracht hat, und Methoden sind nur bei Bedarf JITed. Die im JIT verbrachte Zeit ist so gering, dass es fast nie erkennbar ist, und sobald eine Methode JITed wurde, führen Sie nie die Kosten für diese Methode erneut. Ich werde mehr über dies im Abschnitt "Vorkompilierung von Code" sprechen.

Wie oben erwähnt, führt der JIT (v1) die meisten Optimierungen aus, die ein Compiler ausführt, und wird nur schneller in der nächsten Version (vNext) angezeigt, da erweiterte Optimierungen hinzugefügt werden. Wichtiger ist, dass der JIT einige Optimierungen ausführen kann, die ein regulärer Compiler nicht kann, z. B. CPU-spezifische Optimierungen und Cacheoptimierungen.

JIT-Only Optimierungen

Da der JIT zur Laufzeit aktiviert ist, gibt es viele Informationen darüber, dass ein Compiler nicht bekannt ist. Dies ermöglicht es, mehrere Optimierungen auszuführen, die nur zur Laufzeit verfügbar sind:

  • Prozessorspezifische Optimierungen – Zur Laufzeit weiß der JIT, ob er SSE- oder 3DNow-Anweisungen verwenden kann. Ihre ausführbare Datei wird speziell für P4, Athlon oder zukünftige Prozessorfamilien kompiliert. Sie stellen einmal bereit, und derselbe Code verbessert sich zusammen mit dem JIT und dem Computer des Benutzers.
  • Optimieren von Dereferenzierungsebenen, da Funktion und Objektspeicherort zur Laufzeit verfügbar sind.
  • Der JIT kann Optimierungen über Assemblys hinweg durchführen und bietet viele vorteile, die Sie beim Kompilieren eines Programms mit statischen Bibliotheken erhalten, aber die Flexibilität und den kleinen Platzbedarf der Verwendung dynamischer Bibliotheken beibehalten.
  • Aggressive Inlinefunktionen , die häufiger aufgerufen werden, da sie den Steuerungsfluss während der Laufzeit kennen. Die Optimierungen können eine erhebliche Geschwindigkeitssteigerung bieten, und es gibt viel Platz für zusätzliche Verbesserungen in vNext.

Diese Laufzeitverbesserungen ergeben sich auf Kosten einer kleinen, einmaligen Startkosten und können mehr als die im JIT ausgegebene Zeit verrechnen.

Vorkompilierung von Code (Verwenden von ngen.exe)

Für einen Anwendungsanbieter ist die Möglichkeit, code während der Installation vorab zu kompilieren, eine attraktive Option. Microsoft stellt diese Option im Formular ngen.exebereit, wodurch Sie den normalen JIT-Compiler einmal über Ihr gesamtes Programm ausführen und das Ergebnis speichern können. Da die Laufzeitoptimierungen während der Vorkompilierung nicht ausgeführt werden können, ist der generierte Code in der Regel nicht so gut wie das, das von einem normalen JIT generiert wird. Ohne jedoch JIT-Methoden auf dem Fliegen zu müssen, ist die Startkosten viel niedriger, und einige Programme werden deutlich schneller gestartet. In Zukunft kann ngen.exe mehr als die gleiche Laufzeit JIT ausführen: aggressivere Optimierungen mit höheren Grenzen als die Laufzeit, Ladereihenfolgenoptimierung gegenüber Entwicklern (Optimieren der Art und Weise, wie Code in VM-Seiten verpackt wird) und komplexere, zeitaufwendige Optimierungen, die die Zeit während der Vorkompilierung nutzen können.

Das Kürzen der Startzeit hilft in zwei Fällen, und für alles andere konkurrieren sie nicht mit den Laufzeitoptimierungen, die normale JITing tun kann. Die erste Situation ist, wo Sie früh in Ihrem Programm eine enorme Anzahl von Methoden aufrufen. Sie müssen viele Methoden im Voraus jiT ausführen, was zu einer inakzeptablen Ladezeit führt. Dies wird für die meisten Menschen nicht der Fall sein, aber pre-JITing kann sinnvoll sein, wenn es sich auf Sie auswirkt. Die Vorkompilierung ist auch bei großen freigegebenen Bibliotheken sinnvoll, da Sie die Kosten für das Laden dieser viel häufiger bezahlen. Microsoft kompiliert die Frameworks für die CLR, da die meisten Anwendungen sie verwenden.

Es ist einfach, ngen.exe zu verwenden, um festzustellen, ob es sich bei der Vorkompilierung um die Antwort für Sie handelt, daher empfehle ich, es auszuprobieren. Die meiste Zeit ist es jedoch besser, den normalen JIT zu verwenden und die Laufzeitoptimierungen zu nutzen. Sie haben eine große Auszahlung und werden die einmaligen Startkosten in den meisten Situationen mehr als verrechnen.

Optimierung für Geschwindigkeit

Für den Programmierer gibt es wirklich nur zwei Dinge zu notieren. Erstens, dass der JIT sehr intelligent ist. Versuchen Sie nicht, den Compiler auszuprobieren. Coden Sie die Art und Weise, wie Sie normalerweise vorgehen würden. Angenommen, Sie verwenden den folgenden Code:

...

for(int i = 0; i < myArray.length; i++){

...

}

...

...

int l = myArray.length;

for(int i = 0; i < l; i++){

...

}

...

Einige Programmierer glauben, dass sie eine Geschwindigkeitssteigerung erhalten können, indem Sie die Längenberechnung herausziehen und auf eine Temp speichern, wie im Beispiel rechts.

Die Wahrheit ist, Optimierungen wie diese sind seit fast 10 Jahren nicht hilfreich: Moderne Compiler sind mehr als in der Lage, diese Optimierung für Sie auszuführen. In der Tat kann manchmal dinge wie dies tatsächlich die Leistung beeinträchtigen. Im obigen Beispiel würde ein Compiler wahrscheinlich überprüfen, ob die Länge von myArray konstant ist, und eine Konstante im Vergleich der For-Schleife einfügen. Der Code auf der rechten Seite kann jedoch den Compiler dazu verleiten, zu denken, dass dieser Wert in einem Register gespeichert werden muss, da l er in der gesamten Schleife live ist. Die folgende Zeile lautet: Schreiben Sie den Code, der am besten lesbar ist und das am besten sinnvoll ist. Es wird nicht helfen, den Compiler auszuprobieren, und manchmal kann es schaden.

Die zweite Sache, über die gesprochen werden soll, ist Tail-Calls. Derzeit bieten die Compiler C# und Microsoft® Visual Basic ® nicht die Möglichkeit, anzugeben, dass ein Tail-Aufruf verwendet werden soll. Wenn Sie dieses Feature wirklich benötigen, besteht eine Option darin, die PE-Datei in einem Disassembler zu öffnen und stattdessen die MSIL.tail-Anweisung zu verwenden. Dies ist keine elegante Lösung, aber Tail-Calls sind in C# und Visual Basic nicht so nützlich wie in Sprachen wie Schema oder ML. Personen, die Compiler für Sprachen schreiben, die wirklich die Vorteile von Tail-Calls nutzen, sollten Sie diese Anweisung verwenden. Die Realität für die meisten Menschen ist, dass selbst die manuelle Anpassung der IL zur Verwendung von Tail-Calls keinen enormen Geschwindigkeitsvorteil bietet. Manchmal ändert sich die Laufzeit tatsächlich wieder in reguläre Anrufe, aus Sicherheitsgründen! Vielleicht werden in zukünftigen Versionen mehr Anstrengungen unternommen, um Tail-Calls zu unterstützen, aber im Moment ist der Leistungsgewinn nicht ausreichend, um es zu garantieren, und sehr wenige Programmierer werden es nutzen wollen.

AppDomains

Die Grundlagen

Die Interprocesskommunikation wird immer häufiger. Aus Stabilitäts- und Sicherheitsgründen behält das Betriebssystem Anwendungen in separaten Adressräumen bei. Ein einfaches Beispiel ist die Art und Weise, in der alle 16-Bit-Anwendungen in NT ausgeführt werden: Wenn sie in einem separaten Prozess ausgeführt werden, kann eine Anwendung die Ausführung einer anderen nicht beeinträchtigen. Das Problem ist hier die Kosten des Kontextwechsels und das Öffnen einer Verbindung zwischen Prozessen. Dieser Vorgang ist sehr teuer und beeinträchtigt die Leistung sehr. In Serveranwendungen, die häufig mehrere Webanwendungen hosten, ist dies sowohl bei der Leistung als auch bei der Skalierbarkeit ein großer Abfluss.

Die CLR führt das Konzept einer AppDomain ein, das einem Prozess ähnelt, in dem es sich um einen eigenständigen Raum für eine Anwendung handelt. AppDomains sind jedoch nicht auf einen Prozess beschränkt. Es ist möglich, zwei völlig nicht verbundene AppDomains im gleichen Prozess auszuführen, dank der Typsicherheit, die von verwaltetem Code bereitgestellt wird. Die Leistungssteigerung ist hier für Situationen enorm, in denen Sie normalerweise viel Zeit für die Ausführung in Interprocess-Kommunikationsaufwand verbringen: IPC zwischen Assemblys ist fünfmal schneller als zwischen Prozessen in NT. Durch eine dramatische Reduzierung dieser Kosten erhalten Sie sowohl eine Geschwindigkeitssteigerung als auch eine neue Option während des Programmentwurfs: Es ist jetzt sinnvoll, separate Prozesse zu verwenden, wo es möglicherweise viel zu teuer war. Die Fähigkeit, mehrere Programme im gleichen Prozess mit derselben Sicherheit auszuführen wie zuvor, hat enorme Auswirkungen auf Skalierbarkeit und Sicherheit.

Die Unterstützung für AppDomains ist im Betriebssystem nicht vorhanden. AppDomains werden von einem CLR-Host behandelt, z. B. die In ASP.NET, eine ausführbare Shell oder Microsoft Internet Explorer. Sie können auch eigene Schreiben. Jeder Host gibt eine Standarddomäne an, die geladen wird, wenn die Anwendung zuerst gestartet wird und nur geschlossen wird, wenn der Prozess beendet wird. Wenn Sie andere Assemblys in den Prozess laden, können Sie angeben, dass sie in eine bestimmte AppDomain geladen werden und unterschiedliche Sicherheitsrichtlinien für jede dieser Assemblys festlegen. Dies wird in der Microsoft .NET Framework SDK-Dokumentation ausführlicher beschrieben.

Optimierung für Geschwindigkeit

Um AppDomains effektiv zu verwenden, müssen Sie überlegen, welche Art von Anwendung Sie schreiben, und welche Art von Arbeit sie tun muss. Als gute Faustregel sind AppDomains am effektivsten, wenn Ihre Anwendung einige der folgenden Merkmale erfüllt:

  • Es wird häufig eine neue Kopie von sich selbst spawns.
  • Es funktioniert mit anderen Anwendungen, um Informationen zu verarbeiten (z. B. Datenbankabfragen innerhalb eines Webservers).
  • Es verbringt viel Zeit in IPC mit Programmen, die ausschließlich mit Ihrer Anwendung arbeiten.
  • Es wird geöffnet und schließt andere Programme.

Ein Beispiel für eine Situation, in der AppDomains hilfreich sind, können in einer komplexen ASP.NET Anwendung gesehen werden. Angenommen, Sie möchten die Isolation zwischen verschiedenen vRoots erzwingen: Im nativen Raum müssen Sie jede vRoot in einen separaten Prozess setzen. Dies ist ziemlich teuer, und der Kontextwechsel zwischen ihnen ist viel Aufwand. In der verwalteten Welt kann jede vRoot eine separate AppDomain sein. Dadurch bleibt die Isolation erhalten, die erforderlich ist, während der Aufwand drastisch reduziert wird.

AppDomains sind etwas, das Sie nur verwenden sollten, wenn Ihre Anwendung komplex genug ist, um eng mit anderen Prozessen oder anderen Instanzen selbst zusammenarbeiten zu müssen. Die Iter-AppDomain-Kommunikation ist zwar weit schneller als die Kommunikation zwischen Prozessen, aber die Kosten für das Starten und Schließen einer AppDomain können tatsächlich teurer sein. AppDomains können die Leistung beeinträchtigen, wenn sie aus falschen Gründen verwendet werden. Stellen Sie daher sicher, dass Sie sie in den richtigen Situationen verwenden. Beachten Sie, dass nur verwalteter Code in eine AppDomain geladen werden kann, da nicht verwalteter Code nicht garantiert werden kann.

Assemblys, die zwischen mehreren AppDomains gemeinsam genutzt werden, müssen für jede Domäne JITed sein, um die Isolation zwischen Domänen beizubehalten. Dies führt zu einer Menge doppelter Codeerstellung und verschwendetem Arbeitsspeicher. Berücksichtigen Sie den Fall einer Anwendung, die Anforderungen mit einer Art XML-Dienst beantwortet. Wenn bestimmte Anforderungen voneinander isoliert bleiben müssen, müssen Sie sie an verschiedene AppDomains weiterleiten. Das Problem besteht darin, dass jede AppDomain jetzt dieselben XML-Bibliotheken erfordert, und die gleiche Assembly wird mehrmals geladen.

Eine Möglichkeit besteht darin, eine Assembly als Domänenneutral zu deklarieren, was bedeutet, dass keine direkten Verweise zulässig sind und die Isolation durch Indirektion erzwungen wird. Dies spart Zeit, da die Assembly nur einmal JITed ist. Es speichert auch Arbeitsspeicher, da nichts dupliziert wird. Leider gibt es einen Leistungstreffer aufgrund der erforderlichen Indirektion. Das Deklarieren einer Assembly als domänenneutral führt zu einem Leistungsgewinn, wenn der Speicher ein Problem darstellt, oder wenn zu viel Zeit JITing-Code verschwendet wird. Szenarien wie dies sind häufig bei einer großen Assembly, die von mehreren Domänen gemeinsam genutzt wird.

Sicherheit

Die Grundlagen

Codezugriffssicherheit ist ein leistungsstarkes, äußerst nützliches Feature. Es bietet Benutzern eine sichere Ausführung von halbvertrauenswürdigen Code, schützt vor bösartiger Software und verschiedenen Arten von Angriffen und ermöglicht kontrollierten, identitätsbasierten Zugriff auf Ressourcen. In systemeigenem Code ist die Sicherheit äußerst schwierig bereitzustellen, da es wenig Typsicherheit gibt und der Programmierer den Arbeitsspeicher verarbeitet. In der CLR weiß die Laufzeit genug über das Ausführen von Code, um starke Sicherheitsunterstützung hinzuzufügen, ein Feature, das für die meisten Programmierer neu ist.

Die Sicherheit wirkt sich sowohl auf die Geschwindigkeit als auch auf die Arbeitssatzgröße einer Anwendung aus. Und wie bei den meisten Bereichen der Programmierung kann der Entwickler die Sicherheit erheblich bestimmen, was sich auf die Leistung auswirkt. Das Sicherheitssystem ist im Hinblick auf die Leistung konzipiert und sollte in den meisten Fällen gut mit wenig oder keinem Gedanken des Anwendungsentwicklers ausgeführt werden. Es gibt jedoch mehrere Dinge, die Sie tun können, um jemals letzte Leistung aus dem Sicherheitssystem zu zwängen.

Optimierung für Geschwindigkeit

Die Ausführung einer Sicherheitsüberprüfung erfordert in der Regel einen Stapellauf, um sicherzustellen, dass der Code, der die aktuelle Methode aufruft, über die richtigen Berechtigungen verfügt. Die Laufzeit verfügt über mehrere Optimierungen, mit denen sie das Gehen des gesamten Stapels vermeiden können, aber es gibt mehrere Dinge, die der Programmierer unterstützen kann. Dies bringt uns zum Begriff der imperativen und deklarativen Sicherheit: Deklarative Sicherheit schmückt einen Typ oder seine Mitglieder mit verschiedenen Berechtigungen, während die imperative Sicherheit ein Sicherheitsobjekt erstellt und Vorgänge ausführt.

  • Deklarative Sicherheit ist die schnellste Möglichkeit, für Assert, Deny und PermitOnly zu gehen. Diese Vorgänge erfordern in der Regel einen Stapellauf, um den richtigen Anrufrahmen zu finden, dies kann jedoch vermieden werden, wenn Sie diese Modifizierer explizit deklarieren. Anforderungen sind schneller, wenn sie unbedingt erledigt werden.
  • Wenn Sie die Interoperabilität mit nicht verwaltetem Code ausführen, können Sie die Laufzeitsicherheitsüberprüfungen entfernen, indem Sie das Attribut "SuppressUnmanagedCodeSecurity" verwenden. Dadurch wird die Verknüpfungszeit verschoben, was viel schneller ist. Stellen Sie als Vorsicht sicher, dass der Code keine Sicherheitslöcher für andere Code verfügbar macht, was die entfernte Überprüfung in unsicheren Code ausnutzen könnte.
  • Identitätsüberprüfungen sind teurer als Codeüberprüfungen. Stattdessen können Sie LinkDemand verwenden, um diese Prüfungen zur Linkzeit auszuführen.

Es gibt zwei Möglichkeiten zur Optimierung der Sicherheit:

  • Führen Sie Prüfungen zur Linkzeit anstelle der Laufzeit aus.
  • Stellen Sie deklarative Sicherheitsüberprüfungen anstelle von Imperativen vor.

Das erste, auf das Sie sich konzentrieren sollten, bewegt sich so viele dieser Prüfungen, um die Zeit so zu verknüpfen, wie möglich. Beachten Sie, dass sich dies auf die Sicherheit Ihrer Anwendung auswirken kann. Stellen Sie daher sicher, dass Sie keine Prüfungen in den Linker verschieben, der vom Laufzeitzustand abhängt. Nachdem Sie so viel wie möglich in die Verknüpfungszeit verschoben haben, sollten Sie die Laufzeitüberprüfungen mithilfe deklarativer oder imperativer Sicherheit optimieren: Wählen Sie aus, welche für die bestimmte Art der von Ihnen verwendeten Überprüfung optimal ist.

Remoting

Die Grundlagen

Die Remoting-Technologie in .NET erweitert das Rich-Type-System und die Funktionalität des CLR über das Netzwerk. Mithilfe von XML, SOAP und HTTP können Sie Prozeduren aufrufen und Objekte remote übergeben, genauso wie sie auf demselben Computer gehostet wurden. Sie können sich dies als .NET-Version von DCOM oder CORBA vorstellen, da sie eine Übermenge ihrer Funktionalität bereitstellt.

Dies ist besonders nützlich in einer Serverumgebung, wenn Sie mehrere Server haben, die verschiedene Dienste hosten, alle miteinander sprechen, um diese Dienste nahtlos zu verknüpfen. Auch die Skalierbarkeit wird verbessert, da Prozesse physisch auf mehreren Computern unterbrochen werden können, ohne dass die Funktionalität verloren geht.

Optimierung für Geschwindigkeit

Da das Remoting häufig eine Strafe in Bezug auf die Netzwerklatenz verursacht, gelten die gleichen Regeln in der CLR, die immer haben: Versuchen Sie, die Menge des von Ihnen gesendeten Datenverkehrs zu minimieren, und vermeiden Sie, dass der Rest des Programms auf einen Remoteanruf wartet, um zurückzugeben. Hier sind einige gute Regeln zum Leben, indem Sie Remoting verwenden, um die Leistung zu maximieren:

  • Machen Sie "Chunky" anstelle von Chatty-Anrufen – Überprüfen Sie, ob Sie die Anzahl der Anrufe reduzieren können, die Sie remote tätigen müssen. Angenommen, Sie legen einige Eigenschaften für ein Remoteobjekt mithilfe von get() und set() -Methoden fest. Sie sparen Zeit, um das Objekt einfach remote zu erstellen, wobei diese Eigenschaften beim Erstellen festgelegt sind. Da dies mit einem einzelnen Remoteanruf geschehen kann, sparen Sie Zeit, die im Netzwerkdatenverkehr verschwendet wird. Manchmal kann es sinnvoll sein, das Objekt auf den lokalen Computer zu verschieben, die Eigenschaften dort festzulegen und dann wieder zu kopieren. Je nach Bandbreite und Latenz ist manchmal eine Lösung sinnvoller als die andere.
  • Ausgleich der CPU-Auslastung mit Netzwerklast – Manchmal ist es sinnvoll, etwas über das Netzwerk zu senden, und andere Zeiten ist es besser, die Arbeit selbst auszuführen. Wenn Sie viel Zeit verlieren, um das Netzwerk zu durchlaufen, leiden Ihre Leistung. Wenn Sie zu viel CPU verwenden, können Sie keine anderen Anforderungen beantworten. Die Suche nach einem guten Gleichgewicht zwischen diesen beiden ist wichtig, damit Ihre Anwendung skaliert werden kann.
  • Verwenden Sie asynchrone Anrufe– Wenn Sie einen Anruf über das Netzwerk tätigen, stellen Sie sicher, dass es asynchron ist, es sei denn, Sie benötigen sonst noch keine anderen Schritte. Andernfalls wird Ihre Anwendung bis zur Antwort angehalten, und dies kann in einer Benutzeroberfläche oder einem Server mit hohem Volumen nicht akzeptabel sein. Ein gutes Beispiel für die Betrachtung ist im Framework SDK verfügbar, das mit .NET ausgeliefert wird, unter "Samples\technologies\remoting\advanced\asyncdelegate".
  • Verwenden Sie Objekte optimal – Sie können angeben, dass für jede Anforderung (SingleCall) ein neues Objekt erstellt wird oder dasselbe Objekt für alle Anforderungen (Singleton) verwendet wird. Das Vorhandensein eines einzelnen Objekts für alle Anforderungen ist sicherlich weniger ressourcenintensiv, Sie müssen jedoch vorsichtig sein, um die Synchronisierung und Konfiguration des Objekts von der Anforderung zu anfordern.
  • Verwenden Sie Pluggable Channels und Formatters – Ein leistungsstarkes Feature von Remoting ist die Möglichkeit, jeden Kanal oder Formatierer in Ihre Anwendung einzuschließen. Es sei denn, Sie müssen eine Firewall durchlaufen, es gibt keinen Grund, den HTTP-Kanal zu verwenden. Wenn Sie einen TCP-Kanal anschließen, erhalten Sie viel bessere Leistung. Stellen Sie sicher, dass Sie den Kanal oder formatierer auswählen, der für Sie am besten geeignet ist.

ValueTypes

Die Grundlagen

Die flexibilität, die von Objekten geboten wird, kommt zu einem kleinen Leistungspreis. Heap-verwaltete Objekte dauern mehr Zeit zum Zuweisen, Zugreifen und Aktualisieren als stapelverwaltete Objekte. Aus diesem Grund ist beispielsweise eine Anweisung in C++ viel effizienter als ein Objekt. Natürlich können Objekte Dinge tun, die strukturieren können und weit vielseitiger sind.

Aber manchmal brauchen Sie nicht alle diese Flexibilität. Manchmal möchten Sie etwas so einfach wie eine Struktur, und Sie möchten die Leistungskosten nicht bezahlen. Der CLR bietet Ihnen die Möglichkeit, anzugeben, was als ValueType bezeichnet wird, und zur Kompilierungszeit wird dies genauso wie eine Struktur behandelt. ValueTypes werden vom Stapel verwaltet und bieten Ihnen alle Geschwindigkeiten einer Struktur. Wie erwartet, kommen sie auch mit der begrenzten Flexibilität von Strukturen (z. B. keine Vererbung). Aber für die Instanzen, in denen alles, was Sie benötigen, eine Struktur ist, bieten ValueTypes eine unglaubliche Geschwindigkeitssteigerung. Ausführlichere Informationen zu ValueTypes und dem restlichen CLR-Typsystem sind in der MSDN Library verfügbar.

Optimierung für Geschwindigkeit

ValueTypes sind nur in Fällen nützlich, in denen Sie sie als Struktur verwenden. Wenn Sie einen ValueType als Objekt behandeln müssen, behandelt die Laufzeit das Boxen und Aufheben des Posteingangs des Objekts für Sie. Dies ist jedoch noch teurer als die Erstellung als Objekt an erster Stelle!

Hier ist ein Beispiel für einen einfachen Test, der die Zeit vergleicht, mit der eine große Anzahl von Objekten und ValueTypes erstellt werden muss:

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
class Class1{
  static void Main(string[] args){
    Console.WriteLine("starting struct loop....");
    int t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      foo test1 = new foo(3.14);
      foo test2 = new foo(3.15);
       if (test1.y == test2.y) break; // prevent code from being 
       eliminated JIT
    }
    int t2 = Environment.TickCount;
    Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object 
       loop....");
    t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      bar test1 = new bar(3.14);
      bar test2 = new bar(3.15);
      if (test1.y == test2.y) break; // prevent code from being 
      eliminated JIT
    }
    t2 = Environment.TickCount;
    Console.WriteLine("object loop: (" + (t2-t1) + ")");
    }

Probieren Sie es selbst aus. Der Zeitabstand befindet sich in der Reihenfolge von mehreren Sekunden. Nun ändern wir das Programm so, dass die Laufzeit ein- und entboxt werden muss. Beachten Sie, dass die Geschwindigkeitsvorteile der Verwendung eines ValueType vollständig verschwunden sind! Die Moral ist, dass ValueTypes nur in extrem seltenen Situationen verwendet werden, wenn Sie sie nicht als Objekte verwenden. Es ist wichtig, nach diesen Situationen zu suchen, da der Leistungsgewinn oft extrem groß ist, wenn Sie sie richtig verwenden.

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
  class Class1{
    static void Main(string[] args){
      Hashtable boxed_table = new Hashtable(2);
      Hashtable object_table = new Hashtable(2);
      System.Console.WriteLine("starting struct loop...");
      for(int i = 0; i < 10000000; i++){
        boxed_table.Add(1, new foo(3.14)); 
        boxed_table.Add(2, new foo(3.15));
        boxed_table.Remove(1);
      }
      System.Console.WriteLine("struct loop complete. 
                                starting object loop...");
      for(int i = 0; i < 10000000; i++){
        object_table.Add(1, new bar(3.14)); 
        object_table.Add(2, new bar(3.15));
        object_table.Remove(1);
      }
      System.Console.WriteLine("All done");
    }
  }
}

Microsoft verwendet ValueTypes auf große Weise: Alle Grundtypen in den Frameworks sind ValueTypes. Meine Empfehlung ist, dass Sie ValueTypes verwenden, wenn Sie sich für eine Struktur fühlen. Solange Sie keinen Box-/Unbox-Posteingang haben, können sie eine enorme Geschwindigkeitssteigerung bieten.

Eine äußerst wichtige Anmerkung ist, dass ValueTypes keine Marshalling in Interop-Szenarien erfordert. Da marshalling einer der größten Leistungstreffer beim Interoperieren mit systemeigenem Code ist, ist die Verwendung von ValueTypes als Argumente für systemeigene Funktionen vielleicht die einzige größte Leistungsanpassung, die Sie tun können.

Weitere Ressourcen

Verwandte Themen zur Leistung in der .NET Framework umfassen:

Schauen Sie sich zukünftige Artikel an, die derzeit entwickelt werden, einschließlich einer Übersicht über Entwurfs-, Architektur- und Codierungsphilosophien, eine exemplarische Vorgehensweise der Leistungsanalysetools in der verwalteten Welt und einen Leistungsvergleich von .NET zu anderen Unternehmensanwendungen, die heute verfügbar sind.

Anhang: Hosten der Serverlaufzeit

#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")

long main(){
  long retval = 0;
  LPWSTR pszFlavor = L"svr";

  // Bind to the Run time.
  ICorRuntimeHost *pHost = NULL;
  HRESULT hr = CorBindToRuntimeEx(NULL,
               pszFlavor, 
               NULL,
               CLSID_CorRuntimeHost, 
               IID_ICorRuntimeHost, 
               (void **)&pHost);

  if (SUCCEEDED(hr)){
    printf("Got ICorRuntimeHost\n");
      
    // Start the Run time (this also creates a default AppDomain)
    hr = pHost->Start();
    if(SUCCEEDED(hr)){
      printf("Started\n");
         
      // Get the Default AppDomain created when we called Start
      IUnknown *pUnk = NULL;
      hr = pHost->GetDefaultDomain(&pUnk);

      if(SUCCEEDED(hr)){
        printf("Got IUnknown\n");
            
        // Ask for the _AppDomain Interface
        _AppDomain *pDomain = NULL;
        hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
            
        if(SUCCEEDED(hr)){
          printf("Got _AppDomain\n");
               
          // Execute Assembly's entry point on this thread
          BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
          hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
          SysFreeString(pszAssemblyName);
               
          if (SUCCEEDED(hr)){
            printf("Execution completed\n");

            //Execution completed Successfully
            pDomain->Release();
            pUnk->Release();
            pHost->Stop();
            
            return retval;
          }
        }
        pDomain->Release();
        pUnk->Release();
      }
    }
    pHost->Release();
  }
  printf("Failure, HRESULT: %x\n", hr);
   
  // If we got here, there was an error, return the HRESULT
  return hr;
}

Wenn Sie Fragen oder Kommentare zu diesem Artikel haben, wenden Sie sich an Claudio Caldato, Programmmanager für .NET Framework Leistungsprobleme.