C# simultaneidad de matriz de estructura

Dada una matriz de estructura:

public struct Instrument
{
    public double NoS;
    public double Last;
}

var a1 = new Instrument[100];

Y un grupo de tareas de subprocesos que está escribiendo en esos elementos sobre la base de que dos subprocesos pueden escribir en un solo elemento al mismo tiempo, uno para cada uno de los campos dobles (hay una cola ascendente por tema efectivamente).

Y el conocimiento de que los dobles se pueden escribir atómicamente en 64 bits. (edite esto por error dicho originalmente en 32 bits)

Necesito realizar periódicamente un cálculo utilizando todos los valores de la matriz y me gustaría que fueran coherentes durante el cálculo.

Entonces puedo tomar una instantánea de la matriz con:

var snapshot = a1.Clone();

Ahora la pregunta que tengo es con respecto a los detalles de la sincronización. Si hago que los miembros sean volátiles, no creo que eso vaya a ayudar al clon en absoluto, ya que la adquisición/lanzamiento de lectura/escritura no están en el nivel de la matriz.

Ahora podría tener un bloqueo de matriz, pero esto agregará mucha controversia sobre el proceso más frecuente de escritura de datos en la matriz. Así que no es ideal.

Alternativamente, podría tener un bloqueo por fila, pero eso sería un verdadero dolor ya que todos deberían adquirirse antes de la clonación, mientras tanto, tengo todas las escrituras respaldadas.

Ahora realmente no importa si la instantánea no tiene el valor más reciente si es una cuestión de microsegundos, etc., así que creo que probablemente podría salirme con la mía sin tener un bloqueo. Mi única preocupación es si podría haber un escenario en el que no haya una reescritura de caché durante un período sostenido. ¿Es esto algo de lo que debería preocuparme? Los escritores están en el flujo de datos TPL y la única lógica es establecer los dos campos en la estructura. Sin embargo, realmente no sé cómo o si el alcance de la función tiende a correlacionarse con las reescrituras de caché.

¿Pensamientos/consejos?

editar: ¿Qué pasa si utilizo una escritura entrelazada en las variables de la estructura?

edit2: El volumen de escrituras es MUCHO más alto que las lecturas. También hay dos servicios separados y concurrentes que escriben en los campos Nos y Last. Entonces podrían estar siendo escritos simultáneamente a la vez. Esto causa problemas con un enfoque de objeto de referencia para la atomicidad.

edit3: Más detalles. Suponga que la matriz tiene entre 30 y 1000 elementos y cada elemento podría actualizarse varias veces por segundo.

preguntado el 12 de junio de 12 a las 10:06

ADVERTENCIA: Un doble es un 64 bits value y no se puede escribir atómicamente en máquinas de 32 bits: msdn.microsoft.com/en-us/library/system.double.aspx. -

Steven buena captura gracias! Estaba trabajando sobre esa base hasta que un colega me confundió anoche. Buen punto. -

re the edit: interlocked solo ayudaría si todos las acceso utilizado Enclavado. Una copia de mem (semántica de copia de estructura) no hará eso, por lo que aún podría obtener un estado roto. -

¿Consideró hacer de Instrument una estructura inmutable? De esa manera, puede leer en cualquier momento sin bloqueos, o con muy pocos bloqueos (solo alrededor del método Clone) -

@ Dr.ABT vea la respuesta que acabo de agregar sobre convertirlo en una clase, que aborda eso. -

5 Respuestas

Como Instrument contiene dos dobles (dos valores de 64 bits), no puede escribirlo atómicamente (incluso en máquinas de 64 bits). Esto significa que el Clone El método nunca puede hacer una copia segura para subprocesos sin hacer algún tipo de sincronización.

TLDR; No use una estructura, use una clase inmutable.

Probablemente tendrías más suerte con un pequeño rediseño. Intente usar estructuras de datos inmutables y colecciones simultáneas del marco .NET. Por ejemplo, haga su Instrument an clase inmutable:

// Important: Note that Instrument is now a CLASS!! 
public class Instrument
{
    public Instrument(double nos, double last)
    {
        this.NoS = nos;
        this.Last = last;
    }

    // NOTE: Private setters. Class can't be changed
    // after initialization.
    public double NoS { get; private set; }
    public double Last { get; private set; }
}

De esta manera actualizando un Instrument significa que tienes que crear uno nuevo, lo que hace que sea mucho más fácil razonar sobre esto. Cuando esté seguro de que solo un subproceso está trabajando con un solo Instrument ya terminó, ya que un trabajador ahora puede hacer esto de manera segura:

Instrument old = a[5];

var newValue = new Instrument(old.NoS + 1, old.Last - 10);

a[5] = newValue;

Dado que los tipos de referencia son de 32 bits (o 64 bits en una máquina de 64 bits), se garantiza que la actualización de la referencia será atómica. El clon ahora siempre dará como resultado una copia correcta (puede que falte, pero eso no parece ser un problema para usted).

ACTUALIZACIÓN

Después de volver a leer su pregunta, veo que la leí mal, ya que un hilo no está escribiendo en un Instrument, pero está escribiendo en un valor de instrumento, pero la solución es prácticamente la misma: usar tipos de referencia inmutables. Un truco simple, por ejemplo, es cambiar los campos de respaldo del NoS y Last propiedades a los objetos. Esto hace que actualizarlos sea atómico:

// Instrument can be a struct again.
public struct Instrument
{
    private object nos;
    private object last;

    public double NoS
    {
        get { return (double)(this.nos ?? 0d); }
        set { this.nos = value; }
    }

    public double Last
    {
        get { return (double)(this.last ?? 0d); }
        set { this.last = value; }
    }
}

Al cambiar una de las propiedades, el valor se encuadrará y los valores encuadrados son tipos de referencia inmutables. De esta manera, puede actualizar esas propiedades de forma segura.

Respondido el 13 de junio de 12 a las 10:06

(comentario) De hecho, no vi este: voté a favor y eliminé mi duplicado más joven. - marc gravell

Esto no funciona en mi caso de uso ya que dos servicios están escribiendo en cada doble respectivamente. Por lo tanto, la instanciación de una nueva instancia de referencia provocará condiciones de carrera que requerirán un bloqueo detallado. Podría tener dos referencias a los dobles (por ejemplo, boxearlos), pero creo que se está volviendo un poco ridículo ir por ese camino. - DanH

Y el conocimiento de que los dobles se pueden escribir atómicamente en 32 bits.

No, eso no está garantizado:

12.5 Atomicidad de referencias variables

Las lecturas y escrituras de los siguientes tipos de datos deben ser atómicas: bool, char, byte, sbyte, short, ushort, uint, int, float y tipos de referencia. Además, las lecturas y escrituras de tipos de enumeración con un tipo subyacente en la lista anterior también serán atómicas. Lecturas y escrituras de otros tipos, incluidos long, ulong, doble, y decimal, así como los tipos definidos por el usuario, no necesitan ser atómicos.

(énfasis mío)

No se ofrece ninguna garantía con respecto a los dobles en 32 bits o incluso en 64 bits. A strcut compuesto por 2 dobles es aún más problemático. Deberías replantearte tu estrategia.

Respondido el 12 de junio de 12 a las 10:06

¿Cuál es la fuente de esa cita? - Steven

@Steven eso es ECMA 334 v4. También puedo comprobar las especificaciones de MS si quieres. - marc gravell

@Steven, la especificación de MS dice lo mismo, pero la numeración es diferente, es la sección 5.5. marc gravell

No estoy discutiendo contigo, pero sería bueno ver la fuente de esa cita o con fines educativos. Un enlace para ser apreciado. - Steven

Sin embargo, es una buena captura, que en teoría, escribir dobles en un entorno de 64 bits no es atómico, aunque espero que este sea el caso en la práctica. Sin embargo, siempre es aconsejable ceñirse a las especificaciones. - Steven

Podrías (ab)usar un ReaderWriterLockSlim.

Tome un bloqueo de lectura al escribir (ya que dice que no hay contención entre los escritores). Y tome un bloqueo de escritura al clonar.

Sin embargo, no estoy seguro de que haría esto a menos que realmente no haya otra alternativa. Podría ser confuso para quien mantenga esto en el futuro.

Respondido el 12 de junio de 12 a las 10:06

En la mayoría de los casos, el ReaderWriterLockSlim es más lento que hacer un simple lock . ReaderWriterLockSlim es especialmente útil cuando el tiempo empleado durante la operación de lectura es alto (por ejemplo, al realizar E/S), pero probablemente ese no sea el caso en esta situación. - Steven

@Steven, puede que tengas razón, pero una referencia autorizada ayudaría a convencer. El siguiente blog sugiere que el rendimiento es aproximadamente igual al del monitor: bluebytesoftware.com/blog/… En el caso de los OP, los bloqueos de lectura superarán en gran medida a los bloqueos de escritura, no se requieren actualizaciones ni recursividad, y espero que ReaderWriterLockSlim funcione bien. - Joe

Eche un vistazo a este artículo (más nuevo) del mismo escritor, Joe Duffy: Bloqueos de lector-escritor y su falta de aplicabilidad a la sincronización de grano fino. Cita: "pregúntese seriamente si un bloqueo de lector/escritor realmente le va a comprar algo". - Steven

Las lecturas y escrituras de elementos de matriz individuales, o campos de estructura individuales, generalmente son independientes. Si mientras un subproceso está escribiendo un campo particular de una instancia de estructura particular, ningún otro subproceso intentará acceder a ese mismo campo, una matriz de estructuras será implícitamente segura para subprocesos sin ningún bloqueo requerido más allá de la lógica que impone las condiciones anteriores.

Si es posible que un subproceso intente leer un double mientras otro subproceso lo está escribiendo, pero no es posible que dos subprocesos intenten escribir simultáneamente, hay una serie de enfoques que puede tomar para asegurarse de que una lectura no vea un valor parcialmente escrito. Una que aún no se ha mencionado sería definir un int64 y use métodos personalizados para leer y escribir double valores allí (convirtiéndolos bit a bit, y usando Interlocked según sea necesario).

Otro enfoque sería tener un changeCount variable para cada ranura de matriz, que se incrementa para que los dos LSB sean "10" antes que cualquier otra cosa antes de que se escriba la estructura, y Interlocked.Increment por 2 después (vea la nota a continuación). Antes de que el código lea la estructura, debe verificar si hay una escritura en curso. De lo contrario, debe realizar la lectura y asegurarse de que no haya comenzado o ocurrido una escritura (si se produjo una escritura después de que se inició la lectura, vuelva al principio). Si una escritura está en progreso cuando el código quiere leer, debe adquirir un bloqueo compartido, verificar si la escritura aún está en progreso y, de ser así, usar una operación entrelazada para establecer el LSB de changeCount y Monitor.Wait en la cerradura El código que escribió la estructura debería notar en su Interlocked.Increment que el LSB se estableció, y debería Pulse La cerradura. Si el modelo de memoria garantiza que las lecturas de un solo subproceso se procesarán en orden, y que las escrituras de un solo subproceso se procesarán en orden, y si solo un subproceso intentará escribir una ranura de matriz a la vez, este enfoque debería limitar la sobrecarga del multiprocesador a un solo Interlocked operación en el caso de no contienda. Tenga en cuenta que uno debe estudiar cuidadosamente las reglas sobre lo que implica o no el modelo de memoria antes de usar este tipo de código, ya que puede ser engañoso.

Por cierto, hay dos enfoques más que uno podría tomar si quisiera que cada elemento de la matriz fuera un tipo de clase en lugar de una estructura:

  1. Use un tipo de clase inmutable y use `Interlocked.CompareExchange` cada vez que desee actualizar un elemento. El patrón a utilizar es este:
      MiClase antiguoVal,nuevoValor; do { oldVal = theArray[subíndice]; newVal = new MyClass(oldVal.this, oldVal.that+5); // O cualquier cambio } while (Threading.Interlocked.CompareExchange(theArray[subíndice], newVal, oldVal) != oldVal);
    
    Este enfoque siempre producirá una actualización atómica lógicamente correcta del elemento de matriz. Si, entre el momento en que se lee el elemento de la matriz y el momento en que se actualiza, algo más cambia el valor, `CompareExchange` no afectará al elemento de la matriz, y el código regresará e intentará nuevamente. Este enfoque funciona razonablemente bien en ausencia de contención, aunque cada actualización requerirá generar una nueva instancia de objeto. Sin embargo, si muchos subprocesos intentan actualizar la misma ranura de matriz y el constructor de `MyClass` tarda una cantidad significativa de tiempo en ejecutarse, es posible que el código se deteriore, creando repetidamente nuevos objetos y luego descubriendo que están obsoletos por el tiempo que podrían ser almacenados. El código siempre avanzará, pero no necesariamente rápido.
  2. Use una clase mutable y bloquee los objetos de clase cada vez que desee leerlos o escribirlos. Este enfoque evitaría tener que crear nuevas instancias de objetos de clase cada vez que se cambie algo, pero el bloqueo agregaría algunos gastos generales propios. Tenga en cuenta que tanto las lecturas como las escrituras tendrían que estar bloqueadas, mientras que el enfoque de clase inmutable solo requería que se usaran métodos "interbloqueados" en las escrituras.

Tiendo a pensar que las matrices de estructuras son mejores contenedores de datos que las matrices de objetos de clase, pero ambos enfoques tienen ventajas.

Respondido el 12 de junio de 12 a las 21:06

Gracias. ¡Sin embargo, definitivamente una optimización demasiado lejos en mi caso! Bastante seguro de que pasaría días haciendo que eso funcione. - DanH

@DanH: El mínimo-Interlocked el enfoque sería bastante retorcido, y generalmente no lo recomendaría a menos que uno necesite un rendimiento super-duper. Desearía que Framework hubiera proporcionado Interlocked sobrecargas para double; Realmente no veo ninguna razón por la que no podría haberlo hecho, ya que a las instrucciones subyacentes de la CPU no les importaría si un puntero a flotador se convirtiera en un puntero a int64. - Super gato

@DanH: Se agregaron dos sugerencias más. - Super gato

Ok, pensé en esto durante el almuerzo.

Veo dos, posiblemente 3 soluciones aquí.

Primera nota importante: la idea inmutable no funciona en mi caso de uso porque tengo dos servicios ejecutándose en paralelo escribiendo en NoS y Last de forma independiente. Esto significa que necesitaría una capa adicional de lógica de sincronización entre esos dos servicios para garantizar que mientras uno de los servicios crea la nueva referencia, el otro no está haciendo lo mismo. Problema clásico de condición de carrera, por lo que definitivamente no es adecuado para este problema (aunque sí, podría tener un árbitro para cada doble y hacerlo de esa manera, pero se está volviendo ridículo en ese punto)

solución 1 Bloqueo de nivel de caché completo. Tal vez use un spinlock y solo bloquee todas las actualizaciones y la instantánea (con memcpy). Esto es más simple y probablemente esté totalmente bien para los volúmenes de los que estoy hablando.

solución 2 Haga que todas las escrituras en dobles utilicen escrituras entrelazadas. cuando quiero tomar una instantánea, iterar la matriz y cada valor usando lectura entrelazada para completar la copia. Esto puede provocar el desgarro de la estructura, pero los dobles están intactos, lo cual está bien, ya que se actualizan continuamente los datos, por lo que el concepto de lo último es un poco abstracto.

solución 3 No creo que esto funcione, pero ¿qué pasa con las escrituras entrelazadas en todos los dobles, y luego solo use memcopy? Aunque no estoy seguro de si me romperán los dobles. (recuerde que no me importa rasgar a nivel de estructura).

Si la solución 3 funciona, supongo que su mejor rendimiento, pero de lo contrario, me inclino más por la solución 1.

Respondido el 12 de junio de 12 a las 14:06

Ok, entonces la solución 3 no funciona: stackoverflow.com/questions/10998730/… - DanH

No es la respuesta que estás buscando? Examinar otras preguntas etiquetadas or haz tu propia pregunta.