Mécanisme de verrouillage et CAS

Mécanisme de verrouillage et CAS

(1) Le coût des serrures et les avantages du sans serrure

Le verrouillage est le moyen le plus simple de faire de la concurrence, et bien sûr, son coût est le plus élevé. Les verrous d'état du noyau nécessitent que le système d'exploitation effectue un changement de contexte. Le verrouillage et la libération du verrou entraîneront davantage de changement de contexte et de retards de planification. Les threads en attente du verrou seront suspendus jusqu'à ce que le verrou soit libéré. Lors du changement de contexte, les instructions et les données précédemment mises en cache par le processeur seront invalides, ce qui entraînera une grande perte de performances. Le jugement du système d'exploitation sur les verrous multi-threads est comme deux sœurs se disputant à propos d'un jouet. Le système d'exploitation est le parent qui peut déterminer qui peut obtenir le jouet. C'est très lent. Bien que les verrous en mode utilisateur évitent ces problèmes, ils ne sont efficaces que lorsqu'il n'y a pas de réelle concurrence.

Avant JDK1.5, la synchronisation était assurée par le mot-clé synchronized. En utilisant un protocole de verrouillage cohérent pour coordonner l'accès à l'état partagé, il peut garantir que quel que soit le thread qui détient le verrou de la variable guardian, il utilise un moyen exclusif d'accéder à ces derniers. Variables. Si plusieurs threads accèdent au verrou en même temps, certains threads seront suspendus. Lorsque le thread reprend l'exécution, il doit attendre que les autres threads terminent leur tranche horaire avant de pouvoir être planifié pour l'exécution. Il existe pendant la suspension et la reprise de l'exécution. C'est beaucoup de frais généraux. Les verrous présentent également d'autres inconvénients: lorsqu'un thread attend le verrou, il ne peut rien faire. Si un thread est retardé tout en maintenant un verrou, tous les threads qui ont besoin de ce verrou ne pourront pas s'exécuter. Si la priorité du thread bloqué est élevée et la priorité du thread tenant le verrou est basse, la priorité sera inversée. La figure suivante montre le processus complexe de la synchronisation:

Insérez la description de l'image ici

CAS peut résoudre ce genre d'inconvénients, alors qu'est-ce que CAS? Pour le contrôle d'accès concurrentiel, le verrouillage est une stratégie pessimiste qui bloquera l'exécution des threads, tandis que le verrouillage sans verrouillage est une stratégie optimiste. Il suppose qu'il n'y a pas de conflit lors de l'accès aux ressources. Puisqu'il n'y a pas de conflit, il n'est pas nécessaire d'attendre et le thread ne le fait pas. Besoin de bloquer. Comment plusieurs threads accèdent-ils ensemble aux ressources de la section critique? La stratégie sans verrouillage utilise une technologie de comparaison et d'échange CAS (comparer et permuter) pour identifier les conflits de threads. Une fois qu'un conflit est détecté, l'opération en cours est répétée jusqu'à ce qu'il n'y ait pas de conflit. Comparé aux verrous, CAS compliquera la conception du programme, mais en raison de son immunité inhérente au blocage (il n'y a pas de verrou du tout, bien sûr, il n'y aura pas de thread bloqué tout le temps), et plus important encore, il n'y a rien à voir avec l'utilisation de méthodes sans verrouillage. La surcharge causée par la concurrence n'a pas la surcharge causée par la planification fréquente entre les threads. Elle a des performances supérieures à l'approche basée sur le verrouillage, elle est donc largement utilisée à l'heure actuelle.

(2) Verrou optimiste et verrou pessimiste

Je viens de mentionner la stratégie pessimiste et la stratégie optimiste, alors jetons un coup d'œil à ce qu'est le verrouillage optimiste et le verrouillage pessimiste:

Verrouillage optimiste (également connu sous le nom de lock-free, ce n'est pas en fait un verrou, mais une idée): pensez de manière optimiste que les autres threads ne modifieront pas la valeur, si la valeur est modifiée, vous pouvez réessayer jusqu'à ce qu'elle réussisse . Le mécanisme CAS (Compare And Swap) dont nous allons parler est un verrou optimiste.

Verrou pessimiste: croient de manière pessimiste que d'autres threads modifieront la valeur. Le verrou exclusif est une sorte de verrou pessimiste. Après le verrouillage, il peut garantir que le programme ne sera pas perturbé par d'autres threads pendant l'exécution du programme, afin d'obtenir le résultat correct.

(3) Mécanisme CAS

Le nom complet du mécanisme CAS est compare and swap, qui se traduit par comparer et swap, et est un algorithme sans verrouillage bien connu. C'est aussi une opération au niveau des instructions du processeur qui est largement prise en charge par les processeurs modernes. Elle n'a qu'une seule opération atomique, elle est donc très rapide. De plus, CAS évite le problème de demander au système d'exploitation de décider du verrou, et cela se fait directement à l'intérieur du CPU.

Insérez la description de l'image ici

CAS a trois paramètres de fonctionnement:

1. Emplacement de mémoire M (sa valeur est ce que nous voulons mettre à jour)
2. Valeur d'origine attendue E (la valeur lue dans la mémoire la dernière fois)
3. Nouvelle valeur V (la nouvelle valeur à écrire)

Processus d'opération CAS: commencez par lire la valeur d'origine de l'emplacement de mémoire M, marquez-la comme E, puis calculez la nouvelle valeur V, comparez la valeur de l'emplacement de mémoire actuel M avec E, si elles sont égales, cela signifie qu'il n'y a rien d'autre dans le processus Le thread a modifié cette valeur, donc la valeur de l'emplacement mémoire M est mise à jour en V (swap). Bien sûr, cela doit être fait sans problèmes ABA (les problèmes ABA seront discutés plus tard). S'ils ne sont pas égaux, cela signifie que la valeur de l'emplacement mémoire M a été modifiée par d'autres threads, donc elle n'est pas mise à jour, et elle revient au début de l'opération pour s'exécuter à nouveau (spin).

Nous pouvons regarder l'algorithme CAS représenté par C:

int cas(long *addr, long old, long new) {
    
    
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

Par conséquent, lorsque plusieurs threads tentent d'utiliser CAS pour mettre à jour la même variable en même temps, l'un des threads met à jour avec succès la valeur de la variable et le reste échoue. Le thread ayant échoué peut continuer à réessayer jusqu'à ce qu'il réussisse. En termes simples, la signification de CAS est "ce que je pense que la valeur d'origine devrait être, si c'est le cas, alors mettez à jour la valeur d'origine avec la nouvelle valeur, sinon elle ne sera pas modifiée, et dites-moi quelle est la valeur maintenant".

Certaines personnes peuvent être curieuses, opération CAS, d'abord lire puis comparer, puis définir la valeur, tant d'étapes, sera-t-il interféré par d'autres threads et provoquera des conflits entre les étapes? En fait, ce ne sera pas le cas, car l'opération CAS dans le code d'assemblage sous-jacent n'est pas implémentée avec trois instructions, mais une seule instruction: verrouiller cmpxchg (architecture x86), de sorte que la tranche de temps ne sera pas volée pendant l'exécution CAS Cas. Mais cela implique un autre problème: l'opération CAS dépend trop de la conception du CPU, ce qui signifie que CAS est essentiellement une instruction dans le CPU. Si la CPU ne prend pas en charge les opérations CAS, CAS ne peut pas être implémenté.

Puisqu'il y a un processus d'essai continu dans CAS, cela causera-t-il un grand gaspillage de ressources? La réponse est possible, et c'est aussi l'une des lacunes du CAS. Cependant, étant donné que CAS est un verrou optimiste, le concepteur doit être optimiste lors de la conception. En d'autres termes, CAS estime qu'il a une très forte probabilité de réussir à terminer l'opération en cours, donc selon CAS , Une nouvelle tentative après la fin (rotation) est un événement de faible probabilité.

(4) Problèmes avec CAS

  1. Problème ABA
    Parce que CAS vérifiera si l'ancienne valeur a changé, il y a un problème tellement intéressant ici. Par exemple, une ancienne valeur A devient B, puis devient A. Il arrive que l'ancienne valeur n'ait pas changé et reste A, mais elle a en fait changé. La solution peut suivre la méthode de verrouillage optimiste couramment utilisée dans la base de données et ajouter un numéro de version pour la résoudre. Le chemin de changement d'origine A-> B-> A devient 1A-> 2B-> 3C. AtomicStampedReference est fourni dans le package atomic après Java 1.5 pour résoudre le problème ABA, et la solution est comme ça.

  2. Si le temps de rotation est trop long
    , synchronisation non bloquante lors de l'utilisation de CAS, c'est-à-dire, le thread ne sera pas suspendu, et il tournera (ce n'est rien de plus qu'une boucle infinie) pour la prochaine tentative. Si le temps de rotation ici est trop long, les performances seront excellentes Consommation. Si la JVM peut prendre en charge l'instruction de pause fournie par le processeur, il y aura une certaine amélioration de l'efficacité.

  3. Le fonctionnement atomique
    d'une variable partagée ne peut être garanti. Lorsqu'une opération est effectuée sur une variable partagée, CAS peut garantir son atomicité. Si plusieurs variables partagées sont exploitées, CAS ne peut pas garantir son atomicité. Une solution consiste à utiliser des objets pour intégrer plusieurs variables partagées, c'est-à-dire que les variables membres d'une classe sont ces variables partagées. Ensuite, l'opération CAS de cet objet peut assurer son atomicité. AtomicReference est fourni en atomique pour garantir l'atomicité entre les objets référencés.

On peut voir que bien que CAS ait quelques problèmes, il est constamment optimisé et résolu.

(5) Quelques applications de CAS

Il existe un package java.util.concurrent.atomic dans jdk. Les classes à l'intérieur sont toutes des opérations sans verrouillage basées sur l'implémentation CAS. Ces classes atomiques sont toutes thread-safe. Le package atomique fournit un total de 13 classes, appartenant à 4 types de méthodes de mise à jour atomique, à savoir le type de base de mise à jour atomique, le tableau de mise à jour atomique, la référence de mise à jour atomique et l'attribut de mise à jour atomique (champ). Les classes du package Atomic sont essentiellement des classes d'empaquetage implémentées à l'aide de Unsafe. Nous pouvons choisir une classe classique pour parler (j'ai entendu dire que de nombreuses personnes entrent en contact avec CAS à partir de cette classe), qui est la classe AtomicInteger.

Nous pouvons d'abord regarder comment AtomicInteger est initialisé:

public class AtomicInteger extends Number implements java.io.Serializable {
    
    
    private static final long serialVersionUID = 6214790243416807050L;

    // 很显然AtomicInteger是基于Unsafe类实现的
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    
    // 属性value值在内存中偏移量
    private static final long valueOffset;

    static {
    
    
        try {
    
    
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) {
    
     throw new Error(ex); }
    }

    // AtomicInteger本身是个整型,所以属性就是int,被volatile修饰保证线程可见性
    private volatile int value;

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initial value
     */
    public AtomicInteger(int initialValue) {
    
    
        value = initialValue;
    }

    /**
     * Creates a new AtomicInteger with initial value {@code 0}.
     */
    public AtomicInteger() {
    
    
    }
}   

Nous pouvons voir plusieurs points: la classe Unsafe (similaire aux pointeurs en langage C) est le cœur de CAS, qui est le cœur d'AtomicInteger. valueOffset est l'adresse de décalage de la valeur en mémoire et Unsafe fournit la méthode d'opération correspondante. La valeur est modifiée par le mot clé volatile pour assurer la visibilité du thread. Il s'agit de la variable qui stocke réellement la valeur.

Voyons comment la méthode getAndIncrement la plus couramment utilisée dans AtomicInteger est implémentée:

public final int getAndIncrement() {
    
    
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

De toute évidence, il a appelé la méthode getAndAddInt en unsafe, puis nous passons à cette méthode:

/**
 * Atomically adds the given value to the current value of a field
 * or array element within the given object <code>o</code>
 * at the given <code>offset</code>.
 *
 * @param o object/array to update the field/element in
 * @param offset field/element offset
 * @param delta the value to add
 * @return the previous value
 * @since 1.8
 */
public final int getAndAddInt(Object o, long offset, int delta) {
    
    
    int v;
    do {
    
    
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}

 /**
  * Atomically update Java variable to <tt>x</tt> if it is currently
  * holding <tt>expected</tt>.
  * @return <tt>true</tt> if successful
  */
 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

Nous pouvons voir que la boucle do-while dans la méthode getAndAddInt est équivalente à la partie spin dans CAS. Si le remplacement échoue, il continuera d'essayer jusqu'à ce qu'il réussisse. Le code de base du CAS réel (le processus de comparaison et d'échange) appelle le code C ++ local dans la méthode native de compareAndSwapInt:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
	UnsafeWrapper("Unsafe_CompareAndSwapInt");
	oop p = JNIHandles::resolve(obj);
	//获取对象的变量的地址
	jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
	//调用Atomic操作
	//先去获取一次结果,如果结果和现在不同,就直接返回,因为有其他人修改了;否则会一直尝试去修改。直到成功。
	return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

Nous avons enfin vu la commande que nous connaissons bien: cmpxchg. En d'autres termes, le contenu dont nous avons parlé auparavant a été complètement concaténé. L'essence de CAS est une instruction CPU, et l'implémentation de toutes les classes atomiques appelle cette instruction couche par couche pour réaliser l'opération sans verrouillage dont nous avons besoin.

Supposons qu'il y ait un nouvel AtomicInteger (0); maintenant il y a thread 1 et thread 2 pour effectuer l'opération getAndAddInt dessus en même temps.
1) Thread 1 obtient d'abord la valeur 0, puis le thread change;
2) Thread 2 obtient la valeur 0, à ce moment appelant Unsafe pour comparer la valeur dans la mémoire est également 0, c'est relativement réussi, c'est-à-dire que l'opération de mise à jour de +1 est effectuée, c'est-à-dire La valeur actuelle est 1. Changement de thread;
3) Thread 1 reprend son exécution, en utilisant CAS pour trouver que sa valeur est 0, mais qu'elle est 1 en mémoire. Get: La valeur est modifiée par un autre thread à ce moment, et je ne peux pas la modifier;
4) Thread 1 ne parvient pas à juger, continuez à boucler la valeur et à juger. Parce que volatile modifie la valeur, la valeur obtenue est également 1. Ceci exécute l'opération CAS, et il s'avère que la valeur de expect et la mémoire à ce moment sont égales, la modification est réussie et la valeur est 2;
5) Dans le processus de la quatrième étape, même s'il y a un thread 3 pour préempter les ressources pendant l'opération CAS, il ne peut toujours pas La préemption est réussie car compareAndSwapInt est une opération atomique.

30 mai 2020

Je suppose que tu aimes

Origine blog.csdn.net/weixin_43907422/article/details/106423364
conseillé
Classement