Pregunta

Si tengo un tipo profundamente inmutable (todos los miembros son de solo lectura y si son miembros del tipo de referencia, también se refieren a objetos que son profundamente inmutables).

Me gustaría implementar una propiedad inicializada diferida en el tipo, como esta:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            m_PropName = temp;
        }
        return m_PropName;
    }
}

Por lo que puedo decir:

m_PropName = temp; 

... es seguro para subprocesos. No estoy demasiado preocupado por dos subprocesos, ambos corriendo para inicializar al mismo tiempo, porque será raro, ambos resultados serían idénticos desde una perspectiva lógica, y prefiero no usar un candado si no tengo a.

¿Funcionará esto? ¿Cuáles son los pros y los contras?

Editar: Gracias por tus respuestas. Probablemente avanzaré con el uso de un candado. Sin embargo, me sorprende que nadie haya planteado la posibilidad de que el compilador se dé cuenta de que la variable temporal es innecesaria y simplemente asigne directamente a m_PropName. Si ese fuera el caso, un hilo de lectura podría leer un objeto que no ha terminado de construirse. ¿El compilador previene tal situación?

(Las respuestas parecen indicar que el tiempo de ejecución no permitirá que esto suceda).

Editar: Así que decidí optar por un método Interlocked CompareExchange inspirado en este artículo Joe Duffy .

Básicamente:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            System.Threading.Interlocked(ref m_PropName, temp, null);
        }
        return m_PropName;
    }
}

Se supone que esto garantiza que todos los hilos que llaman a este método en esta instancia de objeto obtendrán una referencia al mismo objeto, por lo que el operador == funcionará. Es posible que haya desperdiciado el trabajo, lo cual está bien, solo lo convierte en un algoritmo optimista.

Como se señala en algunos comentarios a continuación, esto depende del modelo de memoria .NET 2.0 para funcionar. De lo contrario, m_PropName debería declararse volátil.

¿Fue útil?

Solución

Eso funcionará. Se garantiza que escribir en referencias en C # será atómico, como se describe en la sección 5.5 de spec . Probablemente esta no sea una buena forma de hacerlo, porque su código será más confuso para depurar y leer a cambio de un efecto probablemente menor en el rendimiento.

Jon Skeet tiene una excelente página sobre la implementación de singeltons en C #.

El consejo general sobre pequeñas optimizaciones como estas es no hacerlas a menos que un generador de perfiles le indique que este código es un punto de acceso. Además, debe tener cuidado al escribir código que la mayoría de los programadores no puede comprender completamente sin verificar las especificaciones.

EDITAR: Como se señaló en los comentarios, aunque diga que no le importa si se crean 2 versiones de su objeto, esa situación es tan contra-intuitiva que este enfoque nunca debería usarse.

Otros consejos

Deberías usar un candado. De lo contrario, corre el riesgo de dos instancias de m_PropName existentes y en uso por diferentes hilos. Esto puede no ser un problema en muchos casos; sin embargo, si desea poder usar == en lugar de .equals () , entonces esto será un problema. Las raras condiciones de carrera no son el mejor error que tener. Son difíciles de depurar y reproducir.

En su código, si dos hilos diferentes obtienen simultáneamente su propiedad PropName (por ejemplo, en una CPU de varios núcleos), pueden recibir diferentes instancias nuevas de la propiedad que contendrán datos idénticos pero no sea la misma instancia de objeto.

Una ventaja clave de los objetos inmutables es que == es equivalente a .equals () , permitiendo el uso del == más eficiente para comparacion. Si no sincroniza en la inicialización diferida, corre el riesgo de perder este beneficio.

También pierdes la inmutabilidad. Su objeto se inicializará dos veces con diferentes objetos (que contienen los mismos valores), por lo que un subproceso que ya obtuvo el valor de su propiedad, pero que lo obtiene nuevamente, puede recibir un objeto diferente la segunda vez.

Me interesaría escuchar otras respuestas a esto, pero no veo ningún problema. La copia duplicada se abandonará y se GCed.

Sin embargo, debe hacer que el campo sea volátil .

Con respecto a esto:

  

Sin embargo, me sorprende que nadie haya traído   hasta la posibilidad del compilador   dándose cuenta de que la variable temporal es   innecesario, y simplemente asignando   directo a m_PropName. Si eso fuera   el caso, entonces un hilo de lectura podría   posiblemente leer un objeto que no tiene   terminado de ser construido. Hace el   compilador evitar tal situación?

Pensé en mencionarlo, pero no hace ninguna diferencia. El nuevo operador no devuelve una referencia (y, por lo tanto, la asignación al campo no ocurre) hasta que se completa el constructor, esto está garantizado por el tiempo de ejecución, no por el compilador.

Sin embargo, el lenguaje / tiempo de ejecución NO garantiza realmente que otros hilos no puedan ver un objeto parcialmente construido - depende de lo que haga el constructor .

Update:

El OP también se pregunta si esta página tiene una idea útil . Su fragmento de código final es una instancia de Bloqueo de verificación doble , que es el ejemplo clásico de una idea que miles de personas se recomiendan entre sí sin tener idea de cómo hacerlo bien. El problema es que las máquinas SMP consisten en varias CPU con sus propias memorias caché. Si tuvieran que sincronizar sus cachés cada vez que hubiera una actualización de memoria, esto anularía los beneficios de tener varias CPU. Por lo tanto, solo se sincronizan en una "barrera de memoria", que ocurre cuando se retira un bloqueo, se produce una operación enclavada o se accede a una variable volátil .

El orden habitual de los eventos es:

  • El codificador descubre el bloqueo doblemente verificado
  • El codificador descubre barreras de memoria

Entre estos dos eventos, lanzan una gran cantidad de software roto.

Además, muchas personas creen (como lo hace ese tipo) que puedes "eliminar el bloqueo" mediante el uso de operaciones entrelazadas. Pero en tiempo de ejecución son una barrera de memoria, por lo que hacen que todas las CPU se detengan y sincronicen sus cachés. Tienen una ventaja sobre los bloqueos en que no necesitan hacer una llamada al kernel del sistema operativo (son solo "código de usuario"), pero pueden eliminar el rendimiento tanto como cualquier técnica de sincronización .

Resumen: el código de subprocesamiento parece aproximadamente 1000 veces más fácil de escribir de lo que es.

Estoy totalmente a favor de lazy init cuando no siempre se puede acceder a los datos y puede tomar una buena cantidad de recursos obtener o almacenar los datos.

Creo que hay un concepto clave que se está olvidando aquí: según los conceptos de diseño de C #, no debe hacer que los miembros de su instancia sean seguros para subprocesos de forma predeterminada. Solo los miembros estáticos deben ser seguros para subprocesos de manera predeterminada. A menos que esté accediendo a algunos datos estáticos / globales, no debe agregar bloqueos adicionales a su código.

Por lo que muestra su código, el inicio diferido está dentro de una propiedad de instancia, por lo que no le agregaría bloqueos. Si, por diseño, está destinado a ser accedido por múltiples hilos simultáneamente, entonces continúe y agregue el bloqueo.

Por cierto, puede que no reduzca mucho el código, pero soy fanático del operador de fusión nula. El cuerpo para su captador podría convertirse en esto:

m_PropName = m_PropName ?? nuevo ... ();
return m_PropName;


Se deshace del " if (m_PropName == null) ... " adicional y, en mi opinión, lo hace más conciso y legible.

No soy un experto en C #, pero por lo que puedo decir, esto solo plantea un problema si requiere que solo se cree una instancia de ReadOnlyCollection. Usted dice que el objeto creado siempre será el mismo y no importa si dos (o más) hilos crean una nueva instancia, por lo que diría que está bien hacerlo sin bloqueo.

Una cosa que podría convertirse en un error extraño más adelante sería si uno comparara la igualdad de las instancias, lo que a veces no sería lo mismo. Pero si tienes eso en mente (o simplemente no lo haces) no veo otros problemas.

Desafortunadamente, necesitas un candado. Hay muchos errores bastante sutiles cuando no se bloquea correctamente. Para un ejemplo desalentador, mire esta respuesta .

Se puede usar de forma segura la inicialización diferida sin bloqueo si el campo solo se escribirá si está en blanco o ya contiene el valor a escribir o, en algunos casos, un equivalente . Tenga en cuenta que no hay dos objetos mutables equivalentes; un campo que contiene una referencia a un objeto mutable solo se puede escribir con una referencia a el mismo objeto (lo que significa que la escritura no tendría ningún efecto).

Hay tres patrones generales que uno puede usar para la inicialización diferida, dependiendo de las circunstancias:

  1. Use un candado si calcular el valor para escribir sería costoso, y uno desea evitar gastar ese esfuerzo innecesariamente. El patrón de bloqueo de doble verificación es bueno en sistemas cuyo modelo de memoria lo admite.
  2. Si uno está almacenando un valor inmutable, calcule si parece necesario, y simplemente guárdelo. Otros subprocesos que no ven la tienda pueden realizar un cálculo redundante, pero simplemente intentarán escribir el campo con el valor que ya está allí.
  3. Si uno está almacenando una referencia a un objeto de clase mutable barato de producir, cree un nuevo objeto si parece necesario, y luego use `Interlocked.CompareExchange` para almacenarlo si el campo es todavía en blanco

Tenga en cuenta que si se puede evitar bloquear cualquier acceso que no sea el primero dentro de un hilo, hacer que el lector perezoso sea seguro para los hilos no debería imponer ningún costo de rendimiento significativo. Si bien es común que las clases mutables no sean seguras para subprocesos, todas las clases que afirman ser inmutables deben ser 100% seguras para subprocesos para cualquier combinación de acciones del lector. Cualquier clase que no pueda cumplir con un requisito de seguridad de hilo no debería afirmar que es inmutable.

Esto definitivamente es un problema.

Considere este escenario: Hilo " A " accede a la propiedad y la colección se inicializa. Antes de asignar la instancia local al campo '' m_PropName '', Hilo '' B '' accede a la propiedad, excepto que se completa. Hilo " B " ahora tiene una referencia a esa instancia, que actualmente está almacenada en " m_PropName " ... hasta Thread " A " continúa, en cuyo punto " m_PropName " es sobrescrito por la instancia local en ese hilo.

Ahora hay un par de problemas. Primero, Hilo " B " ya no tiene la instancia correcta, ya que el objeto propietario piensa que " m_PropName " es la única instancia, pero se filtró una instancia inicializada cuando Thread " B " completado antes del hilo '' A ''. Otra es si la colección cambió cuando Thread " A " y Hilo " B " Tengo sus instancias. Entonces tienes datos incorrectos. Incluso podría ser peor si estuviera observando o modificando la colección de solo lectura internamente (lo que, por supuesto, no puede con ReadOnlyCollection, pero podría si lo reemplazara con alguna otra implementación que pudiera observar a través de eventos o modificar internamente, pero no externamente).

Licenciado bajo: CC-BY-SA con atribución
No afiliado a StackOverflow
scroll top