Multiprogrammation efficace – considérations techniques

Quelques raccourcis :

Une question qui revient de plus en plus souvent dans mes cours portant sur le parallélisme et la programmation concurrente tient aux bonnes pratiques de multiprogrammation dans une optique de « performance »; ce terme est galvaudé en français mais sera utilisé ici au sens de « programme plus rapide d'exécution », « programme traitant un plus haut débit de données », « programme dont les fils d'exécution sont actifs dans une plus grande proportion du temps total d'exécution », etc.

J'ai essayé de colliger ici une série de considérations en ce sens. Je doute que le présent texte soit un jour complet, le propos étant en évolution continue.

Chacune des considérations portera un nom bref et peu nuancé, puis un « sous-titre » Idée qui nuance un peu, puis une explication et des exemples (comparatifs si possible).

Viser des abstractions de plus haut niveau

Hartmut Kaiser a proposé une conférence nommée Plain Threads are the GOTO of Today's Computing en 2014. Le sous-titre, clin d' oeil à Dijkstra, était Plain Threads Considered Harmful.

Pour les diapos : http://stellar.cct.lsu.edu/pubs/Plain_Threads_are_the_GOTO_of_Todays_Computing_MeetingCpp_2014.pdf

Pour la présentation : https://www.youtube.com/watch?v=4OCUEgSNIAY

Idée : un thread est un outil primitif. Mieux vaut viser des abstractions qui reposent sur des threads mais mettent en valeur les meilleures pratiques.

Bien qu' il soit possible d' utiliser des threads directement, en C++ comme ailleurs, il est typiquement plus efficace d' avoir recours à des abstractions de plus haut niveau (futures, continuations, coroutines, tâches, regroupements de threads, etc.). Cette recommandation s' appuie sur plusieurs considérations :

Conséquemment, lorsque la situation s' y prête, essayez de faire reposer votre code concurrent sur des abstractions de plus haut niveau que celle des threads.

Utiliser un regroupement

Idée : réfléchir en termes de tâches à accomplir

Démarrer un thread est une opération dispendieuse. Pour cette raison, démarrer les threads une seule fois, avant de débuter les traitements parallèles et concurrents d'un processus, puis confier à ces threads des tâches à accomplir, permet de déplacer la charge de travail vers des unités plus simples à conceptualiser, et permet de ne payer le prix de démarrage d'un thread qu'une seule fois, au démarrage du regroupement.

Avec un regroupement :

Notez qu' il est possible que le recours aux futures standards de C++ entraîne un recours à un regroupement de threads, mais cela n' est pas garanti (l' implémentation dépend des vendeurs).

Viser l'immuabilité

Idée : choisir l'immuabilité si possible, synchronisation si nécessaire.

Les accès à une entité accédée concurremment doivent être synchronisés si au moins l'un des threads y accédant le fait en écriture. La synchronisation est alors nécessaire, mais coûteuse (synchroniser sérialise les accès). Lorsqu'une entité accédée concurremment est immuable, il n'est plus nécessaire d'y synchroniser les accès.

Typiquement, un objet immuable n'est modifiable qu' à la construction et à la destruction. Pour les conteneurs, les versions immuables sont souvent soutenues par des moteurs de collecte automatique d' ordures, mais ce n' est pas nécessaire en soi.

L' immuabilité est une optimisation. Évidemment, il arrive que l' immuabilité ne soit pas une approche raisonnable : parfois, écrire une fonction qui accepterait un conteneur immuable, et en créerait une copie légèrement modifiée pour la retourner par la suite serait une séquence d' opérations au coût prohibitif. Dans de tels cas, mieux vaut avoir recours à un conteneur mutable synchronisé...  En retour, lorsque c' est une approche raisonnable, l' immuabilité rapporte beaucoup.

Ne pas bloquer

Idée : bloquer si nécessaire, mais le moins souvent possible

Bloquer un thread dans un processus concurrent risque de paralyser l' une des unités de traitement utilisant ce processus. Les outils de synchronisation (méthode lock() d'un mutex, par exemple, ou méthodes get() et wait() d'une future) sont bloquants et réduisent le débit d' un programme. Certaines métaphores non-bloquantes, comme try_lock(), peuvent réduire les conséquences d' une non-obtention de verrou, mais le fait demeure qu' un verrou sérialise l' exécution d' une section de code, ce qui tend à réduire le débit d' un programme.

Il est possible de mettre au point des conteneurs synchronisés sans verrous, mais c' est très difficile à réaliser et particulièrement difficile à mettre au point. Si vous n' avez pas de tels outils sous la main, sachez qu' il est possible de le faire vous-mêmes, mais ce n' est vraiment pas une tâche banale.

Connaître ses outils

Idée : le standard de C++, comme ceux d'autres langages, offre une gamme d'outils pour soutenir la multiprogrammation. Il est sage de les connaître et de (bien!) les utiliser.

Bien qu'il soit possible d'écrire ses propres abstractions au-dessus des outils du système d' exploitation, celles offertes par un langage de programmation donné ou par sa bibliothèque standard accompagnent typiquement bien, sur le plan idiomatique, ce langage. Pour cette raison, mieux vaut comprendre et connaître les outils offerts par un langage de programmation avant de chercher à les remplacer, et de ne poser ce geste qu' en pleine et entière connaissance de cause.

Connaître ses infastructures

Idée : il existe des infrastructures de soutien à la multiprogrammation. Il est sage de les connaître et, quand ça s'y prête, de (bien!) les utiliser.

Plusieurs moteurs / plusieurs bibliothèques spécialisées permettent d' atteindre une meilleure utilisation des ressources que celle vers laquelle mène une utilisation directe (et souvent naïve) des outils de bas niveau. Parmi ces moteurs, on trouve :

Bien utiliser ces outils sophitiqués rapporte beaucoup.

Connaître ses schémas de conception (en particulier les schémas de conception en programmation parallèle)

Comme c' est souvent le cas en informatique, certaines pratiques usitées en multiprogrammation ont fini par devenir si répandues que plusieurs les ont implémenté de manière similaire, au point où ces pratiques ont fini par être nommées et enseignées comme telles  Ces pratiques, qu' on nomme schémas de conception (Design Patterns), existent dans plusieurs domaines (pas seulement l' informatique!) et dans plusieurs champs d' application; sans surprises, il existe de manière plus spécifique des schémas de conception en programmation parallèle.

Comprendre ces pratiques nous permet de mieux structurer notre pensée, et d' en arriver plus rapidement à des solutions pertinentes et efficaces aux problèmes courants de la programmation parallèle et concurrente.

Utiliser la Cache avec sagesse

Évidemment, l' antémémoire (la Cache) est l' un des principaux facteurs de vitesse d' un programme, et à plus forte partie d' un programme parallèle. Conséquemment :

Pour un exemple comparatif de calculs tirant profit (ou non) de la Cache, dans divers langages, voir ../../CLG/Profs/ProfQuiz-Q03.html#organisation_donnees_optimisation


Valid XHTML 1.0 Transitional

CSS Valide !