He oído que i ++ no es seguro para subprocesos, ¿es ++ i seguro para subprocesos?

90

Escuché que i ++ no es una declaración segura para subprocesos, ya que en el ensamblaje se reduce a almacenar el valor original como una temperatura en algún lugar, incrementándolo y luego reemplazándolo, lo que podría ser interrumpido por un cambio de contexto.

Sin embargo, me pregunto acerca de ++ i. Por lo que puedo decir, esto se reduciría a una sola instrucción de ensamblaje, como 'agregar r1, r1, 1' y dado que es solo una instrucción, sería ininterrumpida por un cambio de contexto.

¿Alguien puede aclarar? Supongo que se está utilizando una plataforma x86.

Samoz
fuente
Sólo una pregunta. ¿Qué tipo de escenarios se necesitarían para que dos (o más) hilos accedan a una variable como esa? Honestamente pregunto aquí, no critico. Es solo a esta hora, mi cabeza no puede pensar en ninguno.
OscarRyz
5
¿Una variable de clase en una clase C ++ que mantiene un recuento de objetos?
paxdiablo
1
Buen video sobre el asunto que acabo de ver hoy porque otro chico me dijo: youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub - litb
1
etiquetado como C / C ++; Java no se está considerando aquí, C # es similar, pero carece de una semántica de memoria tan rígidamente definida.
Tim Williscroft
1
@Oscar Reyes Digamos que tienes dos hilos que usan la variable i. Si el hilo uno solo aumenta el hilo cuando está en un cierto punto y el otro solo lo disminuye cuando está en otro punto, tendría que preocuparse por la seguridad del hilo.
Samoz

Respuestas:

158

Has escuchado mal. Bien puede ser que "i++"sea ​​seguro para subprocesos para un compilador específico y una arquitectura de procesador específica, pero no es obligatorio en los estándares en absoluto. De hecho, dado que el subproceso múltiple no forma parte de los estándares ISO C o C ++ (a) , no puede considerar que nada sea seguro para subprocesos en función de lo que cree que se compilará.

Es bastante factible que se ++ipueda compilar en una secuencia arbitraria como:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

que no sería seguro para subprocesos en mi CPU (imaginaria) que no tiene instrucciones de incremento de memoria. O puede ser inteligente y compilarlo en:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

donde lockdeshabilita y unlockhabilita las interrupciones. Pero, incluso entonces, esto puede no ser seguro para subprocesos en una arquitectura que tiene más de una de estas CPU compartiendo memoria (lalock posible que solo deshabilite las interrupciones para una CPU).

El lenguaje en sí (o las bibliotecas para él, si no está integrado en el lenguaje) proporcionará construcciones seguras para subprocesos y debe usarlas en lugar de depender de su comprensión (o posiblemente malentendido) de qué código de máquina se generará.

Cosas como Java synchronizedy pthread_mutex_lock()(disponible para C / C ++ en algunos sistemas operativos) son lo que necesita examinar (a) .


(a) Esta pregunta se hizo antes de que se completaran los estándares C11 y C ++ 11. Esas iteraciones ahora han introducido el soporte de subprocesos en las especificaciones del lenguaje, incluidos los tipos de datos atómicos (aunque ellos, y los subprocesos en general, son opcionales, al menos en C).

paxdiablo
fuente
8
+1 por enfatizar que este no es un problema específico de la plataforma, sin mencionar una respuesta clara ...
RBerteig
2
felicidades por tu insignia de plata C :)
Johannes Schaub - litb
Creo que debería precisar que ningún sistema operativo moderno autoriza a los programas en modo de usuario a desactivar las interrupciones, y pthread_mutex_lock () no es parte de C.
Bastien Léonard
@Bastien, ningún sistema operativo moderno se ejecutaría en una CPU que no tuviera una instrucción de incremento de memoria :-) Pero su punto es sobre C.
paxdiablo
5
@Bastien: Toro. Los procesadores RISC generalmente no tienen instrucciones de incremento de memoria. El triplete de carga / adición / almacenamiento es la forma de hacerlo en, por ejemplo, PowerPC.
derobert
42

No se puede hacer una declaración general sobre ++ i o i ++. ¿Por qué? Considere incrementar un número entero de 64 bits en un sistema de 32 bits. A menos que la máquina subyacente tenga una instrucción de cuatro palabras "cargar, incrementar, almacenar", incrementar ese valor requerirá múltiples instrucciones, cualquiera de las cuales puede ser interrumpida por un cambio de contexto de hilo.

Además, ++ino siempre "agrega uno al valor". En un lenguaje como C, incrementar un puntero en realidad agrega el tamaño de la cosa apuntada. Es decir, si ies un puntero a una estructura de ++i32 bytes , agrega 32 bytes. Mientras que casi todas las plataformas tienen una instrucción de "valor de incremento en la dirección de memoria" que es atómica, no todas tienen una instrucción atómica de "agregar valor arbitrario al valor en la dirección de memoria".

Jim Mischel
fuente
35
Por supuesto, si no se limita a aburridos enteros de 32 bits, en un lenguaje como C ++, ++ realmente puedo ser una llamada a un servicio web que actualiza un valor en una base de datos.
Eclipse
16

Ambos son subprocesos inseguros.

Una CPU no puede hacer matemáticas directamente con la memoria. Lo hace indirectamente cargando el valor de la memoria y haciendo los cálculos con los registros de la CPU.

yo ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

Para ambos casos, existe una condición de carrera que da como resultado el valor i impredecible.

Por ejemplo, supongamos que hay dos subprocesos ++ i concurrentes y cada uno usa los registros a1, b1 respectivamente. Y, con el cambio de contexto ejecutado como el siguiente:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

Como resultado, i no se convierte en i + 2, se convierte en i + 1, lo cual es incorrecto.

Para remediar esto, las CPU modernas proporcionan algún tipo de BLOQUEO, DESBLOQUEO de instrucciones de la CPU durante el intervalo en que se deshabilita el cambio de contexto.

En Win32, use InterlockedIncrement () para hacer i ++ para la seguridad de subprocesos. Es mucho más rápido que confiar en mutex.

yogman
fuente
6
"Una CPU no puede hacer matemáticas directamente con la memoria": esto no es exacto. Hay CPU-s, donde puede hacer cálculos "directamente" en elementos de memoria, sin la necesidad de cargarlos primero en un registro. P.ej. MC68000
darklon
1
Las instrucciones LOCK y UNLOCK de la CPU no tienen nada que ver con los cambios de contexto. Bloquean las líneas de caché.
David Schwartz
11

Si comparte incluso un int entre subprocesos en un entorno de varios núcleos, necesita las barreras de memoria adecuadas en su lugar. Esto puede significar usar instrucciones entrelazadas (ver InterlockedIncrement en win32 por ejemplo), o usar un lenguaje (o compilador) que ofrezca ciertas garantías de seguridad para subprocesos. Con el reordenamiento de instrucciones a nivel de CPU, las cachés y otros problemas, a menos que tenga esas garantías, no asuma que cualquier cosa compartida entre subprocesos es segura.

Editar: Una cosa que puede asumir con la mayoría de las arquitecturas es que si está tratando con palabras individuales alineadas correctamente, no terminará con una sola palabra que contenga una combinación de dos valores que se combinaron. Si dos escrituras se superponen, una ganará y la otra se descartará. Si tiene cuidado, puede aprovechar esto y ver que ++ i o i ++ son seguros para subprocesos en la situación de un solo escritor / lector múltiple.

Eclipse
fuente
Realmente incorrecto en entornos donde el acceso int (lectura / escritura) es atómico. Hay algoritmos que pueden funcionar en tales entornos, aunque la falta de barreras de memoria puede significar que a veces está trabajando con datos obsoletos.
MSalters
2
Solo digo que la atomicidad no garantiza la seguridad de los subprocesos. Si es lo suficientemente inteligente como para diseñar estructuras de datos o algoritmos sin bloqueos, entonces adelante. Pero aún necesita saber cuáles son las garantías que le dará su compilador.
Eclipse
10

Si desea un incremento atómico en C ++, puede usar bibliotecas C ++ 0x (el std::atomictipo de datos) o algo como TBB.

Hubo un tiempo en que las pautas de codificación GNU decían que actualizar tipos de datos que caben en una palabra era "generalmente seguro", pero ese consejo es incorrecto para máquinas SMP, incorrecto para algunas arquitecturas y incorrecto cuando se usa un compilador optimizador.


Para aclarar el comentario de "actualización del tipo de datos de una palabra":

Es posible que dos CPU en una máquina SMP escriban en la misma ubicación de memoria en el mismo ciclo y luego intenten propagar el cambio a las otras CPU y al caché. Incluso si solo se escribe una palabra de datos, por lo que las escrituras solo toman un ciclo para completarse, también ocurren simultáneamente, por lo que no puede garantizar qué escritura se realiza correctamente. No obtendrá datos parcialmente actualizados, pero una escritura desaparecerá porque no hay otra forma de manejar este caso.

Compare e intercambie correctamente las coordenadas entre varias CPU, pero no hay razón para creer que cada asignación de variable de tipos de datos de una palabra utilizará comparar e intercambiar.

Y aunque un compilador de optimización no afecta cómo se compila una carga / almacenamiento, puede cambiar cuando ocurre la carga / almacenamiento, causando serios problemas si espera que sus lecturas y escrituras ocurran en el mismo orden en que aparecen en el código fuente ( el bloqueo más famoso con doble verificación no funciona en vainilla C ++).

NOTA Mi respuesta original también decía que la arquitectura Intel de 64 bits estaba rota al tratar con datos de 64 bits. Eso no es cierto, así que edité la respuesta, pero mi edición afirmaba que los chips PowerPC estaban rotos. Eso es cierto cuando se leen valores inmediatos (es decir, constantes) en registros (consulte las dos secciones denominadas "Cargando punteros" en la lista 2 y la lista 4). Pero hay una instrucción para cargar datos de la memoria en un ciclo ( lmw), así que eliminé esa parte de mi respuesta.

Max Lybbert
fuente
Las lecturas y escrituras son atómicas en la mayoría de las CPU modernas si sus datos están alineados de forma natural y tienen el tamaño correcto, incluso con SMP y compiladores optimizados. Sin embargo, existen muchas salvedades, especialmente con máquinas de 64 bits, por lo que puede resultar engorroso asegurarse de que los datos cumplan con los requisitos de cada máquina.
Dan Olson
Gracias por actualizar. Correcto, las lecturas y escrituras son atómicas, ya que usted afirma que no pueden estar a medias, pero su comentario destaca cómo abordamos este hecho en la práctica. Lo mismo ocurre con las barreras de memoria, no afectan la naturaleza atómica de la operación, sino cómo la abordamos en la práctica.
Dan Olson
4

Si el lenguaje de programación no dice nada sobre las discusiones, sin embargo, se ejecuta en una plataforma multiproceso, ¿cómo puede cualquier construcción de lenguaje ser segura para subprocesos?

Como otros señalaron: debe proteger cualquier acceso multiproceso a variables mediante llamadas específicas de la plataforma.

Existen bibliotecas que abstraen la especificidad de la plataforma, y ​​el próximo estándar C ++ ha adaptado su modelo de memoria para hacer frente a los subprocesos (y por lo tanto puede garantizar la seguridad de los subprocesos).

xtofl
fuente
4

Incluso si se reduce a una sola instrucción de ensamblaje, incrementando el valor directamente en la memoria, todavía no es seguro para subprocesos.

Al incrementar un valor en la memoria, el hardware realiza una operación de "lectura-modificación-escritura": lee el valor de la memoria, lo incrementa y lo vuelve a escribir en la memoria. El hardware x86 no tiene forma de incrementarse directamente en la memoria; la RAM (y las cachés) solo puede leer y almacenar valores, no modificarlos.

Ahora suponga que tiene dos núcleos separados, ya sea en sockets separados o compartiendo un solo socket (con o sin un caché compartido). El primer procesador lee el valor y, antes de que pueda volver a escribir el valor actualizado, el segundo procesador lo lee. Después de que ambos procesadores vuelvan a escribir el valor, se habrá incrementado solo una vez, no dos.

Hay una forma de evitar este problema; Los procesadores x86 (y la mayoría de los procesadores multinúcleo que encontrará) son capaces de detectar este tipo de conflicto en el hardware y secuenciarlo, de modo que toda la secuencia de lectura-modificación-escritura parezca atómica. Sin embargo, dado que esto es muy costoso, solo se hace cuando lo solicita el código, en x86 generalmente a través del LOCKprefijo. Otras arquitecturas pueden hacer esto de otras formas, con resultados similares; por ejemplo, comparación e intercambio atómicos / condicional de almacenamiento / ligado a carga (los procesadores x86 recientes también tienen este último).

Tenga en cuenta que el uso volatileno ayuda aquí; solo le dice al compilador que la variable podría haber sido modificada externamente y las lecturas de esa variable no deben almacenarse en caché en un registro ni optimizarse. No hace que el compilador utilice primitivas atómicas.

La mejor manera es usar primitivas atómicas (si su compilador o bibliotecas las tienen), o hacer el incremento directamente en ensamblador (usando las instrucciones atómicas correctas).

CesarB
fuente
2

Nunca asuma que un incremento se compilará en una operación atómica. Utilice InterlockedIncrement o cualquier función similar que exista en su plataforma de destino.

Editar: Acabo de buscar esta pregunta específica y el incremento en X86 es atómico en sistemas de un solo procesador, pero no en sistemas multiprocesador. Usar el prefijo de bloqueo puede hacerlo atómico, pero es mucho más portátil solo para usar InterlockedIncrement.

Dan Olson
fuente
1
InterlockedIncrement () es una función de Windows; todas mis máquinas Linux y modernas máquinas OS X están basadas en x64, por lo que decir InterlockedIncrement () es "mucho más portátil" que el código x86 es bastante falso.
Pete Kirkham
Es mucho más portátil en el mismo sentido que C es mucho más portátil que el ensamblaje. El objetivo aquí es aislarse de depender de un ensamblaje generado específico para un procesador específico. Si le preocupan otros sistemas operativos, InterlockedIncrement se ajusta fácilmente.
Dan Olson
2

De acuerdo con esta lección de ensamblaje sobre x86, puede agregar atómicamente un registro a una ubicación de memoria , por lo que potencialmente su código puede ejecutar atómicamente '++ i' o 'i ++'. Pero como se dijo en otra publicación, C ansi no aplica atomicidad a la operación '++', por lo que no puede estar seguro de lo que generará su compilador.

Selso Liberado
fuente
1

El estándar C ++ de 1998 no tiene nada que decir acerca de los subprocesos, aunque el siguiente estándar (que vence este año o el próximo) sí. Por lo tanto, no se puede decir nada inteligente sobre la seguridad de las operaciones de subprocesos sin hacer referencia a la implementación. No solo se utiliza el procesador, sino la combinación del compilador, el sistema operativo y el modelo de subproceso.

En ausencia de documentación que indique lo contrario, no asumiría que ninguna acción es segura para subprocesos, particularmente con procesadores de múltiples núcleos (o sistemas de múltiples procesadores). Tampoco confiaría en las pruebas, ya que es probable que los problemas de sincronización de subprocesos surjan solo por accidente.

Nada es seguro para subprocesos a menos que tenga documentación que indique que es para el sistema particular que está utilizando.

David Thornley
fuente
1

Lanzar i en el almacenamiento local de subprocesos; no es atómico, pero luego no importa.


fuente
1

AFAIK, según el estándar C ++, las lecturas / escrituras en un intson atómicas.

Sin embargo, todo lo que esto hace es deshacerse del comportamiento indefinido asociado con una carrera de datos.

Pero aún habrá una carrera de datos si ambos hilos intentan incrementar i.

Imagina el siguiente escenario:

Deje i = 0inicialmente:

El subproceso A lee el valor de la memoria y lo almacena en su propia caché. El hilo A incrementa el valor en 1.

El subproceso B lee el valor de la memoria y lo almacena en su propia caché. El hilo B incrementa el valor en 1.

Si todo esto es un solo hilo, obtendrás i = 2en la memoria.

Pero con ambos subprocesos, cada subproceso escribe sus cambios y, por lo tanto, el subproceso A escribe de i = 1nuevo en la memoria y el subproceso B escribe i = 1en la memoria.

Está bien definido, no hay destrucción o construcción parcial ni ningún tipo de desgarro de un objeto, pero sigue siendo una carrera de datos.

Para incrementar atómicamente ipuedes usar:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

Se puede usar un orden relajado porque no nos importa dónde se lleva a cabo esta operación, lo único que nos importa es que la operación de incremento sea atómica.

Moshe Rabaev
fuente
0

Dice "es sólo una instrucción, sería ininterrumpida mediante un cambio de contexto". - eso está muy bien para una sola CPU, pero ¿qué pasa con una CPU de doble núcleo? Entonces realmente puede tener dos subprocesos accediendo a la misma variable al mismo tiempo sin ningún cambio de contexto.

Sin conocer el idioma, la respuesta es ponerlo a prueba.

Chris
fuente
4
No averigua si algo es seguro para subprocesos al probarlo; los problemas de subprocesos pueden ocurrir una en un millón. Búscalo en tu documentación. Si su documentación no garantiza que sea seguro para subprocesos, no lo es.
Eclipse
2
De acuerdo con @Josh aquí. Algo solo es seguro para subprocesos si se puede probar matemáticamente mediante un análisis del código subyacente. Ninguna cantidad de pruebas puede comenzar a acercarse a eso.
Rex M
Fue una gran respuesta hasta la última frase.
Rob K
0

Creo que si la expresión "i ++" es la única en una declaración, es equivalente a "++ i", el compilador es lo suficientemente inteligente como para no mantener un valor temporal, etc. Entonces, si puede usarlos indistintamente (de lo contrario, ganó no preguntes cuál usar), no importa el que uses, ya que son casi iguales (excepto por la estética).

De todos modos, incluso si el operador de incremento es atómico, eso no garantiza que el resto del cálculo sea consistente si no usa los bloqueos correctos.

Si desea experimentar por sí mismo, escriba un programa en el que N subprocesos incrementen simultáneamente una variable compartida M veces cada uno ... si el valor es menor que N * M, entonces se sobrescribió algún incremento. Pruébalo tanto con preincremento como con postincremento y cuéntanos ;-)

fortran
fuente
0

Para un contador, recomiendo usar el idioma de comparar e intercambiar, que es tanto sin bloqueo como seguro para subprocesos.

Aquí está en Java:

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}
AtariPete
fuente
Parece similar a una función test_and_set.
Samoz
1
Escribiste "sin bloqueo", pero ¿no significa "sincronizado" bloquear?
Corey Trager