Artikel

August 2002 | Artikel

Was ist eigentlich los mit Rotor?

(Link zum Artikel: http://www.it-republik.de/dotnet/artikel/0211)

Microsofts Shared Source-Version der .NET Laufzeitumgebung

Text: von Christian Gross
  • Teilen
  • kommentieren
  • empfehlen
  • Bookmark and Share
Hurra, .NET ist da! Aber worum geht es bei .NET eigentlich. Eine Marketing-Folie könnte sagen: Es geht um eine Sprache wie C#, die in einer gemanagten Umgebung namens .NET ausgeführt wird. Aber würde diese Folie Ihnen auch sagen, wie das eigentlich alles ausgeführt wird? Die Antwort ist ein entschiedenes Nein. Als ich diesen Artikel ursprünglich plante, wollte ich über die Sourcen und ihre Verwendung beim Schreiben optimalen Codes berichten. Als ich die Quellen aber studierte, fand ich heraus, dass das, wofür ich .NET ursprünglich hielt, nicht ganz dem entspricht, was tatsächlich abläuft. Auf den Punkt gebracht ist .NET nichts anderes als eine nette Makro-Sprache für COM.

Wie man eine Runtime erzeugt
Die zentrale Motivation hinter der Erstellung einer Laufzeitumgebung besteht darin, es einfacher zu machen, bestimmte Typen der Programmierung zu verwenden. Betrachten wir also das Problem einer hypothetischen Laufzeitumgebung für die Erstellung von Datenbankanwendungen. In dieser Laufzeitumgebung gibt es drei Operationen: Öffnen einer Verbindung, Schließen einer Verbindung und Ausführen einer Anweisung. Aus diesen drei Anweisungen werden drei op-codes (operation codes), die einen Satz implementierter Funktionalität ausführen. In dieser Laufzeitumgebung sind die op-codes also Vereinfachungen einer komplexen Operation.
Versuchen wir jetzt, diese Laufzeitumgebung um weitere Funktionalität zu erweitern. An diesem Punkt rennen Sie gegen eine Wand, da es sich um eine Umgebung handelt, in der bestimmte Operationen vereinfacht wurden. Um einen op-code zu erweitern, müssen bestimmte andere Operationen erzeugt werden. Daraus resultiert dann einen Laufzeitumgebung, die in sich selbst geschlossen ist, das heißt man kann diese Laufzeitumgebung nutzen, um reine Runtime-Applikationen zu entwickeln. Ein Teil der Kernfunktionalität wird dabei immer nativ sein.
Was ist das Ergebnis einer solchen Laufzeitumgebung? Langsame Geschwindigkeit, da eine Infrastruktur geschaffen werden muss, die das tut, was einfacher auf einer niedrigeren Ebene erledigt werden könnte. Ein konkretes Beispiel wäre das Erstellen von Klassen: In dieser Art Umgebung würden Klassen unter Verwendung bestimmter op-codes für Erstellung und Management von Klassen erstellt und verwaltet werden.
Wenden wir uns jetzt der .NET Laufzeitumgebung zu. Ich habe gesagt, dass es sich dabei um nichts anderes als eine Makro-Sprache für COM handelt. In einer Laufzeitumgebung vom .NET-Typ basiert diese auf Objekten und nicht auf Vereinfachungen spezifischer Operationen. Wenn wir das Laufzeitumgebungsbeispiel mit den Datenbank-op-codes mit einer .NET Runtime vergleichen, gäbe es dort keine op-codes für die Datenbank, sondern man hätte ein Objekt namens database, das drei verschiedene Operationen enthielte. Das ist ein dramatischer Unterschied, da es bedeutet, dass man die Laufzeitumgebung für wirklich schwierige Dinge wie z.B. Garbage Collection nutzt. Und man erzeugt keine op-codes, um Objekte zu erzeugen, die Objekte verwalten.
Der Hauptunterschied liegt in der zugrunde liegenden Sprache: Im ersten Beispiel wäre es typischerweise C, wohingegen es in .NET C++ ist. Wenn also eine Anweisung zur Erstellung einer Klasse angetroffen wird, wird direkt eine C++-Klasse erzeugt. Innerhalb des C-Ansatzes würde der op-code zur Erzeugung der Klasse eine Subroutine ausführen, die eine Struktur erzeugt, welche die Runtime als Klasse interpretiert. Die Klassenerzeugung in C++ ist sehr viel schneller und besser wartbar.
In der .NET-Laufzeitumgebung erbt ein Vererbungskontext in MSIL-Code von einem C++-Objekt. Warum also nicht gleich reines C++ verwenden und die MSIL überspringen? Der Grund ist, dass - wie sich bei COM gezeigt hat - sehr viel Beinarbeit nötig ist, um eine verwendbare C++-COM-Komponente zu erstellen. Und Sie müssen auch beachten, dass MSIL MSIL heißt und nicht Assembler. MSIL ist eine Zwischensprache oberhalb von C++. Wenn die .NET Laufzeitumgebung eine Klasse mit überladenen Klassenmembern sieht, sieht sie nicht eine spezielle Laufzeitklasse, sondern eine C++-Klasse.
Warum sollte Sie das alles interessieren? Die Antwort hat damit zu tun, wie man Anwendungen programmiert. .NET-Anwendungen sind nicht wie Java-Anwendungen, sondern eher wie Anwendungen mit dem guten alten VB - wenn auch einer optimierten und korrekt designten Version von Visual Basic. Wenn Sie sich die Rotor-Sourcen anschauen, werden Sie feststellen, dass es tatsächlich eine ganze Reihe von Lower-Level-Klassen gibt, die alles managen. Und Sie werden feststellen, dass die .NET Base-Level-Klassen eine ganze Reihe von Aufrufen an native Klassen absetzen. Als Ergebnis dieses Ansatzes ist .NET konsistent, gut engineert und trotzdem performanceorientiert. Diese Tatsache hat mich - vor allen anderen - überrascht.
Einen Array erzeugen
Um die Dynamik von C# und .NET zu zeigen, wollen wir mit etwas C#-Code herumspielen. Im Rotor-Quellcode gibt es eine Klasse Array. Das heißt, dass man, anstatt die []-Notation zu verwenden, Arrays manuell manipulieren könnte. Betrachten Sie den folgenden Quellcode:
  1. class TestClass
  2. {
  3. public void TestArray()
  4. {
  5. Array arr = new Array();
  6. }
  7. }
Wenn dieser Code kompiliert wird, wird folgende Fehlermeldung generiert:
  1. Cannot create an instance of the abstract class or interface 'System.Array'
Dieser Error zeigt an, dass Arrays nicht direkt instantiiert werden können, sonder vererbt werden. Das ist interessant, weil es bedeutet, dass wir spezielle Collections erzeugen könnten. Also schreiben wir unseren Code wie folgt um:
  1. class TestClass : Array
  2. {
  3. public void TestArray()
  4. {
  5. }
  6. }
Jetzt bekommen wir jedoch folgende Fehlermeldung:
  1. 'TestClass' cannot inherit from special class 'System.Array'
Dies heißt, dass es sich bei der Klasse System.Array um eine besondere Klasse handelt, die nur in einem bestimmten Kontext verwendet werden kann. Es zeigt sich, dass in .NET nicht alle Klassen gleich sind und dass es vom Compiler abhängt, wie jede Klasse behandelt wird. Aus diesem Grund kann etwas, das in einer Sprache funktioniert, möglicherweise in einer anderen Sprache nicht funktionieren. Ist dies ein großes Problem? Im Allgemeinen nicht, solange man mit den Common Language Specification (CLIS)-Konstrukten arbeitet. Die CLS stellt sicher, dass das, was in einer Sprache funktioniert, auch in einer anderen Sprache funktioniert. Wie unser Beispiel zeigt, bedeutet die CLS nicht nur Safe und Unsafe Code in C#.
Hoppla, wurde das Objekt collectet?
Die .NET Runtime verwaltet per Default Objekte für den Programmierer. Wenn also ein Objekt nicht mehr verwendet wird, wird dieses Objekt vom Garbage Collector aufgesammelt. Es gibt aber auch einen Mechanismus, um Schwache Referenzen auf ein Objekt zu legen. Eine schwache Referenz bedeutet, dass ein Objekt fast aufgegeben wurde, aber - wenn nötig - noch referenziert werden kann. Ein Einsatzfall für die Schwache Referenzierung wäre, wenn Sie ein globales Objekt haben wollen, dieses globale Objekt aber nicht mitführen wollen, wenn dies das System verlangsamen würde, weil zu viele Ressourcen verwendet werden. Der folgende Quellcode zeigt ein Beispiel für Schwache Referenzierung:
  1. BaseObj obj = new BaseObj("WeakRef");
  2. WeakReference wr = new WeakReference(obj, trackResurrection);
Das Objekt obj wird durch die Verwendung der WeakReference-Variablen wr verfolgt. Um die gemanagte Weak Reference-Objektinstanz abzurufen, wird wr.Track abgerufen.
Betrachten wir die Schwache Referenz nun hinsichtlich der Implementierung. Auf den ersten Blick könnte der Entwickler versucht sein, die WeakReference-Klasse als eine reine .NET-Klasse zu begreifen. Und innerhalb dieser reinen .NET-Klasse gäbe es dann einen raffinierten Algorithmus für die Verwaltung der Referenz auf ein Objekt. Wenn man sich jedoch die Rotor-Sourcen anschaut, sieht man, dass WeakReference in Wahrheit keine reine .NET-Klasse ist.
In der .NET-Implementierung bedient sich WeakReference des COM Interoperability Layers. Um zu verstehen, warum dies so ist, müssen Sie sich den Fall der Interoperation mit traditionellen COM-Objekten vorstellen. Diese COM-Objekte müssen von der .NET Runtime gemanagt werden, weshalb es Hilfsmittel gibt, die dies automatisch erledigen. Diese werden verwendet, um Objekte auf einer gepinnten oder ungepinnten Basis zu verwalten.

Listing 1
  1. FCIMPL2(LPVOID, GCHandleInternalAlloc, Object *obj, int type)
  2. {
  3. OBJECTREF or(obj);
  4. OBJECTHANDLE hnd;
  5. HELPER_METHOD_FRAME_BEGIN_RET_NOPOLL();
  6. THROWSCOMPLUSEXCEPTION();
  7. // If it is a pinned handle, check the object type.
  8. if (type == HNDTYPE_PINNED) GCHandleValidatePinnedObject(or);
  9. // Create the handle.
  10. if((hnd = GetAppDomain()->CreateTypedHandle(or, type)) == NULL)
  11. COMPlusThrowOM();
  12. HELPER_METHOD_FRAME_END_POLL();
  13. return (LPVOID) hnd;
  14. }
  15. FCIMPLEND
Schauen Sie sich den Ausschnitt aus den Rotor-Sourcen in Listing 1 an. Die Funktion GCHandleInternalAlloc wird für die Allozierung des Objekts verwendet und es geht darum, ob das Objekt pinned ist oder nicht. Ein gepinntes Objekt ist ein Objekt, das eine Referenz besitzt. Eine Schwache Referenzierung wäre dann die Allozierung eines ungepinnten Objekts. Aus welchem Grund werden dann aber die Klassen GCHandle und WeakReference gebraucht? Die Antwort ist, dass GCHandle eine einfachere Abstraktion der rohen Garbage Collection bereitstellt. Und die Klasse WeakReference macht es leichter, die Klasse GCHandle für die Verwaltung Schwacher Referenzen zu verwenden.
Welche Lektion haben wir nun gelernt? Weak References sind sehr hilfreich, wenn man einen globalen Status verwalten will, ohne ständig allozieren und deallozieren zu müssen. Schwache Referenzen erreichen dasselbe wie Allokation und Deallokation - aber eben nur wenn nötig, etwa in Fällen, in denen Ressourcen knapp sind. Und während wir gerne immer nur reines .NET für alle unsere Programmieraufgaben verwenden würden, ist es manchmal doch besser, nativen Code zu schreiben und bereits bestehende .NET-Möglichkeiten zu verwenden. Im Weak References-Beispiel wird etwa der Garbage Collector verwendet, um ein Problem zu lösen, bei dem es ursprünglich um Interoperabilität ging.
Hast Du noch 'nen Buffer?
Eine der am häufigsten verwendeten Klassen in .NET und jeder anderen Programmierumgebung ist die String-Klasse. Im Kontext der Java Virtual Machine ist ein String eine reine Java-Klasse. Es stellt sich die Frage, ob dies im Kontext der .NET-Umgebung genauso ist. Die Antwort ist Nein. Und bei genauerer Betrachtung der String-Klasse ergeben sich viele interessante Aspekte: Warum, zum Beispiel, ist es möglich, einem String eine Zahl hinzuzufügen und einen verketteten String-Buffer zu erhalten. Die Antwort liegt in der Implementierung der String-Klasse begründet. Das Interessante an der String-Klasse ist die Tatsache, dass diese viele Konstrukte implementiert, die wir in unseren täglichen Programmieraufgaben verwenden.
Eine String-Klasse in .NET ist keine reine MSIL-Klasse. Ein String ist ein .NET-befähigter Wrapper der zugrunde liegenden COMString-Klasse der .NET Laufzeitumgebung. COMString verwaltet die Stringdaten in einer Serie von Buffer-Manipulationen. Dabei ist es interessant zu beobachten, wie eine generische C++-Klasse in .NET unter Verwendungen von .NET Best Practices zugänglich gemacht wird.
Der folgende Code zeigt, wie es möglich ist, über eine Array-Notation auf einzelne Elemente des Buffers in .NET zuzugreifen:
  1. String myString = "true";
  2. Console.WriteLine( "Hello" + myString[ 1]);
Die String-Klasse ermöglicht dies durch die Verwendung eines Indexers, der wie folgt definiert ist:
  1. [System.Runtime.CompilerServices.IndexerName("Chars")]
  2. public char this[int index] {
  3. get { return InternalGetChar(index); }
  4. }
In dem definierten Indexer wurde lediglich die Methode get implementiert. Dies zeigt, dass die String-Klasse nur iteriert werden kann, wenn man Read-only-Techniken verwendet. Es stellt sich nun folgende Frage: Wenn die String-Klasse ein dünner Wrapper über COMString ist und wenn COMString die Manipulation des Buffers erlaubt, warum sollte man nicht das Setzen eines individuellen Characters erlauben? Die Antwort ist, dass COMString Methoden für die Manipulation des Contents von Buffern besitzt, diese Methoden aber neue Buffer erzeugen, wie der gekürzte Rotor-Quellcode in Listing 2 zeigt.

Listing 2
  1. FCIMPL3(Object*, COMString::Insert, StringObject* thisRefUNSAFE, INT32 startIndex, StringObject* valueUNSAFE)
  2. {
  3. STRINGREF refRetVal = NULL;
  4. STRINGREF thisRef = (STRINGREF) thisRefUNSAFE;
  5. STRINGREF value = (STRINGREF) valueUNSAFE;
  6. //Allocate a new String.
  7. valueLength = value->GetStringLength();
  8. newLength = thisLength + valueLength;
  9. refRetVal = NewString(newLength);
  10. //Get the buffers to access the characters directly.
  11. newChars = refRetVal->GetBuffer();
  12. thisChars = thisRef->GetBuffer();
  13. valueChars = value->GetBuffer();
  14. //Copy all of the characters to the appropriate locations.
  15. memcpyNoGCRefs(newChars, thisChars, (startIndex*sizeof(WCHAR)));
  16. newChars+=startIndex;
  17. memcpyNoGCRefs(newChars, valueChars, valueLength*sizeof(WCHAR));
  18. newChars+=valueLength;
  19. memcpyNoGCRefs(newChars, thisChars+startIndex, (thisLength - startIndex)*sizeof(WCHAR));
  20. //Set the String length and return;
  21. //We'll count on the fact that Strings are 0 initialized to set the terminating null.
  22. refRetVal->SetStringLength(newLength);
  23. return OBJECTREFToObject(refRetVal);
  24. }
  25. FCIMPLEND
In der Implementierung wird, basierend auf der Länge des eingefügten Buffers und des ursprünglichen Buffers, ein neuer Buffer alloziert. Dies geschieht aus Gründen der Geschwindigkeit. Stellen Sie sich vor, Sie würden ein Buffer-Objekt mit vorallozierten Buffern mit festgelegter Länge verwenden. Es wäre schneller, die korrekte Länge eines Buffers abzufragen und dann in den Buffer zu kopieren, als zu überprüfen, ob der aktuelle Buffer groß genug ist. Der Preis der Überprüfung ist nicht die Überprüfung an sich, sondern der Aufwand, Buffer in variierbaren Größen aufrecht zu erhalten. Ein Teil der Effizienz einer Laufzeitumgebung besteht darin, nur eine Sache zu tun und diese extrem gut zu erledigen.
Welche Auswirkung hat dies nun für Ihre tägliche Programmierarbeit? Betrachten Sie den folgenden Code:
  1. String result = "hello" + firstString + " " + secondString;
Dieser Code ist leicht zu schreiben, aber der Preis ist, dass es fünf String-Allozierungen und Kopiervorgänge gibt, die durch folgende Zyklen definiert werden:
  • "hello" erzeugt einen Buffer.
  • " " erzeugt einen Buffer.
  • Das Verketten von "hello" mit firstString erzeugt einen Buffer.
  • Das Verketten von " " mit secondString erzeugt einen Buffer.
  • Das Verketten der anderen beiden Buffer erzeugt einen weiteren Buffer.
Wenn man sich diese fünf Schritte anschaut, hat es den Anschein, als wäre es sehr langsam, auf diese Weise Strings zu bauen, wenn auch leicht zu schreiben. Eine effizientere Lösung bestünde darin, eine weitere String-Klasse zu schreiben, die Buffer-Manipulationen auf einer individuellen Basis erlaubt. Diesen Ansatz hat man bei .NET gewählt und die Klasse System.Text.StringBuilder entwickelt. Diese enthält Methoden, um String-Content mit Read- und Write-Operationen anzufügen und zu manipulieren. Der Nachteil bei der Verwendung von System.Text.StringBuilder ist, dass das Kodieren nicht so einfach ist wie beim ersten Beispiel. Die ursprünglichen Entwickler des C#-Compilers wussten jedoch bereits um dieses Problem und haben entsprechende Fixes am Compiler durchgeführt. Schauen Sie sich den folgenden generierten MSIL-Code an:
  1. IL_000c: ldstr "hello"
  2. IL_0011: ldloc.0
  3. IL_0012: ldstr " "
  4. IL_0017: ldloc.1
  5. IL_0018: call string [mscorlib]System.String::Concat(string,
  6. string,
  7. string,
  8. string)
Der Compiler hat die Konstanten extrahiert, sie zu dem Stack addiert und den multiplen Parameter String.Concat function aufgerufen. Wenn wir uns die Sourcen von String.Concat function anschauen, sehen wir, dass eine Optimierung durchgeführt wurde, wobei die Gesamtlänge der Buffer addiert, eine StringBuilder-artige Klasse erzeugt und die verschiedenen Elemente dem Buffer hinzugefügt wurden. Als Resultat wurde die Anzahl der erzeugten Buffer auf ein absolutes Minimum reduziert, wodurch sich die Effizienz der Applikation erhöht. Für den Programmierer bedeutet dies, dass er nicht die StringBuilder-Klasse verwenden muss, um effizient Klassen zu entwickeln.
Objekte sortierbar machen
Wenn ein Objekt Teil eines Arrays oder einer Collection ist, ist es möglich, das Objekt zu sortieren. Um die die Sortier-Fähigkeiten zu aktivieren, muss man die Schnittstelle IComparable implementieren. Es gibt nur eine Methode IComparable.CompareTo, die wie folgt implementiert wird:
  1. public int CompareTo(Object value) {
  2. if (value == null) {
  3. return 1;
  4. }
  5. if (!(value is String)) {
  6. throw new ArgumentException(Environment.GetResourceString("Arg_MustBeString"));
  7. }
  8. return String.Compare(this,(String)value);
  9. }
Beachten Sie in der Implementierung, dass nur Strings sortiert werden können. Falls die Collection unterschiedliche Datentypen enthielte, könnte das Sortieren also nicht funktionieren, wenn keine Konvertierung zum Datentyp String durchgeführt wird. Andernfalls würde ArgumentException geworfen werden. Dieses Verhalten ist akzeptabel, da es sich bei der String-Klasse um eine Basisklasse handelt. Eine Basisklasse kann nicht im voraus wissen, mit welchen Klassen sie verglichen werden wird. Also ist unsere Strategie in diesem Fall korrekt - beim Schreiben Ihrer eigenen Applikation könnte dies allerdings nicht der Fall sein. Wenn Sie sich jedoch entscheiden, andere Objekte zu vergleichen, versichern Sie sich, dass Sie nur mit intern deklarierten Objekten vergleichen, um Überraschung bei der späteren Wartung zu vermeiden.
Ein Objekt klonen
Innerhalb der .NET Laufzeitumgebung ist es möglich, unter Verwendung der ICloneable-Schnittstelle Objekte zu kopieren. Das Interface ICloneable stellt eine Methode, Clone, zur Verfügung, die folgendermaßen implementiert werden kann:
  1. class MyClass : ICloneable
  2. {
  3. public MyClass()
  4. {
  5. }
  6. public virtual Object Clone()
  7. {
  8. return MemberwiseClone();
  9. }
  10. }
Das Besondere an dieser Implementierung ist die Verwendung der Methode MemberwiseClone. Diese Methode wird in der Objekt-Deklaration wie folgt deklariert:
  1. [MethodImplAttribute(MethodImplOptions.InternalCall)]
  2. protected extern Object MemberwiseClone();
In den Rotor-Sourcen sieht man, dass diese Methode eine native C++-Methode ist, die die Felder des Objekts einzeln iteriert und die Werte in den Stream speichert. Der Haken beim Einsatz dieser Methode liegt darin, dass sie eine shallow copy liefert, das heißt, dass nur die Datenmember des Objekts kopiert werden. Wenn Sie eine tiefere Kopie der Unterobjekte benötigen, müssen diese einzeln aufgerufen werden.
Ein Fall für Ausnahmen
Wenn man sich in den Rotor-Sourcen umschaut, wir schnell offensichtlich, dass eine riesige Menge des Codes safe kodiert wurde. Früher wurde Code geschrieben und dann getestet, um zu gewährleisten, dass bestimmte Grenzen nicht überschritten wurden. Der Test auf diese Grenzen beinhaltete die Hinzufügung von Assert-Code. Das Problem bei Assert-Codes liegt darin, dass diese typischerweise nur in Debug-Builds funktionieren.
In Rotor wurde eine riesige Menge Code getestet, um die Einhaltung bestimmter Grenzen zu sichern. Betrachten Sie folgenden Ausschnitt:
  1. public virtual IEnumerator GetEnumerator(int index, int count) {
  2. if (index < 0 || count < 0)
  3. throw new ArgumentOutOfRangeException((index<0 ? "index" : "count"), Environment.GetResourceString("ArgumentOutOfRange_NeedNonNegNum"));
  4. if (_size - index < count)
  5. throw new ArgumentException(Environment.GetResourceString("Argument_InvalidOffLen"));
  6. return new ArrayListEnumerator(this, index, count);
  7. }
  8. }
Die Methode beginnt mit zwei Tests, die sicherstellen, dass die Input-Parameter die richtigen Dimensionen und die richtige Range besitzen. Ist dies nicht der Fall, wird eine Exception geworfen. Nur wenn beide Tests gelingen, wird die Methode ausgeführt.
Das ist Safe Coding, da die Tests vor jeder Ausführung durchgeführt werden. Auch in Ihrer Programmiertätigkeit sollte Code so geschrieben werden. Bedeutet das, dass es überall try- und catch-Blöcke geben sollte. Nein. Fehler sollten in zentralen Bereichen abgefangen und behandelt werden.
Buffer Management in .NET
Manchmal kann man es nicht vermeiden, seinen eigenen Buffer in .NET zu managen. Die einfachste Lösung besteht darin, den Buffer als ein Array von Bytes zu betrachten und dann entweder diese Bytes zu verwalten oder die Klasse System.IO.MemoryStream zu verwenden. Dieser Ansatz funktioniert in den meisten Fällen. In den Rotor-Sourcen gibt es jedoch im Namespace System.IO eine interne Klasse namens __UnmanagedMemoryStream. __UnmanagedMemoryStream ist intern und versiegelt, sodass sie ausschließlich in diesem spezifischen Kontext verwendet werden kann. Wenn man jedoch die Kommentare liest, wird schnell deutlich, dass diese Klasse dafür bestimmt ist, auch in anderen Kontexten eingesetzt zu werden. Und da diese Klasse von .NET zur Verfügung gestellt wird, wäre es sinnvoll, sie für die Erstellung eigener Memory Buffer-Klassen zu verwenden.
Das erste, was zu beachten ist, ist die korrekte Deklaration der Klasse:
  1. [CLSCompliant(false)]
  2. internal sealed class __UnmanagedMemoryStream : Stream
Die Attribut-Deklaration CLSCompliant(false) zeigt an, dass die Klasse Daten ausführt oder verwaltet, die eine CLS-konforme Klasse nicht behandeln könnte. Wenn der Buffer von einer CLS-konformen Sprache verwaltet werden soll, verwenden Sie C#, um die Klasse in eine CLS-konforme Klasse zu wrappen, wie es bei den meisten Basisklassen geschieht.
Eine weitere gute Angewohnheit beim Managen von Buffern ist die Vermeidung von Overflow-Situationen, indem man die Länge des Buffers deklariert:
  1. private const long MemStreamMaxLength = Int32.MaxValue;
Dies ist sicherer Code, weil die maximale Buffergröße, die dynamisch ermittelt wird, auf einer Laufzeit-Deklaration basiert. Diese Deklaration wurde im Kontext eines Buffers verwendet, aber auch in anderen Deklarationen und Programmen sollten Sie diese Obergrenzen setzen. Es gibt zwar Leute, die behaupten, dass gute Programme keine Grenzen haben und mit diesen Fällen umgehen können. Während dies bei Rechnern mit unbegrenztem RAM zwar eine gute Idee sein mag, sind Ressourcen in der Realität begrenzt und ein Entwickler sollte sicherstellen, dass bestimmte Obergrenzen nicht überschritten werden. Auf diese Weise lassen sich zum Beispiel auch Denial of Services-Attacken vermeiden. Das Lesen und Schreiben auf den Buffer wird dabei behandelt wie bei einer Datei, wo es einen Datenstrom zum Lesen und einen Datenstrom zum Schreiben gibt.
Geschoss im Anmarsch!
Bill Gates hat Secure Computing zur Priorität erklärt und die Veröffentlichung der .NET-Quellen war dabei ein sehr guter erster Schritt. Dabei habe ich etwas entdeckt, das mich nur den Kopf schütteln ließ - und wofür der Programmierer, der den ursprünglichen Code geschrieben hat, eigentlich ausgepeitscht gehört. Es sei angemerkt, dass der entsprechende Code in Listing 3 Teil der Quellcode-Distribution von .NET Beta 2 ist.

Listing 3
  1. FCIMPL1(INT32, COMString::GetHashCode, StringObject* str) {
  2. VALIDATEOBJECTREF(str);
  3. if (str == NULL) {
  4. FCThrow(kNullReferenceException);
  5. }
  6. WCHAR *thisChars;
  7. int thisLength;
  8. _ASSERTE(str);
  9. //Get our values;
  10. RefInterpretGetStringValuesDangerousForGC(str, &thisChars, &thisLength);
  11. // HashString looks for a terminating null. We've generally said all strings
  12. // will be null terminated. Enforce that.
  13. _ASSERTE(thisChars[thisLength] == L'\0' && "String should have been null-terminated. This one was created incorrectly");
  14. INT32 ret = (INT32) HashString(thisChars);
  15. FC_GC_POLL_RET();
  16. return(ret);
  17. }
  18. FCIMPLEND
Beachten Sie zunächst die Referenz auf die Funktion RefInterpretGetStringValuesDangerousForGC, die zugleich den Preis für den längsten Funktionsnamen gewinnt - die Windows-Programmierer unter Ihnen werden sich noch an den letzten Gewinner erinnern, der mit dem Marshaling eines COM-Pointers von einem Apartment zum einem anderen zu tun hatte. Der schlechte Code besteht darin, dass der HashString voraussetzt, dass der Programmierer den String mit NULL terminiert. Das Problem bei dieser Annahme ist, dass COMStrings durch Buffer-Content und -Länge definiert sind und wir uns damit über die NULL-Terminierung hinaus bewegt haben. Und trotzdem benutzt ein .NET-Codefragment diese Annahmen. Beunruhigend ist daran, dass ein ausdrücklicher Kommentar angefügt wurde, den ein Hacker einfach ausnutzen könnte, indem er eine Managed C++-Anwendung schreiben würde. Das würde natürlich voraussetzen, dass der Hacker die nötigen Rechte hätte. Aber der Punkt ist, das dies schlampiges Kodieren ist und nichts in der .NET Runtime zu suchen hat, zumal es in den Sourcen eine sichere Version von HashString gibt. Vielleicht defaultet dieser Aufruf auch zum sicheren HashString, aber es bleibt die Tatsache, dass es mehrere HashStrings gibt und dass manche ein lstrlen verwenden, um die Länge zu ermitteln. Vielleicht hätte der Programmierer Bill Gates' Memo zum sicheren Programmieren aufmerksamer studieren sollen. Abschließend kann ich jedoch nach dem Studium der Quellen sagen, dass diese allgemein gut geschrieben sind, wie auch in dem obigen Buffer-Beispiel zu sehen war.
Schluss
Die Inspektion der Rotor-Quellen ist sehr aufschlussreich. Rotor zeigt, welche Fortschritte das Programmieren gemacht bzw. manchmal nicht gemacht hat. Was mir beim Betrachten der Sourcen gefallen hat, war zu sehen, wie Microsoft seinen Code schreibt. Bei den ganzen Basisklassen wurde ich immer wieder daran erinnert, dass hier versucht wurde, den Code so effizient wie möglich und so sicher wie möglich zu schreiben. Microsoft hat versucht, Tabula rasa zu machen und eine neue Art, Code zu schreiben, zu finden.
Ich musste mich fragen, ob dies nicht letztlich der Heilsberg-Effekt sei - analog zum Schumi-Effekt (die Formel 1- und Michael Schumacher-Fans unter Ihnen werden diese Anspielung verstehen): Vor Schumi war Ferrari am Ende und er baute über die Jahre durch sein Talent ein Team auf, dass ständig gewinnt.
Wenn Sie an weiteren Informationen interessiert sind, erreichen Sie Christian Gross per eMail unter contact@devspace.com oder besuchen Sie seine Website www.devspace.com.


Anzeige

Kommentare

zurück zum Seitenanfang