Gestion de la mémoire pour le passage rapide des messages entre les threads en C ++


9

Supposons qu'il existe deux threads qui communiquent en s'envoyant des messages de données de manière asynchrone. Chaque thread a une sorte de file d'attente de messages.

Ma question est de très bas niveau: quel peut être le moyen le plus efficace de gérer la mémoire? Je peux penser à plusieurs solutions:

  1. L'expéditeur crée l'objet via new. Le récepteur appelle delete.
  2. Pool de mémoire (pour retransférer la mémoire à l'expéditeur)
  3. Collecte des ordures (par exemple, Boehm GC)
  4. (si les objets sont suffisamment petits) copier par valeur pour éviter l'allocation complète du tas

1) est la solution la plus évidente, je vais donc l'utiliser pour un prototype. Il y a de fortes chances qu'il soit déjà assez bon. Mais indépendamment de mon problème spécifique, je me demande quelle technique est la plus prometteuse si vous optimisez les performances.

Je m'attendrais à ce que la mise en commun soit théoriquement la meilleure, surtout parce que vous pouvez utiliser des connaissances supplémentaires sur le flux d'informations entre les threads. Cependant, je crains que ce soit aussi le plus difficile à réussir. Beaucoup de réglages ... :-(

La récupération de place devrait être assez facile à ajouter par la suite (après la solution 1), et je m'attendrais à ce qu'elle fonctionne très bien. Donc, je suppose que c'est la solution la plus pratique si 1) se révèle trop inefficace.

Si les objets sont petits et simples, la copie par valeur peut être la plus rapide. Cependant, je crains que cela n'impose des limitations inutiles à la mise en œuvre des messages pris en charge, donc je veux l'éviter.

Réponses:


9

Si les objets sont petits et simples, la copie par valeur peut être la plus rapide. Cependant, je crains que cela n'impose des limitations inutiles à la mise en œuvre des messages pris en charge, donc je veux l'éviter.

Si vous pouvez anticiper une limite supérieure char buf[256], par exemple une alternative pratique si vous ne pouvez pas invoquer uniquement les allocations de tas dans les rares cas:

struct Message
{
    // Stores the message data.
    char buf[256];

    // Points to 'buf' if it fits, heap otherwise.
    char* data;
};

3

Cela dépendra de la façon dont vous implémentez les files d'attente.

Si vous optez pour un tableau (style round robin), vous devez définir une limite supérieure de taille pour la solution 4. Si vous optez pour une file d'attente liée, vous avez besoin d'objets alloués.

Ensuite, le regroupement des ressources peut être fait facilement lorsque vous remplacez simplement le nouveau et supprimez avec AllocMessage<T>et freeMessage<T>. Ma suggestion serait de limiter la quantité de tailles potentielles Tet d’arrondir lors de l’attribution du béton messages.

Le ramassage des ordures peut fonctionner, mais cela peut entraîner de longues pauses lorsqu'il doit collecter une grande partie et fonctionnera (je pense) un peu moins bien que new / delete.


3

Si c'est en C ++, utilisez simplement l'un des pointeurs intelligents - unique_ptr fonctionnerait bien pour vous, car il ne supprimera pas l'objet sous-jacent tant que personne n'aura de poignée dessus. Vous passez l'objet ptr au récepteur par valeur et n'avez jamais à vous soucier du thread qui doit le supprimer (dans les cas où le récepteur ne reçoit pas l'objet).

Vous auriez toujours besoin de gérer le verrouillage entre les threads mais les performances seront bonnes car aucune mémoire n'est copiée (uniquement l'objet ptr lui-même, qui est minuscule).

L'allocation de mémoire sur le tas n'est pas la chose la plus rapide jamais réalisée, donc la mise en commun est utilisée pour rendre cela beaucoup plus rapide. Vous venez de récupérer le bloc suivant à partir d'un tas pré-dimensionné dans un pool, il vous suffit donc d'utiliser une bibliothèque existante pour cela.


2
Le verrouillage est généralement un problème beaucoup plus important que la copie de mémoire. Je dis juste.
tdammers

Lorsque vous écrivez unique_ptr, je suppose que vous voulez dire shared_ptr. Mais s'il ne fait aucun doute que l'utilisation d'un pointeur intelligent est bonne pour la gestion des ressources, cela ne change pas le fait que vous utilisez une forme d'allocation et de désallocation de mémoire. Je pense que cette question est plus bas niveau.
5gon12eder

3

La plus grande performance atteinte lors de la communication d'un objet d'un thread à un autre est la surcharge de saisie d'un verrou. C'est de l'ordre de plusieurs microsecondes, ce qui est nettement supérieur au temps moyen qu'une paire de new/ deleteprend (de l'ordre d'une centaine de nanosecondes). Les newimplémentations sensées essaient d'éviter le verrouillage à presque tous les coûts pour éviter que leurs performances ne soient affectées.

Cela dit, vous voulez vous assurer que vous n'avez pas besoin de saisir des verrous lors de la communication des objets d'un thread à un autre. Je connais deux méthodes générales pour y parvenir. Les deux ne fonctionnent que de manière unidirectionnelle entre un expéditeur et un récepteur:

  1. Utilisez un tampon en anneau. Les deux processus contrôlent un pointeur dans ce tampon, l'un est le pointeur de lecture, l'autre est le pointeur d'écriture.

    • L'expéditeur vérifie d'abord s'il y a de la place pour ajouter un élément en comparant les pointeurs, puis ajoute l'élément, puis incrémente le pointeur d'écriture.

    • Le récepteur vérifie s'il y a un élément à lire en comparant les pointeurs, puis lit l'élément, puis incrémente le pointeur de lecture.

    Les pointeurs doivent être atomiques car ils sont partagés entre les threads. Cependant, chaque pointeur n'est modifié que par un thread, l'autre n'a besoin que d'un accès en lecture au pointeur. Les éléments du tampon peuvent être des pointeurs eux-mêmes, ce qui vous permet de dimensionner facilement votre tampon en anneau à une taille qui ne fera pas le bloc expéditeur.

  2. Utilisez une liste chaînée qui contient toujours au moins un élément. Le récepteur a un pointeur sur le premier élément, l'expéditeur a un pointeur sur le dernier élément. Ces pointeurs ne sont pas partagés.

    • L'expéditeur crée un nouveau nœud pour la liste liée, en définissant son nextpointeur sur nullptr. Il met ensuite à jour le nextpointeur du dernier élément pour pointer vers le nouvel élément. Enfin, il stocke le nouvel élément dans son propre pointeur.

    • Le récepteur regarde le nextpointeur du premier élément pour voir si de nouvelles données sont disponibles. Si tel est le cas, il supprime l'ancien premier élément, avance son propre pointeur pour pointer vers l'élément actuel et commence à le traiter.

    Dans cette configuration, les nextpointeurs doivent être atomiques et l'expéditeur doit être sûr de ne pas déréférencer l'avant-dernier élément après avoir défini son nextpointeur. L'avantage est, bien sûr, que l'expéditeur n'a jamais à bloquer.

Les deux approches sont beaucoup plus rapides que toute approche basée sur un verrou, mais elles nécessitent une mise en œuvre minutieuse pour bien fonctionner. Et, bien sûr, ils nécessitent une atomicité matérielle native des écritures / charges de pointeurs; si votre atomic<>implémentation utilise un verrou en interne, vous êtes à peu près condamné.

De même, si vous avez plusieurs lecteurs et / ou écrivains, vous êtes à peu près condamné: vous pouvez essayer de proposer un schéma sans verrouillage, mais il sera difficile à mettre en œuvre au mieux. Ces situations sont beaucoup plus faciles à gérer avec une serrure. Cependant, une fois que vous avez saisi un verrou, vous pouvez cesser de vous soucier de new/ deleteperformance.


+1 Je dois vérifier cette solution de tampon en anneau comme alternative aux files d'attente simultanées utilisant des boucles CAS. Cela semble très prometteur.
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.