Évitez les blocages de synchronisation

Dans mon article précédent "Verrouillage à double contrôle: intelligent, mais cassé" ( JavaWorld,Février 2001), j'ai décrit comment plusieurs techniques courantes pour éviter la synchronisation sont en fait peu sûres, et j'ai recommandé une stratégie «En cas de doute, synchroniser». En général, vous devez synchroniser chaque fois que vous lisez une variable qui aurait pu être précédemment écrite par un autre thread, ou chaque fois que vous écrivez une variable qui pourrait être lue par la suite par un autre thread. De plus, bien que la synchronisation entraîne une baisse des performances, la pénalité associée à une synchronisation incontrôlée n'est pas aussi importante que certaines sources l'ont suggéré, et a régulièrement diminué avec chaque implémentation JVM successive. Il semble donc qu'il y ait maintenant moins de raisons que jamais d'éviter la synchronisation. Cependant, un autre risque est associé à une synchronisation excessive: le blocage.

Qu'est-ce qu'une impasse?

Nous disons qu'un ensemble de processus ou de threads est bloqué lorsque chaque thread attend un événement que seul un autre processus de l'ensemble peut provoquer. Une autre façon d'illustrer un blocage est de construire un graphe orienté dont les sommets sont des threads ou des processus et dont les arêtes représentent la relation "en attente". Si ce graphique contient un cycle, le système est bloqué. À moins que le système ne soit conçu pour sortir des blocages, un blocage entraîne le blocage du programme ou du système.

Blocages de synchronisation dans les programmes Java

Des blocages peuvent se produire dans Java car le synchronizedmot - clé provoque le blocage du thread en cours d'exécution en attendant le verrou, ou le moniteur, associé à l'objet spécifié. Comme le thread peut déjà contenir des verrous associés à d'autres objets, deux threads peuvent chacun attendre que l'autre libère un verrou; dans un tel cas, ils finiront par attendre éternellement. L'exemple suivant montre un ensemble de méthodes susceptibles de provoquer un blocage. Les deux méthodes acquièrent des verrous sur deux objets de verrouillage cacheLocket tableLock, avant de continuer. Dans cet exemple, les objets agissant comme des verrous sont des variables globales (statiques), une technique courante pour simplifier le comportement de verrouillage d'application en effectuant un verrouillage à un niveau de granularité plus grossier:

Listing 1. Un blocage de synchronisation potentiel

public static Object cacheLock = new Object (); public static Object tableLock = new Object (); ... public void oneMethod () {synchronized (cacheLock) {synchronized (tableLock) {doSomething (); }}} public void anotherMethod () {synchronized (tableLock) {synchronized (cacheLock) {doSomethingElse (); }}}

Maintenant, imaginez que le thread A appelle oneMethod()tandis que le thread B appelle simultanément anotherMethod(). Imaginez en outre que le thread A acquiert le verrou cacheLocket, en même temps, le thread B acquiert le verrou tableLock. Maintenant, les threads sont bloqués: aucun des threads n'abandonnera son verrou jusqu'à ce qu'il acquière l'autre verrou, mais aucun ne pourra acquérir l'autre verrou jusqu'à ce que l'autre thread l'abandonne. Lorsqu'un programme Java se bloque, les threads bloqués attendent simplement une éternité. Alors que d'autres threads peuvent continuer à fonctionner, vous devrez éventuellement tuer le programme, le redémarrer et espérer qu'il ne se bloquera pas à nouveau.

Tester les blocages est difficile, car les blocages dépendent du moment, de la charge et de l'environnement, et peuvent donc se produire rarement ou uniquement dans certaines circonstances. Le code peut avoir le potentiel de blocage, comme le listing 1, mais pas de blocage jusqu'à ce qu'une combinaison d'événements aléatoires et non aléatoires se produise, comme le programme étant soumis à un certain niveau de charge, exécuté sur une certaine configuration matérielle ou exposé à un certain mélange d'actions de l'utilisateur et de conditions environnementales. Les impasses ressemblent à des bombes à retardement qui attendent d'exploser dans notre code; quand ils le font, nos programmes se bloquent tout simplement.

L'ordre de verrouillage incohérent entraîne des blocages

Heureusement, nous pouvons imposer une exigence relativement simple sur l'acquisition de verrous qui peut empêcher les blocages de synchronisation. Les méthodes du listing 1 ont le potentiel de blocage car chaque méthode acquiert les deux verrous dans un ordre différent. Si le listing 1 avait été écrit de manière à ce que chaque méthode acquière les deux verrous dans le même ordre, deux threads ou plus exécutant ces méthodes ne pouvaient pas se bloquer, indépendamment du timing ou d'autres facteurs externes, car aucun thread ne pouvait acquérir le deuxième verrou sans déjà maintenir le première. Si vous pouvez garantir que les verrous seront toujours acquis dans un ordre cohérent, votre programme ne sera pas bloqué.

Les blocages ne sont pas toujours aussi évidents

Une fois conscient de l'importance de l'ordre des verrous, vous pouvez facilement reconnaître le problème du Listing 1. Cependant, des problèmes analogues pourraient s'avérer moins évidents: peut-être que les deux méthodes résident dans des classes séparées, ou peut-être que les verrous impliqués sont acquis implicitement en appelant des méthodes synchronisées au lieu d'expliciter via un bloc synchronisé. Considérez ces deux classes coopérantes Modelet View, dans un cadre MVC (Model-View-Controller) simplifié:

Listing 2. Un blocage potentiel de synchronisation plus subtil

classe publique Model {private View myView; public synchronized void updateModel (Object someArg) {doSomething (someArg); myView.somethingChanged (); } Objet public synchronisé getSomething () {return someMethod (); }} classe publique View {modèle privé sous-jacentModel; public synchronized void somethingChanged () {doSomething (); } public synchronized void updateView () {Object o = myModel.getSomething (); }}

Le listing 2 a deux objets coopérants qui ont des méthodes synchronisées; chaque objet appelle les méthodes synchronisées de l'autre. Cette situation ressemble au Listing 1 - deux méthodes acquièrent des verrous sur les deux mêmes objets, mais dans des ordres différents. Cependant, l'ordre des verrous incohérent dans cet exemple est beaucoup moins évident que celui du listing 1 car l'acquisition de verrous est une partie implicite de l'appel de méthode. Si un thread appelle Model.updateModel()tandis qu'un autre thread appelle simultanément View.updateView(), le premier thread pourrait obtenir le Modelverrou de 's et attendre le Viewverrou de' s, tandis que l'autre obtient le Viewverrou de 's et attend indéfiniment le Modelverrou de' s.

Vous pouvez enterrer encore plus le potentiel de blocage de la synchronisation. Prenons cet exemple: vous disposez d'une méthode pour transférer des fonds d'un compte à un autre. Vous souhaitez acquérir des verrous sur les deux comptes avant d'effectuer le transfert pour vous assurer que le transfert est atomique. Considérez cette implémentation inoffensive:

Listing 3. Un blocage potentiel de synchronisation encore plus subtil

 public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {synchronized (fromAccount) {synchronized (toAccount) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit) (amountTo}) } 

Même si toutes les méthodes qui fonctionnent sur deux ou plusieurs comptes utilisent le même ordre, le Listing 3 contient les germes du même problème de blocage que les Listings 1 et 2, mais d'une manière encore plus subtile. Considérez ce qui se passe lorsque le thread A s'exécute:

 transferMoney (accountOne, accountTwo, montant); 

En même temps, le thread B exécute:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Encore une fois, les deux threads tentent d'acquérir les deux mêmes verrous, mais dans des ordres différents; le risque de blocage persiste, mais sous une forme beaucoup moins évidente.

Comment éviter les blocages

L'un des meilleurs moyens d'éviter le potentiel de blocage est d'éviter d'acquérir plus d'un verrou à la fois, ce qui est souvent pratique. Cependant, si cela n'est pas possible, vous avez besoin d'une stratégie qui garantit l'acquisition de plusieurs verrous dans un ordre cohérent et défini.

Selon la façon dont votre programme utilise les verrous, il peut ne pas être compliqué de s'assurer que vous utilisez un ordre de verrouillage cohérent. Dans certains programmes, comme dans le listing 1, tous les verrous critiques susceptibles de participer à plusieurs verrouillages sont dessinés à partir d'un petit ensemble d'objets de verrouillage singleton. Dans ce cas, vous pouvez définir un ordre d'acquisition des verrous sur l'ensemble de verrous et vous assurer que vous acquérez toujours les verrous dans cet ordre. Une fois que l'ordre de verrouillage est défini, il doit simplement être bien documenté pour encourager une utilisation cohérente tout au long du programme.

Réduisez les blocs synchronisés pour éviter les verrouillages multiples

Dans le Listing 2, le problème se complique car, suite à l'appel d'une méthode synchronisée, les verrous sont acquis implicitement. Vous pouvez généralement éviter le genre de blocages potentiels qui découlent de cas comme le Listing 2 en réduisant la portée de la synchronisation à un bloc aussi petit que possible. A Model.updateModel()vraiment besoin de maintenir la Modelserrure pendant qu'elle appelleView.somethingChanged()? Souvent, ce n'est pas le cas; la méthode entière a probablement été synchronisée comme un raccourci, plutôt que parce que la méthode entière devait être synchronisée. Toutefois, si vous remplacez des méthodes synchronisées par des blocs synchronisés plus petits dans la méthode, vous devez documenter ce comportement de verrouillage dans le cadre du Javadoc de la méthode. Les appelants doivent savoir qu'ils peuvent appeler la méthode en toute sécurité sans synchronisation externe. Les appelants doivent également connaître le comportement de verrouillage de la méthode afin de s'assurer que les verrous sont acquis dans un ordre cohérent.

Une technique de commande de serrure plus sophistiquée

Dans d'autres situations, comme l'exemple de compte bancaire du listing 3, l'application de la règle d'ordre fixe devient encore plus compliquée; vous devez définir un ordre total sur l'ensemble des objets éligibles au verrouillage et utiliser cet ordre pour choisir la séquence d'acquisition du verrou. Cela semble compliqué, mais c'est en fait simple. Le listing 4 illustre cette technique; il utilise un numéro de compte numérique pour induire un ordre sur les Accountobjets. (Si l'objet que vous devez verrouiller ne possède pas de propriété d'identité naturelle telle qu'un numéro de compte, vous pouvez utiliser la Object.identityHashCode()méthode pour en générer un à la place.)

Listing 4. Utilisez un ordre pour acquérir des verrous dans une séquence fixe

public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {Account firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) throw new Exception ("Impossible de transférer du compte vers lui-même"); else if (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } else {firstLock = toAccount; secondLock = fromAccount; } synchronized (firstLock) {synchronized (secondLock) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit (amountToTransfer);}}}}

Or, l'ordre dans lequel les comptes sont spécifiés dans l'appel à transferMoney()n'a pas d'importance; les serrures sont toujours acquises dans le même ordre.

La partie la plus importante: la documentation

La documentation est un élément critique - mais souvent négligé - de toute stratégie de verrouillage. Malheureusement, même dans les cas où l'on prend beaucoup de soin pour concevoir une stratégie de verrouillage, on consacre souvent beaucoup moins d'efforts à la documenter. Si votre programme utilise un petit ensemble de verrous singleton, vous devez documenter vos hypothèses d'ordre de verrouillage aussi clairement que possible afin que les futurs responsables puissent répondre aux exigences d'ordre de verrouillage. Si une méthode doit acquérir un verrou pour exécuter sa fonction ou doit être appelée avec un verrou spécifique maintenu, le Javadoc de la méthode doit noter ce fait. De cette façon, les futurs développeurs sauront que l'appel d'une méthode donnée peut impliquer l'acquisition d'un verrou.

Peu de programmes ou de bibliothèques de classes documentent correctement leur utilisation du verrouillage. Au minimum, chaque méthode doit documenter les verrous qu'elle acquiert et indiquer si les appelants doivent maintenir un verrou pour appeler la méthode en toute sécurité. De plus, les classes doivent documenter si oui ou non, ou dans quelles conditions, elles sont thread-safe.

Focus sur le comportement de verrouillage au moment de la conception

Étant donné que les blocages ne sont souvent pas évidents et se produisent rarement et de manière imprévisible, ils peuvent causer de graves problèmes dans les programmes Java. En prêtant attention au comportement de verrouillage de votre programme au moment de la conception et en définissant des règles pour savoir quand et comment acquérir plusieurs verrous, vous pouvez réduire considérablement le risque de blocage. N'oubliez pas de documenter attentivement les règles d'acquisition de verrouillage de votre programme et son utilisation de la synchronisation; le temps passé à documenter les hypothèses de verrouillage simples sera payant en réduisant considérablement le risque de blocage et d'autres problèmes de concurrence par la suite.

Brian Goetz est un développeur de logiciels professionnel avec plus de 15 ans d'expérience. Il est consultant principal chez Quiotix, une société de développement de logiciels et de conseil située à Los Altos, en Californie.