JavaRush /Blog Java /Random-FR /Fondamentaux de la concurrence : blocages et moniteurs d'...
Snusmum
Niveau 34
Хабаровск

Fondamentaux de la concurrence : blocages et moniteurs d'objets (sections 1, 2) (traduction de l'article)

Publié dans le groupe Random-FR
Article source : http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Publié par Martin Mois Cet article fait partie de notre cours Java Concurrency Fundamentals . Dans ce cours, vous plongerez dans la magie du parallélisme. Vous apprendrez les bases du parallélisme et du code parallèle et vous familiariserez avec des concepts tels que l'atomicité, la synchronisation et la sécurité des threads. Jetez-y un œil ici !

Contenu

1. Vivacité  1.1 Deadlock  1.2 Famine 2. Surveillance d'objets avec wait() et notify()  2.1 Blocs synchronisés imbriqués avec wait() et notify()  2.2 Conditions dans les blocs synchronisés 3. Conception pour le multithreading  3.1 Objet immuable  3.2 Conception d'API  3.3 Stockage des threads locaux
1. Vitalité
Lorsque vous développez des applications qui utilisent le parallélisme pour atteindre leurs objectifs, vous pouvez rencontrer des situations dans lesquelles différents threads peuvent se bloquer mutuellement. Si l’application s’exécute plus lentement que prévu dans cette situation, nous dirions qu’elle ne s’exécute pas comme prévu. Dans cette section, nous examinerons de plus près les problèmes qui peuvent menacer la capacité de survie d'une application multithread.
1.1 Blocage mutuel
Le terme blocage est bien connu parmi les développeurs de logiciels et même la plupart des utilisateurs ordinaires l'utilisent de temps en temps, mais pas toujours dans le bon sens. À proprement parler, ce terme signifie que chacun des deux (ou plusieurs) threads attend que l'autre thread libère une ressource verrouillée par lui, tandis que le premier thread a lui-même verrouillé une ressource à laquelle le second attend d'accéder : Pour mieux comprendre le problème, jetez un oeil au Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A code suivant: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Comme vous pouvez le voir dans le code ci-dessus, deux threads démarrent et tentent de verrouiller deux ressources statiques. Mais pour le blocage, nous avons besoin d'une séquence différente pour les deux threads, nous utilisons donc une instance de l'objet Random pour choisir quelle ressource le thread souhaite verrouiller en premier. Si la variable booléenne b est vraie, alors la ressource1 est verrouillée en premier, puis le thread tente d'acquérir le verrou pour la ressource2. Si b est faux, le thread verrouille la ressource 2 puis tente d'acquérir la ressource 1. Ce programme n'a pas besoin de s'exécuter longtemps pour atteindre le premier blocage, c'est-à-dire Le programme se bloquera pour toujours si nous ne l'interrompons pas : [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. Dans cette exécution, la bande de roulement-1 a acquis le verrou de la ressource2 et attend le verrou de la ressource1, tandis que la bande de roulement-2 a le verrou de la ressource1 et attend la ressource2. Si nous devions définir la valeur de la variable booléenne b dans le code ci-dessus sur true, nous ne pourrions observer aucun blocage car la séquence dans laquelle les threads 1 et 2 demandent des verrous serait toujours la même. Dans cette situation, l'un des deux threads obtiendrait le verrou en premier, puis demanderait le second, qui est toujours disponible car l'autre thread attend le premier verrou. En général, nous pouvons distinguer les conditions nécessaires suivantes pour qu'un blocage se produise : - Exécution partagée : Il existe une ressource accessible par un seul thread à la fois. - Resource Hold : lors de l'acquisition d'une ressource, un thread tente d'acquérir un autre verrou sur une ressource unique. - Pas de préemption : Il n'existe aucun mécanisme pour libérer une ressource si un thread détient le verrou pendant un certain temps. - Attente circulaire : lors de l'exécution, une collection de threads se produit dans laquelle deux (ou plus) threads s'attendent pour libérer une ressource qui a été verrouillée. Bien que la liste des conditions semble longue, il n’est pas rare que des applications multithread bien exécutées rencontrent des problèmes de blocage. Mais vous pouvez les empêcher si vous pouvez supprimer l'une des conditions ci-dessus : - Exécution partagée : cette condition ne peut souvent pas être supprimée lorsque la ressource doit être utilisée par une seule personne. Mais cela ne doit pas nécessairement être la raison. Lors de l'utilisation de systèmes SGBD, une solution possible, au lieu d'utiliser un verrou pessimiste sur certaines lignes de table qui doivent être mises à jour, consiste à utiliser une technique appelée Verrouillage optimiste . - Un moyen d'éviter de détenir une ressource en attendant une autre ressource exclusive est de verrouiller toutes les ressources nécessaires au début de l'algorithme et de les libérer toutes s'il est impossible de toutes les verrouiller d'un coup. Bien sûr, cela n'est pas toujours possible : peut-être que les ressources qui nécessitent un verrouillage sont inconnues à l'avance, ou cette approche entraînera simplement un gaspillage de ressources. - Si le verrou ne peut pas être acquis immédiatement, un moyen de contourner un éventuel blocage consiste à introduire un délai d'attente. Par exemple, la classe ReentrantLockdu SDK offre la possibilité de définir une date d’expiration pour le verrou. - Comme nous l'avons vu dans l'exemple ci-dessus, un blocage ne se produit pas si la séquence de requêtes ne diffère pas entre les différents threads. Ceci est facile à contrôler si vous pouvez regrouper tout le code de blocage dans une seule méthode par laquelle tous les threads doivent passer. Dans des applications plus avancées, vous pourriez même envisager de mettre en œuvre un système de détection de blocage. Ici, vous devrez implémenter un semblant de surveillance des threads, dans lequel chaque thread signale qu'il a réussi à acquérir le verrou et qu'il tente de l'acquérir. Si les threads et les verrous sont modélisés sous forme de graphe orienté, vous pouvez détecter lorsque deux threads différents détiennent des ressources tout en essayant d'accéder simultanément à d'autres ressources verrouillées. Si vous pouvez ensuite forcer les threads bloquants à libérer les ressources requises, vous pouvez résoudre automatiquement la situation de blocage.
1.2 Jeûne
Le planificateur décide quel thread dans l'état RUNNABLE il doit exécuter ensuite. La décision est basée sur la priorité des threads ; par conséquent, les threads avec une priorité inférieure reçoivent moins de temps CPU par rapport à ceux avec une priorité plus élevée. Ce qui semble être une solution raisonnable peut également causer des problèmes en cas d’abus. Si les threads de haute priorité s'exécutent la plupart du temps, alors les threads de faible priorité semblent mourir de faim car ils n'ont pas suffisamment de temps pour faire leur travail correctement. Par conséquent, il est recommandé de définir la priorité des threads uniquement lorsqu’il existe une raison impérieuse de le faire. Un exemple non évident de manque de threads est donné, par exemple, par la méthode finalize(). Il permet au langage Java d'exécuter du code avant qu'un objet ne soit récupéré. Mais si vous regardez la priorité du thread de finalisation, vous remarquerez qu'il ne s'exécute pas avec la priorité la plus élevée. Par conséquent, la famine des threads se produit lorsque les méthodes finalize() de votre objet passent trop de temps par rapport au reste du code. Un autre problème avec le temps d'exécution vient du fait qu'il n'est pas défini dans quel ordre les threads traversent le bloc synchronisé. Lorsque de nombreux threads parallèles parcourent du code encadré dans un bloc synchronisé, il peut arriver que certains threads doivent attendre plus longtemps que d'autres avant d'entrer dans le bloc. En théorie, ils n’y arriveront peut-être jamais. La solution à ce problème est le blocage dit « équitable ». Les verrous équitables prennent en compte les temps d'attente des threads pour déterminer qui passer ensuite. Un exemple d'implémentation de verrouillage équitable est disponible dans le SDK Java : java.util.concurrent.locks.ReentrantLock. Si un constructeur est utilisé avec un indicateur booléen défini sur true, alors ReentrantLock donne accès au thread qui attend le plus longtemps. Cela garantit l’absence de faim mais, en même temps, conduit au problème de l’ignorance des priorités. Pour cette raison, les processus de priorité inférieure qui attendent souvent à cette barrière peuvent s'exécuter plus fréquemment. Enfin et surtout, la classe ReentrantLock ne peut prendre en compte que les threads qui attendent un verrou, c'est-à-dire des fils qui étaient lancés assez souvent et atteignaient la barrière. Si la priorité d'un thread est trop faible, cela ne se produira pas souvent et, par conséquent, les threads de haute priorité passeront le verrou plus souvent.
2. Les moniteurs d'objets avec wait() et notify()
Dans l'informatique multithread, une situation courante est que certains threads de travail attendent que leur producteur crée du travail pour eux. Mais, comme nous l’avons appris, attendre activement en boucle tout en vérifiant une certaine valeur n’est pas une bonne option en termes de temps CPU. Utiliser la méthode Thread.sleep() dans cette situation n'est pas non plus particulièrement adapté si nous voulons commencer notre travail immédiatement après notre arrivée. A cet effet, le langage de programmation Java possède une autre structure qui peut être utilisée dans ce schéma : wait() et notify(). La méthode wait(), héritée par tous les objets de la classe java.lang.Object, peut être utilisée pour suspendre le thread en cours et attendre qu'un autre thread nous réveille à l'aide de la méthode notify(). Pour fonctionner correctement, le thread appelant la méthode wait() doit détenir un verrou qu'il a précédemment acquis à l'aide du mot-clé synchronisé. Lorsque wait() est appelé, le verrou est libéré et le thread attend qu'un autre thread qui détient désormais le verrou appelle notify() sur la même instance d'objet. Dans une application multithread, il peut naturellement y avoir plusieurs threads en attente de notification sur un objet. Par conséquent, il existe deux méthodes différentes pour réveiller les threads : notify() et notifyAll(). Alors que la première méthode réveille l'un des threads en attente, la méthode notifyAll() les réveille tous. Mais sachez que, comme pour le mot-clé synchronisé, il n'existe aucune règle qui détermine quel thread sera ensuite réveillé lorsque notify() est appelé. Dans un exemple simple avec un producteur et un consommateur, cela n'a pas d'importance, puisque peu nous importe quel thread est réveillé. Le code suivant montre comment wait() et notify() peuvent être utilisés pour obliger les threads consommateurs à attendre que de nouveaux travaux soient mis en file d'attente par un thread producteur : package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } La méthode main() démarre cinq threads consommateurs et un thread producteur, puis attend qu'ils se terminent. Le thread producteur ajoute ensuite la nouvelle valeur à la file d'attente et informe tous les threads en attente que quelque chose s'est produit. Les consommateurs obtiennent un verrouillage de file d'attente (c'est-à-dire un consommateur aléatoire) puis se mettent en veille, pour être déclenchés plus tard lorsque la file d'attente est à nouveau pleine. Lorsque le producteur termine son travail, il avertit tous les consommateurs pour les réveiller. Si nous n'effectuions pas la dernière étape, les threads consommateurs attendraient indéfiniment la prochaine notification car nous n'avons pas défini de délai d'attente. Au lieu de cela, nous pouvons utiliser la méthode wait(long timeout) pour être réveillé au moins après un certain temps.
2.1 Blocs synchronisés imbriqués avec wait() et notify()
Comme indiqué dans la section précédente, appeler wait() sur le moniteur d'un objet libère uniquement le verrou sur ce moniteur. Les autres verrous détenus par le même thread ne sont pas libérés. Comme il est facile de le comprendre, dans le travail quotidien, il peut arriver que le thread appelant wait() maintienne le verrou davantage. Si d'autres threads attendent également ces verrous, une situation de blocage peut se produire. Regardons le verrouillage dans l'exemple suivant : public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Comme nous l'avons appris précédemment , ajouter synchronisé à une signature de méthode équivaut à créer un bloc synchronisé(this){}. Dans l'exemple ci-dessus, nous avons accidentellement ajouté le mot-clé synchronisé à la méthode, puis synchronisé la file d'attente avec le moniteur de l'objet file d'attente pour mettre ce thread en veille pendant qu'il attendait la valeur suivante de la file d'attente. Ensuite, le thread actuel libère le verrou sur la file d'attente, mais pas le verrou sur celle-ci. La méthode putInt() informe le thread en veille qu'une nouvelle valeur a été ajoutée. Mais par hasard, nous avons également ajouté le mot-clé synchronisé à cette méthode. Maintenant que le deuxième thread s'est endormi, il détient toujours le verrou. Par conséquent, le premier thread ne peut pas entrer dans la méthode putInt() tant que le verrou est détenu par le deuxième thread. Résultat : nous nous retrouvons dans une impasse et un programme gelé. Si vous exécutez le code ci-dessus, cela se produira immédiatement après le démarrage du programme. Dans la vie de tous les jours, cette situation n’est peut-être pas si évidente. Les verrous détenus par un thread peuvent dépendre des paramètres et des conditions rencontrés au moment de l'exécution, et le bloc synchronisé à l'origine du problème peut ne pas être aussi proche dans le code de l'endroit où nous avons placé l'appel wait(). Cela rend difficile la détection de tels problèmes, d'autant plus qu'ils peuvent survenir avec le temps ou sous une charge élevée.
2.2 Conditions dans les blocs synchronisés
Vous devez souvent vérifier qu'une condition est remplie avant d'effectuer une action sur un objet synchronisé. Lorsque vous avez une file d’attente, par exemple, vous souhaitez attendre qu’elle se remplisse. Par conséquent, vous pouvez écrire une méthode qui vérifie si la file d’attente est pleine. S'il est toujours vide, vous mettez le thread actuel en veille jusqu'à ce qu'il soit réveillé : public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } Le code ci-dessus se synchronise avec la file d'attente avant d'appeler wait(), puis attend dans une boucle while jusqu'à ce qu'au moins un élément apparaisse dans la file d'attente. Le deuxième bloc synchronisé utilise à nouveau la file d'attente comme moniteur d'objets. Il appelle la méthode poll() de la file d'attente pour obtenir la valeur. À des fins de démonstration, une IllegalStateException est levée lorsque le sondage renvoie null. Cela se produit lorsque la file d'attente n'a aucun élément à récupérer. Lorsque vous exécutez cet exemple, vous verrez qu'IllegalStateException est levée très souvent. Bien que nous ayons synchronisé correctement à l’aide du moniteur de file d’attente, une exception a été levée. La raison en est que nous avons deux blocs synchronisés différents. Imaginez que nous ayons deux threads arrivés au premier bloc synchronisé. Le premier thread est entré dans le bloc et s'est endormi car la file d'attente était vide. La même chose est vraie pour le deuxième fil. Maintenant que les deux threads sont réveillés (grâce à l'appel notifyAll() appelé par l'autre thread pour le moniteur), ils voient tous les deux la valeur (élément) dans la file d'attente ajoutée par le producteur. Puis tous deux arrivèrent à la deuxième barrière. Ici, le premier thread est entré et a récupéré la valeur de la file d'attente. Lorsque le deuxième thread entre, la file d'attente est déjà vide. Par conséquent, il reçoit null comme valeur renvoyée par la file d’attente et lève une exception. Pour éviter de telles situations, vous devez effectuer toutes les opérations qui dépendent de l'état du moniteur dans le même bloc synchronisé : public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Ici, nous exécutons la méthode poll() dans le même bloc synchronisé que la méthode isEmpty(). Grâce au bloc synchronisé, nous sommes sûrs qu'un seul thread exécute une méthode pour ce moniteur à un instant donné. Par conséquent, aucun autre thread ne peut supprimer des éléments de la file d'attente entre les appels à isEmpty() et poll(). Suite de la traduction ici .
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION