2
votes

Enregistreur unique rapide et sans verrouillage, lecteur multiple

J'ai un seul graveur qui doit incrémenter une variable à une fréquence assez élevée et aussi un ou plusieurs lecteurs qui accèdent à cette variable sur une fréquence plus basse.

L'écriture est déclenchée par une interruption externe. p>

Puisque j'ai besoin d'écrire à grande vitesse, je ne veux pas utiliser de mutex ou d'autres mécanismes de verrouillage coûteux.

L'approche que j'ai proposée consistait à copier la valeur après avoir écrit dessus. Le lecteur peut maintenant comparer l'original avec la copie. S'ils sont égaux, le contenu de la variable est valide.

Voici mon implémentation en C ++

template<typename T>
class SafeValue
{
private:
    volatile T _value;
    volatile T _valueCheck;
public:
    void setValue(T newValue)
    {
        _value = newValue;
        _valueCheck = _value;
    }

    T getValue()
    {
        volatile T value;
        volatile T valueCheck;
        do
        {
            valueCheck = _valueCheck;
            value = _value;
        } while(value != valueCheck);

        return value;
    }
}

L'idée derrière cela est de détecter les courses de données lors de la lecture et réessayez s'ils se produisent. Cependant, je ne sais pas si cela fonctionnera toujours. Je n'ai rien trouvé à propos de cette approche en ligne, donc ma question:

Y a-t-il un problème avec mon approche lorsqu'elle est utilisée avec un seul écrivain et plusieurs lecteurs?

Je sais déjà que des fréquences d'écriture élevées peuvent entraîner la famine du lecteur. Y a-t-il d'autres effets néfastes dont je dois me méfier? Se pourrait-il même que ce ne soit pas du tout threadsafe?

Édition 1:

Mon système cible est un ARM Cortex-A15. P >

T devrait pouvoir devenir au moins n'importe quel type intégral primitif.

Édition 2:

std :: atomic est trop lent sur le site de lecture et d'écriture. Je l'ai évalué sur mon système. Les écritures sont environ 30 fois plus lentes, les lectures environ 50 fois par rapport aux opérations primitives non protégées.


9 commentaires

Les commentaires ne sont pas destinés à une discussion approfondie; cette conversation a été déplacée vers le chat < / a>.


Avez-vous vraiment besoin de wite à grande vitesse? Ne pourriez-vous pas incrémenter une variable écrivain-privée, puis l'ajouter simplement au total accessible au lecteur périodiquement , au lieu d'avoir à incrémenter le total de 1 fréquemment ? En d'autres termes, les lecteurs peuvent-ils tolérer un peu d'obsolescence?


@Branko Dimitrijevic - Pas grand chose. Le compteur est utilisé pour suivre la position d'un moteur pas à pas. Le lecteur a besoin d'obtenir la position de ce moteur aussi exacte que possible.


@Detonar Je recommanderais de modifier la question pour expliquer clairement en détail pourquoi les atomiques ne vous conviennent pas. Comme vous l'écrivez dans le commentaire ci-dessous: Le problème avec std :: atomic est que les appels système internes le ralentissent considérablement. Ce que appelle le système interne ? Les opérations atomiques devraient se résumer à des instructions spéciales uniquement si les atomes sont pris en charge par le matériel pour un type de données donné (est-ce votre cas ou non?). Qu'entendez-vous par ralentir ? Producteur qui ralentit? Ralentir les consommateurs? Comment mesurez-vous un tel ralentissement?


@Daniel Langr - Je l'ai évalué sur le système cible. J'ai édité ma question.


@Detonar Merci. Je ne sais toujours pas si je comprends tout le problème, car quand je compare l'assemblage généré de votre solution avec celui qui utilise std::atomic (pour int type, ARM et GCC 8.2), ce dernier est beaucoup plus simple et devrait être plus efficace: godbolt.org/z / lpvFQB . BTW, comment mesurez-vous le ralentissement?


Juste par curiosité, j'ai aussi essayé de remplacer volatile dans votre soluton par des atomiques : godbolt.org/z/tl4wdB . L'assemblage généré est très similaire et je doute qu'il y ait une telle différence de performance significative (notez qu'il n'y a aucune raison pour que ces variables locales dans getValue soient volatiles ).


Vous avez comparé std :: atomic avec quelle contrainte de commande? La cohérence séquentielle par défaut? Avez-vous profilé avec la commande de lancement / acquisition ou de lancement / consommation? Avez-vous regardé l'assemblage généré pour ces cas?


Ce que vous faites est très similaire à ce que j'ai vu appelé "verrouillage de séquence". Voici un autre fil avec quelques détails. stackoverflow.com/questions/48749643/is-volatile-required-he‌ re


4 Réponses :


4
votes

Cette variable unique est-elle juste un entier, un pointeur ou un ancien type de valeur, vous pouvez probablement simplement utiliser std :: atomic .


11 commentaires

std :: atomic est trop lent, même sans verrouillage, ce qui n'est pas toujours garanti.


@Detonar Vos affirmations n'ont aucun sens. Etudiez l'assemblage généré par std :: atomic .


@MaximEgorushkin Pas vraiment si vous lisez la question. Il veut juste éviter la boucle CAS. Le problème qu'il essaie de résoudre PEUT être résolu plus rapidement que std :: atomic car sa tâche est beaucoup plus spécifique.


@Ivan Il n'y a qu'un seul rédacteur, la variable partagée est int , aucune boucle CAS n'est nécessaire.


@MaximEgorushkin La lecture des valeurs de 8 octets sur les plates-formes 32 bits est implémentée en utilisant la boucle CAS, par ex. échouera sur les pages en lecture seule par exemple. Je pense qu'il veut accélérer le côté lecteur. Le problème est que son code n'est pas du tout «sans verrouillage».


@Ivan D'où avez-vous tiré les numéros 8 et 32?


@MaximEgorushkin De ses commentaires. Bien qu'il y ait maintenant une longue discussion de 30 commentaires. Il en a besoin pour fonctionner pour des valeurs de 8 octets sur ARM 32 bits.


@Ivan: Le Cortex-A15 (ARMv7) de l'OP n'a pas de CAS dans le matériel, il a LL / SC. Et il peut effectuer un chargement ou un stockage atomique 64 bits à l'aide de LDREXD ou LDREXD + STREXD. Pensez-vous peut-être à x86 32 bits avec cmpxchg8b ?


@PeterCordes GCC génère un CAS (appel __sync_val_compare_and_swap_8 ) pour lire une valeur de 8 octets pour ARM 32 bits - lien . Je pense que vous aurez besoin de LDREXD et de STREXD pour l'implémenter. La même chose est faite pour x86, mais ce sera une instruction cmpxchg de 8 octets, oui.


@Ivan: Vous avez oublié de dire au compilateur qu'il ciblait un processeur ARMv7-a, en particulier -mcpu = cortex-a15 . godbolt.org/z/q-S5x1 . Dans votre lien, gcc compilait pour n'importe quel ARM générique et devait émettre du code qui fonctionnait sur n'importe quoi, y compris ARMv5 et les versions antérieures avant même que LDREX / STREX n'existe, et avant que ldrd ne soit garanti atomique n'importe où, donc il ne pouvait pas les intégrer.


@PeterCordes En effet, j'ai vérifié les spécifications ARM à ce sujet. Surprise que par défaut une telle implémentation lente soit utilisée. Linux n'utilise pas du tout CAS lors de l'implémentation des accès 64 bits.



0
votes

Une autre option est d'avoir un tampon de valeurs non atomiques produit par l'éditeur et un pointeur atomique vers la dernière.

#include <atomic>
#include <utility>

template<class T>
class PublisherValue {
    static auto constexpr N = 32;
    T values_[N];
    std::atomic<T*> current_{values_};

public:
    PublisherValue() = default;
    PublisherValue(PublisherValue const&) = delete;
    PublisherValue& operator=(PublisherValue const&) = delete;

    // Single writer thread only.
    template<class U>
    void store(U&& value) {
        T* p = current_.load(std::memory_order_relaxed);
        if(++p == values_ + N)
            p = values_;
        *p = std::forward<U>(value);
        current_.store(p, std::memory_order_release); // (1) 
    }

    // Multiple readers. Make a copy to avoid referring the value for too long.
    T load() const {
        return *current_.load(std::memory_order_consume); // Sync with (1).
    }
};

C'est sans attente , mais il y a une petite chance qu'un lecteur soit désordonné lors de la copie de la valeur et par conséquent lit la valeur la plus ancienne alors qu'elle a été partiellement écrasée. Rendre N plus grand réduit ce risque.


0 commentaires

2
votes

Personne ne peut le savoir. Vous devriez voir si votre compilateur documente une sémantique multithread qui garantirait que cela fonctionnera ou regarder le code assembleur généré et vous convaincre que cela fonctionnera. Soyez averti que dans ce dernier cas, il est toujours possible qu'une version ultérieure du compilateur, ou différentes options d'optimisation ou un processeur plus récent, puisse casser le code.

Je suggère de tester std :: atomic avec le memory_order approprié. Si, pour une raison quelconque, c'est trop lent, utilisez l'assemblage en ligne.


2 commentaires

Je voulais me concentrer sur l'algorithme plutôt que sur les détails spécifiques à la langue. Et comment l'utilisation de l'assemblage en ligne fournirait-elle une augmentation viable des performances? Le problème avec std :: atomic est que les appels système internes le ralentissent considérablement.


@Detonar L'algorithme est si simple ici que tout dépend des spécificités de la mise en œuvre. L'utilisation de l'assemblage en ligne offrirait une augmentation viable des performances, car elle vous permettrait d'être sûr à 100% d'obtenir ce que vous demandiez afin de ne pas avoir à coder de manière défensive. Le codage défensif vous coûtera probablement.



3
votes

Vous devriez essayer d'utiliser std :: atomic d'abord, mais assurez-vous que votre compilateur connaît et comprend votre architecture cible. Puisque vous ciblez Cortex-A15 (CPU ARMv7-A), assurez-vous d'utiliser -march = armv7-a ou même -mcpu = cortex-a15 .

Le premier doit générer une instruction ldrexd qui doit être atomique selon la documentation ARM:

Atomicité à copie unique

Dans ARMv7, les accès au processeur atomique à copie unique sont:

  • tous les accès octets
  • tous les accès en demi-mot aux emplacements alignés en demi-mot
  • tous les accès de mots aux emplacements alignés sur les mots
  • accès à la mémoire causés par les instructions LDREXD et STREXD à des emplacements alignés en double mot.

Ce dernier doit générer l'instruction ldrd qui doit être atomique sur les cibles supportant la grande extension d'adresse physique:

Dans une implémentation qui inclut l'extension de grande adresse physique, les accès LDRD et STRD aux emplacements alignés sur 64 bits sont atomiques à copie unique 64 bits, comme vu par traduction balades dans les tables et accès aux tables de traduction.

--- Remarque ---

La grande extension d'adresse physique ajoute cette exigence pour éviter d'avoir à prendre des mesures complexes pour éviter les problèmes d'atomicité lors de la modification des entrées de table de traduction, sans créer une exigence que tous les emplacements dans le système de mémoire soient atomiques à copie unique 64 bits. p>

Vous pouvez également vérifier comment le noyau Linux implémente ceux-ci:

#ifdef CONFIG_ARM_LPAE
static inline long long atomic64_read(const atomic64_t *v)
{
    long long result;

    __asm__ __volatile__("@ atomic64_read\n"
"   ldrd    %0, %H0, [%1]"
    : "=&r" (result)
    : "r" (&v->counter), "Qo" (v->counter)
    );

    return result;
}
#else
static inline long long atomic64_read(const atomic64_t *v)
{
    long long result;

    __asm__ __volatile__("@ atomic64_read\n"
"   ldrexd  %0, %H0, [%1]"
    : "=&r" (result)
    : "r" (&v->counter), "Qo" (v->counter)
    );

    return result;
}
#endif


0 commentaires