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 volatile
palabra 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.
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:
// 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:
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.
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_flag
y 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 ; }
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.
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 () }
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 VarHandle
clase 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 .
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 ; }
el método anterior es aproximadamente 1,4 veces más rápido que la versión obvia sin una variable local.
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.