Programmation des threads Java dans le monde réel, partie 1

Tous les programmes Java autres que les simples applications basées sur la console sont multithread, que cela vous plaise ou non. Le problème est que l'AWT (Abstract Windowing Toolkit) traite les événements du système d'exploitation (OS) sur son propre thread, de sorte que vos méthodes d'écoute s'exécutent réellement sur le thread AWT. Ces mêmes méthodes d'écoute accèdent généralement aux objets qui sont également accessibles à partir du thread principal. Il peut être tentant, à ce stade, de se mettre la tête dans le sable et de prétendre que vous n'avez pas à vous soucier des problèmes de filetage, mais vous ne pouvez généralement pas vous en sortir. Et, malheureusement, pratiquement aucun des livres sur Java n'aborde suffisamment les problèmes de threading. (Pour une liste de livres utiles sur le sujet, voir Ressources.)

Cet article est le premier d'une série qui présentera des solutions concrètes aux problèmes de programmation Java dans un environnement multithread. Il est destiné aux programmeurs Java qui comprennent les éléments au niveau du langage (le synchronizedmot - clé et les diverses fonctionnalités de la Threadclasse), mais qui souhaitent apprendre à utiliser efficacement ces fonctionnalités du langage.

Dépendance à la plateforme

Malheureusement, la promesse d'indépendance de la plate-forme de Java tombe à plat dans l'arène des threads. Bien qu'il soit possible d'écrire un programme Java multithread indépendant de la plate-forme, vous devez le faire les yeux ouverts. Ce n'est pas vraiment la faute de Java; il est presque impossible d'écrire un système de threading véritablement indépendant de la plateforme. (Le cadre ACE [Adaptive Communication Environment] de Doug Schmidt est une bonne tentative, bien que complexe. Voir Ressources pour un lien vers son programme.) Donc, avant de pouvoir parler des problèmes de programmation Java dans les versions ultérieures, je dois Discutez des difficultés introduites par les plates-formes sur lesquelles la machine virtuelle Java (JVM) pourrait fonctionner.

Énergie atomique

Le premier concept au niveau du système d'exploitation qu'il est important de comprendre est l' atomicité. Une opération atomique ne peut pas être interrompue par un autre thread. Java définit au moins quelques opérations atomiques. En particulier, l'affectation à des variables de tout type sauf longou doubleest atomique. Vous n'avez pas à vous soucier d'un thread qui anticipe une méthode au milieu de l'affectation. En pratique, cela signifie que vous ne devez jamais synchroniser une méthode qui ne fait que renvoyer la valeur d'une variable d'instance booleanou (ou lui attribuer une valeur) int. De même, une méthode qui effectuait beaucoup de calculs en utilisant uniquement des variables et des arguments locaux, et qui affectait les résultats de ce calcul à une variable d'instance en dernier lieu, n'aurait pas à être synchronisée. Par exemple:

class some_class {int some_field; void f (some_class arg) // délibérément non synchronisé {// Faites beaucoup de choses ici qui utilisent des variables locales // et des arguments de méthode, mais n'accède // à aucun champ de la classe (ou n'appelle aucune méthode // qui accède à champs de la classe). // ... un_champ = nouvelle_valeur; // faites cela en dernier. }}

En revanche, lors de l'exécution de x=++you x+=y, vous pouvez être préempté après l'incrément mais avant l'affectation. Pour obtenir l'atomicité dans cette situation, vous devrez utiliser le mot clé synchronized.

Tout cela est important car la surcharge de synchronisation peut être non triviale et peut varier d'un système d'exploitation à l'autre. Le programme suivant illustre le problème. Chaque boucle appelle de manière répétitive une méthode qui effectue les mêmes opérations, mais l'une des méthodes ( locking()) est synchronisée et l'autre ( not_locking()) ne l'est pas. À l'aide de la machine virtuelle JDK «performance-pack» exécutée sous Windows NT 4, le programme signale une différence de 1,2 seconde d'exécution entre les deux boucles, soit environ 1,2 microsecondes par appel. Cette différence peut ne pas sembler beaucoup, mais elle représente une augmentation de 7,25% du temps d'appel. Bien sûr, l'augmentation du pourcentage diminue à mesure que la méthode fait plus de travail, mais un nombre important de méthodes - dans mes programmes, au moins - ne sont que quelques lignes de code.

import java.util. *; class synch { verrouillage int synchronisé (int a, int b) {retourne a + b;} int not_locking (int a, int b) {retourne a + b;} int int ITERATIONS = 1000000; static public void main (String [] args) {synch tester = new synch (); double début = nouvelle Date (). getTime (); for (long i = ITERATIONS; --i> = 0;) tester.locking (0,0); double end = nouvelle date (). getTime (); double lock_time = fin - début; début = nouvelle Date (). getTime (); for (long i = ITERATIONS; --i> = 0;) tester.not_locking (0,0);end = nouvelle date (). getTime (); double not_locking_time = fin - début; double time_in_synchronization = lock_time - not_locking_time; System.out.println ("Temps perdu à la synchronisation (millis.):" + Time_in_synchronization); System.out.println ("surcharge de verrouillage par appel:" + (time_in_synchronization / ITERATIONS)); System.out.println (not_locking_time / lock_time * 100.0 + "% d'augmentation"); }}

Bien que la VM HotSpot soit censée résoudre le problème de la surcharge de synchronisation, HotSpot n'est pas un freebee - vous devez l'acheter. Sauf si vous octroyez une licence et expédiez HotSpot avec votre application, il est impossible de dire quelle VM sera sur la plate-forme cible, et bien sûr, vous voulez que la vitesse d'exécution de votre programme dépende le moins possible de la VM qui l'exécute. Même si les problèmes de blocage (dont je parlerai dans le prochain épisode de cette série) n'existaient pas, l'idée que vous devriez «tout synchroniser» est tout simplement fausse.

Concurrence contre parallélisme

Le prochain problème lié au système d'exploitation (et le problème principal lorsqu'il s'agit d'écrire en Java indépendant de la plate-forme) concerne les notions de concurrence et de parallélisme. Les systèmes multithreading simultanés donnent l'apparence de plusieurs tâches s'exécutant à la fois, mais ces tâches sont en fait divisées en morceaux qui partagent le processeur avec des morceaux d'autres tâches. La figure suivante illustre les problèmes. Dans les systèmes parallèles, deux tâches sont en fait exécutées simultanément. Le parallélisme nécessite un système à plusieurs processeurs.

À moins que vous ne passiez beaucoup de temps bloqué, à attendre la fin des opérations d'E / S, un programme qui utilise plusieurs threads simultanés s'exécutera souvent plus lentement qu'un programme à un seul thread équivalent, bien qu'il soit souvent mieux organisé que l'équivalent single. -version à filetage. Un programme qui utilise plusieurs threads s'exécutant en parallèle sur plusieurs processeurs s'exécutera beaucoup plus rapidement.

Bien que Java permette d'implémenter entièrement le threading dans la VM, au moins en théorie, cette approche exclurait tout parallélisme dans votre application. Si aucun thread au niveau du système d'exploitation n'était utilisé, le système d'exploitation considérerait l'instance de VM comme une application à un seul thread, qui serait très probablement planifiée sur un seul processeur. Le résultat net serait que deux threads Java exécutés sous la même instance de VM ne fonctionneraient jamais en parallèle, même si vous disposiez de plusieurs processeurs et que votre VM était le seul processus actif. Deux instances de la VM exécutant des applications séparées pourraient bien sûr fonctionner en parallèle, mais je veux faire mieux que cela. Pour obtenir le parallélisme, la VM doitmapper les threads Java vers les threads du système d'exploitation; vous ne pouvez donc pas vous permettre d'ignorer les différences entre les différents modèles de threads si l'indépendance de la plate-forme est importante.

Mettez vos priorités au clair

Je vais vous montrer comment les problèmes dont je viens de parler peuvent avoir un impact sur vos programmes en comparant deux systèmes d'exploitation: Solaris et Windows NT.

Java, au moins en théorie, fournit dix niveaux de priorité pour les threads. (Si deux ou plusieurs threads attendent tous les deux d'être exécutés, celui avec le niveau de priorité le plus élevé s'exécutera.) Sous Solaris, qui prend en charge 231 niveaux de priorité, ce n'est pas un problème (bien que les priorités Solaris puissent être difficiles à utiliser - plus dans un moment). NT, d'autre part, a sept niveaux de priorité disponibles, et ceux-ci doivent être mappés dans les dix de Java. Ce mappage n'est pas défini, donc de nombreuses possibilités se présentent. (Par exemple, les niveaux de priorité Java 1 et 2 peuvent tous deux correspondre au niveau de priorité NT 1 et les niveaux de priorité Java 8, 9 et 10 peuvent tous correspondre au niveau NT 7.)

Le manque de niveaux de priorité de NT est un problème si vous voulez utiliser la priorité pour contrôler la planification. Les choses sont rendues encore plus compliquées par le fait que les niveaux de priorité ne sont pas fixes. NT fournit un mécanisme appelé augmentation de priorité, que vous pouvez désactiver avec un appel système C, mais pas à partir de Java. Lorsque l'augmentation de priorité est activée, NT augmente la priorité d'un thread d'une quantité indéterminée pendant une durée indéterminée à chaque fois qu'il exécute certains appels système liés aux E / S. En pratique, cela signifie que le niveau de priorité d'un thread peut être plus élevé que vous ne le pensez car ce thread a effectué une opération d'E / S à un moment difficile.

Le but de l'augmentation des priorités est d'empêcher les threads qui effectuent un traitement en arrière-plan d'avoir un impact sur la réactivité apparente des tâches lourdes de l'interface utilisateur. D'autres systèmes d'exploitation ont des algorithmes plus sophistiqués qui réduisent généralement la priorité des processus d'arrière-plan. L'inconvénient de ce schéma, en particulier lorsqu'il est implémenté sur un niveau par thread plutôt que par processus, est qu'il est très difficile d'utiliser la priorité pour déterminer quand un thread particulier s'exécutera.

Ça s'empire.

Dans Solaris, comme c'est le cas dans tous les systèmes Unix, les processus ont la priorité ainsi que les threads. Les threads des processus à haute priorité ne peuvent pas être interrompus par les threads des processus à faible priorité. De plus, le niveau de priorité d'un processus donné peut être limité par un administrateur système afin qu'un processus utilisateur n'interrompe pas les processus critiques du système d'exploitation. NT ne prend en charge rien de tout cela. Un processus NT n'est qu'un espace d'adressage. Il n'a pas de priorité en soi et n'est pas programmé. Le système planifie les threads; puis, si un thread donné s'exécute sous un processus qui n'est pas en mémoire, le processus est permuté. Les priorités de thread NT appartiennent à diverses «classes de priorité», qui sont réparties sur un continuum de priorités réelles. Le système ressemble à ceci:

Les colonnes sont des niveaux de priorité réels, dont seulement 22 doivent être partagés par toutes les applications. (Les autres sont utilisés par NT lui-même.) Les lignes sont des classes de priorité. Les threads en cours d'exécution dans un processus lié à la classe de priorité inactive s'exécutent aux niveaux 1 à 6 et 15, en fonction de leur niveau de priorité logique attribué. Les threads d'un processus indexé en tant que classe de priorité normale s'exécuteront aux niveaux 1, 6 à 10 ou 15 si le processus n'a pas le focus d'entrée. S'il a le focus d'entrée, les threads s'exécutent aux niveaux 1, 7 à 11 ou 15. Cela signifie qu'un thread à haute priorité d'un processus de classe de priorité inactif peut préempter un thread de basse priorité d'un processus de classe de priorité normal, mais seulement si ce processus s'exécute en arrière-plan. Notez qu'un processus s'exécutant dans le "haut"la classe de priorité n'a que six niveaux de priorité disponibles. Les autres classes en ont sept.

NT ne fournit aucun moyen de limiter la classe de priorité d'un processus. N'importe quel thread sur n'importe quel processus sur la machine peut prendre le contrôle de la boîte à tout moment en augmentant sa propre classe de priorité; il n'y a aucune défense contre cela.

Le terme technique que j'utilise pour décrire la priorité de NT est le désordre impie. Dans la pratique, la priorité est pratiquement sans valeur sous NT.

Alors, que doit faire un programmeur? Entre le nombre limité de niveaux de priorité de NT et l'augmentation incontrôlable des priorités, il n'y a pas de moyen absolument sûr pour un programme Java d'utiliser des niveaux de priorité pour la planification. Un compromis viable est de se limiter à Thread.MAX_PRIORITY, Thread.MIN_PRIORITYet Thread.NORM_PRIORITYlorsque vous appelez setPriority(). Cette restriction évite au moins le problème de 10 niveaux mappés à 7 niveaux. Je suppose que vous pouvez utiliser la os.namepropriété système pour détecter NT, puis appeler une méthode native pour désactiver l'augmentation de la priorité, mais cela ne fonctionnera pas si votre application fonctionne sous Internet Explorer, sauf si vous utilisez également le plug-in VM de Sun. (La machine virtuelle de Microsoft utilise une implémentation de méthode native non standard.) En tout état de cause, je déteste utiliser des méthodes natives.J'évite généralement le problème autant que possible en mettant la plupart des threads àNORM_PRIORITYet en utilisant des mécanismes de planification autres que la priorité. (J'en discuterai dans les prochaines tranches de cette série.)

Coopérer!

Il existe généralement deux modèles de threads pris en charge par les systèmes d'exploitation: coopératif et préemptif.

Le modèle multithreading coopératif

Dans un système coopératif , un thread conserve le contrôle de son processeur jusqu'à ce qu'il décide de l'abandonner (ce qui pourrait ne jamais l'être). Les différents threads doivent coopérer les uns avec les autres ou tous les threads sauf un seront "affamés" (c'est-à-dire qu'ils ne pourront jamais s'exécuter). La planification dans la plupart des systèmes coopératifs se fait strictement par niveau de priorité. Lorsque le thread actuel abandonne le contrôle, le thread en attente de priorité la plus élevée obtient le contrôle. (Une exception à cette règle est Windows 3.x, qui utilise un modèle coopératif mais n'a pas beaucoup de planificateur. La fenêtre qui a le focus prend le contrôle.)