SkillGridAprende informática practicando

Concurrencia: cuando dos hilos comparten datos

Condiciones de carrera en un contador real, lo que cuesta de verdad un cerrojo, operaciones atomicas y por que la frase en mi maquina funciono no demuestra nada.

17 min · 55 XP

En el primer grado un hilo era una unidad que el planificador puede ejecutar, y un proceso la caja propietaria del espacio de direcciones. Ahora esa distincion empieza a doler. Dos hilos dentro de un proceso comparten los mismos datos: el mismo monton, las mismas variables globales, el mismo contador. Nada en el hardware les impide tocarlo en el mismo instante, y en cuanto lo hacen, la aritmetica que escribiste deja de ser la aritmetica que ejecuta la maquina.

Empieza por el ejemplo mas pequeno posible. Dos hilos, cada uno incrementa un contador compartido un millon de veces. Cualquier programador te dira que la respuesta es 2000000. Ejecutalo y lee el numero.

/* counter.c */
#include <pthread.h>
#include <stdio.h>

static long counter = 0;

static void *worker(void *arg) {
  (void)arg;
  for (long i = 0; i < 1000000; i++) {
    counter = counter + 1;
  }
  return NULL;
}

int main(void) {
  pthread_t a, b;
  pthread_create(&a, NULL, worker, NULL);
  pthread_create(&b, NULL, worker, NULL);
  pthread_join(a, NULL);
  pthread_join(b, NULL);
  printf("%ld\n", counter);
  return 0;
}

/* $ gcc -O2 -pthread counter.c -o counter && ./counter
 * 2000000   <- never guaranteed
 * 1431927   <- typical instead
 */Dos hilos, un contador compartido, ningun cerrojo. Se compila con gcc -O2 -pthread. El valor impreso cambia de una ejecucion a otra.

El cuerpo del bucle parece una accion pero son tres: leer el contador en un registro, sumar uno, escribirlo de vuelta. Un tic del planificador puede caer entre dos cualesquiera de ellas, y con dos nucleos los hilos ni siquiera se turnan. Aqui esta la secuencia mas corta en la que se pierde un incremento.

counter = 0

T1  r1 <- counter     r1 = 3417
T2  r2 <- counter     r2 = 3417
T1  r1 <- r1 + 1      r1 = 3418
T2  r2 <- r2 + 1      r2 = 3418
T1  counter <- r1     counter = 3418
T2  counter <- r2     counter = 3418

T1 done.  T2 done.  counter = 3418

Los dos hilos leen 3417, los dos suman uno, los dos escriben 3418. Hubo dos incrementos y el contador avanzo uno. Eso es una condicion de carrera: el resultado depende del orden que eligio el planificador por casualidad, no del programa. En una ejecucion real del programa de arriba salen unos 1,4 millones en lugar de 2 millones, y un numero distinto cada vez. El fallo no es raro ni es ruido aleatorio. Es la consecuencia inevitable de un entrelazado que no controlaste.

La misma perdida ocurre en todos los sitios donde aparece el patron leer - modificar - escribir: un saldo bancario, un contador de referencias, un elemento anadido a una lista, un contador de conexiones. Lo importante no es el operador. Lo importante es si el valor se lee y luego se reescribe en dos pasos separados por los que se puede colar otro hilo.

Thread 1lockcounterThread 2acquiregrantedload 3417, add 1, store 3418acquireblocked, thread is descheduledreleasegrantedload 3418, add 1, store 3419

Para un solo contador hay una respuesta mas ligera. Los procesadores tienen instrucciones atomicas de leer-modificar-escribir (lock xadd en x86, ldrex y strex en ARM), y el compilador las expone como operaciones atomicas. Sin objeto de cerrojo, sin hilo esperando, sin seccion critica aparte.

atomic_long counter = 0;

void *worker(void *arg) {
  (void)arg;
  for (long i = 0; i < 1000000; i++) {
    atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
  }
  return NULL;
}

/* lock xadd   -> uncontended, a few nanoseconds
 * contended   -> the cache line is ping-ponged between cores
 */

Lo que cuesta un cerrojo depende por completo de la contencion. Sin contencion, una suma atomica son unos nanosegundos y tomar y soltar un mutex unas decenas de nanosegundos: una sola instruccion y dos barreras. El problema empieza cuando un segundo hilo quiere el mismo cerrojo. Entonces el sistema operativo tiene que aparcar ese hilo, lo que significa entrar en el kernel, un cambio de contexto y mas tarde un despertar. Eso son microsegundos, no nanosegundos: dos o tres ordenes de magnitud mas que el trabajo protegido. Si la seccion critica son diez instrucciones y cuatro hilos se pelean por el cerrojo, el cerrojo pasa casi todo el tiempo moviendo hilos entre nucleos, y el programa va mas lento segun anades nucleos. Por eso un contador que crece es la clasica falsa optimizacion: la version atomica escala hasta que la linea de cache empieza a rebotar entre nucleos, y entonces tambien deja de escalar.

Ahora la parte incomoda: por que paso tu test el programa del contador? Una carrera necesita un entrelazado concreto, y los entrelazados no son igual de probables. En un nucleo libre los dos hilos corren tramos largos seguidos y la ventana mala se alcanza quizas una vez por millon de iteraciones. En una maquina cargada, o con un depurador enganchado, o en produccion bajo carga, esa ventana se alcanza continuamente. Asi que el test pasa en el portatil y el incidente ocurre en un servidor de 32 nucleos. Los fallos de concurrencia dependen del tiempo, y por eso la ausencia de un fallo no es prueba de correccion. Peor aun: un print anadido para investigar puede cambiar el tiempo lo suficiente como para esconder el fallo del todo.

El resumen honesto es que los humanos somos malos en esto, y la razon es concreta. Razonar sobre un programa secuencial es seguir un camino. Razonar sobre dos hilos es razonar sobre el conjunto de todos los entrelazados, y su numero crece de forma combinatoria con la longitud del codigo. Nadie tiene ese conjunto en la cabeza. Por eso la respuesta practica no es tener cuidado, sino reducir la superficie: manten el estado mutable compartido lo mas pequeno posible, prefiere datos inmutables entre hilos, sustituye la memoria compartida por una cola de mensajes cuando puedas y toma un cerrojo solo alrededor de las pocas instrucciones que de verdad lo necesitan. Donde la invariante no se pueda hacer obvia, hay que escribirla y comprobarla con herramientas hechas para eso - sanitizadores, verificadores de modelos y pruebas de estres que varian el tiempo a proposito -, no con tests unitarios normales.

Práctica

  1. El programa del contador pierde incrementos. En que punto del cuerpo del bucle puede hacer dano un segundo hilo?

    Pistas
    • Cuenta las instrucciones de maquina que emite el compilador para counter = counter + 1 y preguntate cuantas tienen que ir juntas.
    • El problema no es la instruccion que suma, sino el par de instrucciones a su alrededor que mueven el valor entre la memoria y el procesador.
  2. Empareja cada arreglo con el fallo que realmente elimina.

    Pistas
    • Una respuesta elimina la variable compartida por completo, otra solo cierra la ventana entre la lectura y la escritura.
    • Proteger la funcion entera es la herramienta mas pesada de la lista, y un valor visto a medias es un sintoma distinto de dos incrementos que se funden en uno.
  3. El contador vale 3417 y en cada hilo se ejecuta la sentencia counter = counter + 1 del caso de fallo. Escribe la traza paso a paso que deja el contador en 3418, reutilizando los mismos dos registros r1 y r2.

    El código se ejecuta en tu equipo, no en el nuestro: no lo ejecutamos. Comprueba tu solución con cada requisito y marca los cumplidos.

    Pistas
    • Escribe las seis sentencias en el orden en que los dos hilos tuvieron que ejecutarlas, no en el orden en que aparecen en el codigo fuente.
    • Los dos hilos deben leer el valor viejo antes de que ninguno escriba, si no el contador sale bien y has descrito otro entrelazado.

Preguntas sobre esta lección

¿Qué es realmente un sistema operativo?

Es el gestor entre tus programas y el hardware. Decide quién recibe el procesador, reparte memoria y mantiene los archivos en orden. Todo programa que escribas funciona encima de él.

¿Por qué aprender esto si solo quiero escribir código?

Porque cuando un programa va lento, se cuelga o se queda sin memoria, la causa está casi siempre aquí. Sin esto solo puedes adivinar; con esto puedes medir.

¿Es gratis?

Sí. El primer nivel de cada materia es gratis y no necesita cuenta. Los niveles más profundos y los grados superiores son de pago: eso es lo que mantiene vivo el proyecto.

¿Para qué edad es?

Está escrito para adolescentes curiosos a partir de unos 12 años y para adultos que empiezan de cero. No se supone experiencia previa en programación; las lecciones de redes y física no necesitan matemáticas más allá del colegio.

¿Necesito saber programar ya?

No. Las rutas de lógica, física y redes empiezan desde cero. El código lo escribes en la sección de lenguajes, que también arranca desde el principio.

¿Hay que instalar algo?

No. Todo funciona en el navegador: teoría, práctica y las simulaciones interactivas. No hay nada que descargar ni que configurar.

¿Cuánto se tarda en una materia?

Una lección dura entre 10 y 15 minutos. Un grado junior son unas 12 a 20 lecciones, así que son unas semanas de práctica constante, no una tarde.

¿Hay que seguir el orden?

Dentro de un grado los niveles se abren uno tras otro, porque cada uno se apoya en el anterior. Entre materias eres libre: empieza por lógica, física o redes.