MSDN Magazin > Home > Ausgaben > 2006 > December >  .NET-Themen: Deserialisiersstatus und mehr
.NET-Themen
Deserialisiersstatus und mehr
Stephen Toub

Codedownload verfügbar unter: NETMatters2006_12.exe (161 KB)
Browse the Code Online

Aufgabenstellung: In einer Windows® Forms-Anwendung wird ein BinaryFormatter verwendet, um große Teile des Anwendungszustands zu speichern, wobei manchmal sehr große Objekte auf Festplatte serialisiert werden. Zu einem späteren Zeitpunkt muss diese Zustandsdatei von der Anwendung geladen und deserialisiert werden. Dieser Deserialisierungsvorgang kann einen messbaren Zeitraum beanspruchen. Derzeit wird für sowohl Serialisierung als auch Deserialisierung eine Marquee-Statusanzeige verwendet. Diese zeigt dem Benutzer jedoch nur an, dass etwas geschieht. Für Zwecke der Deserialisierung wäre es wünschenswert, eine Standardstatusanzeige zu verwenden, die angibt, welcher Teil der Deserialisierung bereits abgeschlossen ist und wie viel verbleibt. Da BinaryFormatter diese Funktion nicht bietet, suche ich nach Möglichkeiten, wie diese Klasse um die gewünschte Funktion erweitert werden kann. Haben Sie Vorschläge?
Aufgabenstellung: In einer Windows® Forms-Anwendung wird ein BinaryFormatter verwendet, um große Teile des Anwendungszustands zu speichern, wobei manchmal sehr große Objekte auf Festplatte serialisiert werden. Zu einem späteren Zeitpunkt muss diese Zustandsdatei von der Anwendung geladen und deserialisiert werden. Dieser Deserialisierungsvorgang kann einen messbaren Zeitraum beanspruchen. Derzeit wird für sowohl Serialisierung als auch Deserialisierung eine Marquee-Statusanzeige verwendet. Diese zeigt dem Benutzer jedoch nur an, dass etwas geschieht. Für Zwecke der Deserialisierung wäre es wünschenswert, eine Standardstatusanzeige zu verwenden, die angibt, welcher Teil der Deserialisierung bereits abgeschlossen ist und wie viel verbleibt. Da BinaryFormatter diese Funktion nicht bietet, suche ich nach Möglichkeiten, wie diese Klasse um die gewünschte Funktion erweitert werden kann. Haben Sie Vorschläge?
Ein BinaryFormatter lässt sich nur schwer erweitern, da dieser über eine sehr einfache Schnittstelle verfügt. Er ist ohne virtuelle Elemente versiegelt, weshalb daraus nicht abgeleitet werden kann, um einige Schlüsselelemente zu überschreiben, z. B. zum Hinzufügen von Funktionalität zu einer anderen Klasse. Weiterhin werden keine Ereignisse verfügbar gemacht, an die Sie anknüpfen können, um Statusbenachrichtigungen usw. zu empfangen. Stattdessen ist eine etwas kreativere Vorgehensweise erforderlich.
Ein BinaryFormatter lässt sich nur schwer erweitern, da dieser über eine sehr einfache Schnittstelle verfügt. Er ist ohne virtuelle Elemente versiegelt, weshalb daraus nicht abgeleitet werden kann, um einige Schlüsselelemente zu überschreiben, z. B. zum Hinzufügen von Funktionalität zu einer anderen Klasse. Weiterhin werden keine Ereignisse verfügbar gemacht, an die Sie anknüpfen können, um Statusbenachrichtigungen usw. zu empfangen. Stattdessen ist eine etwas kreativere Vorgehensweise erforderlich.
Diese sieht wie folgt aus: BinaryFormatter nutzt den zu deserialisierenden Stream, soweit dies für die Analyse erforderlich ist. Wenn also ermittelt werden kann, welcher Teil des Streams von BinaryFormatter gelesen wurde, kann eine gute Schätzung darüber abgegeben werden, welcher Teil des Deserialisierungsvorgangs bereits abgeschlossen ist.
Abbildung 1 zeigt den ersten Teil einer Dienstprogrammmethode, mit der der Status der Deserialisierung verfolgt werden kann. Die generische Methode akzeptiert den zu deserialisierenden Stream und einen Callback-Delegaten, der von der Methode aufgerufen wird, um den Aufrufer über Statusaktualisierungen zu benachrichtigen. Intern wird ein BinaryFormatter erstellt und der Stream deserialisiert. Zuvor wird aber noch ein Timer erstellt, der jede Sekunde abläuft. Somit ruft der Timer nach jeder Sekunde den Callback-Delegaten in einem Hintergrundthread auf. Dabei werden die aktuelle Position und die Länge des Streams untersucht. Diese Informationen liefern dem Rückruf eine Zahl, die den Prozentsatz der abgeschlossenen Deserialisierung repräsentiert. Wie Sie bereits aus dem Titel der Abbildung erahnen können, steckt diese Methode voller Fehler. Was noch wichtiger ist: Instanzmethoden in Stream sind nicht zu 100 % threadsicher. Daher sollte auf die Position- und Length-Eigenschaften nicht von einem sekundären Thread aus zugegriffen werden, wie dies hier der Fall ist. Darüber hinaus gibt es eine Reihe von Racebedingungen, die Ausnahmen verursachen, z. B. wenn die Deserialisierung beendet und der Stream in dem Augenblick geschlossen wird, in dem der Rückruf des Timers ausgelöst wird (es ist nicht möglich, auf die Eigenschaften eines entfernten FileStream zuzugreifen). Kurzum: Dies ist eine schlechte Lösung.
public static T Deserialize<T>(
    Stream stream, ProgressChangedEventHandler callback)
{
    if (stream == null) throw new ArgumentNullException("stream");
    if (!stream.CanSeek) throw new ArgumentException("stream");

    Timer timer = null;
    if (callback != null)
    {
        timer = new Timer(delegate
        {
           callback(null, new ProgressChangedEventArgs(
               (int)(stream.Position * 100.0 / stream.Length), null));
        }, 
        null, 0, 1000);
    }

    BinaryFormatter formatter = new BinaryFormatter();
    T graph = (T)formatter.Deserialize(stream);

    if (timer != null) timer.Dispose();

    return graph;
}

Eine deutlich bessere Lösung umfasst die Erstellung eines benutzerdefinierten Decorator-Streams. Das Decorator-Entwurfsmuster und seine Anwendung auf Streams wurden in meinem September 2005-Artikel (möglicherweise in englischer Sprache) besprochen. Kurz gesagt: Ein Decorator ist ein Objekt mit derselben Schnittstelle wie ein anderes Objekt, das es enthält (in der objektorientierten Ausdruckweise: Ein Objekt, das sowohl eine „ist-ein“ als auch eine „hat-ein“-Beziehung mit einem bestimmten Typ hat. Beispiel: System.IO.Compression.GZipStream ist ein Decorator, da es sowohl von Stream abgeleitet ist als auch einen Stream enthält. Ein Benutzer konstruiert einen GZipStream, indem an seinen Konstruktor ein Stream übergeben wird, der entweder komprimiert oder dekomprimiert sein sollte. Aufrufe der Stream-Implementierung von GZipStream werden an die zugrunde liegende Implementierung von Stream delegiert, wodurch zusätzliche Aufgaben davor und danach erledigt werden.
Anstatt an BinaryFormatter den tatsächlich zu deserialisierenden Stream zu übergeben, kann dieser an einen Ersatzdecoratorstream übergeben werden, der den zu deserialisierenden Stream enthält. Dieser Container kann seine Read-Methode implementieren, um die Daten aus dem enthaltenen Stream abzurufen, aber auch ein Ereignis aufrufen, das Informationen über die aktuelle Position und Länge des Streams enthält.
Vor der Implementierung eines neuen Decoratorstreams beginne ich in der Regel mit einer Hilfsbasisklasse, wie diejenige in Abbildung 2. Diese ContainerStream-Klasse liefert einen Konstruktor, der den aufzunehmenden Stream akzeptiert und speichert, sowie eine geschützte Eigenschaft, die abgeleiteten Typen den zugrunde liegenden Stream zur Verfügung stellt, und implementiert anschließend alle Methoden und Eigenschaften im Stream, der an den enthaltenen Stream delegiert wird. Dies ist nützlich, da die meisten Methoden und Eigenschaften im Stream abstrakt sind. Mit dieser Hilfsklasse können Sie vermeiden, alle Methoden in Ihrem eigenen abgeleiteten Typ zu implementieren, wenn Sie nur eine oder zwei spezifische Methoden implementieren möchten.
public abstract class ContainerStream : Stream
{
    private Stream _stream;

    protected ContainerStream(Stream stream)
    {
        if (stream == null) throw new ArgumentNullException("stream");
        _stream = stream;
    }

    protected Stream ContainedStream { get { return _stream; } }

    public override bool CanRead { get { return _stream.CanRead; } }

    public override bool CanSeek { get { return _stream.CanSeek; } }

    public override bool CanWrite { get { return _stream.CanWrite; } }

    public override void Flush() { _stream.Flush(); }

    public override long Length { get { return _stream.Length; } }

    public override long Position
    {
       get { return _stream.Position; }
       set { _stream.Position = value; }
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        return _stream.Read(buffer, offset, count);
    }

    ...
}

Die ReadProgressStream-Implementierung wird in Abbildung 3 gezeigt. Sie wird vom ContainerStream abgeleitet und überschreibt die Read-Methode. Die Read-Überschreibung ruft die Read-Methode der Basisklasse auf (die wiederum die Read-Methode des enthaltenen Streams aufruft). Falls irgendeine Registrierung beim ProgressChanged-Ereignis von ReadProgressStream durchgeführt wurde, wird ein Ereignis mit dem aktuellen Prozentsatz des Fortschritts ausgelöst, der mit dem Einlesen des Streams gemacht wurde (zur Leistungsverbesserung wird das Ereignis nur ausgelöst, wenn sich der Wert des integralen Prozentsatzes seit des letzten Aufrufs von Read geändert hat).
public class ReadProgressStream : ContainerStream
{
    private int _lastProgress = 0;

    public ReadProgressStream(Stream stream) : base(stream) 
    {
        if (!stream.CanRead || !stream.CanSeek || stream.Length <= 0) 
            throw new ArgumentException("stream");
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        int amountRead = base.Read(buffer, offset, count);
        if (ProgressChanged != null)
        {
            int newProgress = (int)(Position * 100.0 / Length);
            if (newProgress > _lastProgress)
            {
                _lastProgress = newProgress;
                ProgressChanged(this, 
                    new ProgressChangedEventArgs(_lastProgress, null));
            }
        }
        return amountRead;
    }

    public event ProgressChangedEventHandler ProgressChanged;
}

Mit dieser Klasse lässt sich der Fortschritt der Deserialisierung problemlos verfolgen, indem einfach ein ReadProgressStream anstatt des tatsächlich zu deserialisierenden Streams an den BinaryFormatter übergeben wird.
public static T Deserialize<T>(
  Stream stream, ProgressChangedEventHandler callback)
{
  using (ReadProgressStream rps = new ReadProgressStream(stream))
  {
    rps.ProgressChanged += callback;
    BinaryFormatter formatter = new BinaryFormatter();
    return (T)formatter.Deserialize(rps);
  }
}
Dies funktioniert und führt dazu, dass der Fortschritt der Deserialisierung allgemein nachverfolgt werden kann. Leider bringt die aktuelle Programmierung der Deserialize<T>-Methode ein gravierendes Leistungsproblem mit sich. Der BinaryFormatter sendet sehr viele Anfragen an die Read-Methode des Streams, häufig jeweils nur für ein paar Bytes. Obwohl der Read-Methode nicht viel Code hinzugefügt wurde, reicht dies aus, um einen signifikanten Engpass zu erzeugen.
Zur Leistungsverbesserung wird eine Methode benötigt, mit der die Anzahl der tatsächlichen Aufrufe der Read-Methode von ReadProgressStream reduziert werden kann. Zur Lösung dieses Problem ist es wichtig zu erkennen, dass es sich dabei nicht um neues Problem handelt. (Nur wenige Probleme sind tatsächlich neu, und Vieles wird einfacher, wenn man akzeptiert, dass die meisten Probleme auf die eine oder andere Art bereits gelöst wurden).
Beispiel: FileStream wird typischerweise zum Lesen und Schreiben von Daten von und auf Datenträger verwendet, und dieser Vorgang ist relativ langsam. Würde jede Anforderung eines FileStream Zugriff auf den Datenträger erfordern, würden Anwendungen, die vom Lesen und Schreiben von Dateien mit FileStream abhängig sind, extrem langsam werden. Stattdessen puffert FileStream Daten vom Datenträger, wobei mehr Daten als erforderlich in kleinen Leseanforderungen gelesen werden, damit zukünftige Anforderungen dieser Daten aus dem Puffercache bedient werden können, und keine zusätzlichen Datenträgerzugriffe erforderlich sind.
Die gleiche Lösung der Datenpufferung kann zur Behebung unseres Leistungsproblems verwendet werden. Unser Anliegen ist es, die Anzahl der tatsächlichen Aufrufe der Read-Methode von ReadProgressStream zu reduzieren, und eine Möglichkeit besteht darin, den Aufrufer zu zwingen, mehrere große Anforderungen anstatt vieler kleiner zu durchzuführen. Tatsächlich erfordert dies nur eine weiter Codezeile:
public static T Deserialize<T>(
  Stream stream, ProgressChangedEventHandler callback)
{
  using (ReadProgressStream rps = new ReadProgressStream(stream))
  using (BufferedStream bs = new BufferedStream(rps))
  {
    rps.ProgressChanged += callback;
    BinaryFormatter formatter = new BinaryFormatter();
    return (T)formatter.Deserialize(bs);
  }
}
Jetzt greift der BinaryFormatter nicht mehr direkt auf die Read-Methode von ProgressStream zu, sondern durchläuft einen System.IO.BufferedStream. Dadurch wird die Leistung der Lösung dramatisch bis zu einem Grad verbessert, an dem sie tatsächlich praxistauglich ist. Jedoch wird dadurch ein neues kleineres Problem erzeugt, das aber mit eine paar weiteren Zeilen Code gelöst werden kann. Der Konstruktor für BufferedStream aus dem vorherigen Beispiel verwendet standardmäßig 4 KB. Dies hat bei einem relativ kleinen Stream zur Folge, dass nicht sehr viele Aufrufe der Read-Methode des zugrunde liegenden ReadProgressStream gemacht werden, und daraus nicht sehr viele Statusbenachrichtigungen gewonnen werden. Angenommen, es handelt sich um einen kleinen Stream, der ohnehin schnell deserialisiert werden sollte, dann stellt dies eventuell kein Problem für Sie dar. Sollte es jedoch problematisch sein und sollten sie häufigere Aktualisierungen benötigen, können Sie die Größe des Puffers mithilfe eines zweiten Konstruktors in BufferedStream verändern, der neben dem einzubindenden Stream die zu verwendende Puffergröße akzeptiert. Eine einfache Methode zur Ermittlung der Puffergröße besteht darin, die Länge des Streams durch Hundert zu teilen (da ProgressChangedEventArgs einen Integralwert für den abgeschlossenen Fortschritt verwendet, wodurch sich typische Werte zwischen 0 und 100 (einschließlich) ergeben). Extrem große Streams können jedoch auch zu riesigen Puffergrößen führen. Um dies zu vermeiden, wurde jeweils der kleinere Wert verwendet, 1/100 der Streamlänge oder 4096 Bytes. Die Lösung mit dieser Berechnung wird in Abbildung 4 gezeigt. Sie sollten diese Formel jedoch in Ihren eigenen Szenarios testen, und Änderungen durchführen, falls diese Wahl für Ihr System ungeeignet ist.
public static T Deserialize<T>(
    Stream stream, ProgressChangedEventHandler callback)
{
    if (stream == null) throw new ArgumentNullException("stream");

    using (ReadProgressStream cs = new ReadProgressStream(stream))
    {
        cs.ProgressChanged += callback;

        const int defaultBufferSize = 4096;
        int onePercentSize = (int)Math.Ceiling(stream.Length / 100.0);

        using (BufferedStream bs = new BufferedStream(cs, 
            onePercentSize > defaultBufferSize ? 
            defaultBufferSize : onePercentSize))
        {
            BinaryFormatter formatter = new BinaryFormatter();
            return (T)formatter.Deserialize(bs);
        }
    }
}


Frage: Bei Verwendung der SortedList<TKey, TValue>-Auflistung aus dem System.Collection.Generics-Namespace ist die Leistung nicht wie erwartet. Welche Algorithmen befinden sich hinter dieser Auflistung?
Frage: Bei Verwendung der SortedList<TKey, TValue>-Auflistung aus dem System.Collection.Generics-Namespace ist die Leistung nicht wie erwartet. Welche Algorithmen befinden sich hinter dieser Auflistung?
Antwort: Wie List<T>, bindet SortedList<TKey, TValue> ein zugrunde liegendes Array. (SortedList bindet tatsächlich zwei Arrays, eines für Schlüssel und eines für Werte). Wenn Sie ein Element SortedList hinzufügen, geschieht Folgendes: Mit einer Binärsuche wird nach der korrekten Stelle gesucht, an der das Element in das Array eingefügt wird. Danach wird sichergestellt, dass das Array groß genug für die vorhandenen Elemente plus das neue Element ist. Zudem werden vorhandene Elemente verschoben, um Platz für das neue Element zu machen. Das neue Element wird schließlich dem Array an der korrekten Stelle hinzugefügt. Je nachdem, wo Sie das Element in die Liste einfügen, kann dieser Verschiebungsvorgang aufwändig sein. Die folgenden beiden Schleifen werden im Anschluss als Beispiel verwendet. Die erste fügt die Zahlen von 1 bis 10.000 in die Liste ein, und die zweite die gleichen Zahlen, jedoch in der umgekehrten Reihenfolge, also beginnend bei 10.000 und absteigend bis 1.
for (int i = 1; i <= 10000; ++i) list.Add(i, i); // loop #1

for (int i = 10000; i >= 1; --i) list.Add(i, i); // loop #2
Antwort: Wie List<T>, bindet SortedList<TKey, TValue> ein zugrunde liegendes Array. (SortedList bindet tatsächlich zwei Arrays, eines für Schlüssel und eines für Werte). Wenn Sie ein Element SortedList hinzufügen, geschieht Folgendes: Mit einer Binärsuche wird nach der korrekten Stelle gesucht, an der das Element in das Array eingefügt wird. Danach wird sichergestellt, dass das Array groß genug für die vorhandenen Elemente plus das neue Element ist. Zudem werden vorhandene Elemente verschoben, um Platz für das neue Element zu machen. Das neue Element wird schließlich dem Array an der korrekten Stelle hinzugefügt. Je nachdem, wo Sie das Element in die Liste einfügen, kann dieser Verschiebungsvorgang aufwändig sein. Die folgenden beiden Schleifen werden im Anschluss als Beispiel verwendet. Die erste fügt die Zahlen von 1 bis 10.000 in die Liste ein, und die zweite die gleichen Zahlen, jedoch in der umgekehrten Reihenfolge, also beginnend bei 10.000 und absteigend bis 1.
for (int i = 1; i <= 10000; ++i) list.Add(i, i); // loop #1

for (int i = 10000; i >= 1; --i) list.Add(i, i); // loop #2
Auf dem Testcomputer ist die erste Schleife mehr als 30 Mal schneller als die zweite, und dieser Abstand nimmt bei wachsender Anzahl von Elementen zu. Hierbei liegt folgendes Phänomen vor: Bei der ersten Schleife wird immer an das Ende des Arrays angefügt, was bedeutet, dass keine Elemente beim Einfügen verschoben werden müssen. Beim der zweiten Schleife wird immer an den Anfang hinzugefügt, was bedeutet, dass alle Elemente beim Einfügen immer verschoben werden müssen. Dadurch wird der Algorithmus zu einem O(n2)-Algorithmus abgewertet, die Worst-Case-Komplexität für diese Art von Einfügen.
Dieses Beispiel soll veranschaulichen, dass Sie Ihre Datenstrukturen sorgfältig auf der Basis der Szenarios auswählen müssen, in denen diese zum Einsatz kommen. Darüber hinaus ist nach Auswahl einer Datenstruktur zu beachten, diese auf eine Art und Weise zu verwenden, die optimale Leistung liefert. Wie bereits gezeigt, kann eine einfache Änderung der Reihenfolge, in der Sie bestimmte Vorgänge durchführen, eine große Wirkung haben.

Frage: Ich muss einen Standarddateipfad in einen 8.3-Dateipfad konvertieren. Wird dieser Vorgang in .NET Framework unterstützt?
Frage: Ich muss einen Standarddateipfad in einen 8.3-Dateipfad konvertieren. Wird dieser Vorgang in .NET Framework unterstützt?
Antwort: Sicher, aber nur insofern, als Sie mit P/Invoke auf die gesamte Sammlung mit Win32®-APIs zugreifen können. Diese Konvertierung lässt sich leicht mithilfe der GetShortPathName-Funktion realisieren, die über kernel32.dll verfügbar gemacht wird, wie in Abbildung 5 gezeigt. Sie akzeptiert drei Parameter: Die Zeichenfolge mit dem zu konvertierenden Pfad, einen Ausgabespeicherpuffer zur Aufnahme der konvertierten Zeichenfolge und eine Ganzzahl mit der Größe dieses Puffers in Zeichen. Wenn GetShortPathName mit einem Puffer aufgerufen wird, der zu klein für die Aufnahme der konvertierten Zeichenfolge ist (oder genauer: wenn der Wert in cchBuffer zu klein ist), gibt GetShortPathName die Größe des Puffers zurück, der zur Speicherung der Zeichenfolge erforderlich ist.
Antwort: Sicher, aber nur insofern, als Sie mit P/Invoke auf die gesamte Sammlung mit Win32®-APIs zugreifen können. Diese Konvertierung lässt sich leicht mithilfe der GetShortPathName-Funktion realisieren, die über kernel32.dll verfügbar gemacht wird, wie in Abbildung 5 gezeigt. Sie akzeptiert drei Parameter: Die Zeichenfolge mit dem zu konvertierenden Pfad, einen Ausgabespeicherpuffer zur Aufnahme der konvertierten Zeichenfolge und eine Ganzzahl mit der Größe dieses Puffers in Zeichen. Wenn GetShortPathName mit einem Puffer aufgerufen wird, der zu klein für die Aufnahme der konvertierten Zeichenfolge ist (oder genauer: wenn der Wert in cchBuffer zu klein ist), gibt GetShortPathName die Größe des Puffers zurück, der zur Speicherung der Zeichenfolge erforderlich ist.
[DllImport("kernel32.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern uint GetShortPathName(
    string lpszLongPath, StringBuilder lpszShortPath, uint cchBuffer);
 
private static string GetShortPathName(FileInfo file)
{
    StringBuilder buffer = new StringBuilder();
    string path = file.FullName;
    uint length = GetShortPathName(path, buffer, 0);
    if (length > 0)
    {
        buffer.Capacity = (int)length;
        GetShortPathName(path, buffer, length);
        return buffer.ToString();
    }
    return null;
}

Sie können diesen Umstand nutzen, indem Sie GetShortPathName zweimal aufrufen. Der erste übergibt die Puffergröße 0, um GetShortPathName, zur Rückgabe der benötigen Puffergröße zu zwingen. Anschließend wird ein zweiter Aufruf von GetShortPathName mit der erforderlichen Puffergröße durchgeführt. Wenn Sie über P/Invoke Win32-Funktionen aufrufen, die voraussetzen, dass ein Zeiger auf einen Zeichenfolgenpuffer den Ausgabewert enthält, verwenden Sie in der Regel einen StringBuffer, dem über seine Capacity-Eigenschaft eine Puffer vorab zugeordnet ist, der groß genug für die Ausgabezeichenfolge ist. Nach P/Invoke kann das Ergebnis problemlos über die ToString-Methode von StringBuffer abgerufen werden.

Senden Sie Stephen Ihre Fragen und Kommentare in englischer Sprache an netqa@microsoft.com.


Stephen Toubist technischer Redakteur beim MSDN-Magazin.

Page view tracker