stringtranslate.com

Bloqueo doblemente comprobado

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

La forma original del patrón, que aparece en Pattern Languages ​​of Program Design 3 , [2] tiene carreras de datos , dependiendo del modelo de memoria en uso, y es difícil hacerlo bien. Algunos lo consideran un antipatrón . [3] Existen formas válidas del patrón, incluido el uso de la volatilepalabra clave en Java y barreras de memoria explícitas en C++. [4]

El patrón se utiliza normalmente para reducir la sobrecarga de bloqueo al implementar la " inicialización diferida " en un entorno multiproceso, 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 subproceso clase  Foo { private static Helper helper ; public Helper getHelper () { if ( helper == null ) { helper = new Helper (); } return helper ; }                       // otras funciones y miembros... }

El problema es que esto no funciona cuando se utilizan varios subprocesos. Se debe obtener un bloqueogetHelper() en caso de que dos subprocesos llamen simultáneamente. De lo contrario, ambos pueden intentar crear el objeto al mismo tiempo o uno puede terminar obteniendo una referencia a un objeto inicializado de forma incompleta.

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

// Versión multiproceso correcta pero posiblemente costosa clase  Foo { private Helper helper ; publicsynchronous Helper getHelper () { if ( helper == null ) { helper = new Helper (); } return helper ; }                       // otras funciones y miembros... }

Esto es correcto y lo más probable es que tenga un rendimiento suficiente. Sin embargo, la primera llamada a getHelper()creará el objeto y solo los pocos subprocesos que intentan acceder a él durante ese tiempo deben sincronizarse; después de eso, todas las llamadas solo obtienen una referencia a la variable miembro. Dado que la sincronización de un método podría, en algunos casos extremos, reducir 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 parece innecesaria: una vez que se ha completado la inicialización, la adquisición y liberación de los bloqueos parecería 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. Obtener el bloqueo.
  3. Verifique nuevamente si la variable ya se ha inicializado: si otro subproceso adquirió el bloqueo primero, es posible que ya haya realizado la inicialización. Si es así, devuelva la variable inicializada.
  4. De lo contrario, inicialice y devuelva la variable.
// Versión multiproceso rota // Modismo original de "Bloqueo con doble comprobación" clase  Foo { private Helper helper ; public Helper getHelper () { if ( helper == null ) { sincronizado ( this ) { if ( helper == null ) { helper = new Helper (); } } } return helper ; }                                // otras funciones y miembros... }

Intuitivamente, este algoritmo es una solución eficiente al problema. Pero si el patrón no se escribe con cuidado, tendrá una carrera de datos . Por ejemplo, considere 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 incluido en línea una llamada a un constructor, la variable compartida puede actualizarse inmediatamente una vez que se haya asignado el almacenamiento, pero antes de que el constructor incluido en línea inicialice el objeto. [6]
  3. El subproceso B se da cuenta de que la variable compartida se ha inicializado (o eso parece) y devuelve su valor. Como el subproceso B cree que el valor ya está inicializado, no adquiere el bloqueo. Si B utiliza el objeto antes de que A vea toda la inicialización realizada por A (ya sea porque A no ha terminado de inicializarlo o porque algunos de los valores inicializados en el objeto aún no se han filtrado a la memoria que utiliza B ( coherencia de caché )), es probable que el programa se bloquee.

La mayoría de los entornos de ejecución tienen barreras de memoria u otros métodos para gestionar la visibilidad de la memoria en las unidades de ejecución. Sin una comprensión detallada del comportamiento del lenguaje en esta área, el algoritmo es difícil de implementar correctamente. Uno de los peligros de utilizar el bloqueo con 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 tiene problemas sutiles. Dependiendo del compilador , la intercalación de subprocesos por parte del programador y la naturaleza de otras actividades concurrentes del sistema , las fallas resultantes de una implementación incorrecta del bloqueo con doble verificación pueden ocurrir solo de manera intermitente. Reproducir las fallas puede ser difícil.

Uso en C++11

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

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

—  § 6.7 [stmt.dcl] pág. 4
Singleton y GetInstance () { Singleton estático s ; return s ; }       

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

#include <mutex> #include <opcional> // Desde C++17  // Clase Singleton.h Singleton { público : estático Singleton * GetInstance (); privado : Singleton () = predeterminado ;           std estático :: opcional < Singleton > s_instance ; std estático :: once_flag s_flag ; };     // Singleton.cpp std :: opcional < Singleton > Singleton :: s_instancia ; std :: una vez_bandera Singleton :: s_bandera {};  Singleton * Singleton ::GetInstance () { std :: call_once ( Singleton :: s_flag , []() { s_instance.emplace ( Singleton { }); }); return &* s_instance ; }         

Si uno realmente desea utilizar el idioma de doble verificación en lugar del ejemplo trivialmente funcional anterior (por ejemplo, porque Visual Studio antes del lanzamiento de 2015 no implementó el lenguaje del estándar C++11 sobre inicialización concurrente citado anteriormente [7] ), uno necesita usar cercas de adquisición y liberación: [8]

#include <atómico> #include <mutex>  clase Singleton { público : Singleton estático * GetInstance ();       privado : Singleton () = predeterminado ;    std estático :: atómico < Singleton *> s_instance ; std estático :: mutex s_mutex ; };     Singleton * Singleton::GetInstance () { Singleton * p = s_instance.load ( std :: memory_order_acquire ) ; if ( p == nullptr ) { // 1.ª comprobación std :: lock_guard < std :: mutex > lock ( s_mutex ); p = s_instance.load ( std :: memory_order_relaxed ); if ( p == nullptr ) { // 2.ª comprobación ( doble) p = new Singleton ( ) ; s_instance.store ( p , std :: memory_order_release ) ; } } return p ; }                                 

Uso en POSIX

pthread_once()debe usarse 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 subproceso.

Uso en Go

paquete principal importar "sincronizar" var arrOnce sync . Una vez var arr [] int    // getArr recupera arr, inicializándose 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á la matriz, mientras que // las demás se bloquearán hasta que Do() se haya completado. Después de que Do se haya ejecutado, solo se requerirá una // única comparación atómica para obtener la matriz. 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 getArr() // no causarán doble inicialización go getArr () go getArr () }    

Uso en Java

A partir de J2SE 5.0 , la palabra clave volátil se define para crear una barrera de memoria. Esto permite una solución que garantiza que varios subprocesos gestionen la instancia singleton correctamente. Este nuevo modismo se describe en [3] y [4].

// Funciona con semántica de adquisición/liberación para volátiles en Java 1.5 y posteriores // No funciona con semántica de Java 1.4 y anteriores para volátiles clase  Foo { private volátil Helper helper ; public Helper getHelper () { Helper localRef = helper ; if ( localRef == null ) { sincronizado ( this ) { localRef = helper ; if ( localRef == null ) { helper = localRef = new Helper (); } } } return localRef ; }                                          // otras funciones y miembros... }

Tenga en cuenta la variable local " localRef ", que parece innecesaria. El efecto de esto es que en los casos en los que el ayudante ya está inicializado (es decir, la mayoría de las veces), solo se accede al campo volátil una vez (debido a " return localRef; " en lugar de " return helper; "), 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 atómicas relajadas para acceder a los campos, brindando lecturas algo más rápidas en máquinas con modelos de memoria débiles, a costa de una mecánica más difícil y una pérdida de consistencia secuencial (los accesos a los campos ya no participan en el orden de sincronización, el orden global de accesos a campos volátiles). [10]

// Funciona con la semántica de adquisición/liberación para VarHandles introducidos en Java 9 class  Foo { private volcanic Helper helper ;      público Helper getHelper () { Helper localRef = getHelperAcquire (); si ( localRef == null ) { sincronizado ( this ) { localRef = getHelperAcquire (); si ( localRef == null ) { localRef = nuevo Helper (); setHelperRelease ( localRef ); } } } return localRef ; }                                   privada estática final VarHandle HELPER ; privada Helper getHelperAcquire () { return ( Helper ) HELPER . getAcquire ( this ); } privada void setHelperRelease ( Helper valor ) { HELPER . setRelease ( this , valor ); }                     static { try { MethodHandles . Lookup lookup = MethodHandles . lookup (); HELPER = lookup . findVarHandle ( Foo . class , "helper" , Helper . class ); } catch ( ReflectiveOperationException e ) { throw new ExceptionInInitializerError ( e ); } }                       // otras funciones y miembros... }

Si el objeto auxiliar es estático (uno por cargador de clase), una alternativa es el modismo de contenedor de inicialización a pedido [11] (consulte el Listado 16.6 [12] del texto citado anteriormente).

// Inicialización diferida correcta en la clase Java  Foo { private static class HelperHolder { public static final Helper helper = new Helper (); }                público estático Helper getHelper () { return HelperHolder . helper ; } }       

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

La semántica del campo final en Java 5 se puede emplear para publicar de forma segura el objeto auxiliar sin utilizar volátiles : [13]

clase pública FinalWrapper < T > { valor público final T ; valor público FinalWrapper ( T ) ​​{ este.valor = valor ; } }               clase pública Foo { privada FinalWrapper < Helper > helperWrapper ;       público Helper getHelper () { FinalWrapper < Helper > tempWrapper = helperWrapper ;        si ( tempWrapper == null ) { sincronizado ( este ) { si ( helperWrapper == null ) { helperWrapper = new FinalWrapper < Helper > ( new Helper ()); } tempWrapper = helperWrapper ; } } devolver tempWrapper . valor ; } }                          

La variable local tempWrapper es necesaria para la corrección: simplemente usar helperWrapper tanto para las verificaciones de nulos como para la declaración de retorno podría fallar debido al reordenamiento de lectura permitido bajo el Modelo de memoria de Java. [14] El rendimiento de esta implementación no es necesariamente mejor que la implementación volátil .

Uso en C#

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

clase pública MySingleton { privada estática de solo lectura Lazy < MySingleton > _mySingleton = nuevo Lazy < MySingleton > (() => nuevo MySingleton ());              privado MySingleton () { }    Instancia pública estática MySingleton => _mySingleton . Valor ; }     

Véase también

Referencias

  1. ^ Schmidt, D et al. Arquitectura de software orientada a patrones, vol. 2, 2000, págs. 353-363
  2. ^ Lenguajes de patrones en el diseño de programas. 3 (PDF) (edición actual). Reading, Mass.: Addison-Wesley. 1998. ISBN 978-0201310115.
  3. ^ Gregoire, Marc (24 de febrero de 2021). C++ profesional. John Wiley & Sons. ISBN 978-1-119-69545-5.
  4. ^ ab David Bacon et al. La declaración "El bloqueo con doble verificación está roto".
  5. ^ Boehm, Hans-J (junio de 2005). "Los subprocesos no se pueden implementar como una biblioteca" (PDF) . ACM SIGPLAN Notices . 40 (6): 261–268. doi :10.1145/1064978.1065042. Archivado desde el original (PDF) el 2017-05-30 . Consultado el 2014-08-12 .
  6. ^ Haggar, Peter (1 de mayo de 2002). «Bloqueo con doble verificación y patrón Singleton». IBM. Archivado desde el original el 27 de octubre de 2017. Consultado 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 solucionó en C++11
  9. ^ Bloch, Joshua (2018). Effective Java (tercera edición). 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. Subprocesos y bloqueos". docs.oracle.com . Consultado el 28 de julio de 2018 .
  11. ^ Brian Goetz y otros. Concurrencia en Java en la práctica, 2006, pág. 348
  12. ^ Goetz, Brian; et al. "Java Concurrency in Practice – listings on website" (Concurrencia en Java en la práctica: listados en el sitio web) . Consultado el 21 de octubre de 2014 .
  13. ^ [1] Lista de correo de discusión de Javamemorymodel
  14. ^ [2] Manson, Jeremy (14 de diciembre de 2008). "Inicialización diferida con fecha y carrera completa para mejorar el rendimiento: concurrencia en Java (etc.)" . Consultado el 3 de diciembre de 2016 .
  15. ^ Albahari, Joseph (2010). "Subprocesos en C#: uso de subprocesos". C# 4.0 en pocas palabras . O'Reilly Media. ISBN 978-0-596-80095-6. Lazy<T>en realidad implementa […] bloqueo con doble verificación. El bloqueo con doble verificación realiza una lectura volátil adicional para evitar el costo de obtener un bloqueo si el objeto ya está inicializado.

Enlaces externos