Stabile .NET Programme durch automatisches Speichermanagement

Veröffentlicht: 04. Okt 2001 | Aktualisiert: 18. Jun 2004
Von Ralf Westphal

Bild01







Eine der häufigsten Fehlerquellen in Programmen ist der falsche Umgang mit Speicher. Mit verschiedenen Mechanismen adressiert die .NET Common Language Runtime dieses Problem. Die Softwareentwicklung wird dadurch einfacher - und der resultierende Code läuft stabiler.

* * *

Auf dieser Seite

Fehler im Umgang mit Hauptspeicher Fehler im Umgang mit Hauptspeicher
Allgemeine Schutzmechanismen und gewollte Lücken Allgemeine Schutzmechanismen und gewollte Lücken
Die lückenlose .NET Philosophie Die lückenlose .NET Philosophie
Fazit Fazit

Software zu schreiben ist keine einfache Aufgabe. Schon die geschäftlichen Anforderungen in Programmcode "zu gießen", stellt immer wieder eine Herausforderung an die Kommunikationsfähigkeit von Auftraggebern und Entwicklern dar. Logische Fehler schleichen sich dabei schnell ein. Zu diesen quasi notwendigen und verständlichen Fehlern gesellen sich jedoch allzu häufig Fehler, die nicht wirklich etwas mit dem zu lösenden Problem zu tun haben, sondern eher mit der Infrastruktur, auf die ein Programm aufsetzt. Infrastruktur kann selbst fehlerhaft sein - oder man kann sie falsch benutzen. Gerade letzteres geschieht häufig beim Umgang mit Hauptspeicher.

Fehler im Umgang mit Hauptspeicher

Bevor man Hauptspeicher nutzen kann, muss man ihn anfordern. Nach der Nutzung muss er freigegeben werden. Eine Nutzung besteht vor allem im Schreiben bzw. Lesen von Daten in bzw. aus Hauptspeicher. Aber man kann auch die weitere Programmausführung an Hauptspeicherbereiche abtreten, d.h. eine Adresse darin als Startpunkt für ein Unterprogramm annehmen und es anspringen.Mögliche Fehler im Umgang mit Hauptspeicher lassen sich aus dieser allgemeinen Beschreibung leicht ableiten:

  • Nutzung ohne Anforderung: Wenn Sie auf Speicher schreibend zugreifen, ohne ihn vorher angefordert zu haben, überschreiben Sie entweder unwillentlich andere Daten, verlassen den Adressraum des Prozesses und provozieren damit sofort einen Programmabsturz oder können sich zumindest nicht darauf verlassen, die Daten später wieder zu finden, da andere Speicheroperationen sie überschrieben haben können.

  • Versäumnis der Freigabe nach Nutzung: Wenn Sie Speicher nach der Benutzung nicht freigeben, erzeugen Sie ein "Speicherleck". Wenn dies häufiger geschieht, z.B. in einer Schleife oder während eines sehr langen Programmlaufs, kann es dazu kommen, dass das Programm den verfügbaren Hauptspeicher (inkl. virtuellem Speicher) aufzehrt. Die Verarbeitungsgeschwindigkeit des gesamten Systems wird in Mitleidenschaft gezogen und es kann zu Abstürzen kommen.

  • Fehler beim Schreiben: Selbst wenn Sie Speicher korrekt anfordern, kann es passieren, dass Sie ihn fehlerhaft nutzen. Sie können versuchen, mehr Daten hinein zu schreiben, als Ihnen Platz zugestanden wurde. Es können dieselben Probleme wie bei der Nutzung ohne Anforderung auftreten.

  • Fehler beim Lesen: Wenn Sie aus Speicher lesen, in den vorher keine Daten geschrieben wurden, kommt ein Programm unweigerlich zu falschen Ergebnissen oder gar zu einem Absturz.

  • Fehler beim Aufrufen: Wenn Sie ein Unterprogramm an einer Hauptspeicheradresse aufrufen, die Adresse jedoch falsch ist, kommt es zum Programmabsturz. Der Hauptspeicherinhalt, an den die Programmkontrolle übertragen wird, enthält entweder keine als Programmcode sinnvoll interpretierbaren Bytes oder der Code hat andere Erwartungen daran, welche Parameter an ihn auf dem Stack übergeben werden.

Allgemeine Schutzmechanismen und gewollte Lücken

Die beschriebenen Gefahren im Umgang mit Speicher bestehen vor allem, wenn Sie in Assembler programmieren. Dort haben Sie alle Freiheiten. In Programmierhochsprachen wie C++ oder VB gibt es jedoch verschiedene Mechanismen, die eine Fehlbenutzung von Speicher von vornherein vermeiden sollen:

  • Variablen: Über Variablen fordern Sie implizit Speicher an. Der Compiler erzeugt Code, der abhängig vom Definitionsort der Variablen Speicher z.B. auf dem Stack oder auf dem Heap anfordert und nach Nutzung - z.B. nach Unterprogrammende - auch wieder freigibt. Sie haben insofern keine Not mehr, Speicher selbst explizit z.B. über einen Betriebssystemaufruf wie malloc() anzufordern - und können so auch nicht vergessen, ihn wieder freizugeben.

  • Speicherinitialisierung: Variablen erhalten meist einen typabhängigen Defaultwert. Wenn Sie dann aus einer Variablen lesen, ohne vorher in sie geschrieben zu haben, mag das Ergebnis einer Operation zwar immer noch falsch sein, führt aber weniger leicht zu einem Programmabsturz.

  • Prozeduren/Funktionen: Unterprogramme springen Sie in Hochsprachen nicht über Adressen, sondern Namen an. Sie definieren Prozeduren/Funktionen mit Parametern und Rückgabewerten und kümmern sich nicht darum, wo sie später im Speicher liegen oder wie die aktuellen Parameter an die formalen übergeben werden. Der Compiler sorgt dafür, dass zur Laufzeit die korrekte Adresse angesprungen wird.

Das TypsystemNeben Prozeduren/Funktionen ist das Typsystem der wichtigste Mechanismus, um Speicherfehlbenutzungen auszuschließen. Das Typsystem, d.h. die Menge aller Typen einer Programmiersprache (z.B. Zahlentypen, Felder, Objekte) sorgt dafür, dass Sie auf den Hauptspeicher nicht mehr über Adressen, sondern Namen (Variablen-/Konstantennamen) zugreifen. Sie sehen sozusagen den Hauptspeicher gar nicht mehr als Ansammlung von Bytes, sondern nur noch als Raum aus typisierten Bereichen. Sie können vor allem nur noch auf diese Bereiche zugreifen, nur noch in sie hineinschreiben, aus ihnen lesen, aber nicht mehr zwischen sie, d.h. auf nicht angeforderte Bereiche. Dass Sie so beschränkt sind in Ihren Möglichkeiten, dafür sorgt das Typesystem mit seinen Operationen, die es auf den Typen definiert. Der Compiler sorgt für immer korrekte Zuweisungen, die nur in angeforderte Speicherbereiche schreiben können.

Lücken im SystemDie bisher beschriebene Philosophie der Hochsprachen ist eigentlich lückenlos. Fehlerhafte Hauptspeichernutzung kann es darin nicht geben. Die Welt könnte also in Ordnung sein - gäbe es da nicht bewusst eingeführte Lücken. Die meisten Hochsprachen enthalten Konstrukte, die es gestatten, Hauptspeicher selbst anzufordern bzw. freizugeben oder in ihm Unterprogramme anzuspringen. Der Grund dafür liegt in einem Wunsch nach Flexibilität, Geschwindigkeit und Systemnähe:

  • Flexibilität: Nicht immer ist zur Übersetzungszeit bekannt, wie viele "Variablen" eines bestimmten Typs zur Laufzeit nötig sein werden. Man möchte sie daher bei Bedarf später erzeugen und verwerfen können. Oder ein Algorithmus soll nicht nur mit Daten, sondern auch mit Funktionalität parametriert werden; bei einer Sortierung möchte man z.B. nicht festlegen, wie ein Vergleich zweier zu sortierender Elemente stattfinden soll. Vielmehr reicht man an die Sortierroutine eine Funktion (d.h. eine Adresse im Hauptspeicher, einen Funktionszeiger), die dann aus dem Algorithmus aufgerufen wird.

  • Geschwindigkeit: Der direkte Zugriff auf Bytes im Hauptspeicher (z.B. beim Umgang mit Zeichenketten) ist oft schneller, als der Weg über "offizielle" Operationen auf Typen.

  • Systemnähe: Datenstrukturen auf Betriebssystemebene oder Schnittstellen zu Hardware sind oft einfacher oder ausschließlich zu manipulieren, indem man darauf direkt per Hauptspeicheradresse zugreift.

Die "Lücken im System" reißen üblicherweise verschiedene Sprachkonstrukte:

  • Manuelle Speicherverwaltung: Viele Hochsprachen erlauben die "manuelle" Anforderung von Hauptspeicher auf Betriebssystemebene. Solcher Art allozierter Speicher entzieht sich der automatischen Kontrolle eines Programms bzw. des Compilers und muss daher auch manuell wieder freigegeben werden.

  • Datenzeiger: Zeigertypen repräsentieren Hauptspeicheradressen. Über Variablen von einem Zeigertyp erfolgt der Zugriff auf den Hauptspeicher zwar typisiert, aber es ist nicht garantiert, dass an der Adresse, auf die eine solche Variable zeigt, wirklich schon bzw. immer noch sinnvoll (d.h. typkonform) Hauptspeicher alloziert ist.

  • Objektverwaltung: Objekte zu instanzieren stellt eine explizite Hauptspeicheranforderung da. Das ist nicht zu vermeiden. Wenn Objekte auch wieder manuell zerstört werden müssen wie in VC++, dann kann das aber vergessen werden und sorgt für Speicherlecks, oder es kommt zu einem Programmabsturz, weil auf ein Objekt zugegriffen wird, das schon freigegeben wurde.COM versucht dieses Problem zu lösen, indem es die Objektfreigabe automatisiert. Objekte werden freigegeben, wenn keine Referenz mehr darauf existiert. Um zu wissen, ob keine Referenz mehr existiert, muss jedoch darüber akribisch Buch geführt werden (COM-Referenzzählung). Ist sie lückenhaft, kann es wieder zu Fehlern kommen. Aber auch bei korrekter Referenzzählung drohen Probleme: Speicherlecks können durch zirkuläre Referenzen entstehen; sie müssen manuell aufgelöst werden.

  • Funktionszeiger: Wo Adressen von Unterprogrammen in Variablen gespeichert werden können, um sie später darüber aufzurufen, kommt es schnell zu Fehlern, weil nicht sichergestellt ist, ob eine solche Variable eine korrekte Adresse enthält. Oder ob das Unterprogramm, auf welches verwiesen wird, in seinen Erwartungen zum Stackaufbau bei Aufruf mit denen vom Aufrufer übereinstimmt. Dieses Problem betrifft auch VB-Entwickler: In VB können mit AddressOf Unterprogrammadressen an Win32-Funktionen übergeben werden, ohne dass es eine Garantie gäbe, dass das Unterprogramm in seiner Signatur den Anforderungen der Win32-Funktion entspräche.

Die lückenlose .NET Philosophie

Eines der Grundprinzipien der .NET Plattform ist, maximale Sicherheit und Robustheit für Zielsysteme zu erreichen. Dazu gehört auch, Fehler im Umgang mit Hauptspeicher auszuschließen. Die Common Language Runtime (CLR) findet dabei eine gute Balance zwischen den Ansprüchen der Softwareentwickler an Freiheitsgrade und dem Bedürfnis der Anwender nach stabilen Programmen. In Bezug auf die Speicherverwaltung ist die Position rigoros: Eine manuelle Hauptspeicherverwaltung ist gefährlich und daher nicht erlaubt. Die CLR baut zu diesem Zweck mehrere "Schutzwälle" auf:

  • Typsystem: .NET bietet ein reichhaltiges Typsystem (Common Type System), an das sich alle .NET Sprachen halten müssen. Compiler, die Intermediate Language Code (IL), d.h. Managed Code, erzeugen, übersetzen ihre eigenen Typen immer in das CTS. IL ist insofern selbst wieder eine streng typisierte Sprache, d.h. sie können sogar auf der IL-Assemblerebene nicht frei mit dem Speicher umgehen. Speicheranforderungen finden auch dort immer typisiert über Variablen statt. Das gilt auch für die Objektinstanzierungen.

  • Keine Zeiger: Das .NET Typsystem kennt keine Zeiger. Sie können also auch auf IL-Ebene nicht am Typsystem vorbei in den Speicher greifen oder dort Unterprogramme aufrufen.

  • Automatische Speicherverwaltung: Das CTS ist komplett objektorientiert. D.h. Speicher muss zur Laufzeit für neue Objekte angefordert werden können. Wie oben erklärt, ist die Freigabe von Objekten nach Nutzung dann ein sensibler Punkt, an dem es zu Fehlern kommen kann. Die CLR implementiert daher eine Garbage Collection (GC) für alle Objekte. Sie können Objekte weder explizit freigeben, noch muss über ihren Gebrauch mit Referenzzählung Buch geführt werden. Beide Verfahren sind zu unsicher. Die GC übernimmt es, nicht mehr benötigte Objekte zu zerstören. Es kann damit weder zu verfrühten, noch zu verspäteten Freigaben kommen und auch zirkuläre Referenzen erzeugen keine Speicherlecks mehr.
    In einer Hinsicht erfordert dieses Vorgehen jedoch ein Umdenken bei Ihnen: Da nicht bekannt ist, wann die CLR eine GC durchführt, können Sie nicht wissen, wann ein Objekt, auf das keine Referenzen mehr bestehen, zerstört wird. Das bedeutet, Sie sollten Ressourcen, die Objekte benutzen (z.B. eine Datenbankverbindung), nicht im Destruktor bzw. in Class_Terminate freigeben! Stattdessen sollten Sie eine explizite Dispose()-Methoden einführen. Hier ein Beispiel:

    using System; 
    namespace Ressourcennutzung 
    { 
    class HalteRessource : IDisposable 
    { 
    public HalteRessource() 
    { 
    Console.WriteLine("Ressource anfordern"); 
    } 
    public void TueEtwas() 
    { 
    Console.WriteLine("\Tue etwas..."); 
    } 
    public void Dispose() 
    { 
    Console.WriteLine("Ressource freigeben"); 
    } 
    } 
    class MyApp 
    { 
    static void Main(string[] args) 
    { 
    Console.WriteLine("Vor Ressourecennutzung"); 
    using(HalteRessource hr = new HalteRessource()) 
    { 
    hr.TueEtwas(); 
    } 
    Console.WriteLine("Nach Ressourecennutzung"); 
    } 
    } 
    }
    

    Für die Klasse HalteRessource wird angenommen, dass sie eine Ressource benutzt, die nicht länger als nötig gehalten werden sollte. Angesichts der Unbestimmtheit des Zerstörungszeitpunkts für Objekte darf die Ressource also nicht erst im Destruktor der Klasse freigegeben werden. .NET bietet für solche Klassen ein "Pattern" an: Sie implementieren das Interface IDisposable und dessen Methode Dispose. Sie können dann Dispose direkt aufrufen

    HalteRessource hr = new HalteRessource(); 
    hr.TueEtwas(); 
    hr.Dispose();
    

    und schließen damit die Ressourcenutzung explizit ab (so wie Sie es bisher auch z.B. mit Datenbankverbindungen und Close() getan haben). Oder Sie benutzen die C#-Anweisung using wie im Beispiel oben. using ruft am Ende des Blocks automatisch Dispose auf allen Objekten auf, die in seiner "Parameterklammer" deklariert sind.

  • Typisierte Funktionszeiger: Funktionszeiger haben einen großen Wert (und sind auch die Basis für eine Ereignisbehandlung). Das CTS bietet daher Funktionszeiger - allerdings in einem Format, das sie sicher macht. Funktionszeiger, so genannte Delegates, sind typisiert. Hier eine Beispieldefinition in VB .NET:

    Delegate Function Compare(ByVal a as String, ByVal b as String) as Boolean
    

    Compare ist ein Delegate-Typ, dessen Instanzen Zeiger auf Funktionen aufnehmen können, die in ihrer Signatur mit der in der Definition übereinstimmen. Hier eine Beispielfunktion:

    Function VerglLaenge(ByVal a as String, ByVal b as String) as Boolean 
        Return a.Length > b.Length 
    End Function
    

    Die Zuweisung der Funktion an eine Delegate-Variable erfolgt über den Konstruktor des Delegate-Typs:

    Dim c as Compare 
    c = new Compare(AddressOf VerglLaenge)
    

    Die Delegate-Variable kann anschließend wie eine Funktion benutzt werden und delegiert den Aufruf an die Funktionen, die ihr zugewiesen wurden:

    If c("Peter", "Mary") Then ...
    

    Die Typisierung von Funktionszeigern sorgt dafür, dass Aufrufer und Funktion in ihren Erwartungen bzgl. des Stackaufbaus übereinstimmen, und dass ein Aufruf überhaupt nur erfolgt, wenn eine passende Funktion zugewiesen wurde. Enthält eine Delegate-Variable keine Funktion, wird ein Fehler geworfen und nicht "ins Nirwana" gesprungen.

Und was ist mit der Systemnähe?CTS und CLR machen den Umgang mit Speicher also lückenlos sicher. Was ist dann aber mit Geschwindigkeit, Systemnähe und Flexibilität, die in der Vergangenheit Gründe genug waren, um bewusst Lücken zu schaffen?Die einfache Antwort zunächst lautet: CTS und CLR bieten genügend Geschwindigkeit und Flexibilität für die allermeisten Fälle. "Gefährliche Konstrukte" sind nicht nötig.Eine differenzierte Antwort dagegen lautet: Wo Geschwindigkeit, Flexibilität und Systemnähe wirklich nur durch manuelles Speichermanagement und direkten Zugriff möglich sind, muss auf Unmanaged Code zurückgegriffen werden. Dann aber sollten dafür möglichst kleine, spezielle Komponenten mit VC++ implementiert und sorgfältig getestet werden; ihre Funktionalität würden sie über eine Schnittstelle auf der Basis von Managed Extensions anderen .NET Sprachen anbieten. Der freie Umgang mit Speicher wäre in ihnen so zu kapseln, dass von außen wiederum kein fehlerhafter Umgang damit möglich ist.Ein Beispiel für eine solche Komponente stellt die StringBuilder-Klasse der .NET Basisklassenbibliothek dar. Sie ist zwar auch in IL geschrieben, setzt aber auf optimierten, CLR-internen Methoden der Klasse String auf. StringBuilder bietet dadurch dramatisch bessere Geschwindigkeit für String-Verkettungen als die üblichen Operatoren für die Verbindung von Zeichenketten.

Fazit

.NET erreicht mehr Stabilität für Ihre Software dadurch, dass es Ihnen das Speichermanagement abnimmt. Genießen Sie diese Bequemlichkeit. In 99,9% aller Fälle entstehen Ihnen dadurch keine Nachteile: Ihre Programme bleiben performant und flexibel. Sie werden von mancher Grübelei sogar entlastet - und müssen Ihre eingetretenen Programmierpfade nur wenig ändern.


Anzeigen: