Le livre "Java Concurrency in Practice"

imageBonjour, habrozhiteli! Les flux sont une partie fondamentale de la plate-forme Java. Les processeurs multicœurs sont monnaie courante et l'utilisation efficace de la concurrence est devenue nécessaire pour créer toute application hautes performances. Une machine virtuelle Java améliorée, la prise en charge de classes hautes performances et un riche ensemble de blocs de construction pour les tâches de parallélisation ont été à un moment donné une percée dans le développement d'applications parallèles. Dans Java Concurrency in Practice, les créateurs de technologies révolutionnaires expliquent eux-mêmes non seulement comment ils fonctionnent, mais parlent également des modèles de conception. Il est facile de créer un programme compétitif qui semble fonctionner. Cependant, le développement, le test et le débogage de programmes multithread posent de nombreux problèmes. Le code cesse de fonctionner au moment le plus important: sous forte charge.Dans «Java Concurrency in Practice», vous trouverez à la fois la théorie et des méthodes spécifiques pour créer des applications parallèles fiables, évolutives et prises en charge. Les auteurs ne proposent pas une liste d'API et de mécanismes de parallélisme; ils introduisent des règles de conception, des modèles et des modèles qui sont indépendants de la version Java et restent pertinents et efficaces pendant de nombreuses années.

Extrait. Sécurité des fils


Vous serez peut-être surpris que la programmation compétitive soit associée aux filetages ou aux verrous (1) pas plus que le génie civil ne soit associé aux rivets et aux poutres en I. Bien sûr, la construction de ponts nécessite l'utilisation correcte d'un grand nombre de rivets et de poutres en I, et il en va de même pour la construction de programmes compétitifs, qui nécessite l'utilisation correcte de filetages et de verrous. Mais ce ne sont que des mécanismes - des moyens pour atteindre l'objectif. Écrire du code thread-safe, c'est essentiellement contrôler l'accès à un état, et en particulier à un état mutable.

En général, l'état d'un objet correspond à ses données stockées dans des variables d'état, telles que des champs d'instance et statiques ou des champs d'autres objets dépendants. L'état du hachage HashMap est partiellement stocké dans le HashMap lui-même, mais aussi dans de nombreux objets Map.Entry. L'état d'un objet comprend toutes les données susceptibles d'affecter son comportement.

(1) lock block, «», , . blocking. lock «», « ». lock , , , «». — . , , , . — . . .

Plusieurs threads peuvent accéder à une variable partagée, mutée - change sa valeur. En fait, nous essayons de protéger les données, et non le code, contre un accès concurrentiel incontrôlé.

La création d'un objet thread-safe nécessite une synchronisation pour coordonner l'accès à un état muté, le non-respect pouvant entraîner une corruption des données et d'autres conséquences indésirables.

Chaque fois que plusieurs threads accèdent à une variable d'état et que l'un des threads y écrit éventuellement, tous les threads doivent coordonner leur accès à l'aide de la synchronisation. La synchronisation en Java est fournie par le mot-clé synchronized, qui donne un verrouillage exclusif, ainsi que des variables volatiles et atomiques et des verrous explicites.

Résistez à la tentation de penser qu'il existe des situations qui ne nécessitent pas de synchronisation. Le programme peut fonctionner et passer ses tests, mais reste défectueux et plante à tout moment.

Si plusieurs threads accèdent à la même variable avec un état muté sans synchronisation appropriée, alors votre programme fonctionne mal. Il existe trois façons de le corriger:

  • Ne partagez pas la variable d'état dans tous les threads
  • rendre la variable d'état non mutable;
  • utilisez la synchronisation d'état chaque fois que vous accédez à la variable d'état.

Les corrections peuvent nécessiter des modifications de conception importantes, il est donc beaucoup plus facile de concevoir une classe thread-safe immédiatement que de la mettre à niveau plus tard.

Il est difficile de savoir si plusieurs threads accéderont à telle ou telle variable. Heureusement, les solutions techniques orientées objet qui aident à créer des classes bien organisées et faciles à entretenir - telles que l'encapsulation et le masquage des données - aident également à créer des classes thread-safe. Moins il y a de threads qui ont accès à une variable particulière, plus il est facile d'assurer la synchronisation et de définir les conditions d'accès à cette variable. Le langage Java ne vous oblige pas à encapsuler l'état - il est parfaitement acceptable de stocker l'état dans des champs publics (même les champs statiques publics) ou de publier un lien vers un objet qui est par ailleurs interne - mais mieux l'état de votre programme est encapsulé,plus il est facile de sécuriser votre thread de programme et d'aider les responsables à le conserver.

Lors de la conception de classes thread-safe, de bonnes solutions techniques orientées objet: encapsulation, mutabilité et spécification claire des invariants seront vos assistants.

Si les bonnes solutions techniques de conception orientée objet divergent des besoins du développeur, cela vaut la peine de sacrifier les règles de bonne conception pour des raisons de performances ou de rétrocompatibilité avec le code hérité. Parfois, l'abstraction et l'encapsulation sont en contradiction avec les performances - bien que pas aussi souvent que le pensent de nombreux développeurs - mais la meilleure pratique consiste à faire le bon code en premier, puis rapidement. Essayez d'utiliser l'optimisation uniquement si les mesures de la productivité et des besoins indiquent que vous devez le faire (2) .

(2)En code concurrentiel, vous devez adhérer à cette pratique encore plus que d'habitude. Étant donné que les erreurs de concurrence sont extrêmement difficiles à reproduire et ne sont pas faciles à déboguer, l'avantage d'un petit gain de performances sur certaines branches de code rarement utilisées peut être assez négligeable par rapport au risque de plantage du programme dans les conditions de fonctionnement.

Si vous décidez que vous devez interrompre l'encapsulation, tout n'est pas perdu. Votre programme peut toujours être rendu sûr pour les threads, mais le processus sera plus compliqué et plus cher, et le résultat ne sera pas fiable. Le chapitre 4 décrit les conditions dans lesquelles l'encapsulation des variables d'état peut être atténuée en toute sécurité.

Jusqu'à présent, nous avons utilisé presque indifféremment les termes «classe thread-safe» et «programme thread-safe». Un programme thread-safe est-il entièrement construit à partir de classes thread-safe? Facultatif: un programme entièrement constitué de classes thread-safe peut ne pas être thread-safe et un programme thread-safe peut contenir des classes qui ne sont pas thread-safe. Les problèmes liés à la disposition des classes thread-safe sont également traités au chapitre 4. Dans tous les cas, le concept de classe thread-safe n'a de sens que si la classe encapsule son propre état. Le terme «thread safety» peut être appliqué au code, mais il parle de l'état et ne peut être appliqué qu'à ce tableau de code qui encapsule son état (il peut s'agir d'un objet ou de l'ensemble du programme).

2.1. Qu'est-ce que la sécurité des fils?


Définir la sécurité des fils n'est pas facile. Une recherche rapide sur Google vous offre de nombreuses options comme celles-ci:

... peut être appelée à partir de plusieurs threads de programme sans interactions indésirables entre les threads.

... peut être appelé par deux threads ou plus en même temps, sans nécessiter d'autre action de la part de l'appelant.

Compte tenu de ces définitions, il n'est pas surprenant que nous trouvions la sécurité des threads déroutante! Comment distinguer une classe thread-safe d'une classe dangereuse? Qu'entendons-nous par le mot «sûr»?

Au cœur de toute définition raisonnable de la sécurité des threads se trouve la notion d'exactitude.

L'exactitude signifie que la classe est conforme à sa spécification. La spécification définit des invariants qui limitent l'état d'un objet et des postconditions qui décrivent les effets des opérations. Comment savez-vous que les spécifications des classes sont correctes? Pas question, mais cela ne nous empêche pas de les utiliser après nous être convaincus que le code fonctionne. Supposons donc que l'exactitude d'un seul thread soit visible. Nous pouvons maintenant supposer que la classe thread-safe se comporte correctement lors de l'accès à partir de plusieurs threads.

Une classe est thread-safe si elle se comporte correctement lors de l'accès à partir de plusieurs threads, quelle que soit la façon dont ces threads sont planifiés ou entrelacés par l'environnement de travail, et sans synchronisation supplémentaire ou autre coordination de la part du code appelant.

Un programme multithread ne peut pas être thread-safe s'il n'est pas correct même dans un environnement monothread (3) . Si l'objet est correctement implémenté, aucune séquence d'opérations - accès aux méthodes publiques et lecture ou écriture dans les champs publics - ne doit violer ses invariants ou ses post-conditions. Aucun ensemble d'opérations exécutées de manière séquentielle ou concurrentielle sur les instances d'une classe thread-safe ne peut entraîner le non-respect d'une instance.

(3) Si l'utilisation lâche du terme correct vous dérange ici, alors vous pouvez considérer une classe thread-safe comme une classe défectueuse dans un environnement concurrentiel, ainsi que dans un environnement à thread unique.

Les classes thread-safe encapsulent elles-mêmes toute synchronisation nécessaire et n'ont pas besoin de l'aide du client.

2.1.1. Exemple: servlet sans support d'état interne


Dans le chapitre 1, nous avons répertorié les structures qui créent des threads et appellent des composants à partir desquelles vous êtes responsable de la sécurité des threads. Nous avons maintenant l'intention de développer un service de factorisation de servlets et d'étendre progressivement ses fonctionnalités tout en préservant la sécurité des threads.

Le listing 2.1 montre une servlet simple qui décompresse un nombre d'une requête, la factorise et encapsule les résultats en réponse.

Listing 2.1. Servlet sans support d'état interne

@ThreadSafe
public class StatelessFactorizer implements Servlet {
      public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            encodeIntoResponse(resp, factors);
      }
}

La classe StatelessFactorizer, comme la plupart des servlets, n'a pas d'état interne: elle ne contient pas de champs et ne fait pas référence à des champs d'autres classes. L'état d'un calcul particulier n'existe que dans les variables locales qui sont stockées dans la pile de flux et ne sont disponibles que pour le flux en cours d'exécution. Un thread accédant à StatelessFactorizer ne peut pas affecter le résultat d'un autre thread faisant de même, car ces threads ne partagent pas l'état.

Les objets sans support d'état interne sont toujours thread-safe.

Le fait que la plupart des servlets peuvent être implémentés sans prise en charge de l'état interne réduit considérablement la charge de threading des servlets eux-mêmes. Et ce n'est que lorsque les servlets doivent se souvenir de quelque chose que les exigences relatives à la sécurité de leur filetage augmentent.

2.2. Atomicité


Que se passe-t-il lorsqu'un élément d'état est ajouté à un objet sans prise en charge d'état interne? Supposons que nous voulons ajouter un compteur d'accès qui mesure le nombre de demandes traitées. Vous pouvez ajouter un champ de type long à la servlet et l'incrémenter à chaque demande, comme indiqué dans UnsafeCountingFactorizer dans le listing 2.2.

Listing 2.2. Servlet qui compte les demandes sans la synchronisation nécessaire. Cela ne devrait pas être fait.

image

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
      private long count = 0;

      public long getCount() { return count; }

      public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            ++count;
            encodeIntoResponse(resp, factors);
      }
}

Malheureusement, la classe UnsafeCountingFactorizer n'est pas adaptée aux threads, même si elle fonctionne correctement dans un environnement à thread unique. Comme UnsafeSequence, il est sujet à des mises à jour perdues. Bien que le nombre d'opérations d'incrémentation ++ ait une syntaxe compacte, il n'est pas atomique, c'est-à-dire indivisible, mais une séquence de trois opérations: fournir la valeur actuelle, en ajouter une et réécrire la nouvelle valeur. Dans les opérations «lire, changer, écrire», l'état résultant est dérivé du précédent.

En figue. 1.1, il est montré ce qui peut arriver si deux threads essaient d'augmenter le compteur en même temps, sans synchronisation. Si le compteur est 9, alors en raison d'une coordination temporelle infructueuse, les deux threads verront la valeur 9, en ajouter un et définir la valeur à 10. Ainsi, le compteur d'accès commencera à être décalé d'un.

Vous pourriez penser qu'avoir un compteur d'accès légèrement inexact dans un service Web est une perte acceptable, et parfois c'est le cas. Mais si le compteur est utilisé pour créer des séquences ou des identificateurs uniques d'objets, le retour de la même valeur à partir de plusieurs activations peut entraîner de graves problèmes d'intégrité des données. La possibilité d'apparition de résultats incorrects en raison d'une coordination temporelle non réussie se présente dans une condition de race.

2.2.1. Conditions de course


La classe UnsafeCountingFactorizer a plusieurs conditions de concurrence (4) . Le type de condition de concurrence le plus courant est la situation «vérifier puis agir», où une observation potentiellement obsolète est utilisée pour décider quoi faire ensuite.

(4) (data race). , . , , , , , . Java. , , . UnsafeCountingFactorizer . 16.

Nous rencontrons souvent une condition de course dans la vraie vie. Supposons que vous prévoyez de rencontrer un ami à midi au Starbucks Café sur Universitetskiy Prospekt. Mais vous découvrirez qu'il y a deux Starbucks sur University Avenue. A 12h10, vous ne voyez pas votre ami dans le café A et allez au café B, mais il n'est pas là non plus. Soit votre ami est en retard, soit il est arrivé au café A immédiatement après votre départ, soit il était au café B, mais il est allé vous chercher et est maintenant en route vers le café A. Nous accepterons ce dernier, c'est-à-dire le pire des cas. Maintenant 12:15, et vous vous demandez tous les deux si votre ami a tenu sa promesse. Retournerez-vous dans un autre café? Combien de fois allez-vous faire des allers-retours? Si vous n'êtes pas d'accord sur un protocole, vous pouvez passer toute la journée à marcher le long de l'avenue University dans une euphorie caféinée.
Le problème avec l'approche «faire une promenade et voir s'il est là» est qu'une promenade le long de la rue entre deux cafés prend plusieurs minutes, et pendant ce temps, l'état du système peut changer.

L'exemple avec Starbucks illustre la dépendance du résultat sur la coordination temporelle relative des événements (sur combien de temps vous attendez un ami dans un café, etc.). L'observation selon laquelle il n'est pas dans le café A devient potentiellement invalide: dès que vous sortez de la porte d'entrée, il peut entrer par la porte arrière. La plupart des conditions de concurrence provoquent des problèmes tels qu'une exception inattendue, des données écrasées et une corruption de fichiers.

2.2.2. Exemple: conditions de concurrence dans l'initialisation paresseuse


Une astuce courante utilisant l'approche «vérifier puis agir» est l'initialisation paresseuse (LazyInitRace). Son but est de reporter l'initialisation de l'objet jusqu'à ce qu'il soit nécessaire et de s'assurer qu'il n'est initialisé qu'une seule fois. Dans le listing 2.3, la méthode getInstance garantit que l'objet ExpensiveObject est initialisé et renvoie une instance existante, ou sinon, crée une nouvelle instance et la renvoie après avoir conservé une référence à celle-ci.

Listing 2.3. La condition de concurrence critique est en initialisation paresseuse. Cela ne devrait pas être fait.

image

@NotThreadSafe
public class LazyInitRace {
      private ExpensiveObject instance = null;

      public ExpensiveObject getInstance() {
            if (instance == null)
                instance = new ExpensiveObject();
            return instance;
      }
}

La classe LazyInitRace contient des conditions de concurrence. Supposons que les threads A et B exécutent la méthode getInstance en même temps. A voit que le champ d'instance est nul et crée un nouvel ExpensiveObject. Le thread B vérifie également si le champ d'instance est le même null. La présence de null dans le champ à ce moment dépend de la coordination temporelle, y compris des aléas de la planification et du temps nécessaire pour créer une instance de ExpensiveObject et définir la valeur dans le champ d'instance. Si le champ d'instance est nul lorsque B le vérifie, deux éléments de code appelant la méthode getInstance peuvent obtenir deux résultats différents, même si la méthode getInstance est censée toujours renvoyer la même instance.

Le compteur d'accès dans UnsafeCountingFactorizer contient également des conditions de concurrence. L'approche «lire, modifier, écrire» implique que pour incrémenter le compteur, le flux doit connaître sa valeur précédente et s'assurer que personne d'autre ne modifie ou n'utilise cette valeur pendant le processus de mise à jour.

Comme la plupart des erreurs de compétition, les conditions de course ne mènent pas toujours à l'échec: la coordination temporaire est réussie. Mais si la classe LazyInitRace est utilisée pour instancier le registre de l'application entière, alors quand elle renverra différentes instances de plusieurs activations, les enregistrements seront perdus ou les actions recevront des représentations conflictuelles de l'ensemble d'objets enregistrés. Ou si la classe UnsafeSequence est utilisée pour générer des identifiants d'entité dans une structure de conservation des données, deux objets différents peuvent avoir le même identifiant, violant les restrictions d'identité.

2.2.3. Actions composées


LazyInitRace et UnsafeCountingFactorizer contiennent une séquence d'opérations qui doit être atomique. Mais pour éviter une condition de concurrence, il doit y avoir un obstacle pour que d'autres threads utilisent la variable pendant qu'un thread la modifie.

Les opérations A et B sont atomiques si, du point de vue du thread exécutant l'opération A, l'opération B a été entièrement réalisée par un autre thread ou pas même partiellement.

L'atomicité de l'opération d'incrémentation dans UnsafeSequence éviterait la condition de concurrence montrée sur la Fig. 1.1. Les opérations «vérifier puis agir» et «lire, changer, écrire» doivent toujours être atomiques. On les appelle des actions composées - des séquences d'opérations qui doivent être exécutées atomiquement afin de rester thread-safe. Dans la section suivante, nous considérerons le verrouillage - un mécanisme intégré à Java qui fournit l'atomicité. En attendant, nous allons résoudre le problème d'une autre manière en appliquant la classe thread-safe existante, comme indiqué dans le Countingfactorizer du Listing 2.4.

Listing 2.4. Demandes de comptage de servlets utilisant AtomicLong

@ThreadSafe
public class CountingFactorizer implements Servlet {
      private final AtomicLong count = new AtomicLong(0);

      public long getCount() { return count.get(); }

      public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            count.incrementAndGet();
            encodeIntoResponse(resp, factors);
      }
}

Le package java.util.concurrent.atomic contient des variables atomiques pour gérer les états de classe. En remplaçant le type de compteur de long par AtomicLong, nous garantissons que toutes les actions qui font référence à l'état du compteur sont atomic1. Étant donné que l'état de la servlet est l'état du compteur et que le compteur est thread-safe, notre servlet devient thread-safe.

Lorsqu'un élément d'état unique est ajouté à une classe qui ne prend pas en charge l'état interne, la classe résultante sera thread-safe si l'état est complètement contrôlé par l'objet thread-safe. Mais, comme nous le verrons dans la section suivante, la transition d'une variable d'état à la suivante ne sera pas aussi simple que la transition de zéro à un.

Lorsque cela est possible, utilisez des objets thread-safe existants, tels que AtomicLong, pour contrôler l'état de votre classe. Les états possibles des objets thread-safe existants et leurs transitions vers d'autres états sont plus faciles à maintenir et à vérifier la sécurité des threads que les variables d'état arbitraires.

»Plus d'informations sur le livre peuvent être trouvées sur le site Web de l'éditeur
» Contenu
» Extrait

pour Khabrozhiteley 25% de réduction sur le coupon - Java

Après le paiement de la version papier du livre, un livre électronique est envoyé par e-mail.

All Articles