Articulo de referencia

Cierre revisado dos veces

En ingeniería de software , el bloqueo de doble verificación (también conocido como "optimización de bloqueo de doble verificación" [ 1 ] ) es un patrón de diseño de software qu...

En ingeniería de software , el bloqueo de doble verificación (también conocido como "optimización de bloqueo de doble verificación" [ 1 ] ) es un patrón de diseño de software que se utiliza para reducir la sobrecarga de adquirir un bloqueo mediante la comprobación del criterio de bloqueo (la "pista de bloqueo") antes de adquirirlo. El bloqueo se produce solo si la comprobación del criterio de bloqueo indica que es necesario.

La forma original del patrón, que aparece en Pattern Languages ​​of Program Design 3 , [ 2 ] tiene condiciones de carrera , dependiendo del modelo de memoria utilizado, y es difícil de implementar correctamente. Algunos lo consideran un antipatrón . [ 3 ] Existen formas válidas del patrón, incluyendo el uso de la palabra clave en Java y barreras de memoria explícitas en C++. [ 4 ]volatile

Este patrón se utiliza normalmente para reducir la sobrecarga de bloqueo al implementar la " inicialización diferida " en un entorno multihilo, especialmente como parte del patrón Singleton . La inicialización diferida evita inicializar un valor hasta la primera vez que se accede a él.

Motivación y patrón original

Consideremos, por ejemplo, este segmento de código en el lenguaje de programación Java : [ 4 ]

// Versión de un solo hilo public class Foo { private static Bar bar ;public Bar bar () { if ( bar == null ) { bar = new Bar (); } return bar ; }// otras funciones y miembros... }

El problema radica en que esto no funciona al usar múltiples hilos. Es necesario obtener un bloqueobar() si dos hilos realizan llamadas simultáneamente. De lo contrario, podrían intentar crear el objeto al mismo tiempo, o uno de ellos podría obtener una referencia a un objeto incompletamente inicializado.

La sincronización con un bloqueo puede solucionar esto, como se muestra en el siguiente ejemplo:

// Versión multihilo correcta pero posiblemente costosa public class Foo { private Bar bar ;public synchronized Bar bar () { if ( bar == null ) { bar = new Bar (); } return bar ; }// otras funciones y miembros... }

Esto es correcto y probablemente tendrá un rendimiento suficiente. Sin embargo, la primera llamada bar()creará el objeto y solo los pocos subprocesos que intenten acceder a él durante ese tiempo necesitarán ser synchronized; después de eso, todas las llamadas simplemente obtienen una referencia a la variable miembro. Dado que sincronizar un método podría, en algunos casos extremos, disminuir el rendimiento en un factor de 100 o más, [ 5 ] la sobrecarga de adquirir y liberar un bloqueo cada vez que se llama a este método después de que se haya completado la inicialización parece innecesaria. Muchos programadores, incluidos los autores del patrón de diseño de bloqueo de doble verificación, han intentado optimizar esta situación de la siguiente manera:

  1. Comprueba que la variable esté inicializada (sin obtener el bloqueo). Si está inicializada, devuélvela inmediatamente.
  2. Obtén el candado.
  3. Verifica si la variable ya ha sido inicializada: si otro hilo adquirió el bloqueo primero, es posible que ya haya realizado la inicialización. De ser así, devuelve la variable inicializada.
  4. De lo contrario, inicializa y devuelve la variable.
// Versión multihilo rota // Modismo original de "bloqueo doblemente verificado" public class Foo { private Bar bar ;public Bar bar () { if ( bar == null ) { synchronized ( this ) { if ( bar == null ) { bar = new Bar (); } } } return bar ; }// otras funciones y miembros... }

Intuitivamente, este algoritmo es una solución eficiente al problema. Pero si el patrón no se escribe con cuidado, se producirá una condición de carrera . Por ejemplo, consideremos la siguiente secuencia de eventos:

  1. El hilo A observa que el valor no está inicializado, por lo que obtiene el bloqueo y comienza a inicializar el valor.
  2. Debido a la semántica de algunos lenguajes de programación, el código generado por el compilador puede actualizar la variable compartida para que apunte a un objeto parcialmente construido antes de que A haya terminado de realizar la inicialización. Por ejemplo, en Java, si se ha insertado en línea una llamada a un constructor, la variable compartida puede actualizarse inmediatamente una vez que se haya asignado el espacio de almacenamiento, pero antes de que el constructor insertado inicialice el objeto. [ 6 ]
  3. El hilo B detecta que la variable compartida se ha inicializado (o eso parece) y devuelve su valor. Dado que el hilo B cree que el valor ya está inicializado, no adquiere el bloqueo. Si B utiliza el objeto antes de que A haya procesado toda la inicialización (ya sea porque A no ha terminado de inicializarlo o porque algunos de los valores inicializados del objeto aún no se han propagado a la memoria que utiliza B ( coherencia de caché )), es probable que el programa falle.

La mayoría de los entornos de ejecución cuentan con barreras de memoria u otros métodos para gestionar la visibilidad de la memoria entre las unidades de ejecución. Sin un conocimiento detallado del comportamiento del lenguaje en este aspecto, el algoritmo resulta difícil de implementar correctamente. Uno de los peligros de usar el bloqueo de doble verificación es que incluso una implementación ingenua parecerá funcionar la mayor parte del tiempo: no es fácil distinguir entre una implementación correcta de la técnica y una que presenta problemas sutiles. Dependiendo del compilador , la intercalación de hilos por parte del planificador y la naturaleza de otras actividades concurrentes del sistema , los fallos derivados de una implementación incorrecta del bloqueo de doble verificación pueden ocurrir solo de forma intermitente. Reproducir estos fallos puede resultar difícil.

Uso en C++

Para el patrón singleton, no es necesario el bloqueo de doble verificación:

Si el control entra en la declaración simultáneamente mientras se inicializa la variable, la ejecución concurrente deberá esperar a que finalice la inicialización.

§  6.7 [stmt.dcl] p4

Singleton & instancia () { estático Singleton s ; return s ; }

C++11 y versiones posteriores también proporcionan un patrón de bloqueo de doble verificación incorporado en forma de std::once_flagy std::call_once:

importar std ;using std :: once_flag ; using std :: optional ;clase Singleton { privado : Singleton () = predeterminado ;static optional < Singleton > inst ; static once_flag flag ; public : static Singleton * instance () { std :: call_once ( Singleton :: flag , [] -> void { inst . emplace ( Singleton ()); } ); return & inst ; } };

Si uno desea implementar manualmente un modismo doblemente verificado explícito y adecuado en lugar del ejemplo trivialmente funcional anterior (por ejemplo, porque Visual Studio antes de la versión 2015 no implementaba el lenguaje del estándar C++11 sobre la inicialización concurrente citado anteriormente [ 7 ] ), se necesitan al menos operaciones atómicas con orden de adquisición/liberación de memoria como en este código (las declaraciones explícitas de barrera de memoria también funcionan, pero pueden usar instrucciones de CPU más lentas, por ejemplo en ARM64): [ 8 ]

importar std ;using std :: atomic ; using std :: lock_guard ; using std :: memory_order ; using std :: mutex ;clase Singleton { privado : Singleton () = predeterminado ;static atomic < Singleton *> inst ; static mutex m ; public : static Singleton * instance () { Singleton * s = inst . load ( memory_order :: acquire ); // Primera comprobación if ( ! s ) { lock_guard < mutex > lock ( m ); s = inst . load ( memory_order :: relaxed ); // Segunda (doble) comprobación if ( ! s ) { s = new Singleton (); inst . store ( s , memory_order :: release ); } } return s ; }~ Singleton () { // lógica de limpieza } };

Uso en POSIX

pthread_once()Debe utilizarse para inicializar el código de la biblioteca (o submódulo) cuando su API no tiene un procedimiento de inicialización dedicado que deba llamarse en modo de un solo hilo.

Uso en Go

paquete principalimportar "sincronizar"var arrOnce sync . Once var arr [] int// getArr recupera arr, inicializándolo de forma diferida en la primera llamada. El bloqueo doblemente verificado // se implementa con la función de biblioteca sync.Once. La primera // goroutine que gane la carrera para llamar a Do() inicializará el array, mientras que // las demás se bloquearán hasta que Do() haya finalizado. Después de que Do se haya ejecutado, solo se requerirá una // única comparación atómica para obtener el array. func getArr () [] int { arrOnce . Do ( func () { arr = [] int { 0 , 1 , 2 } }) return arr }func main () { // gracias al bloqueo de doble verificación, dos goroutines que intenten obtener getArr() // no causarán una doble inicialización go getArr () go getArr () }

Uso en Java

A partir de J2SE 5.0 , la palabra clave volatile se define para crear una barrera de memoria. Esto permite una solución que garantiza que múltiples hilos manejen la instancia singleton correctamente. Este nuevo modismo se describe eny.

// Funciona con la semántica de adquisición/liberación para volatile en Java 1.5 y posteriores // Se rompe con la semántica de volatile de Java 1.4 y anteriores public class Foo { private volatile Bar bar ;public Bar bar () { Bar local = bar ; if ( bar == null ) { synchronized ( this ) { local = bar ; if ( local == null ) { bar = local = new Bar (); } } } return local ; }// otras funciones y miembros... }

Nótese la variable locallocal , que parece innecesaria. El efecto de esto es que en los casos en que barya está inicializada (es decir, la mayoría de las veces), el campo volátil solo se accede una vez (debido a return local;" en lugar de return bar;), lo que puede mejorar el rendimiento general del método hasta en un 40 por ciento. [ 9 ]

Java 9 introdujo la VarHandleclase, que permite el uso de operaciones atómicas relajadas para acceder a campos, lo que proporciona lecturas algo más rápidas en máquinas con modelos de memoria débiles, a costa de una mecánica más compleja y la pérdida de consistencia secuencial (los accesos a campos ya no participan en el orden de sincronización, el orden global de accesos a volatilecampos). [ 10 ]

import java.lang.invoke.MethodHandles ; import java.lang.invoke.VarHandle ;// Funciona con la semántica de adquisición/liberación para VarHandles introducida en Java 9 public class Foo { private volatile Bar bar ; private static final VarHandle BAR ;private Bar getBarAcquire ( ) { return ( Bar ) BAR.getAcquire ( this ) ; } private void setBarRelease ( Bar value ) { BAR.setRelease ( this , value ) ; }static { try { MethodHandles . Lookup lookup = MethodHandles . lookup (); BAR = lookup . findVarHandle ( Foo . class , "bar" , Bar . class ); } catch ( ReflectiveOperationException e ) { throw new ExceptionInInitializerError ( e ); } }public Bar bar () { Bar local = barAcquire (); if ( local == null ) { synchronized ( this ) { local = getBarAcquire (); if ( local == null ) { local = new Bar (); setBarRelease ( local ); } } } return local ; }// otras funciones y miembros... }

Si el objeto auxiliar es estático (uno por cargador de clases), una alternativa es el modismo de contenedor de inicialización bajo demanda [ 11 ] (Véase el listado 16.6 [ 12 ] del texto citado anteriormente).

// Inicialización perezosa correcta en Java public class Foo { private static class Holder { public static final Bar INSTANCE = new Bar (); }public static Bar bar () { return Holder . INSTANCIA ; } }

Esto se basa en el hecho de que las clases anidadas no se cargan hasta que se hace referencia a ellas.

La semántica de finalcampos en Java 5 se puede emplear para publicar de forma segura el objeto auxiliar sin usar volatile: [ 13 ]

clase FinalWrapper < T > { public final T valor ;public FinalWrapper ( T valor ) { this . valor = valor ; } }clase pública Foo { privado FinalWrapper < Bar > bar ;public Bar bar () { FinalWrapper < Bar > temp = bar ;if ( temp == null ) { synchronized ( this ) { if ( bar == null ) { bar = new FinalWrapper < Bar > ( new Bar ()); } temp = bar ; } } return temp . value ; } }

La variable local tempes necesaria para la corrección: simplemente usarla barpara ambas comprobaciones de nulidad y la instrucción de retorno podría fallar debido a la reordenación de lectura permitida bajo el modelo de memoria de Java. [ 14 ] El rendimiento de esta implementación no es necesariamente mejor que el de la volatileimplementación.

Uso en C#

En .NET Framework 4.0, se introdujo la clase, que internamente utiliza bloqueo de doble verificación por defecto ( modo) para almacenar la excepción que se lanzó durante la construcción o el resultado de la función que se pasó a : [ 15 ]Lazy<T>LazyThreadSafetyMode.ExecutionAndPublicationLazy<T>

usando el sistema ;public class MySingleton { private static readonly Lazy < MySingleton > _mySingleton = new (() => new MySingleton ());privado MySingleton () { }public static MySingleton Instance => _mySingleton . Value ; }

Véase también

Referencias

  1. Schmidt, D et al. Arquitectura de software orientada a patrones Vol. 2, 2000 pp. 353-363
  2. Lenguajes de patrones para el diseño de programas. 3 (PDF) (Ed. Nachdr  .). Reading, Mass: Addison-Wesley. 1998. ISBN 978-0201310115.
  3. Gregoire, Marc (24 de febrero de 2021). Professional C++ . John Wiley & Sons. ISBN 978-1-119-69545-5.
  4. 1 2 David Bacon et al. La declaración "El bloqueo doblemente verificado está roto" .
  5. Boehm, Hans-J (junio de 2005). "Los hilos no se pueden implementar como una biblioteca" (PDF) . ACM SIGPLAN Notices . 40 (6): 261–268 . doi : 10.1145/1064978.1065042 . Archivado del original (PDF) el 30 de mayo de 2017. Consultado el 12 de agosto de 2014 .
  6. Haggar, Peter (1 de mayo de 2002). "Bloqueo con doble verificación y el patrón Singleton" . IBM. Archivado del original el 27 de octubre de 2017. Recuperado el 19 de mayo de 2022 .
  7. "Compatibilidad con las características de C++11-14-17 (C++ moderno)" .
  8. El bloqueo con doble verificación se ha corregido en C++11
  9. Bloch, Joshua (2018). Java eficaz (Tercera ed.). Addison-Wesley. pág. 335. ISBN   978-0-13-468599-1En mi máquina , el método anterior es aproximadamente 1,4 veces más rápido que la versión obvia sin una variable local.
  10. "Capítulo 17. Hilos y bloqueos" . docs.oracle.com . Consultado el 28 de julio de 2018 .
  11. Brian Goetz et al. Java Concurrency in Practice, 2006, pág. 348
  12. Goetz, Brian; et al. "Concurrencia en Java en la práctica: listados en el sitio web" . Recuperado el 21 de octubre de 2014 . 
  13. Lista de correo de discusión sobre el modelo de memoria Java
    Página no encontrada: considere actualizar el enlace.
  14. Manson, Jeremy (14 de diciembre de 2008). "Inicialización perezosa con restricciones de fechas para mejorar el rendimiento: concurrencia en Java (&c)" . Recuperado el 3 de diciembre de 2016 .
  15. Albahari, Joseph (2010). "Hilos en C#: Uso de hilos" . C# 4.0 en pocas palabras . O'Reilly Media. ISBN 978-0-596-80095-6. implementa […] bloqueo de doble verificación. El bloqueo de doble verificación realiza una lectura volátil adicional para evitar el costo de obtener un bloqueo si el objeto ya está inicializado.Lazy<T>
  • Problemas con el mecanismo de cierre de doble verificación, tal como se recoge en los blogs de Jeu George.
  • Descripción del sistema de bloqueo de doble verificación del repositorio de patrones de Portland.
  • Descripción del repositorio de patrones de Portland: "El sistema de bloqueo de doble verificación está roto".
  • Artículo " C++ y los peligros del bloqueo de doble verificación " (475  KB) de Scott Meyers y Andrei Alexandrescu
  • Artículo " Cierre con doble verificación: ingenioso, pero defectuoso " de Brian Goetz
  • Artículo "¡ Advertencia! Uso de subprocesos en un mundo multiprocesador " de Allen Holub
  • Cierre doblemente revisado y patrón Singleton
  • Patrón Singleton y seguridad del hilo
  • Palabra clave volatile en VC++ 2005
  • Ejemplos de Java y sincronización de soluciones de bloqueo de doble verificación
  • "Java más eficaz con Joshua Bloch de Google" .