Amélioration des performances en .NET avec le parallélisme et l’asynchronisme.

Les différentes versions du C# ont toujours apporté une nouveauté remarquable (je ne parle pas ici des versions du framework .NET qui sont en décalage, par exemple C# 3.0 est disponible avec le framework v3.5):

  • V1.0 : le support du code managé, l’essence du .NET
  • V2.0 : les générics
  • V3.0 : LINQ
  • V4.0 : la programmation et le binding dynamique (mot clef dynamic) et l'intégration de la TPL (Task Parallel Library) avec le support Parallel.ForEach et .AsParallel sur les requêtes LINQ.
  • V5.0 : (à venir) la programmation asynchrone (nouveaux mots clefs await et async)

Pour optimiser les performances de nos applications .NET, deux outils sont particulièrement importants : le parallélisme et l’asynchronisme.

Le parallélisme

Commençons par le parallélisme, disponible via à la TPL, qui permet de maximiser la performance en éclatant les tâches sur les processeurs :

class Test
{
    public static string ComputeSHA1(string filename)
    {
        using (var stream = new FileStream(filename, FileMode.Open))
        {
            SHA1CryptoServiceProvider crypto = new SHA1CryptoServiceProvider();
            byte[] signature = crypto.ComputeHash(stream);
            return Convert.ToBase64String(signature);
        }
    }

    public static void Benchmark(string label, Action action)
    {
        Stopwatch watch = new Stopwatch();
        watch.Start();
        action();
        watch.Stop();
        Console.WriteLine(String.Format("Version {0}, Execution: {1} secondes", label, watch.Elapsed.TotalSeconds));
    }

    public static void Main(string args)
    {
        var path = @"E:\Devl\";
        var files = Directory.EnumerateFiles(path);
        Console.WriteLine(String.Format("{0} hash SH1 a calculer", files.Count()));

        Benchmark("Classique", () => { foreach (var file in files) ComputeSHA1(file); });
        Benchmark("Parallèle", () => Parallel.ForEach(files, (file) => ComputeSHA1(file)) );

        Console.ReadKey();
    }
}

Les différences sont notables. Dans notre exemple nous calculons les hash SH1 de 1470 fichiers (plusieurs Go de données) en 25 secondes -ce qui est déjà rapide-, la version parallèle profite de la présence des 8 processeurs disponibles sur notre machine de test.

Benchmark

En effet la version classique ne va utiliser qu’un seul processeur :

Programmation classique

Là où la version parallèle exploite tous les processeurs disponibles, avec un gain considérable :

Programmation parallèle

L'asynchronisme

Attention à ne pas confondre le parallélisme et l’asynchronisme. Le parallélisme permet de maximiser la performance en éclatant les tâches sur les processeurs. L’asynchronisme permet de ne pas attendre la fin d’une tâche pour en commencer une autre. Tout se joue dans cette notion d’appel bloquant ou non bloquant (le ForEach de la TPL est bloquant par exemple). Bien sûr l'asynchronisme va profiter de la présence de multiples processeurs grâce à son architecture threadée, mais les gains sont déjà visibles même avec un unique processeur.

Il est déjà possible depuis longtemps de faire de la programmation asynchrone (avec des Threads, le Threadpool, en utilisant des délégués de callback, etc.). Les nouveaux mots clefs await et async permettent de s’affranchir de toute cette précédente gestion :

  • Async : est un nouveau « modificateur » de méthode comme public ou static par exemple. Il rend l’appel de la méthode non bloquant (si la méthode contient des await)
  • Await : est un nouveau mot clef qui préfixe l’appel d’une méthode renvoyant une « Task » (ou void pour des appels type « call & forget » i.e. sans retour).

Là où tout cela devient assez puissant c’est qu’il devient possible de programmer comme en appel synchrone (avec par exemple des gestionnaires d'exceptions, là où il fallait auparavant propager les exceptions dans des variables de callback), les annulations de tâches sont gérées tout comme la synchronisation etc.

Version asynchrone de notre fonction de calcul précédente:

public async Task<string> ComputeSHA1(string filename)
{
    using (var stream = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        SHA1CryptoServiceProvider crypto = new SHA1CryptoServiceProvider();
        byte[] signature = await TaskEx.Run<byte[]>( ( ) => crypto.ComputeHash(stream) );
        return Convert.ToBase64String(signature);
    }
}

Pour revenir à notre exemple global, il est alors possible d'effectuer tous les calculs en asynchrone puis de faire un point de synchronisation (avant de retourner tous les resultats par exemple) avec la commande suivante:

await TaskEx.WhenAll(from file in files select ComputeSHA1(file));

Comment ça marche:

Le compilateur va créer une machine à état et complètement redécouper le code. Par exemple pour faire que les appels aux méthodes async soient non bloquants, le compilateur va scinder la méthode en sous méthodes en se basant sur les mots clefs await trouvés dans cette dernière. Le nombre de sous méthodes détermine le nombre d'états pour cette entité.

Dès qu'un mot clef await est trouvé, la main est rendue à l'appelant. La tâche globale se poursuivra en background, la machine à état gérera automatiquement les appels successifs aux sous méthodes.

Ce fonctionnement avec une machine à état est très proche de ce qui existe déjà avec le mot clef yield et les itérateurs. Le travail du compilateur est important, et un coup d'oeil avec Reflector permet de se rendre compte du refactoring apporté.

Un commentaire

  1. Belle présentation du parallélisme et l’asynchronisme.

    P.S merci pour tout c’est super article sur votre blog.