Contexte:
Je pose cette question car j'ai actuellement une application avec plusieurs (des centaines à des milliers) de threads. La plupart de ces threads sont inactifs une grande partie du temps, attendant que les éléments de travail soient placés dans une file d'attente. Lorsqu'un élément de travail est disponible, il est ensuite traité en appelant du code existant arbitrairement complexe. Sur certaines configurations de système d'exploitation, l'application se heurte aux paramètres du noyau régissant le nombre maximal de processus utilisateur, je voudrais donc expérimenter des moyens pour réduire le nombre de threads de travail.
Ma solution proposée:
Il semble qu'une approche basée sur la coroutine, où je remplacerais chaque thread de travail par une coroutine, aiderait à accomplir cela. Je peux alors avoir une file d'attente de travail soutenue par un pool de threads de travail réels (noyau). Lorsqu'un élément est placé dans la file d'attente d'une coroutine particulière pour traitement, une entrée est placée dans la file d'attente du pool de threads. Il reprendrait ensuite la coroutine correspondante, traiterait ses données en file d'attente, puis la suspendrait à nouveau, libérant le thread de travail pour effectuer d'autres travaux.
Détails d'implémentation:
En réfléchissant à la façon dont je ferais cela, j'ai du mal à comprendre les différences fonctionnelles entre les coroutines empilées et empilées. J'ai une certaine expérience de l'utilisation de coroutines empilables à l'aide de la bibliothèque Boost.Coroutine . Je trouve qu'il est relativement facile à comprendre à partir d'un niveau conceptuel: pour chaque coroutine, il conserve une copie du contexte et de la pile du processeur, et lorsque vous passez à une coroutine, il bascule vers ce contexte enregistré (tout comme un ordonnanceur en mode noyau le ferait ).
Ce qui est moins clair pour moi, c'est la différence entre une coroutine sans pile. Dans mon application, la quantité de surcharge associée à la mise en file d'attente des éléments de travail décrite ci-dessus est très importante. La plupart des implémentations que j'ai vues, comme la nouvelle bibliothèque CO2 suggèrent que les coroutines sans pile fournissent des changements de contexte beaucoup plus faibles.
Par conséquent, j'aimerais mieux comprendre les différences fonctionnelles entre les coroutines empilées et empilées. Plus précisément, je pense à ces questions:
Des références comme celle-ci suggèrent que la distinction réside dans l'endroit où vous pouvez céder/reprendre dans une coroutine empilée vs sans pile. Est-ce le cas? Y a-t-il un exemple simple de quelque chose que je peux faire dans une coroutine empilée mais pas dans une coroutine sans pile?
Y a-t-il des limites à l'utilisation des variables de stockage automatique (c'est-à-dire des variables "sur la pile")?
Y a-t-il des limitations sur les fonctions que je peux appeler à partir d'une coroutine sans pile?
S'il n'y a pas d'enregistrement du contexte de pile pour une coroutine sans pile, où vont les variables de stockage automatique lorsque la coroutine est en cours d'exécution?
Tout d'abord, merci de jeter un œil à CO2 :)
Le Boost.Coroutine doc décrit bien l'avantage d'une coroutine empilable:
empilement
Contrairement à une coroutine sans pile une coroutine empilable peut être suspendue à l'intérieur d'un stackframe imbriqué. L'exécution reprend exactement au même point du code où elle a été suspendue auparavant. Avec une coroutine sans pile, seule la routine de niveau supérieur peut être suspendue. Toute routine appelée par cette routine de niveau supérieur ne peut pas elle-même se suspendre. Cela interdit de fournir des opérations de suspension/reprise dans les routines d'une bibliothèque à usage général.
suite de première classe
Une continuation de première classe peut être passée comme argument, retournée par une fonction et stockée dans une structure de données pour être utilisée plus tard. Dans certaines implémentations (par exemple, le rendement C #), la suite ne peut pas être directement accessible ou manipulée directement.
Sans empilement et sémantique de première classe, certains flux de contrôle d'exécution utiles ne peuvent pas être pris en charge (par exemple, le multitâche coopératif ou le point de contrôle).
Qu'est-ce que cela signifie pour vous? par exemple, imaginez que vous ayez une fonction qui prend un visiteur:
template<class Visitor>
void f(Visitor& v);
Vous voulez le transformer en itérateur, avec une coroutine empilée, vous pouvez:
asymmetric_coroutine<T>::pull_type pull_from([](asymmetric_coroutine<T>::Push_type& yield)
{
f(yield);
});
Mais avec la coroutine sans pile, il n'y a aucun moyen de le faire:
generator<T> pull_from()
{
// yield can only be used here, cannot pass to f
f(???);
}
En général, la coroutine empilable est plus puissante que la coroutine sans pile. Alors pourquoi voulons-nous une coroutine sans pile? réponse courte: efficacité.
La coroutine empilable doit généralement allouer une certaine quantité de mémoire pour accueillir sa pile d'exécution (doit être suffisamment grande), et le changement de contexte est plus cher que celui sans pile, par ex. Boost.Coroutine prend 40 cycles tandis que le CO2 ne prend que 7 cycles en moyenne sur ma machine, car la seule chose qu'une coroutine sans pile doit restaurer est le compteur de programmes.
Cela dit, avec la prise en charge du langage, la coroutine empilable peut également tirer parti de la taille maximale calculée par le compilateur pour la pile tant qu'il n'y a pas de récursivité dans la coroutine, de sorte que l'utilisation de la mémoire peut également être améliorée.
En parlant de coroutine sans pile, gardez à l'esprit que cela ne signifie pas qu'il n'y a pas du tout de pile d'exécution, cela signifie seulement qu'il utilise la même pile d'exécution que le côté hôte, de sorte que vous pouvez également appeler des fonctions récursives, juste que toutes les récursions se produiront sur la pile d'exécution de l'hôte. En revanche, avec la coroutine empilée, lorsque vous appelez des fonctions récursives, les récursions se produisent sur la propre pile de la coroutine.
Pour répondre aux questions:
C'est la limitation d'émulation du CO2. Avec la prise en charge des langues, les variables de stockage automatique visibles par la coroutine seront placées sur la mémoire interne de la coroutine. Notez mon accent sur "visible par la coroutine", si la coroutine appelle une fonction qui utilise des variables de stockage automatique en interne, alors ces variables seront placées sur la pile d'exécution. Plus précisément, la coroutine sans pile n'a qu'à conserver les variables/temporelles qui peuvent être utilisées après la reprise.
Pour être clair, vous pouvez également utiliser des variables de stockage automatique dans le corps de coroutine du CO2:
auto f() CO2_RET(co2::task<>, ())
{
int a = 1; // not ok
CO2_AWAIT(co2::suspend_always{});
{
int b = 2; // ok
doSomething(b);
}
CO2_AWAIT(co2::suspend_always{});
int c = 3; // ok
doSomething(c);
} CO2_END
Tant que la définition ne précède aucun await
.
Non.
Répondu ci-dessus, une coroutine sans pile ne se soucie pas des variables de stockage automatique utilisées dans les fonctions appelées, elles seront simplement placées sur la pile d'exécution normale.
Si vous avez un doute, vérifiez simplement le code source de CO2, cela peut vous aider à comprendre la mécanique sous le capot;)
Ce que vous voulez, ce sont des threads/fibres utilisateur - généralement, vous voulez suspendre votre code (exécuté en fibre) dans une pile d'appels imbriquée profonde (par exemple en analysant les messages de la connexion TCP). Dans ce cas, vous ne pouvez pas utiliser le changement de contexte sans pile (la pile d'application est partagée entre les coroutines sans pile -> les cadres de pile des sous-programmes appelés seraient remplacés).
Vous pouvez utiliser quelque chose comme boost.fiber qui implémente des threads/fibres utilisateur-land basés sur boost.context.