Async (launch :: async) dans C ++ 11 rend-il les pools de threads obsolètes pour éviter la création de threads coûteux?

117

Il est vaguement lié à cette question: std :: thread est-il regroupé en C ++ 11? . Bien que la question diffère, l'intention est la même:

Question 1: Est-il toujours judicieux d'utiliser vos propres pools de threads (ou ceux d'une bibliothèque tierce) pour éviter la création de threads coûteux?

La conclusion de l'autre question était que vous ne pouvez pas compter sur la std::threadmise en commun (cela pourrait ou non). Cependant, std::async(launch::async)semble avoir une chance beaucoup plus élevée d'être mis en commun.

Il ne pense pas qu'il soit forcé par la norme, mais à mon humble avis, je m'attendrais à ce que toutes les bonnes implémentations C ++ 11 utilisent le regroupement de threads si la création de threads est lente. Uniquement sur les plates-formes où il est peu coûteux de créer un nouveau fil, je m'attendrais à ce qu'ils génèrent toujours un nouveau fil.

Question 2: C'est exactement ce que je pense, mais je n'ai aucun fait pour le prouver. Je peux très bien me tromper. Est-ce une supposition éclairée?

Enfin, j'ai fourni ici un exemple de code qui montre d'abord comment je pense que la création de threads peut être exprimée par async(launch::async):

Exemple 1:

 thread t([]{ f(); });
 // ...
 t.join();

devient

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Exemple 2: déclencher et oublier le fil

 thread([]{ f(); }).detach();

devient

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Question 3: Préférez-vous les asyncversions aux threadversions?


Le reste ne fait plus partie de la question, mais uniquement pour clarification:

Pourquoi la valeur de retour doit-elle être affectée à une variable factice?

Malheureusement, le standard C ++ 11 actuel oblige à capturer la valeur de retour std::async, sinon le destructeur est exécuté, ce qui se bloque jusqu'à ce que l'action se termine. C'est par certains considéré comme une erreur dans la norme (par exemple, par Herb Sutter).

Cet exemple de cppreference.com l' illustre bien:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Une autre précision:

Je sais que les pools de threads peuvent avoir d'autres utilisations légitimes, mais dans cette question, je ne suis intéressé que par l'aspect d'éviter les coûts de création de thread coûteux .

Je pense qu'il existe encore des situations où les pools de threads sont très utiles, surtout si vous avez besoin de plus de contrôle sur les ressources. Par exemple, un serveur peut décider de ne traiter qu'un nombre fixe de demandes simultanément pour garantir des temps de réponse rapides et augmenter la prévisibilité de l'utilisation de la mémoire. Les pools de threads devraient être bien, ici.

Les variables locales de thread peuvent également être un argument pour vos propres pools de threads, mais je ne suis pas sûr que cela soit pertinent dans la pratique:

  • La création d'un nouveau thread avec std::threaddémarre sans variables locales de thread initialisées. Ce n'est peut-être pas ce que vous voulez.
  • Dans les threads générés par async, ce n'est pas clair pour moi car le thread aurait pu être réutilisé. D'après ce que je comprends, les variables locales de thread ne sont pas garanties d'être réinitialisées, mais je peux me tromper.
  • L'utilisation de vos propres pools de threads (de taille fixe), en revanche, vous donne un contrôle total si vous en avez vraiment besoin.
Philipp Claßen
la source
8
"Cependant, std::async(launch::async)semble avoir une chance beaucoup plus élevée d'être mis en commun." Non, je crois std::async(launch::async | launch::deferred)que c'est peut-être mis en commun. Avec juste launch::asyncla tâche est censée être lancée sur un nouveau thread indépendamment des autres tâches en cours d'exécution. Avec la politique, launch::async | launch::deferredla mise en œuvre peut alors choisir quelle politique, mais plus important encore, elle retarde le choix de la politique. Autrement dit, il peut attendre qu'un thread d'un pool de threads devienne disponible, puis choisir la stratégie asynchrone.
bames53
2
Autant que je sache, seul VC ++ utilise un pool de threads avec std::async(). Je suis toujours curieux de voir comment ils prennent en charge les destructeurs thread_local non triviaux dans un pool de threads.
bames53
2
@ bames53 J'ai parcouru la libstdc ++ fournie avec gcc 4.7.2 et j'ai constaté que si la politique de lancement n'est pas exactement, launch::async elle la traite comme si elle était seulement launch::deferredet ne l'exécute jamais de manière asynchrone - donc en fait, cette version de libstdc ++ "choisit" à toujours utiliser différé sauf indication contraire.
doug65536
3
@ doug65536 Mon point sur les destructeurs thread_local était que la destruction à la sortie de thread n'est pas tout à fait correcte lors de l'utilisation de pools de threads. Lorsqu'une tâche est exécutée de manière asynchrone, elle est exécutée «comme sur un nouveau thread», selon la spécification, ce qui signifie que chaque tâche asynchrone obtient ses propres objets thread_local. Une implémentation basée sur un pool de threads doit faire particulièrement attention pour s'assurer que les tâches partageant le même thread de support se comportent toujours comme si elles avaient leurs propres objets thread_local. Considérez ce programme: pastebin.com/9nWUT40h
bames53
2
@ bames53 Utiliser "comme sur un nouveau fil" dans les spécifications était une énorme erreur à mon avis. std::asyncaurait pu être une belle chose pour les performances - cela aurait pu être le système standard d'exécution de tâches à court terme, naturellement soutenu par un pool de threads. Pour le moment, c'est juste un std::threadavec des conneries pour que la fonction de thread puisse renvoyer une valeur. Oh, et ils ont ajouté des fonctionnalités redondantes «différées» qui chevauchent std::functioncomplètement le travail .
doug65536

Réponses:

55

Question 1 :

J'ai changé cela de l'original parce que l'original était faux. J'avais l'impression que la création de threads Linux était très bon marché et après des tests, j'ai déterminé que la surcharge de l'appel de fonction dans un nouveau thread par rapport à un thread normal est énorme. La surcharge pour créer un thread pour gérer un appel de fonction est quelque chose comme 10000 fois ou plus plus lent qu'un appel de fonction simple. Donc, si vous émettez beaucoup de petits appels de fonction, un pool de threads peut être une bonne idée.

Il est tout à fait évident que la bibliothèque C ++ standard fournie avec g ++ n'a pas de pools de threads. Mais je peux certainement voir un cas pour eux. Même avec la surcharge d'avoir à pousser l'appel à travers une sorte de file d'attente inter-thread, ce serait probablement moins cher que de démarrer un nouveau thread. Et la norme le permet.

À mon humble avis, les gens du noyau Linux devraient travailler à rendre la création de thread moins chère qu'elle ne l'est actuellement. Mais, la bibliothèque C ++ standard devrait également envisager d'utiliser pool pour l'implémentation launch::async | launch::deferred.

Et l'OP est correct, utiliser ::std::threadpour lancer un thread force bien sûr la création d'un nouveau thread au lieu d'en utiliser un à partir d'un pool. Ainsi ::std::async(::std::launch::async, ...)est préféré.

Question 2 :

Oui, fondamentalement, cela lance «implicitement» un fil de discussion. Mais vraiment, ce qui se passe est encore assez évident. Je ne pense donc pas vraiment que le mot soit implicitement un mot particulièrement bon.

Je ne suis pas non plus convaincu que vous forcer à attendre un retour avant la destruction soit nécessairement une erreur. Je ne sais pas si vous devriez utiliser l' asyncappel pour créer des threads «démons» qui ne devraient pas revenir. Et si on s'attend à ce qu'ils reviennent, il n'est pas acceptable d'ignorer les exceptions.

Question 3 :

Personnellement, j'aime que les lancements de threads soient explicites. J'accorde beaucoup de valeur aux îles où vous pouvez garantir un accès série. Sinon, vous vous retrouvez avec un état mutable dans lequel vous devez toujours envelopper un mutex quelque part et vous rappeler de l'utiliser.

J'ai beaucoup mieux aimé le modèle de file d'attente de travail que le modèle «futur» car il y a des «îlots de série» qui traînent pour que vous puissiez gérer plus efficacement l'état mutable.

Mais vraiment, cela dépend exactement de ce que vous faites.

Test de performance

J'ai donc testé les performances de diverses méthodes d'appels et j'ai trouvé ces chiffres sur un système à 8 cœurs (AMD Ryzen 7 2700X) exécutant Fedora 29 compilé avec clang version 7.0.1 et libc ++ (pas libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

Et natif, sur mon MacBook Pro 15 "(processeur Intel (R) Core (TM) i7-7820HQ à 2,90 GHz) Apple LLVM version 10.0.0 (clang-1000.10.44.4)sous OSX 10.13.6, j'obtiens ceci:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Pour le thread de travail, j'ai démarré un thread, puis utilisé une file d'attente sans verrouillage pour envoyer des requêtes à un autre thread, puis attendre qu'une réponse «C'est fait» soit renvoyée.

Le "Ne rien faire" consiste simplement à tester les frais généraux du faisceau de test.

Il est clair que les frais généraux liés au lancement d'un thread sont énormes. Et même le thread de travail avec la file d'attente inter-thread ralentit les choses d'un facteur 20 environ sur Fedora 25 dans une machine virtuelle, et d'environ 8 sur OS X natif.

J'ai créé un projet Bitbucket contenant le code que j'ai utilisé pour le test de performance. Il peut être trouvé ici: https://bitbucket.org/omnifarious/launch_thread_performance

Très varié
la source
3
Je suis d'accord sur le modèle de file d'attente de travail, mais cela nécessite d'avoir un modèle de «pipeline» qui peut ne pas être applicable à chaque utilisation d'accès simultané.
Matthieu M.
1
Il me semble que des modèles d'expression (pour les opérateurs) pourraient être utilisés pour composer les résultats, pour les appels de fonction, vous auriez besoin d'une méthode d' appel , je suppose, mais à cause des surcharges, cela pourrait être légèrement plus difficile.
Matthieu M.
3
"très bon marché" est relatif à votre expérience. Je trouve que la surcharge de création de threads Linux est importante pour mon utilisation.
Jeff
1
@Jeff - Je pensais que c'était beaucoup moins cher qu'il ne l'est. J'ai mis à jour ma réponse il y a quelque temps pour refléter un test que j'ai effectué pour découvrir le coût réel.
Omnifarious
4
Dans la première partie, vous sous-estimez quelque peu ce qu'il faut faire pour créer une menace et combien il faut faire peu pour appeler une fonction. Un appel et un retour de fonction sont quelques instructions de processeur qui manipulent quelques octets en haut de la pile. Une création de menace signifie: 1. allouer une pile, 2. effectuer un appel système, 3. créer des structures de données dans le noyau et les relier, attraper des verrous en cours de route, 4. attendre que le planificateur exécute le thread, 5. basculer contexte au fil. Chacune de ces étapes prend en elle-même beaucoup plus de temps que les appels de fonction les plus complexes.
cmaster - réintégrer monica