En la programación en C, especialmente en la programación de sistemas integrados, ¿por qué el desarrollador necesita una operación atómica cuando se usa?

La respuesta a esto para C y C ++ es equivalente, por cierto: para evitar condiciones de carrera (a menudo llamadas “carreras de datos”).

Tenga en cuenta que la “programación del sistema integrado” es algo ortogonal a esto: lo que importa es la concurrencia / paralelismo. Sin embargo, muchos sistemas embebidos más pequeños no implican nada de eso y, para ellos, las condiciones de carrera o el uso de tipos atómicos simplemente no son un problema.

Veamos esto con cierto detalle, comenzando con el código para agregar en un int simple almacenado en la memoria:

void add_one (int * p) {
* p + = 1;
}

Compilaré esto con gcc para ARM64 con optimizaciones habilitadas y miraré la salida del ensamblaje. Puedes probar esto usando el explorador de compiladores de Matt Godbolt. La parte relevante se ve así:

Agrega uno:
ldr w1, [x0]
agregar w1, w1, 1
str w1, [x0]
jubilado

(Uso ARM como objetivo porque el problema es más claramente visible en el código de ensamblaje. El mismo problema existe en x86–64, pero tendría que profundizar en los detalles de la addl porque realiza la operación con un solo addl instrucción que es bastante opaca.)

Como puede ver, la adición se produce en tres pasos: (1) cargue los datos de la memoria en un registro, (2) incremente el registro y (3) almacene el registro nuevamente en la memoria.

Eso está perfectamente bien … hasta que decida ejecutar múltiples procesos simultáneamente con acceso a los mismos datos. Supongamos que en un punto el proceso A y B ejecutan el código anterior para el mismo valor de p que apunta a un int de valor 40 , con el proceso A solo una instrucción por delante del proceso B:

  1. El proceso A carga el valor 40 en su registro
  2. El proceso B carga el valor 40 en su registro
  3. Al mismo tiempo, el proceso A actualiza su registro a 41
  4. El proceso B actualiza su registro a 41
  5. Al mismo tiempo, el proceso A guarda 41 en *p
  6. El proceso B guarda 41 en *p

Es decir, aunque hubo dos llamadas a add_one para un *p que estaba en 40, el resultado final es *p solo 41 (en lugar del 42 “correcto”). Esta es una “condición de carrera” típica, y puede abordarse con tipos atómicos.

Las condiciones de carrera pueden ocurrir con múltiples subprocesos que se ejecutan en un solo procesador y el sistema operativo puede suspender un proceso en cualquier momento y transferir el control a otro, o, más radicalmente, con múltiples procesos que se ejecutan en paralelo en múltiples núcleos. Ambos escenarios arrojan una llave inglesa en el código anterior, pero el último puede ser aún más complicado.

Ahora, déjame cambiar el código anterior para usar un tipo atómico. En C11 eso da:

vacío add_one (_Atomic int * p) {
* p + = 1;
}

o lo mismo en C ++:

void add_one (std :: atomic * p) {
* p + = 1;
}

El código generado es el mismo para C y C ++, pero diferente del original:

add_one (std :: atomic *):
.L2:
ldaxr w1, [x0]
agregar w1, w1, 1
stlxr w2, w1, [x0]
cbnz w2, .L2
jubilado

ARM utiliza un enfoque denominado “cargar-adquirir / almacenar-liberar” para las actualizaciones atómicas: la instrucción ldaxr no solo carga el contenido de la dirección x0 (es decir, *p ) en el registro w1 , sino que también configura un “monitor” en la memoria que contiene *p : Si algo intenta modificar esa memoria, el “monitor” levantará una bandera. La instrucción stlxr intenta almacenar el valor de w1 en *p (es decir, [x0] ), pero también consulta el monitor configurado con la instrucción ldaxr y si se ldaxr su ldaxr , se ldaxr el almacenamiento real y w2 será establecido en 1. Si la tienda tiene éxito, w2 se establece en cero. De cualquier manera, se libera el monitor. cbnz es “rama condicional si no es cero”: por lo tanto, si la instrucción stlxr cancela, se reinicia toda la secuencia.

Si trabaja a través de esa secuencia, se dará cuenta de que el código anterior garantiza que la “carga” y la “tienda” parecen ocurrir atómicamente. En este caso, la instrucción stlxr también es en realidad una barrera de memoria , que garantiza que las operaciones atómicas en diferentes ubicaciones de memoria son secuencialmente consistentes, pero eso está un poco más allá de esta respuesta (una instrucción stxr más relajada es un poco más eficiente si la atomicidad local es todo necesita; hay formas de generarlo usando C ++ estándar a través de las opciones de “orden de memoria” en el encabezado ). También es interesante notar que el “monitor” mencionado anteriormente no es súper preciso: los almacenes en la memoria que está cerca de *p también harán que stlxr falle. Tales fallas espurias no afectan la corrección, pero disminuyen el rendimiento.

En x86–64 encontrará un enfoque diferente para el problema (usando modificadores de LOCK ), pero bajo el capó (es decir, a nivel de hardware) están involucradas técnicas algo similares.

La operación atómica, según tengo entendido, significa una secuencia de instrucciones que no se pueden interrumpir por ningún motivo. Esto generalmente se debe a que está llevando a cabo una operación de tiempo crítico que se debe completar de manera absolutamente positiva dentro de un cierto número de ciclos de la máquina. Los ejemplos serían cambiar el estado del temporizador de vigilancia, que en muchos procesadores requiere que establezca un bit de “cambio” y luego haga otra escritura dentro de un tiempo absurdamente corto, como cuatro ciclos de instrucción. Otra razón para usarlo podría ser leer una variable de varios bytes que puede ser modificada por una rutina de interrupción, como quizás un valor de ADC. No serviría recuperar el primer byte, luego ser interrumpido y ambos bytes cambiados, luego reanudar y recoger el segundo byte (ahora cambiado) como si nada hubiera pasado.

Las operaciones atómicas son bastante comunes en muchos lenguajes de programación. Se usan comúnmente para evitar puntos muertos mientras se ejecutan subprocesos. Te daré un ejemplo:

Digamos que tiene las variables A y B que, en este escenario, son dos cuentas de transacciones. Ahora ejecutaré dos hilos para simular múltiples transacciones. En mi primer hilo tengo transferencia (A, B, $ 300) y en mi segundo hilo tengo transferencia (B, A, $ 300). Dado que los subprocesos ejecutan operaciones simultáneamente, ahora tengo la cuenta A y la cuenta B simultáneamente tratando de transferir fondos entre sí (tomando los recursos de los demás). Voila, ahora tenemos un punto muerto.

Tal vez podamos encontrar una manera de que uno de ellos espere hasta que el otro esté completo. Por lo tanto, ahora crearemos una “sección crítica” de código que manejará las operaciones atómicas para nuestras transacciones. Para hacer esto, podemos “bloquear” ciertos recursos para que una operación sepa que estos recursos se están utilizando, y la operación debe esperar hasta que el recurso esté “desbloqueado” y esté disponible para su uso.

Espero que esto haya ayudado 🙂

La razón del nivel más bajo es obtener datos consistentes en los manejadores de interrupciones.

Los sistemas integrados (piense en los microcontroladores AVR que puede programar en C o ensamblado) generalmente tienen un núcleo único y un flujo de código único, pero hay interrupciones en las que se interrumpe el flujo del código principal y se maneja el evento entrante. Es muy similar en sistemas operativos más abstractos con manejo de señal.

La forma en que funcionan las CPU es que tienen registros (en algunos casos muy pocos) y están desacoplados de la RAM. Por otro lado, las variables son en realidad datos en RAM. La forma en que funciona el procesamiento de datos es:

  1. La CPU lee los datos de la RAM y los almacena temporalmente en uno de sus registros.
  2. La CPU realiza cambios en sus registros.
  3. La CPU almacena los datos modificados nuevamente en la RAM.

Cuando ocurre una interrupción, generalmente el primer paso obligatorio en el controlador de interrupciones es guardar el estado de la CPU, también conocido como sus registros. En conjunto, este es el código de operación PUSH. El último paso antes de volver al flujo del código principal es restaurar el estado de la CPU previamente guardado, también conocido como el código de operación POP en el ensamblaje. Esto debe hacerse para que los datos en el flujo del código principal no estén dañados.

Ahora, sobre operaciones atómicas. La forma en que interrumpe el trabajo es que no pueden interrumpir un código de operación de CPU actualmente en ejecución, todos ellos son realmente atómicos a este respecto. En cambio, la CPU registra el evento de interrupción y cuando la CPU terminó de trabajar con el código de operación que estaba ejecutando, llama al controlador de interrupción. El problema es que el paso 2 anterior puede ser un cálculo bastante complejo. Ahora, existen algunos códigos de operación de CPU (limitados) que realizan los 3 pasos anteriores de una sola vez. Se pueden usar para cambios muy simples, como aumentar / disminuir una variable en uno. Dichas variables se pueden usar para la sincronización (por ejemplo, un semáforo) entre el código principal y el controlador de interrupciones.

En C, está la palabra clave “volátil” que le dice al compilador que la variable se puede modificar desde múltiples ubicaciones de código (el código principal y el controlador de interrupciones, o más genéricamente: algunos controladores o subprocesos de señal / evento) y el compilador eliminará el almacenamiento en caché la variable en sus registros, es decir, lee y almacena cada estado e intenta utilizar operaciones atómicas para ello cuando sea posible.

En sistemas operativos abstractos (no tan reducidos al hardware) con subprocesos o procesos múltiples que trabajan con datos compartidos en RAM, los cálculos más complejos también pueden ser atómicos, pero es necesario bloquearlos para lograrlo, con mutexes, semáforos SysV o POSIX.

El problema más básico que requiere operaciones atómicas es una operación de lectura-modificación-escritura.

Digamos que desea incrementar el valor de una ubicación de memoria. Lees el valor en un registro. Entonces lo incrementas. Luego sobrescribe la ubicación de la memoria con el nuevo valor. Todo está bien. Si el valor era X, ahora es X + 1.

Pero ahora, imagínese si justo después de leer el valor en el registro, aparece una interrupción de alta prioridad u otro hilo y se hace cargo. También quiere incrementar ese valor. Entonces el valor en memoria ahora es X + 1. Entonces la interrupción termina. Ya ha leído el valor X en su registro, por lo que lo incrementa y escribe X + 1 en la memoria. Por lo tanto, el incremento que hizo la interrupción de mayor prioridad se pierde: lo sobrescribió. Debería haber sido X + 2, ya que tanto usted como el proceso de interrupción lo incrementaron una vez, por lo que obtiene una respuesta incorrecta.

Si la operación hubiera sido atómica, no habría sido interrumpido hasta que su X + 1 fuera escrito en la memoria. Por lo tanto, ambos incrementos habrían sucedido y obtendrías la respuesta correcta: X + 2.

Gracias por su A2A, DAVID Li. Lo siento, no pude hacer un enlace de referencia adecuado a su nombre.

Supongo que quiere decir “cuándo se usa” en su pregunta.

Las operaciones atómicas se utilizan cuando el flujo de su programa puede ser precedido por otro hilo (cambio de contexto) o interrumpido por una señal o algo. El problema es que su programa puede verse interrumpido mientras carga datos en los registros del procesador o guarda datos de los registros en la memoria, básicamente cuando está actualizando datos de alguna manera. Si el cambio de contexto o la señal se produce en medio de esta operación, solo una parte de los datos es correcta, la otra parte de los datos es incorrecta.

A menudo, esto no es un problema. Solo es un problema si su señal va a usar los datos, o si otros hilos usan los datos. Si ningún otro subproceso va a utilizar los datos, no importa si es correcto o no, porque su función continuará cargando o guardando el resto de los datos cuando reanude la ejecución.

Si los datos que está manipulando en un subproceso se usarán en otro subproceso (es decir, en otro “contexto”), entonces debe tomar medidas para garantizar la atomicidad de los datos. Debe asegurarse de que los datos sean correctos cuando sea necesario.

La atomicidad en C puede ser un poco desalentadora a veces. La mayoría de las personas parecen encontrar tuberías y colas como la mejor solución para garantizar la atomicidad de los datos. Si su única preocupación es la de las señales, puede enmascararlas temporalmente para atomizar la operación.

Espero que esto ayude. Cuídate.

En la programación en C, especialmente en la programación de sistemas integrados, ¿por qué el desarrollador necesita una operación atómica cuando se usa?

Lo necesitará para adquirir recursos como un trozo de memoria o un dispositivo. Para proclamar su propiedad, solicitará un semáforo y luego lo publicará. Para evitar que la memoria o el dispositivo sean propiedad dos veces, la operación de solicitud debe ser atómica (indivisible). De lo contrario, un proceso podría estar ocupado adquiriendo un semáforo y otro se rompería a la mitad y también adquiriría lo mismo.

Es como vender una casa a dos personas. La firma del contrato también debe ser atómica, para evitar que dos partes lo intercalen, lo que resulta en dos propietarios. El contrato físico (papel) es indivisible. Si hubiera dos copias, las cosas podrían salir mal.

Jacques de Hooge

Transcrypt – Python en el navegador – ¡Lean, rápido, abierto!

Esta respuesta debería ser bastante obvia, dada la naturaleza misma de la operación.

Operaciones atómicas versus no atómicas

En esencia, es para proteger la corrupción de lectura si otro hilo escribe en el mismo espacio de datos.