web-dev-qa-db-fra.com

Tampon circulaire sans blocage

Je suis en train de concevoir un système qui se connecte à un ou plusieurs flux de données et effectue une analyse des données plutôt que des événements déclencheurs basés sur le résultat. Dans une configuration producteur/consommateur multi-thread typique, j'aurai plusieurs threads producteurs mettant des données dans une file d'attente, plusieurs threads consommateurs lisant les données, et les consommateurs ne sont intéressés que par le dernier point de données plus n nombre de points. Les threads producteurs devront bloquer si le consommateur lent ne peut pas suivre, et bien sûr, les threads consommateurs vont bloquer en l'absence de mises à jour non traitées. L'utilisation d'une file d'attente simultanée typique avec un verrou lecteur/graveur fonctionnera sans problème, mais le débit de données entrant pourrait être énorme, aussi je voulais réduire les frais de verrouillage, en particulier les verrous d'écrivain pour les producteurs. Je pense que j'avais besoin d'un tampon circulaire sans verrouillage. 

Maintenant deux questions:

  1. Est-ce que le tampon circulaire sans verrouillage est la solution?

  2. Si tel est le cas, connaissez-vous une mise en œuvre publique qui répondrait à mes besoins?

Tous les pointeurs implémentant un tampon circulaire sans verrouillage sont toujours les bienvenus.

BTW, cela en C++ sous Linux.

Quelques informations supplémentaires:

Le temps de réponse est essentiel pour mon système. Idéalement, les threads consommateurs voudront voir toutes les mises à jour arriver le plus rapidement possible, car un délai supplémentaire de 1 milliseconde pourrait rendre le système inutile, voire beaucoup moins.

L’idée de conception sur laquelle je me penche est un tampon circulaire sans semi-verrouillage, dans lequel le processus producteur place les données dans le tampon aussi rapidement que possible. Appelons la tête du tampon A, sans bloquer, sauf si le tampon est plein, A rencontre la fin du tampon Z. Les threads consommateurs contiendront chacun deux pointeurs vers le tampon circulaire, P et Pn, où P est la tête de mémoire tampon locale du thread et Pn est le nième élément après P. Chaque thread consommateur fera avancer ses P et Pn une fois le traitement du courant P terminé et la fin du pointeur de la mémoire tampon Z avancée avec le P le plus lentn. Lorsque P rattrape A, ce qui signifie qu'il n'y a plus de nouvelle mise à jour à traiter, le consommateur tourne et attend occupé que A avance de nouveau. Si le fil d'un consommateur tourne trop longtemps, il peut être mis en veille et attendre une variable de condition, mais je suis d'accord pour que le consommateur prenne le cycle du processeur en attente de mise à jour car cela n'augmente pas ma latence (j'aurai plus de cœurs de processeur que les fils). Imaginez que vous avez une piste circulaire et que le producteur s’exécute devant un groupe de consommateurs; l’important est d’ajuster le système de manière à ce que le producteur ait généralement une longueur d’avance sur les consommateurs fait en utilisant des techniques sans verrouillage. Je comprends qu’il n’est pas facile d’obtenir les détails de la mise en œuvre… très dur, c’est pourquoi je veux apprendre des erreurs des autres avant d’en faire quelques-unes des miennes. 

65
Shing Yip

J'ai effectué une étude particulière sur les structures de données sans verrouillage au cours des deux dernières années. J'ai lu la plupart des articles sur le terrain (il n'y en a qu'environ une quarantaine - bien que seulement dix ou quinze soient vraiment utiles :-)

Autant que je sache, un tampon circulaire sans verrouillage n'a pas été inventé. Le problème concernera la situation complexe dans laquelle un lecteur dépasse un écrivain ou inversement.

Si vous n’avez pas passé au moins six mois à étudier des structures de données sans verrouillage, n’essayez pas d’en écrire une vous-même. Vous vous tromperez et il ne sera peut-être pas évident pour vous que des erreurs existent jusqu'à ce que votre code échoue, après le déploiement, sur de nouvelles plateformes.

Je crois cependant qu'il existe une solution à votre besoin.

Vous devez associer une file d'attente sans verrouillage à une liste libre sans verrouillage.

La liste libre vous donnera une pré-allocation et vous évitera ainsi l'exigence (coûteuse) d'un allocateur sans blocage; quand la liste libre est vide, vous répliquez le comportement d'un tampon circulaire en retirant instantanément un élément de la file d'attente et en l'utilisant à la place.

(Bien sûr, dans un tampon circulaire basé sur un verrou, une fois le verrou obtenu, obtenir un élément est très rapide - il s’agit simplement d’une déréférencement de pointeur - mais vous n’obtiendrez pas cela dans un algorithme sans verrou; ils doivent souvent aller ils ont bien du mal à faire les choses, car le fait de ne pas réussir une liste blanche suivie d’une file d'attente équivaut à la quantité de travail que tout algorithme sans verrouillage devra faire).

Michael et Scott ont mis au point une très bonne file d'attente sans verrouillage en 1996. Un lien ci-dessous vous donnera suffisamment de détails pour retrouver le PDF de leur document. Michael et Scott, FIFO

Une liste sans verrou est l’algorithme le plus simple et, en fait, je ne pense pas avoir vu un article à ce sujet.

34
user82238

Le terme technique pour ce que vous voulez est une file d'attente lock-free. Il y a un excellent ensemble de notes avec des liens vers du code et des articles } de Ross Bencina. Le type dont je fais le plus confiance est Maurice Herlihy (pour les Américains, il prononce son prénom comme "Morris").

32
Norman Ramsey

L'exigence que les producteurs ou les consommateurs bloquent si la mémoire tampon est vide ou pleine suggère que vous devriez utiliser une structure de données de verrouillage normale, avec des sémaphores ou des variables de condition, afin de bloquer les producteurs et les consommateurs jusqu'à ce que les données soient disponibles. Le code sans verrouillage ne bloque généralement pas dans de telles conditions - il tourne ou abandonne des opérations impossibles à effectuer au lieu de bloquer à l'aide du système d'exploitation. (Si vous pouvez vous permettre d'attendre qu'un autre thread produise ou consomme des données, pourquoi est-il encore plus difficile d'attendre le verrouillage d'un autre thread pour mettre à jour la structure de données?) 

Sous Linux (x86/x64), la synchronisation intra-thread utilisant des mutex est relativement peu coûteuse s’il n’ya pas de conflit. Concentrez-vous sur la réduction du temps nécessaire aux producteurs et aux consommateurs pour conserver leurs serrures. Étant donné que vous avez dit que vous ne vous souciez que des N derniers points de données enregistrés, je pense qu'un tampon circulaire le ferait raisonnablement bien. Cependant, je ne comprends pas vraiment en quoi cela correspond à l'exigence de blocage et à l'idée que les consommateurs consomment (suppriment) les données qu'ils lisent. (Voulez-vous que les consommateurs ne regardent que les N derniers points de données et ne les suppriment pas? Voulez-vous que les producteurs ne se soucient pas si les consommateurs ne peuvent pas suivre et écrasent simplement les anciennes données?)

En outre, comme l'a commenté Zan Lynx, vous pouvez regrouper/mettre en mémoire tampon vos données en gros morceaux lorsque vous en recevez beaucoup. Vous pouvez mettre en mémoire tampon un nombre fixe de points, ou toutes les données reçues dans un certain délai. . Cela signifie qu'il y aura moins d'opérations de synchronisation. Cela introduit cependant de la latence, mais si vous n'utilisez pas Linux en temps réel, vous devrez quand même vous en occuper dans une certaine mesure.

11
Doug

L’implémentation dans la bibliothèque de boost mérite d’être examinée. Il est facile à utiliser et assez performant. J'ai écrit un test et l'ai exécuté sur un ordinateur portable quad core i7 (8 threads) et j'ai obtenu une seconde environ 4 millions d'opérations de mise en file d'attente/de mise en file d'attente. Une autre implémentation non mentionnée jusqu'ici est la file d'attente MPMC à http://moodycamel.com/blog/2014/detailed-design-of-a-lock-free-queue . J'ai fait quelques tests simples avec cette mise en œuvre sur le même ordinateur portable avec 32 producteurs et 32 ​​consommateurs. Comme annoncé, il est plus rapide que la file d’attente boost lockless.

Comme la plupart des autres réponses, la programmation sans verrouillage est difficile. La plupart des implémentations auront du mal à détecter les cas critiques nécessitant beaucoup de tests et de débogages. Celles-ci sont généralement corrigées par un placement judicieux des barrières de mémoire dans le code. Vous trouverez également des preuves d'exactitude publiées dans de nombreux articles académiques. Je préfère tester ces implémentations avec un outil de force brute. Tout algorithme sans verrou que vous envisagez d’utiliser en production doit être vérifié à l’aide d’un outil tel que http://research.Microsoft.com/en-us/um/people/lamport/tla/tla.html .

5
Alex

Il y a une bonne série d'articles à ce sujet sur DDJ . Comme signe de la difficulté de ces choses, il s’agit d’une correction sur un article précédent qui s’est trompée. Assurez-vous de bien comprendre les erreurs avant de lancer les vôtres) -;

5
Henk Holterman

Je ne suis pas un expert des modèles de mémoire matérielle et des structures de données non verrouillées; j'ai tendance à éviter de les utiliser dans mes projets et j'utilise des structures de données verrouillées traditionnelles.

Cependant, j’ai récemment remarqué que la vidéo: File d’attente SPSC sans verrou basée sur le tampon en anneau

Ceci est basé sur une librairie Java haute performance open source appelée LMAX distruptor utilisée par un système commercial: LMAX Distruptor

Sur la base de la présentation ci-dessus, vous créez des pointeurs de tête et de queue atomiques et recherchez de manière atomique la condition dans laquelle la tête capture la queue par derrière ou inversement.

Ci-dessous, vous pouvez voir une implémentation très basique de C++ 11:

// USING SEQUENTIAL MEMORY
#include<thread>
#include<atomic>
#include <cinttypes>
using namespace std;

#define RING_BUFFER_SIZE 1024  // power of 2 for efficient %
class lockless_ring_buffer_spsc
{
    public :

        lockless_ring_buffer_spsc()
        {
            write.store(0);
            read.store(0);
        }

        bool try_Push(int64_t val)
        {
            const auto current_tail = write.load();
            const auto next_tail = increment(current_tail);
            if (next_tail != read.load())
            {
                buffer[current_tail] = val;
                write.store(next_tail);
                return true;
            }

            return false;  
        }

        void Push(int64_t val)
        {
            while( ! try_Push(val) );
            // TODO: exponential backoff / sleep
        }

        bool try_pop(int64_t* pval)
        {
            auto currentHead = read.load();

            if (currentHead == write.load())
            {
                return false;
            }

            *pval = buffer[currentHead];
            read.store(increment(currentHead));

            return true;
        }

        int64_t pop()
        {
            int64_t ret;
            while( ! try_pop(&ret) );
            // TODO: exponential backoff / sleep
            return ret;
        }

    private :
        std::atomic<int64_t> write;
        std::atomic<int64_t> read;
        static const int64_t size = RING_BUFFER_SIZE;
        int64_t buffer[RING_BUFFER_SIZE];

        int64_t increment(int n)
        {
            return (n + 1) % size;
        }
};

int main (int argc, char** argv)
{
    lockless_ring_buffer_spsc queue;

    std::thread write_thread( [&] () {
             for(int i = 0; i<1000000; i++)
             {
                    queue.Push(i);
             }
         }  // End of lambda expression
                                                );
    std::thread read_thread( [&] () {
             for(int i = 0; i<1000000; i++)
             {
                    queue.pop();
             }
         }  // End of lambda expression
                                                );
    write_thread.join();
    read_thread.join();

     return 0;
}
4
Akin Ocal

La file d'attente de Sutter est sous-optimale et il le sait. La programmation Art of Multicore est une excellente référence, mais ne faites pas confiance aux gars de Java sur les modèles de mémoire, un point c'est tout. Les liens de Ross ne vous apporteront aucune réponse définitive car ils avaient leurs bibliothèques dans de tels problèmes, etc.

Faire de la programmation sans verrouillage, c'est poser des problèmes, à moins que vous ne vouliez consacrer beaucoup de temps à quelque chose que vous êtes manifestement trop en ingénierie avant de résoudre le problème (à en juger par la description, c'est une folie commune de "rechercher la perfection". 'dans la cohérence du cache). Cela prend des années et conduit à ne pas résoudre les problèmes d'abord et à optimiser plus tard, une maladie commune.

4
rama-jka toti

Je suis d’accord avec cet article et recommande de ne pas utiliser de structures de données sans verrouillage. Un article relativement récent sur les files d'attente sans verrou de fifo est this , recherche d'autres articles du même auteur; Il existe également une thèse de doctorat sur Chalmers concernant les structures de données sans verrouillage (j'ai perdu le lien). Cependant, vous n'avez pas précisé la taille de vos éléments - les structures de données sans verrouillage ne fonctionnent efficacement qu'avec des éléments de la taille d'un mot. Vous devez donc allouer dynamiquement vos éléments s'ils sont plus volumineux qu'une machine (32 ou 64). morceaux). Si vous allouez des éléments de manière dynamique, vous déplacez le goulot d'étranglement (supposé, puisque vous n'avez pas profilé votre programme et que vous effectuez une optimisation prématurée) vers la mémoire. Vous avez donc besoin d'un allocateur de mémoire sans verrou, par exemple, Streamflow , et l'intégrer à votre application. 

4
zvrba

Une technique utile pour réduire les conflits consiste à hacher les éléments en plusieurs files d'attente et à dédier chaque consommateur à un "sujet" .

Pour le plus récent nombre d'articles qui intéressent vos consommateurs - vous ne voulez pas verrouiller toute la file d'attente et itérer dessus pour trouver un élément à remplacer - publiez simplement des éléments dans N-uplets, c.-à-d. Tous les N éléments récents . Points bonus pour la mise en œuvre où le producteur bloquerait la file d'attente complète (lorsque les consommateurs ne peuvent pas suivre) avec un délai d'expiration, mettant à jour son cache Tuple local - de cette manière, vous ne remettez pas la pression sur la source de données.

4
Nikolai Fetissov

Check out Disruptor ( Comment l’utiliser ) qui est un tampon circulaire auquel plusieurs threads peuvent s’abonner:

2
Rolf Kristensen

Bien que ce soit une vieille question, personne n’a mentionné le tampon en anneau sans verrouillage de DPDK . Il s'agit d'un tampon en anneau à haut débit prenant en charge plusieurs producteurs et plusieurs consommateurs. Il fournit également les modes consommateur unique et producteur unique, et la mémoire tampon en anneau est sans attente en mode SPSC. Il est écrit en C et supporte plusieurs architectures. 

De plus, il prend en charge les modes Bulk et Burst où les éléments peuvent être mis en file d'attente/sortis en file d'attente. La conception permet à plusieurs consommateurs ou plusieurs producteurs d'écrire dans la file d'attente en même temps en réservant simplement l'espace en déplaçant un pointeur atomique. 

2
Saman Barghi

C'est un vieux fil, mais comme il n'a pas encore été mentionné - il existe un producteur sans verrou, circulaire, - 1 consommateur -> 1 consommateur, FIFO disponible dans le cadre JUCE C++.

https://www.juce.com/doc/classAbstractFifo#details

2
Nikolay Tsenkov

Voici comment je le ferais:

  • mapper la file d'attente dans un tableau
  • garder l'état avec une prochaine lecture et la prochaine prochaine écriture index
  • garder un vecteur vide complet

L’insertion consiste à utiliser un CAS avec une incrémentation et à passer sur l’écriture suivante. Une fois que vous avez un créneau, ajoutez votre valeur, puis définissez le bit vide/complet qui lui correspond.

Les suppressions nécessitent une vérification du bit avant de tester sur des sous-débits, mais elles sont identiques à celles de l'écriture, mais utilisent un index de lecture et effacent le bit vide/complet.

Être averti, 

  1. Je ne suis pas expert en ces choses
  2. les opérations atomiques en ASM semblent être très lentes quand je les ai utilisées. Par conséquent, si vous en avez plusieurs, il sera peut-être plus rapide d’utiliser des verrous intégrés aux fonctions d’insertion/suppression. La théorie est qu'une opération atomique unique pour saisir le verrou suivi de (très) quelques opérations ASM non atomiques pourrait être plus rapide que la même chose effectuée par plusieurs opérations atomiques. Mais pour que cela fonctionne, il faut un alignement manuel ou automatique, il s’agit donc d’un seul bloc d’ASM.
1
BCS

Juste pour compléter, il existe un tampon circulaire sans verrouillage bien testé dans OtlContainers , mais il est écrit en Delphi (TOmniBaseBoundedQueue est un tampon circulaire et TOmniBaseBoundedStack est une pile liée. Il existe également une file d'attente non limitée dans la même unité (TOmniBaseQueue). La file d'attente sans limite est décrite dans File d'attente dynamique sans verrouillage - Pour le faire correctement . L'implémentation initiale de la file d'attente limitée (tampon circulaire) a été décrite dans Une file d'attente sans verrouillage, enfin! mais le code a été mis à jour depuis.

1
gabr

Il existe des situations pour lesquelles vous n'avez pas besoin de verrouillage afin d'éviter une situation critique, en particulier lorsque vous n'avez qu'un seul producteur et un seul consommateur.

Considérons ce paragraphe de LDD3:

Lorsqu'il est soigneusement mis en œuvre, un tampon circulaire ne nécessite aucun verrouillage en l'absence de plusieurs producteurs ou consommateurs. Le producteur est le seul thread autorisé à modifier l'index d'écriture et l'emplacement du tableau vers lequel il pointe. Tant que le rédacteur stocke une nouvelle valeur dans la mémoire tampon avant de mettre à jour l'index d'écriture, le lecteur verra toujours une vue cohérente. Le lecteur, à son tour, est le seul thread qui peut accéder à l'index de lecture et à la valeur qu'il pointe. En veillant à ce que les deux pointeurs ne se superposent pas, le producteur et le consommateur peuvent accéder au tampon simultanément sans aucune condition de concurrence.

0
Dražen G.

Vous pouvez essayer lfqueue

Il est simple à utiliser, il est sans verrou de conception circulaire 

int *ret;

lfqueue_t results;

lfqueue_init(&results);

/** Wrap This scope in multithread testing **/
int_data = (int*) malloc(sizeof(int));
assert(int_data != NULL);
*int_data = i++;
/*Enqueue*/
while (lfqueue_enq(&results, int_data) != 1) ;

/*Dequeue*/
while ( (ret = lfqueue_deq(&results)) == NULL);

// printf("%d\n", *(int*) ret );
free(ret);
/** End **/

lfqueue_clear(&results);
0
Oktaheta