Pourquoi abandonner les Threads ?

image

Rappelez-vous Windows NT 3.1

Les plus anciens se souviennent sans doute, que les threads systèmes ont été introduits sur la plateforme Windows en 1993 avec l'apparition du premier système 32 bits Microsoft, Windows NT 3.1. À cette époque, la majorité des ordinateurs possédaient un seul processeur. Mais alors pourquoi avoir introduit des threads alors que de nombreux systèmes d'exploitation se suffisaient d'une gestion multiprocessus ? Simplement pour favoriser la disponibilité et la robustesse des applications. La version bureautique de Windows à l'époque, Windows 3.1 (Win16), n'était pas multitâche et reposait sur un système d'ordonnancement collaboratif pour animer les applications. Naturellement, il arrivait parfois qu'une application ne collabore pas très bien avec le reste du système et dans ce cas, pour l'utilisateur final, la sensation de blocage était immédiate. Dans ce contexte, les threads ont été à la base d'améliorations significatives en terme de disponibilité pour des services de type serveur et pour des applications clientes.

Le multithreading et la course au nombre de cœurs

Aujourd'hui lorsqu’un thread est bloqué pour des raisons diverses, le système continue de fonctionner sans problème (normalement). Pour la majorité d'entre nous, cette technologie multithread n'a pas bouleversé nos habitudes et cela ne nous pose pas de problèmes. En effet pendant des années, nous avons connu des progressions significatives des performances de nos ordinateurs à travers une augmentation régulière du nombre de Hertz (Loi de Moore). Ainsi, sans modifier nos applications, elles profitaient progressivement de l'augmentation de la cadence des processeurs sans intervention de notre part. Cependant, depuis 2004 les constructeurs de processeurs comme le sont Intel et ADM se sont lancés dans une nouvelle course, celle du nombre de cœurs. Ce changement (motivé par de réelles contraintes physiques) comporte de nombreuses conséquences pour l'écosystème logiciel. En effet, pour les développeurs retrouver de la puissance facilement dans ce contexte est devenue presque impossible (The Free Lunch Is Over). Notre patrimoine logiciel étant majoritairement séquentiel le défi est monstrueux : comment transformer nos applications séquentielles en applications parallèles sans sombrer dans les nombreuses difficultés qu'entrainent la programmation multithreads. Mais au-delà des difficultés de développement inhérentes à la programmation système, les threads peuvent s'avérer extrêmement coûteux en mémoire pour tous les développeurs d'applications massivement parallèles. En effet, les threads sont des objets systèmes, ayant une empreinte conséquente sur le système d'exploitation. Pour vous en convaincre, voici un petit programme qui alloue une cinquantaine de threads. Observons les variations de leurs empreintes mémoires.

static void RunWithThreads(int items)

{

    var threadNum = 0;

    const long oneMb = 1024 * 1024;

    var threads = new Thread[items];

    Console.WriteLine("Launch {0} threads", threads.Length);

    using (var wakeThreads = new ManualResetEvent(false))

    {

        while (threadNum < items)

        {

            threads[threadNum] = new Thread(WaitOnEvent);

            threads[threadNum].Start(wakeThreads);

            ++threadNum;

            long memoryAfter = Process.GetCurrentProcess().PrivateMemorySize64 / oneMb;

            Console.WriteLine("Thread {0,3}: {1,3} MB", threadNum, memoryAfter);

        }

        wakeThreads.Set();

    }

}

private static void WaitOnEvent(Object eventObj)

{

    ((ManualResetEvent)eventObj).WaitOne();

}

Le programme ci-dessus déclare un tableau de références sur des instances de type Thread. Ici nous étudierons le comportement de 50 Threads vis-à-vis de leurs empreintes mémoires. Nous déclarons ensuite un outil de synchronisation de type ManualResetEvent, wakeThread, que nous initialisons à false afin de le créer avec un état non-signaler. Puis nous entrons dans une boucle et nous créons à chaque itération une instance de la classe Thread où, nous transmettons la méthode WaitOnEvent. Ensuite, nous démarrons le thread en passant l'instance de l'évènement précédemment créé, wakeThread, puis nous affichons sur la console l'indice courant de notre tableau ainsi que la taille de mémoire de notre processus en méga-octets. Dans la méthode WaitOnEvent, on ne fait que se mettre en attente sur l'évènement passé en argument (wakeThread). En d'autres mots, nos 50 threads sont créés successivement et attentent que quelqu'un signale l'évènement pour qu'ils se terminent. Et c'est exactement ce que fait le thread principal à l'issue de la boucle pour conclure le programme. Si vous exécutez ce code, vous obtiendrez un résultat similaire à la figure 1.

image

Figure 1: Coût mémoire pour 50 threads

On observe une progression significative de la mémoire de notre processus alors que nos threads ne font qu'attendre sans exécuter de code fonctionnel. Nous obtenons une croissance de 56 Mo et donc une moyenne de 1,12 Mo par Thread, ce qui est relativement prohibitif lorsque nous recherchons à paralléliser massivement notre code afin d'obtenir des performances importantes. Cependant, ceci n'est pas vraiment une surprise, car un thread consomme de nombreuses ressources, par exemple la pile en mode utilisateur fixée par défaut à 1 Mo de mémoire alloué dés la création d'un thread .NET (c'est à dire à l'appel de la méthode Start qui est responsable de la création d'un thread Win32). Ce choix peut sembler excessif, mais il permet de couvrir un usage varié de la pile sans que les programmes s'en inquiètent.

Le pool de thread et la course au nombre de cœurs

Comme nous venons de le constater, la création d'un thread est une opération coûteuse en terme de mémoire consommée, mais aussi en terme de performance, car initialiser un thread est une opération lourde pour le système d'exploitation comme allocation mémoire à la fois en mode noyau et en mode utilisateur accompagnée de notifications vis-à-vis des librairies dynamiques (DLL_THREAD_ATTACH / DLL_THREAD_DETACH). Pour éviter que les développeurs consomment trop de ressources en utilisant directement l'API Thread. Le Framework .NET met à disposition un pool de threads dont la motivation est de fournir aux développeurs une solution simple et efficace pour réaliser des traitements en asynchrone. Par défaut, le pool de threads ne contient pas de threads. C'est en fonction de la fréquence de l'appel de la méthode ThreadPool.QueueUserWorkItem  que le pool décide du nombre de threads de travail qu'il doit créer. Par défaut, si vous sollicitez régulièrement le pool il créera un nombre de threads travail, identique au nombre de cœurs disponibles sur la machine courante. Lorsqu'un thread du pool termine son travail, le thread n'est pas détruit, mais recyclé dans le pool, où il reste inactif en attente d'une autre éventuelle demande. Si passé un certain délai, il n'y a plus demande, notre thread de travail se suicide libérant du même coup ses ressources associées pour le plus grand bonheur de notre programme. En d'autres mots, le pool se comporte dynamiquement en fonction des demandes. Pour constater les bénéfices du pool de threads. Voici une nouvelle version du programme précèdent adapté avec la classe ThreadPool.

private static void RunWithThreadPool(int items)

{

    var workItemNum = 0;

    const long oneMb = 1024 * 1024;

    Console.WriteLine("Launch {0} WorkItems", items);

    using (var wakeWorkIems = new ManualResetEvent(false))

    {

        while (workItemNum < items)

        {

            ThreadPool.QueueUserWorkItem(WaitOnEvent, wakeWorkIems);

            ++workItemNum;

            long memoryAfter = Process.GetCurrentProcess().PrivateMemorySize64 / oneMb;

            Console.WriteLine("WorkItem {0,3}: {1,3} MB", workItemNum, memoryAfter);

        }

        wakeWorkIems.Set();

    }

}

Si vous exécutez ce code pour 50 items, vous obtiendrez un résultat similaire à la figure 2.

image

Figure 2 : Coût mémoire pour 50 workItems

On observe sur la figure 2, une progression légère de la mémoire. Elle semble se stabiliser autour de 17 méga-octets ce qui est extrêmement bien au regard des résultats constatés avec API Thread. Note aussi un temps d'exécution globale un peu meilleur. Si vos traitements peuvent être lancés sans se soucier des aspects de synchronisation inter-tâches, le pool de threads conviendra parfaitement. Cependant, l'API Thread offrait un contrôle fin sur les traitements ce que le pool de threads ne propose pas facilement. Pour obtenir un contrôle fin sans pour autant payer le prix fort comme nous l'impose l'API Thread, les équipes Microsoft nous proposent à travers le Framework 4.0 une nouvelle API, System.Threading.Tasks.Task.

L'API Task et la course au nombre de cœurs

Avec Visual Studio 2010 et .NET 4.0, l'offre parallèle orientée tâches s'inscrit comme une réponse réactualisée des threads système. Nous verrons (dans un prochain billet) que cette nouvelle offre est plus simple que l'API Thread, mais pour l'instant nous allons nous attacher à cette faible consommation mémoire ainsi que ses meilleures performances. Pour vous convaincre, voici une version réactualisée du programme précèdent, mais cette fois-ci en utilisant la nouvelle classe Task disponible dans .NET 4.0.

static void RunWithTasks(int items)

{

    var taskNum = 0;

    const long oneMb = 1024 * 1024;

    var tasks = new Task[items];

    Console.WriteLine("Launch {0} tasks", tasks.Length);

    using (var wakeTasks = new ManualResetEvent(false))

    {

        while (taskNum < items)

        {

            tasks[taskNum] = new Task(WaitOnEvent, wakeTasks);

            tasks[taskNum].Start();

            ++taskNum;

            long memoryAfter = Process.GetCurrentProcess().PrivateMemorySize64 / oneMb;

            Console.WriteLine("Task {0,3}: {1,3} MB", taskNum, memoryAfter);

        }

        wakeTasks.Set();

    }

}

Cette nouvelle version est sémantiquement très proche du programme qui utilisait l'API Thread. Mais si vous exécutez ce code, vous obtiendrez un résultat similaire à la figure 3.

image

Figure 3: Coût mémoire pour 50 tâches

La figure 3 nous montre des résultats très similaires à ceux du pool de threads, ce qui nous permet de dire que les deux technologies sont équivalentes à la fois sur le plan de l'empreinte mémoire et sur le plan des performances. Pour obtenir ces bons résultats, les équipes Microsoft ont misé sur une révision significative du pool de threads .NET. En interne du Framework 4.0, le pool de threads est responsable de l'animation des tâches de l'API Task. Dans le Framework 2.0 et le pool threads, nous disposions d'une file d'attente globale qui réceptionnait les demandes de travail pour ensuite les distribuer aux threads de travail disponibles.

image

Figure 4: Le ThreadPool avant le Framework 4.0

Dans le schéma ci-dessous, figure 4, cinq items ont été insérés dans le pool de threads dont deux sont en cours d'exécution. L’architecture interne du pool de threads reposait sur une file d'attente globale ce qui n'était pas optimal pour assurer des performances accrues face à la croissance du nombre de cœurs dans nos prochaines machines. L'unique file d'attente globale permettait de réceptionner les demandes de traitement. Mais pour être traité, chaque élément contenu dans la file doit être extrait pour être affecté à un thread de travail. Comme le pool de threads est utilisable depuis un environnement multithreads, chaque insertion ou retrait dans la file d'attente,  gérée par un verrou global, créant du même coup un frein potentiel pour obtenir de bonnes performances. Dans cette architecture, on note deux problèmes sous-jacents :

·         En cas d'affluence importante de nouvelles demandes dont la granularité serait très fine, le verrou global deviendrait rapidement un frein, car le nombre d’appels au verrou augmenterait de manière conséquente.

·         L'évolution significative du nombre de cœurs dans les prochains ordinateurs entrainerait une distribution plus accrue pour la boucle de ventilation des items de travail. L'appel systématique au verrou de la file globale ralentirait la distribution des traitements sur les cœurs et pénaliserait l'utilisation effective de tous les cœurs.

Pour améliorer les performances du pool de threads du Framework 2.0, quelques suggestions ont été proposées, comme la décentralisation des techniques de distribution des items de file d'attente globale afin de soulager l'usage du verrou. En associant chaque thread de travail à une file d'attente locale (sans verrou, car confiné dans le pool de threads et donc inaccessible depuis les librairies), nous modifions significativement la technique de répartition des items tout en corrigeant le problème de goulot d'étranglement de la file d'attente globale. Un autre point d'amélioration proposé est le changement de la notion d’item de travail en la notion de tâche de travail, offrant une sémantique plus riche et permettant de corréler les tâches entre elles via leurs liens de filiation afin de les regrouper si possible dans la même file. Comme dans le pool de threads, les éléments entrants (ici des tâches) sont insérés dans la file d'attente globale et ventilés dans l'ensemble des files d'attente associées aux threads de travail. Chaque thread de travail dépile (sans verrouiller de ressource) sa file d'attente au fur et à mesure de l’exécution des traitements. Dans ce modèle, la fréquence de l'usage du verrou de file global n'est plus complètement corrélée avec la vitesse de traitement des threads de travail.

image

Figure 5: Le pool de thread en action

En fonctionnement nominal, la ventilation des traitements dans les files d'attente se fait en fonction des affinités de filiation entre les tâches, afin que les caches des processeurs (essentiellement L2) puissent éventuellement satisfaire plusieurs tâches successives. Un thread de travail vérifie d'abord si sa file d'attente contient des tâches à traiter. Si c'est le cas, il dépile une tâche en suivant l'algorithme suivant: Last Input, First Output (LIFO), le tout sans utiliser de verrou. Cependant, vous pouvez remarquer que les tâches sont alors exécutées dans l'ordre inverse de leur file d'attente (figure 5, le thread de travail 1 a déplié la tâche 5).

image 

Figure 6: Illustration du vol de travail

Le point essentiel qu'apporte cette nouvelle architecture est dans la capacité de rediriger une tâche d'une file locale à une autre file locale en fonction des taux d'occupation (figure 6), lorsqu'un thread de travail termine sa tâche courante. Si le thread de travail constate que sa file d'attente courante n'a plus de tâches en attentes (thread de travail p), il "vole du travail" à une autre file contentant des tâches en attentes, en retirant une tâche par le haut de sa file voisine (ici la tâche 3 depuis la file d'attente du thread de travail 1).

Nous verrons dans un prochain billet que l'API Task permet un excellent contrôle des traitements, offrant même une forme de composition engendrant alors une forme de Workflow des traitements parallélisés. Cependant si vos traitements n'exigent pas de contrôles particuliers, vous pouvez peut-être vous suffire du ThreadPool.

En résumé

Pour conclure ce premier billet sur la nouvelle offre parallèle pour les développeurs .NET, je souhaitai proposer des schémas synthétiques comme la figure 7 et la figure 8 ci-dessous. La figure 7 propose une comparaison de la mémoire consommée à la fois les technologies ThreadTask et ThreadPool.

image

Figure 7: Consommation mémoire en Mo pour 50 items avec le technologies Thread, Task et ThreadPool

Nous trouvons en ordonnée le volume mémoire en méga-octets et en abscisse le nombre de threads, de tâches et WorkItems.

La figure 8: ci-dessous, illustre les performances des trois technologies en ms.

image

Figure 8: Performance en ms pour 50 items avec les technologies Thread, Task et ThreadPool

Ces tests nous confirment que les threads ne devraient plus être utilisés directement par nos programmes, du moins si vous développez en .NET 4.0. En d'autres mots, nous pouvons imaginer d'utiliser les tâches ou le pool de threads pour paralléliser nos traitements sans que la mémoire pénalise notre programme. Aujourd'hui, nous disposons de machines bon marché allant de 2 à 8 cœurs, mais peut-être que demain nous disposerons de machines contenant beaucoup plus de CPU, rappelez-vous que nous sommes rentrés dans une course au nombre de cœurs qui va sans doute durer un bon moment.

Dans cette première partie, je vous ai parlé de l'API Task, mais sans la détailler. Dans mon prochain billet, je corrigerai ce manque en vous expliquant les fonctionnalités de cette nouvelle librairie tout en expliquant le rôle du pool de threads vis-à-vis de son fonctionnement interne.

A bientôt,

Bruno

boucard.bruno@free.fr