MSDN Magazin > Home > Ausgaben > 2008 > Juli >  .NET-Themen: Asynchrone Ein- und Ausgabe mit We...
.NET-Themen
Asynchrone Ein- und Ausgabe mit WebClient
Stephen Toub

F: Ich finde die statischen Hilfsmethoden der System.IO.File-Klasse für das Lesen und Schreiben von Daten wirklich gut: ReadAllText, ReadAllBytes, WriteAllText, WriteAllBytes und so weiter. Allerdings sind diese Methoden synchron, und ich möchte sie asynchron verwenden können, sodass sie im Hintergrund ebenfalls asynchrone E/A-Vorgänge verwenden. Ich weiß, dass die System.IO.FileStream-Klasse asynchrone E/A-Vorgänge unterstützt, aber ist dies auch bei der System.IO.File-Klasse der Fall?

A: Die File-Methoden unterstützen nur synchrone Vorgänge. Aber es gibt die Funktionalität zur Implementierung asynchroner Methoden wie die, die Sie beschreiben. In diesem Artikel werden zwei Möglichkeiten dafür vorgestellt, wobei die kompliziertere, aber effizientere der beiden Methoden zuerst behandelt wird.
Zunächst werden die APIs definiert, die tatsächlich implementiert werden sollen. Die ursprünglichen, von Ihnen angegebenen Signaturen sehen folgendermaßen aus:
public static byte [] ReadAllBytes(string path);
public static string ReadAllText(string path);

public static void WriteAllBytes(string path, byte [] bytes);
public static void WriteAllText(string path, string contents);
Asynchrone Versionen dieser Signaturen werden folgendermaßen definiert:
public static void ReadAllBytesAsync(
    string path, Action<byte[]> success, Action<Exception> failure);
public static void ReadAllTextAsync(
    string path, Action<string> success, Action<Exception> failure);

public static void WriteAllBytesAsync(
    string path, byte[] bytes, 
    Action success, Action<Exception> failure);
public static void WriteAllTextAsync(
    string path, string contents, 
    Action success, Action<Exception> failure);
Diese Signaturen sind den Originalen sehr ähnlich. Statt Daten synchron zurückzugeben, akzeptieren die Read*-Methoden zwei Delegaten, einen für die erfolgreiche Ausführung und einen für die Ausführung in Ausnahmefällen. Dabei werden dem Delegaten der ersteren die Lesedaten und dem Delegaten der letzteren jegliche resultierende Ausnahmen übergeben. Die Write*-Methoden akzeptieren ebenfalls zwei Delegaten, aber der Erfolgsdelegat weist keine Parameter auf, da es keine erwartete Ausgabe gibt (die ursprünglichen Write*-Methoden geben „Void“ zurück).
Der kompliziertere Ansatz beinhaltet die direkte Implementierung mit dem APM-Muster (asynchrones Programmiermodell), das von der System.IO.Stream-Klasse verfügbar gemacht wird, von der FileStream abgeleitet wird. In meinem Artikel in der MSDN® Magazin-Ausgabe vom März 2008 (msdn.microsoft.com/magazine/cc337900) wurde die Implementierung einer CopyStreamToStream-Methode demonstriert, die asynchron von einem zu einem anderen Datenstrom kopiert und dazu die APM-Implementierung jedes Datenstroms verwendet. Zur Referenz wird diese Implementierung in Abbildung 1 gezeigt und hier wiederverwendet. (Die Implementierung ist etwas vereinfacht und weist geringe Unterschiede zu der aus der Märzausgabe auf. Dazu gehört auch die Entfernung der Verwendung von System.ComponentModel.AsyncOperationManager und AsyncOperation, da deren Verwendung vorzugsweise in die in diesem Artikel implementierten APIs höherer Ebene verschoben wird. Warum, werden Sie gleich sehen.)
public static void CopyStreamToStream(
    Stream source, Stream destination, Action<Exception> completed)
{
    byte[] buffer = new byte[0x1000];
    if (completed == null) completed = delegate {};

    AsyncCallback rc = null;
    rc = readResult =>
    {
        try
        {
            int read = source.EndRead(readResult);
            if (read > 0)
            {
                destination.BeginWrite(buffer, 0, read, writeResult =>
                {
                    try
                    {
                        destination.EndWrite(writeResult);
                        source.BeginRead(
                            buffer, 0, buffer.Length, rc, null);
                    }
                    catch (Exception exc) { completed(exc); }
                }, null);
            }
            else completed(null);
        }
        catch (Exception exc) { completed(exc); }
    };

    source.BeginRead(buffer, 0, buffer.Length, rc, null);
}
Diese Implementierung repräsentiert den schwierigsten Aspekt der Implementierung dieser asynchronen Methoden. Zusätzlich zum Kopieren von Datenstrom zu Datenstrom werden hier zwei Hilfsmethoden implementiert, eine für das asynchrone Lesen von Bytes aus einer Datei und eine für das asynchrone Schreiben von Bytes in eine Datei. Diese Hilfsmethoden erledigen den größten Teil der Arbeit (siehe Abbildung 2).
private static void ReadAllBytesAsyncInternal(string path, 
    Action<byte[]> success, Action<Exception> failure)
{
    var input = new FileStream(path, FileMode.Open, 
        FileAccess.Read, FileShare.Read, 0x1000, true);
    var output = new MemoryStream((int)input.Length);
    CopyStreamToStream(input, output, e =>
    {
        byte [] bytes = e == null ? output.GetBuffer() : null;
        output.Close();
        input.Close();

        if (e != null) failure(e);
        else success(bytes);
    });
}

private static void WriteAllBytesAsyncInternal(
    string path, byte[] bytes, 
    Action success, Action<Exception> failure)
{
    var input = new MemoryStream(bytes);
    var output = new FileStream(path, FileMode.Create, 
        FileAccess.Write, FileShare.None, 0x1000, true);
    CopyStreamToStream(input, output, e =>
    {
        input.Close();
        output.Close();

        if (e != null) failure(e);
        else success();
    });
}
ReadAllBytesAsyncInternal erstellt einen Eingabe-FileStream für die asynchrone Arbeit mit dem zugrunde liegenden Datenstrom und einen MemoryStream zum Speichern der aus der Datei gelesenen Bytes. Dann wird die CopyStreamToStream-Methode verwendet, um alle Daten asynchron aus dem FileStream zum MemoryStream zu kopieren. Wenn der Vorgang abgeschlossen ist, werden die Datenströme geschlossen. Der Fehlerdelegat wird aufgerufen, wenn eine Ausnahme ausgelöst wurde. Tritt dies nicht ein, wird der Erfolgsdelegat aufgerufen, und es werden für ihn die aus der Datei in den MemoryStream gelesenen Daten bereitgestellt.
WriteAllBytesAsyncInternal ist sehr ähnlich. Hier wird ein Eingabe-MemoryStream erstellt, der das bereitgestellte Bytearray umschließt, und es wird ein Ausgabe-FileStream erstellt, bei dem es sich auch wieder um einen Datenstrom handelt, der asynchrone E/A-Vorgänge unterstützt. Wie bei ReadAllBytesAsyncInternal werden die Datenströme nach Abschluss des Vorgangs geschlossen, und der Fehlerdelegat wird aufgerufen, wenn eine Ausnahme eingetreten ist.
Die Implementierung jeder der an früherer Stelle gezeigten öffentlichen Signaturen erfordert jetzt nur noch einige Zeilen zusätzlich zu den in Abbildung 1 und 2 gezeigten Methoden. Diese Zeilen sind in Abbildung 3 aufgeführt.
public static void ReadAllBytesAsync(
    string path, Action<byte[]> success, Action<Exception>
        failure)
{
    AsyncOperation asyncOp = 
        AsyncOperationManager.CreateOperation(null);
    ReadAllBytesAsyncInternal(path,
        bytes => asyncOp.Post(delegate { success(bytes); }, null),
        exception => asyncOp.Post(
            delegate { failure(exception); }, null));
}

public static void ReadAllTextAsync(
    string path, Action<string> success, Action<Exception>
        failure)
{
    AsyncOperation asyncOp = 
        AsyncOperationManager.CreateOperation(null);
    ReadAllBytesAsyncInternal(path,
        bytes => {
            string text;
            using (var ms = new MemoryStream(bytes)) 
                text = new StreamReader(ms).ReadToEnd();
            asyncOp.Post(delegate { success(text); }, null);
        },
        exception => asyncOp.Post(
            delegate { failure(exception); }, null));
}

public static void WriteAllBytesAsync(
    string path, byte[] bytes, Action success, Action<Exception>
       failure)
{
    AsyncOperation asyncOp = 
        AsyncOperationManager.CreateOperation(null);
    WriteAllBytesAsyncInternal(path, bytes, 
        () => asyncOp.Post(delegate {success(); }, null),
        exception => asyncOp.Post(
            delegate { failure(exception); }, null));
}

public static void WriteAllTextAsync(
    string path, string contents, Action success, Action<Exception>
       failure)
{
    AsyncOperation asyncOp = 
        AsyncOperationManager.CreateOperation(null);
    ThreadPool.QueueUserWorkItem(delegate
    {
        var bytes = Encoding.UTF8.GetBytes(contents);
        WriteAllBytesAsyncInternal(path, bytes,
            () => asyncOp.Post(delegate {success(); }, null),
            exception => asyncOp.Post(
                delegate { failure(exception); }, null));
    });
}
Jede dieser Methoden stellt hauptsächlich einen einfachen Wrapper um die in Abbildung 2 dargestellten internen Implementierungen dar. Es gibt allerdings auch einige interessante Feinheiten. Zunächst einmal habe ich an früherer Stelle erwähnt, dass ich die AsyncOperationManager-Unterstützung aus CopyStreamToStream entferne. AsyncOperation selbst ist ein Wrapper um System.Threading.SynchronizationContext, und es verwendet den im Aufruf von AsyncOperationManager.CreateOperation erfassten, zugrunde liegenden SynchronizationContext, um Delegaten wieder in einer Weise bereitzustellen, die dem zum Zeitpunkt der Erstellung aktuellen Synchronisierungskontext entspricht. So gibt SynchronizationContext.Current zum Beispiel im Benutzeroberflächenthread in einer Windows® Forms-Anwendung wahrscheinlich einen WindowsFormsSynchronizationContext zurück, der das Marshalling von Delegaten zurück zum Benutzeroberflächenthread unterstützt. Wenn daher AsyncOperationManager.CreateOperation im Benutzeroberflächenthread aufgerufen wird, führt die Verwendung der resultierenden Post-Methode von AsyncOperation dazu, dass der bereitgestellte Delegat zum Benutzeroberflächenthread gemarshallt und dort ausgeführt wird.
Das Anliegen ist aber, die Menge der Arbeit zu minimieren, die im Benutzeroberflächenthread ausgeführt wird. Sehen Sie sich nun die ReadAllTextAsync-Methode an. Wenn das Laden der Daten aus der Datei abgeschlossen ist, sollen die Lesebytes in eine Zeichenfolge konvertiert werden. (Dies wäre effizienter, wenn eine nicht schreibgeschützte StringStream-Klasse vorhanden wäre und eine Instanz der Klasse anstelle eines MemoryStream an CopyStreamToStream übergeben werden könnte.) Bei der Konvertierung könnten höhere Kosten anfallen, als dies in einem Benutzeroberflächenthread wünschenswert wäre. Daher sollte die Konvertierung vor der erneuten Bereitstellung in der Benutzeroberfläche erfolgen. Aber in der ursprünglichen Implementierung von CopyStreamToStream wurde der Abschlussdelegat unter dem erfassten SynchronizationContext ausgeführt, und folglich befänden wir uns zu dem Zeitpunkt, zu dem die Konvertierung ausgeführt werden soll, bereits im Benutzeroberflächenthread. Stattdessen wurde die AsyncOperation-Arbeit in jede dieser äußeren Methoden gezogen. Nun kann die Bereitstellung also verzögert werden, bis die wirkliche Rechenarbeit erledigt ist.
WriteAllTextAsync weist ein weiteres interessantes Implementierungsdetail auf. WriteAllTextAsync führt die Rückgabe erst durch, wenn der asynchrone Vorgang gestartet wurde. Aber bevor WriteAllBytesAsyncInternal zu diesem Zweck aufgerufen werden kann, muss die bereitgestellte Textzeichenfolge in ein Bytearray konvertiert werden. Also wird eine Arbeitsaufgabe in den ThreadPool eingefügt statt den aufrufenden Thread zu blockieren. Diese Arbeitsaufgabe führt die Konvertierung von einer Zeichenfolge in Bytes durch und startet dann die interne Kopie.
Letztendlich handelt es sich dabei nicht um eine Unmenge an Code, aber es ist auch nicht einfach. Man sollte meinen, dass diese häufig auftretenden asynchronen Muster für das Lesen und Schreiben von Dateidaten bereits irgendwo im Microsoft® .NET Framework existieren, besonders wenn man daran denkt, dass es andere Dinge gibt, die Sie wahrscheinlich hinzufügen möchten, wie z. B. Fortschrittsbenachrichtigungen, während die Daten gelesen oder geschrieben werden. Tatsächlich existiert solche Funktionalität bereits, aber an einer unerwarteten Stelle: in System.Net.
Die System.Net.WebClient-Klasse ist für verschiedene Zwecke äußerst nützlich. Bei dem Namen und ihrer Position im System.Net-Namespace überrascht es nicht, dass die meisten Leute denken, dass diese Klasse nur für webbezogene Aktivitäten verwendet wird: das Herunterladen von Dateien von einem HTTP-Server, das Hochladen von Dateien zu einer FTP-Website und Ähnliches. Aber WebClient ist gut abstrahiert, zusätzlich zu WebRequest und WebResponse, die ein austauschbares Factorymodell zum Erstellen konkreter Implementierungen jedes dieser Typen unterstützen. Beim Aufrufen von WebRequest.Create mit einer HTTP-URL wird eine HttpWebRequest-Instanz zurückgegeben, genau wie ein Aufruf mit einer FTP-URL eine Instanz einer FtpWeb­Anforderung zurückgibt. WebClient verwendet WebRequest und WebResponse intern, um eine ganze Reihe nützlicher Funktionen zu implementieren. Hier eine Auswahl einiger der relevanteren Methoden:
public void DownloadDataAsync(Uri address);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;

public void DownloadStringAsync(Uri address);
public event 
    DownloadStringCompletedEventHandler DownloadStringCompleted;

public void UploadDataAsync(Uri address, byte[] data);
public event UploadDataCompletedEventHandler UploadDataCompleted;

public void UploadStringAsync(Uri address, string data);
public event UploadStringCompletedEventHandler UploadStringCompleted;
Wenn Sie also nun DownloadDataAsync aufrufen und die URL der herunterzuladenden Daten bereitstellen, erfolgt das Herunterladen asynchron. Wenn der Vorgang abgeschlossen ist, wird das DownloadDataCompleted-Ereignis ausgelöst. Ebenso erfolgt auch das Hochladen asynchron, wenn Sie UploadStringAsync aufrufen und eine Textzeichenfolge sowie den Speicherort, zu dem die Zeichenfolge hochgeladen werden soll, bereitstellen. Nach Abschluss des Vorgangs wird das UploadStringCompleted-Ereignis ausgelöst. Ganz einfach.
Das Interessante dabei ist, dass einer der in das .NET Framework integrierten WebRequest-Anbieter FileWebRequest ist (und ein entsprechendes FileWebResponse). Damit kann z. B. folgender Code geschrieben werden:
WebRequest wr = Webrequest.Create(@"file://C:\test.txt");
Dieser Code bietet ein WebRequest, mit dem wie mit jeder anderen Anforderung gearbeitet werden kann, aber dieses WebRequest zielt auf eine Datei auf einem Datenträger statt auf eine Datei irgendwo auf einer Website ab. Kombinieren Sie dies mit der Verwendung von WebClient seitens des WebRequest, und Sie verstehen, worauf ich hinaus will: WebClient kann für das asynchrone Lesen und Schreiben von Dateien verwendet werden!
Für eine Implementierung der zuvor erstellten Lese- und Schreibmethoden sind nur einige Zeilen Code pro Methode erforderlich, wie in Abbildung 4 gezeigt. (Tatsächlich stellt sich zu diesem Zeitpunkt die Frage, ob diese Wrapper wirklich erforderlich sind, da der größte Teil des Codes nur zum Anpassen der erfundenen API-Signaturen an die von WebClient für die gleichen Aufgaben bereitgestellten Signaturen verwendet wird.) WebClient verwendet AsyncOperationManager intern, um sicherzustellen, dass die Rückrufe im richtigen Kontext erfolgen, und FileWebRequest/FileWebResponse verwenden asynchrone E/A-Vorgänge, wenn die asynchronen Methoden von WebClient verwendet werden. (WebClient bietet auch synchrone Versionen dieser Methoden.)
public static void ReadAllBytesAsync(
    string path, Action<byte[]> success, Action<Exception> failure)
{
    var wc = new WebClient();
    wc.DownloadDataCompleted += (sender, e) =>
    {
        if (e.Error != null) failure(e.Error);
        else success(e.Result);
    };
    wc.DownloadDataAsync(new Uri("file://" + path));
}

public static void ReadAllTextAsync(
    string path, Action<string> success, Action<Exception> failure)
{
    var wc = new WebClient();
    wc.DownloadStringCompleted += (sender, e) =>
    {
        if (e.Error != null) failure(e.Error);
        else success(e.Result);
    };
    wc.DownloadStringAsync(new Uri("file://" + path));
}

public static void WriteAllBytesAsync(
    string path, byte [] bytes, 
    Action success, Action<Exception> failure)
{
    var wc = new WebClient();
    wc.UploadDataCompleted += (sender, e) =>
    {
        if (e.Error != null) failure(e.Error);
        else success();
    };
    wc.UploadDataAsync(new Uri("file://" + path), bytes);
}

public static void WriteAllTextAsync(
    string path, string contents, 
    Action success, Action<Exception> failure)
{
    var wc = new WebClient();
    wc.UploadStringCompleted += (sender, e) =>
    {
        if (e.Error != null) failure(e.Error);
        else success();
    };
    wc.UploadStringAsync(new Uri("file://" + path), contents);
}
Sogar noch besser ist, dass WebClient nützliche zusätzliche Funktionalität bietet. Insbesondere bietet WebClient bereits integrierte Unterstützung für die Fortschrittsüberwachung. Es werden zwei relevante Ereignisse geboten:
public event 
    DownloadProgressChangedEventHandler DownloadProgressChanged;
public event 
    UploadProgressChangedEventHandler UploadProgressChanged;
Diese werden ausgelöst, wenn Fortschrittsmeldungen über einen vorgangsinternen Download bzw. Upload bereitgestellt werden sollen. Die den Handlern für diese Ereignisse gebotenen Ereignisargumente enthalten nützliche Informationen für die Fortschrittsberichterstattung. Hier ist z. B. der DownloadProgressChangedEventArgs-Typ:
public class DownloadProgressChangedEventArgs : 
    ProgressChangedEventArgs
{
    public long BytesReceived { get; }
    public long TotalBytesToReceive { get; }

    /* from the base ProgressChangedEventArgs type
    public int ProgressPercentage { get; }
    public object UserState { get; }
    */
}
Damit können die asynchronen E/A-Vorgänge ganz einfach in GUI-Anwendungen integriert werden. Nehmen wir eine Windows Forms-Anwendung, die eine große Datei von einem Datenträger in den Speicher laden muss, bevor sie damit arbeiten kann. Die Datei kann asynchron mit Fortschrittsbenachrichtigungen, die eine Fortschrittsanzeige aktualisieren, und mit einer Abschlussmeldung geladen werden:
private void button1_Click(object sender, EventArgs e) {
    WebClient wc = new WebClient();
    wc.DownloadDataCompleted += (s, de) => {
        _fileData = de.Result;
        MessageBox.Show("File loaded");
    };
    wc.DownloadProgressChanged += 
        (s, de) => progressBar1.Value = de.ProgressPercentage;
    wc.DownloadDataAsync(new Uri(@"file://c:\largeFile.dat"));
}
private byte [] _fileData;
Leider gibt es hier aber ein erhebliches Problem, obwohl die APIs recht elegant sind. Während FileWebRequest/FileWebResponse asynchrone E/A-Vorgänge im Hintergrund verwenden, benötigt die Implementierung für Rückrufmeldungen aus unerfindlichen Gründen auch den Arbeits-ThreadPool. Manchmal blockiert sie die Threads vom Pool, während sie darauf wartet, dass die asynchronen E/A-Vorgänge abgeschlossen werden. (Es lässt sich darüber streiten, ob dies ein Codefehler oder einfach ein Problem schlechter Leistung ist.) Letztendlich bedeutet dies aber, dass, wenn Sie versuchen, dieses WebClient-Verfahren zum asynchronen und gleichzeitigen Lesen oder Schreiben einer großen Zahl von Dateien zu verwenden, Sie feststellen werden, dass die Leistung durch die Anzahl der Threads im ThreadPool stark beeinträchtigt wird.
Das Blockieren von Threads im ThreadPool ist in der Regel eine schlechte Idee, da es den ThreadPool zwingt, neue Threads einzufügen, wodurch nicht nur zusätzliche Ressourcen verbraucht werden, sondern auch die Anwendung verlangsamt wird, da der ThreadPool das Einfügen mit Zeitverzögerungen drosselt. Alles in allem ist der WebClient-Ansatz zwar vielleicht gut für das asynchrone Lesen und Schreiben einiger weniger Dateien geeignet, aber bis FileWebRequest und FileWebResponse so geändert werden, dass dieses Problem behoben ist, stehen Sie wahrscheinlich mit den an früherer Stelle in diesem Artikel gezeigten handcodierten Versionen besser da. Eine andere Lösung besteht darin, Ihre eigenen Implementierungen von WebRequest/WebResponse bereitzustellen, die die gewünschten Vorgänge ausführen, und dann diese Implementierungen in WebClient zu registrieren.

Senden Sie Fragen und Kommentare für Stephen Toub in englischer Sprache an netqa@microsoft.com.

Stephen Toub ist leitender Programmmanager im Parallel Computing Platform-Team von Microsoft. Er schreibt außerdem redaktionelle Beiträge für das MSDN Magazin.

Communityinhalt   Was ist Community Content?
Neuen Inhalt hinzufügen RSS  Anmerkungen
Processing
Page view tracker