Evite los puntos muertos de sincronización

En mi artículo anterior "Bloqueo con doble verificación : inteligente, pero roto" ( JavaWorld,Febrero de 2001), describí cómo varias técnicas comunes para evitar la sincronización son de hecho inseguras y recomendé una estrategia de "En caso de duda, sincronice". En general, debe sincronizar siempre que esté leyendo cualquier variable que pueda haber sido escrita previamente por un hilo diferente, o cuando esté escribiendo cualquier variable que pueda ser leída posteriormente por otro hilo. Además, aunque la sincronización conlleva una penalización del rendimiento, la penalización asociada con la sincronización no atendida no es tan grande como han sugerido algunas fuentes, y se ha reducido de manera constante con cada implementación de JVM sucesiva. Por tanto, parece que ahora hay menos motivos que nunca para evitar la sincronización. Sin embargo, otro riesgo está asociado con la sincronización excesiva: el punto muerto.

¿Qué es un punto muerto?

Decimos que un conjunto de procesos o subprocesos está bloqueado cuando cada subproceso está esperando un evento que solo otro proceso en el conjunto puede causar. Otra forma de ilustrar un punto muerto es construir un grafo dirigido cuyos vértices son hilos o procesos y cuyos bordes representan la relación "está-esperando". Si este gráfico contiene un ciclo, el sistema está bloqueado. A menos que el sistema esté diseñado para recuperarse de interbloqueos, un interbloqueo hace que el programa o el sistema se cuelgue.

Puntos muertos de sincronización en programas Java

Los interbloqueos pueden ocurrir en Java porque la synchronizedpalabra clave hace que el subproceso en ejecución se bloquee mientras espera el bloqueo o monitor asociado con el objeto especificado. Dado que el subproceso ya puede contener bloqueos asociados con otros objetos, dos subprocesos podrían estar esperando que el otro libere un bloqueo; en tal caso, terminarán esperando para siempre. El siguiente ejemplo muestra un conjunto de métodos que tienen el potencial de interbloqueo. Ambos métodos adquieren bloqueos en dos objetos de bloqueo cacheLocky tableLock, antes de continuar. En este ejemplo, los objetos que actúan como bloqueos son variables globales (estáticas), una técnica común para simplificar el comportamiento de bloqueo de aplicaciones al realizar el bloqueo en un nivel más grueso de granularidad:

Listado 1. Un posible punto muerto de sincronización

cacheLock de objeto estático público = nuevo objeto (); Objeto estático público tableLock = nuevo Objeto (); ... public void oneMethod () {sincronizado (cacheLock) {sincronizado (tableLock) {hacer algo (); }}} public void anotherMethod () {sincronizado (tableLock) {sincronizado (cacheLock) {doSomethingElse (); }}}

Ahora, imagina que el hilo A llama oneMethod()mientras que el hilo B llama simultáneamente anotherMethod(). Imagine además que el hilo A adquiere el bloqueo cacheLocky, al mismo tiempo, el hilo B adquiere el bloqueo tableLock. Ahora los hilos están bloqueados: ninguno de los dos abandonará su bloqueo hasta que adquiera el otro bloqueo, pero ninguno podrá adquirir el otro bloqueo hasta que el otro hilo lo abandone. Cuando un programa Java se interbloquea, los subprocesos de interbloqueo simplemente esperan una eternidad. Si bien es posible que otros subprocesos continúen ejecutándose, eventualmente tendrá que cerrar el programa, reiniciarlo y esperar que no se bloquee nuevamente.

La prueba de interbloqueos es difícil, ya que los interbloqueos dependen del tiempo, la carga y el entorno y, por lo tanto, pueden ocurrir con poca frecuencia o solo en determinadas circunstancias. El código puede tener el potencial de un punto muerto, como el Listado 1, pero no exhibir un punto muerto hasta que ocurra alguna combinación de eventos aleatorios y no aleatorios, como que el programa esté sujeto a un cierto nivel de carga, se ejecute en una determinada configuración de hardware o se exponga a una determinada combinación de acciones de los usuarios y condiciones ambientales. Los interbloqueos se asemejan a bombas de tiempo que esperan explotar en nuestro código; cuando lo hacen, nuestros programas simplemente se cuelgan.

El orden de bloqueo inconsistente causa interbloqueos

Afortunadamente, podemos imponer un requisito relativamente simple en la adquisición de bloqueos que puede evitar los puntos muertos de sincronización. Los métodos del Listado 1 tienen el potencial de interbloqueo porque cada método adquiere los dos bloqueos en un orden diferente. Si el Listado 1 se hubiera escrito de modo que cada método adquiriera los dos bloqueos en el mismo orden, dos o más subprocesos que ejecutan estos métodos no podrían interbloquearse, independientemente del tiempo u otros factores externos, porque ningún subproceso podría adquirir el segundo bloqueo sin ya mantener presionado el primero. Si puede garantizar que los bloqueos siempre se adquirirán en un orden coherente, entonces su programa no se bloqueará.

Los interbloqueos no siempre son tan obvios

Una vez que se haya familiarizado con la importancia del orden de las cerraduras, podrá reconocer fácilmente el problema del Listado 1. Sin embargo, problemas análogos pueden resultar menos obvios: quizás los dos métodos residen en clases separadas, o quizás los bloqueos involucrados se adquieren implícitamente llamando a métodos sincronizados en lugar de explícitamente a través de un bloque sincronizado. Considere estas dos clases cooperativas Modely View, en un marco MVC (Modelo-Vista-Controlador) simplificado:

Listado 2. Un punto muerto de sincronización potencial más sutil

Modelo de clase pública {vista privada myView; updateModel vacío sincronizado público (objeto someArg) {hacer algo (someArg); myView.somethingChanged (); } getSomething () de Object sincronizado público {return someMethod (); }} vista de clase pública {modelo privado modelo subyacente; público sincronizado void somethingChanged () {hacerAlgo (); } updateView () vacío sincronizado público {Objeto o = myModel.getSomething (); }}

El Listado 2 tiene dos objetos cooperantes que tienen métodos sincronizados; cada objeto llama a los métodos sincronizados del otro. Esta situación se parece al Listado 1: dos métodos adquieren bloqueos en los mismos dos objetos, pero en diferentes órdenes. Sin embargo, el orden de bloqueo inconsistente en este ejemplo es mucho menos obvio que en el Listado 1 porque la adquisición de bloqueo es una parte implícita de la llamada al método. Si un hilo llama Model.updateModel()mientras otro hilo llama simultáneamente View.updateView(), el primer hilo podría obtener el Modelbloqueo de 'y esperar el Viewbloqueo de', mientras que el otro obtiene el Viewbloqueo de 'y espera eternamente el Modelbloqueo de'.

Puede enterrar aún más el potencial de bloqueo de sincronización. Considere este ejemplo: tiene un método para transferir fondos de una cuenta a otra. Desea adquirir bloqueos en ambas cuentas antes de realizar la transferencia para asegurarse de que la transferencia sea atómica. Considere esta implementación de apariencia inofensiva:

Listado 3. Un punto muerto de sincronización potencial aún más sutil

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

Incluso si todos los métodos que operan en dos o más cuentas usan el mismo orden, el Listado 3 contiene las semillas del mismo problema de interbloqueo que los Listados 1 y 2, pero de una manera aún más sutil. Considere lo que sucede cuando se ejecuta el hilo A:

 transferMoney (accountOne, accountTwo, monto); 

Mientras que al mismo tiempo, el hilo B ejecuta:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Nuevamente, los dos hilos intentan adquirir los mismos dos bloqueos, pero en diferentes órdenes; el riesgo de estancamiento todavía se cierne, pero de una forma mucho menos obvia.

Cómo evitar interbloqueos

Una de las mejores formas de prevenir la posibilidad de un punto muerto es evitar adquirir más de un bloqueo a la vez, lo que suele ser práctico. Sin embargo, si eso no es posible, necesita una estrategia que le asegure adquirir múltiples bloqueos en un orden definido y consistente.

Dependiendo de cómo su programa utilice los bloqueos, puede que no sea complicado asegurarse de utilizar un orden de bloqueo coherente. En algunos programas, como en el Listado 1, todos los bloqueos críticos que pueden participar en el bloqueo múltiple se extraen de un pequeño conjunto de objetos de bloqueo singleton. En ese caso, puede definir un orden de adquisición de cerraduras en el conjunto de cerraduras y asegurarse de que siempre adquiere cerraduras en ese orden. Una vez que se define el orden de bloqueo, simplemente debe estar bien documentado para fomentar el uso constante en todo el programa.

Reducir bloques sincronizados para evitar bloqueos múltiples

En el Listado 2, el problema se vuelve más complicado porque, como resultado de llamar a un método sincronizado, los bloqueos se adquieren implícitamente. Por lo general, puede evitar el tipo de puntos muertos potenciales que surgen de casos como el Listado 2 al reducir el alcance de la sincronización al bloque más pequeño posible. No Model.updateModel()necesita realmente para mantener el Modelbloqueo mientras que llamaView.somethingChanged()? A menudo no es así; Es probable que todo el método se haya sincronizado como un atajo, en lugar de porque todo el método deba sincronizarse. Sin embargo, si reemplaza métodos sincronizados con bloques sincronizados más pequeños dentro del método, debe documentar este comportamiento de bloqueo como parte del Javadoc del método. Las personas que llaman deben saber que pueden llamar al método de forma segura sin sincronización externa. Las personas que llaman también deben conocer el comportamiento de bloqueo del método para poder asegurarse de que los bloqueos se adquieran en un orden coherente.

Una técnica de orden de candado más sofisticada

En otras situaciones, como el ejemplo de la cuenta bancaria del Listado 3, aplicar la regla de orden fijo se vuelve aún más complicado; debe definir un orden total en el conjunto de objetos elegibles para bloqueo y utilizar este orden para elegir la secuencia de adquisición del bloqueo. Esto suena complicado, pero de hecho es sencillo. El Listado 4 ilustra esa técnica; utiliza un número de cuenta numérico para inducir un pedido de Accountobjetos. (Si el objeto que necesita bloquear carece de una propiedad de identidad natural como un número de cuenta, puede usar el Object.identityHashCode()método para generar una).

Listado 4. Utilice un pedido para adquirir bloqueos en una secuencia fija

public void transferMoney (Cuenta fromAccount, Account toAccount, DollarAmount amountToTransfer) {Cuenta firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) lanzar nueva Excepción ("No se puede transferir de la cuenta a sí mismo"); else if (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } else {firstLock = toAccount; secondLock = fromAccount; } sincronizado (firstLock) {sincronizado (secondLock) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit (amountToTransfer);}}}}

Ahora transferMoney()no importa el orden en el que se especifican las cuentas en la llamada ; las cerraduras siempre se adquieren en el mismo orden.

La parte más importante: documentación

Un elemento crítico, pero a menudo pasado por alto, de cualquier estrategia de bloqueo es la documentación. Desafortunadamente, incluso en los casos en los que se tiene mucho cuidado para diseñar una estrategia de bloqueo, a menudo se dedica mucho menos esfuerzo a documentarla. Si su programa utiliza un pequeño conjunto de cerraduras singleton, debe documentar sus supuestos de pedido de cerraduras lo más claramente posible para que los futuros encargados de mantenimiento puedan cumplir con los requisitos de pedido de cerraduras. Si un método debe adquirir un bloqueo para realizar su función o debe ser llamado con un bloqueo específico, el Javadoc del método debe tener en cuenta ese hecho. De esa forma, los futuros desarrolladores sabrán que llamar a un método determinado podría implicar la adquisición de un bloqueo.

Pocos programas o bibliotecas de clases documentan adecuadamente su uso de bloqueo. Como mínimo, cada método debe documentar los bloqueos que adquiere y si las personas que llaman deben mantener un bloqueo para llamar al método de forma segura. Además, las clases deben documentar si son seguras para subprocesos o no, o en qué condiciones.

Centrarse en el comportamiento de bloqueo en tiempo de diseño

Debido a que los interbloqueos a menudo no son obvios y ocurren con poca frecuencia e impredeciblemente, pueden causar problemas graves en los programas Java. Al prestar atención al comportamiento de bloqueo de su programa en el momento del diseño y definir reglas sobre cuándo y cómo adquirir múltiples bloqueos, puede reducir considerablemente la probabilidad de interbloqueos. Recuerde documentar cuidadosamente las reglas de adquisición de bloqueos de su programa y su uso de sincronización; el tiempo dedicado a documentar supuestos de bloqueo simples se verá recompensado al reducir en gran medida la posibilidad de un punto muerto y otros problemas de concurrencia más adelante.

Brian Goetz es un desarrollador de software profesional con más de 15 años de experiencia. Es consultor principal de Quiotix, una firma de consultoría y desarrollo de software ubicada en Los Altos, California.