CodeGym /Cours /C# SELF /Lecture/écriture asynchrones de fichiers (

Lecture/écriture asynchrones de fichiers ( ReadAsync/ WriteAsync)

C# SELF
Niveau 42 , Leçon 1
Disponible

1. Introduction

Imaginez que vous êtes le chef d'orchestre de votre application. Si à chaque fois que le violoniste accorde son instrument (une opération I/O longue) vous attendez qu'il ait fini avant de passer aux autres musiciens, tout l'orchestre restera bloqué. Mais si le violoniste dit : "Je m'accorde, continuez pendant ce temps, je vous ferai signe quand j'aurai fini", — voilà l'asynchronisme !

Dans l'univers C# et .NET 9, il existe des outils pour cette "multitâche sans gel". Les héros du jour sont les versions asynchrones des méthodes Read et Write, appelées ReadAsync et WriteAsync.

Elles vous permettent d'initier une opération de lecture ou d'écriture et de "lâcher" immédiatement le thread courant pour qu'il fasse autre chose. Quand l'opération I/O est terminée (par exemple, les données ont été lues ou écrites sur le disque), votre code "se réveille" et continue à partir de l'endroit où il s'était arrêté.

Pour utiliser ces méthodes, il nous faut deux mots magiques, arrivés dans C# en 2012 avec la version 5.0 (et maintenant, en C# 14, ils sont comme natifs) :

  • async : c'est un modificateur à ajouter à une méthode pour dire au compilateur : "Il peut y avoir des opérations asynchrones ici et j'utiliserai await".
  • await : c'est l'opérateur qu'on place devant un appel asynchrone (comme ReadAsync ou WriteAsync). Il signifie : "Démarre cette opération, mais ne bloque pas ici. Rend la main au code appelant et reviens quand l'opération est prête".

Ne vous inquiétez pas si ces concepts semblent flous pour l'instant. On aura tout un niveau dédié à async et await (Niveau 58) où on creusera en profondeur. Pour l'instant, retenez qu'ils nous aident à ne pas bloquer le thread principal.

2. ReadAsync : lire sans se presser

La méthode ReadAsync permet de lire des données depuis un stream de façon asynchrone. Au lieu d'attendre que les octets soient lus du disque, vous lancez la lecture et vous passez immédiatement à d'autres tâches.

Voici sa signature principale pour la lecture dans un buffer :

public virtual ValueTask<int> ReadAsync(
    byte[] buffer,
    int offset,
    int count,
    CancellationToken cancellationToken = default
)

Ou, plus courant en C# moderne (et .NET 9), en utilisant Memory<byte> :

public virtual ValueTask<int> ReadAsync(
    Memory<byte> buffer,
    CancellationToken cancellationToken = default
)

Décryptage des paramètres :

  • buffer : C'est le tableau de byte (ou Memory<byte>) où les données lues seront écrites. On utilise des buffers pour optimiser — c'est la même idée ici, mais pour les opérations asynchrones.
  • offset : Le décalage dans le buffer où démarrer l'écriture des octets lus.
  • count : Le nombre maximal d'octets à lire.
  • CancellationToken cancellationToken : Paramètre utile pour annuler l'opération si elle n'est plus nécessaire (par ex. l'utilisateur ferme l'app ou appuie sur le bouton "Annuler").
  • ValueTask<int> : C'est une "promesse" qu'à la fin de l'opération on obtiendra un entier (int) égal au nombre d'octets lus. ValueTask est une version optimisée de Task pour les cas où le résultat peut être disponible de façon synchrone ou asynchrone.

Exemple 1 : lecture asynchrone d'un fichier

Imaginons un gros fichier texte qu'on veut lire sans bloquer le thread principal. Exemple basique :

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

class Program
{
    // Fonction asynchrone pour lire un fichier et compter le nombre de lignes
    public static async Task<int> CountLinesAsync(string filePath)
    {
        int lineCount = 0;
        // Ouverture asynchrone du fichier
        using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
        using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);

        string? line;
        while ((line = await reader.ReadLineAsync()) != null)
        {
            lineCount++;
        }
        return lineCount;
    }

    static async Task Main()
    {
        string filename = "bigtext.txt";
        int count = await CountLinesAsync(filename);
        Console.WriteLine($"Dans le fichier {filename} lignes : {count}");
    }
}

Commentaires sur le code :

  • Faites attention au paramètre useAsync: true pour le FileStream. C'est important pour de la vraie asynchronie.
  • On utilise await sur ReadLineAsync pour ne pas bloquer le thread pendant la lecture d'une ligne.
  • La méthode Main est maintenant asynchrone (C# 7+ le permet).

Si c'était une application graphique, pendant la lecture du fichier (pendant que ReadAsync attend les données du disque) l'utilisateur pourrait cliquer sur des boutons, scroller, et faire d'autres actions, parce que le thread UI ne serait pas bloqué. Dans une app console c'est moins visible, mais le principe est le même.

3. WriteAsync : écrire sans délai

De la même manière que ReadAsync, la méthode WriteAsync permet d'écrire dans un stream de façon asynchrone. Très utile quand il faut écrire beaucoup de données sans bloquer l'application en attendant que le disque finisse.

Signatures principales :

public virtual ValueTask WriteAsync(
    byte[] buffer,
    int offset,
    int count,
    CancellationToken cancellationToken = default
)

Et avec ReadOnlyMemory<byte> (pour l'écriture on ne modifie pas le buffer) :

public virtual ValueTask WriteAsync(
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken = default
)

Les paramètres sont similaires à ReadAsync :

  • buffer : tableau de byte (ou ReadOnlyMemory<byte>) contenant les données à écrire.
  • offset : décalage dans le buffer d'où commencer à lire les données à écrire.
  • count : nombre d'octets à écrire.
  • CancellationToken cancellationToken : pour annuler l'opération.
  • ValueTask : pas de valeur retournée, puisque le nombre d'octets écrits est donné par le paramètre count.

Exemple 2 : écriture asynchrone dans un fichier

Écrivons quelque chose dans un fichier de façon asynchrone.

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

class Program
{
    public static async Task WriteTestAsync(string filePath)
    {
        using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
        using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);

        for (int i = 0; i < 10000; i++)
        {
            await writer.WriteLineAsync($"Ligne numéro {i}");
        }
    }

    static async Task Main()
    {
        string filename = "testout.txt";
        await WriteTestAsync(filename);
        Console.WriteLine($"Écriture {filename} terminée.");
    }
}

Ici la boucle écrit 10000 lignes et le thread principal n'est pas bloqué : si c'était une appli GUI, l'interface ne "gelait" pas.

Grâce à async et await, notre appli console peut faire une copie de fichier tout en restant "réactive" aux entrées utilisateur (par ex. appuyer sur Enter pour annuler). C'est un principe fondamental pour créer des applis modernes, performantes et réactives en C#.

4. Nuances utiles

Visualisation : comment fonctionne la lecture/écriture asynchrone


┌───────────────────┐     Start Async Read    ┌────────────────────────────────┐
│Votre code (UI/logique)│ ─────────────────────→  │  OS/E/S : opération asynchrone │ 
└─────┬─────────────┘                         └───────┬────────────────────────┘
      │(fait autre chose)                         │(lit le fichier, attend le disque)
      │<────────────────────────────────────────→│
      └─ Attend le Task, reçoit le résultat  ←────┘

Schéma approximatif : pendant que le disque bosse, le code peut faire autre chose. Ce n'est que quand les données sont nécessaires que l'exécution attend le Task.

Applications pratiques : où c'est vraiment utile ?

  • Applications desktop : si votre app lit/écrit de gros trucs (logs, bases, vidéos), l'asynchronisme est indispensable. Même sur une machine rapide, ouvrir un fichier sur le réseau peut être très lent.
  • Backend ou web apps : des dizaines ou centaines d'utilisateurs peuvent appeler le serveur simultanément. Si chaque thread bloque sur des lectures de fichiers, adieu les performances et bonjour les 502 Bad Gateway.
  • Applications mobiles : ouvrir ou écrire des fichiers prend du temps — l'utilisateur verra du lag sans asynchronisme.
  • Traitement massif de fichiers : applis qui parcourent collections de fichiers (archiveurs, parsers, analyzers) gagnent beaucoup avec l'I/O asynchrone.

Lecture/écriture synchrone vs asynchrone

Méthode Bloque le thread ? Simple à implémenter ? Meilleure performance ? Confort pour UI/serveurs
Synchrone (Read/Write) Oui Oui Non Non
Asynchrone (ReadAsync) Non Presque Oui Oui

5. Nuances et bonnes pratiques

La bufferisation reste importante : même avec ReadAsync et WriteAsync, lire ou écrire un octet à la fois reste très inefficace. L'asynchronisme enlève le blocage, mais n'accélère pas magiquement la lecture de chaque octet individuel. De bons buffers de départ : 4096-8192 octets ; pour de gros fichiers essayez 65536 ou 131072.

"L'asynchronisme doit être partout" (Async All The Way Down) : si vous commencez à utiliser async/await à un endroit, il est généralement conseillé de propager ce pattern sur toute la chaîne d'appels : si C fait une opération asynchrone, alors C devrait être async Task, B aussi, et A aussi. Sinon vous risquez des blocages ou même des deadlocks dans les UI apps.

Gestion des exceptions : dans le code asynchrone utilisez les habituels try-catch. On rencontre souvent OperationCanceledException et IOException — gérez-les explicitement.

Libération des ressources (await using) : pour les streams et autres objets IDisposable, libérez proprement les ressources. Si le type implémente IAsyncDisposable, alors await using appellera DisposeAsync() ; sinon Dispose() sera appelé.

Ce qui se passe sous le capot (bref) : avec await le compilateur transforme la méthode en un automate d'état : l'opération démarre, la méthode "se met en pause", le contrôle retourne à l'appelant. Quand le résultat est prêt, le SynchronizationContext (dans l'UI) ou le ThreadPool (console/serveur) reprend l'exécution là où elle s'était arrêtée. Ça permet à un seul thread de gérer plein de tâches "suspendues" sans blocages.

En conclusion, la programmation asynchrone avec async et await est un outil puissant pour créer des applications réactives et scalables. Elle permet à votre code d'utiliser efficacement les ressources système sans bloquer l'interface ou les threads serveurs. Au début ça peut sembler déroutant, mais ça vaut vraiment le coup de s'y mettre ! Dans les prochaines leçons on creusera davantage l'asynchronisme et le parallélisme. À bientôt !

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION