c# - lock - ¿Necesito sincronizar el acceso de hilos a un int?
thread c# (8)
Acabo de escribir un método al que llaman múltiples subprocesos simultáneamente y necesito hacer un seguimiento de cuándo se han completado todos los subprocesos. El código utiliza este patrón:
private void RunReport()
{
_reportsRunning++;
try
{
//code to run the report
}
finally
{
_reportsRunning--;
}
}
Este es el único lugar dentro del código en el que se _reportsRunning
el valor de _reportsRunning
, y el método tarda aproximadamente un segundo en ejecutarse.
Ocasionalmente, cuando tengo más de seis o más subprocesos ejecutando informes juntos, el resultado final para _reportsRunning puede bajar a -1. Si _runningReports++
las llamadas a _runningReports++
y _runningReports--
en un bloqueo, el comportamiento parece ser correcto y coherente.
Entonces, para la pregunta: cuando estaba aprendiendo multiproceso en C ++, me enseñaron que no era necesario sincronizar las llamadas para incrementar y disminuir las operaciones porque siempre eran una sola instrucción de ensamblaje y, por lo tanto, era imposible que el hilo se cambiara a la mitad. -llamada. ¿Me enseñaron correctamente y, de ser así, por qué no es cierto para C #?
Entonces, para la pregunta: cuando estaba aprendiendo multiproceso en C ++, me enseñaron que no era necesario sincronizar las llamadas para incrementar y disminuir las operaciones porque siempre eran una sola instrucción de ensamblaje y, por lo tanto, era imposible que el hilo se cambiara a la mitad. -llamada. ¿Me enseñaron correctamente y, si es así, por qué no se cumple para C #?
Esto es increíblemente incorrecto
En algunas arquitecturas, como x86, hay instrucciones de incremento y decremento único. Muchas arquitecturas no las tienen y necesitan hacer cargas y almacenes separados. Incluso en x86, no hay garantía de que el compilador genere la versión de memoria de estas instrucciones; es probable que primero se cargue en un registro, especialmente si necesita realizar varias operaciones con el resultado.
Incluso si se pudiera garantizar que el compilador siempre genere la versión de memoria de incremento y decremento en x86, eso todavía no garantiza la atomicidad: dos CPU podrían modificar la variable simultáneamente y obtener resultados inconsistentes. La instrucción necesitaría el prefijo de bloqueo para forzarlo a ser una operación atómica; los compiladores nunca emiten la variante de bloqueo por defecto, ya que es menos eficaz, ya que garantiza que la acción sea atómica.
Considere las siguientes instrucciones de ensamblaje x86:
inc [i]
Si I es inicialmente 0 y el código se ejecuta en dos subprocesos en dos núcleos, el valor después del final de ambos subprocesos podría ser legalmente 1 o 2, ya que no hay garantía de que un subproceso complete su lectura antes de que el otro subproceso finalice su escritura , o que la escritura de un hilo será visible incluso antes de que se lean los otros hilos.
Cambiando esto a:
lock inc [i]
Resultará en obtener un valor final de 2.
El InterlockedIncrement
y el InterlockedDecrement
Win32 y el InterlockedIncrement
y el InterlockedDecrement
.NET resultan en hacer el equivalente (posiblemente el mismo código de máquina) de lock inc
.
Cualquier tipo de operación de incremento / decremento en un lenguaje de nivel superior (y sí, incluso C
es un nivel más alto en comparación con las instrucciones de la máquina) no es atómica por naturaleza. Sin embargo, cada plataforma de procesador generalmente tiene primitivas que soportan varias operaciones atómicas .
Si su profesor se refería a las instrucciones de la máquina, es probable que las operaciones de Incremento y Decremento sean atómicas. Sin embargo, eso no siempre es correcto en las plataformas de múltiples núcleos cada vez mayores de hoy en día, a menos que garanticen la coherency .
Los lenguajes de nivel superior generalmente implementan soporte para transactions
atómicas utilizando instrucciones de máquina atómica de bajo nivel. Esto se proporciona como el mecanismo de interbloqueo por la API de nivel superior.
El operador A ++
no es atómico en C # (y dudo que se garantice que sea atómico en C ++) así que sí, su conteo está sujeto a condiciones de carrera.
Usar enclavamiento. Incremento y .Decremento
System.Threading.Interlocked.Increment(ref _reportsRunning);
try
{
...
}
finally
{
System.Threading.Interlocked.Decrement(ref _reportsRunning);
}
En una máquina con un solo procesador , si uno no está usando la memoria virtual, es probable que x ++ (rvalue ignored) se traduzca en una sola instrucción INC atómica en arquitecturas x86 (si x es larga, la operación es solo atómica cuando se usa un 32- compilador de bits). Además, movsb / movsw / movsl son formas atómicas de mover un byte / word / longword; un compilador no es apto para usarlos como la forma normal de asignar variables, pero uno podría tener una función de utilidad de movimiento atómico. Sería posible que un administrador de memoria virtual se escriba de tal manera que esas instrucciones se comporten atómicamente si se produce un error de página en la escritura, pero no creo que eso esté normalmente garantizado.
En una máquina multiprocesador, todas las apuestas están desactivadas a menos que se utilicen instrucciones entrelazadas explícitas (invocable a través de llamadas de biblioteca especiales). La instrucción más versátil que está disponible comúnmente es CompareExchange. Esa instrucción alterará la ubicación de la memoria solo si contiene un valor esperado; devolverá el valor que tenía cuando decidió modificarlo o no. Si uno desea "xor" una variable con 1, podría hacer algo como (en vb.net)
Dim OldValue as Integer Do OldValue = Variable While Threading.Interlocked.CompareExchange(Variable, OldValue Xor 1, OldValue) OldValue
Este enfoque le permite a uno realizar cualquier tipo de actualización atómica a una variable cuyo nuevo valor debería depender del valor anterior. Para ciertas operaciones comunes como incremento y decremento, existen alternativas más rápidas, pero el CompareExchange le permite a uno implementar otros patrones útiles también.
Advertencias importantes: (1) Mantenga el bucle lo más corto posible; cuanto más largo sea el bucle, más probable será que otra tarea alcance la variable durante el bucle, y se perderá más tiempo cada vez que eso suceda; (2) un número específico de actualizaciones, divididas arbitrariamente entre subprocesos, siempre se completará, ya que la única forma en que un subproceso puede forzar la ejecución del bucle es si algún otro subproceso ha hecho un progreso útil; Sin embargo, si algunos subprocesos pueden realizar actualizaciones sin avanzar hacia el final, el código puede bloquearse automáticamente.
Incrementar el int
es una instrucción, pero ¿qué hay de cargar el valor en el registro?
Eso es lo que i++
hace efectivamente:
- cargar i en un registro
- incrementar el registro
- descargar el registro en i
Como puede ver, hay 3 instrucciones (que pueden ser diferentes en otras plataformas) que, en cualquier etapa, la CPU puede cambiar el contexto a un hilo diferente, dejando a su variable en un estado desconocido.
Debes usar Interlocked.Increment y Interlocked.Decrement para resolverlo.
No, necesitas sincronizar el acceso. En Windows, puedes hacer esto fácilmente con InterlockedIncrement () y InterlockedDecrement (). Estoy seguro de que hay equivalentes para otras plataformas.
EDIT: Acabo de notar la etiqueta C #. Haz lo que dijo el otro tipo. Ver también: He oído que i ++ no es seguro para subprocesos, es ++ i que es seguro para subprocesos?
Te enseñaron mal
Existe un hardware con un incremento de entero entero atómico, por lo que es posible que lo que aprendieron fuera el correcto para el hardware y el compilador que estaba usando en ese momento. Pero en general, en C ++, ni siquiera se puede garantizar que el incremento de una variable no volátil escriba la memoria de forma consecutiva con la lectura, y mucho menos con la lectura.
x ++ probablemente no sea atómico, pero ++ x podría serlo (no muy seguro, pero si considera la diferencia entre post-pre y pre-incremento, debería estar claro por qué pre es más susceptible a la atomicidad).
Un punto importante es que, si estas ejecuciones tardan un segundo en ejecutarse cada una, la cantidad de tiempo agregada por un bloqueo será un ruido en comparación con el tiempo de ejecución del método en sí. Es probable que no valga la pena simular un intento de eliminar el bloqueo en este caso: tiene una solución correcta con bloqueo, que probablemente no tendrá una diferencia visible en el rendimiento de la solución sin bloqueo.