Apuntes · Capítulo 9Concurrencia

Máquinas virtuales e hilos

Máquinas virtuales e hilos

Durante ocho semanas vimos cómo el sistema operativo crea ilusiones: cada proceso cree tener una CPU y una memoria privadas. Esta semana cierra la parte de virtualización y abre la de concurrencia, con dos preguntas que van en sentidos opuestos.

La primera: ¿se puede hacer el mismo truco un nivel más abajo y darle a un sistema operativo entero la ilusión de tener la máquina para él? Eso hace un monitor de máquinas virtuales, la base de la nube y del entorno del curso. La segunda: ¿qué pasa si, dentro de un proceso, queremos varios flujos de ejecución que compartan la memoria? Esos son los hilos (threads), y con ellos aparece el problema central de la segunda parte del curso: las condiciones de carrera (race conditions).

Ruta de lectura.

  • Antes de clase (15–20 min): «Un sistema operativo debajo del sistema operativo» e «Hilos».
  • En clase: sesión A, de «Virtualizar la CPU: trap-and-emulate» a «Contenedores: aislar sin un segundo kernel» (si esta sesión pasa a lectura autónoma, se lee completa); sesión B, «La API de hilos», «Condiciones de carrera», «Exclusión mutua con un mutex» y «Esperar: variables de condición».
  • Después de clase: repasar el «Resumen», leer «Concurrencia en xv6» y resolver los «Ejercicios». Para ampliar: el apéndice Virtual Machine Monitors y los cap. 25 a 27 de OSTEP, y «Popek y Goldberg, y el problema de x86».

Lectura. Este capítulo acompaña al diálogo y al apéndice Virtual Machine Monitors de OSTEP (sesión A) y a los cap. 25–27 (Dialogue on Concurrency, Concurrency: An Introduction e Interlude: Thread API, sesión B). El código está en code/week-09 y se compila con make dentro del contenedor del curso.

Un sistema operativo debajo del sistema operativo

Un monitor de máquinas virtuales (virtual machine monitor, VMM), también llamado hipervisor (hypervisor), controla el hardware y lo reparte entre varios sistemas operativos invitados (guests). Cada invitado cree tener sus propias CPU, su memoria física y sus discos, y corre sin modificaciones: la meta es la transparencia. El VMM es un sistema operativo para sistemas operativos.

La idea es de IBM (VM/370, años 70) y volvió a fines de los 90 con Disco y VMware. Sirve para consolidar servidores, aislar sistemas, probar software y, sobre todo, para la nube: alquilar “una computadora” casi siempre es alquilar una VM. Los hipervisores de tipo 1 corren sobre el hardware (Xen, VMware ESXi, Hyper-V, Linux con KVM); los de tipo 2 son un programa sobre un OS anfitrión (host), como VirtualBox o QEMU.

Un operador con bata blanca sentado ante la consola de un mainframe IBM System/370, un panel negro con filas de luces, perillas e interruptores; al fondo, una hilera de gabinetes de la máquina
La consola de un IBM System/370 modelo 145, la familia de mainframes sobre la que corría VM/370. Foto: autor desconocido, CC0, Wikimedia Commons.

Virtualizar la CPU: trap-and-emulate

El invitado corre con la técnica de la semana 2, la ejecución directa limitada: el VMM salta a su primera instrucción y lo deja correr en la CPU. Pero el kernel invitado no puede correr en modo kernel (kernel mode): podría cambiar la tabla de páginas (page table) real o leer el disco de otra VM. Corre desprivilegiado, en modo usuario (user mode) de verdad, aunque crea estar en modo kernel; el VMM recuerda, por cada CPU virtual (vCPU), en qué modo cree estar.

Cuando el kernel invitado ejecuta una instrucción privilegiada, el hardware hace un trap, como con cualquier programa de usuario. El trap llega al VMM, que instaló el vector de traps real. El VMM hace en software lo que esa instrucción habría hecho en la máquina virtual, avanza el PC del invitado y retorna: trap-and-emulate. Cambiar de VM (machine switch) es como un cambio de contexto (context switch), pero guardando también el estado privilegiado de la vCPU: satp, stvec, sstatus…

Tres máquinas virtuales, con Linux, Windows y xv6 como kernels invitados y dos aplicaciones cada una, sobre el VMM y el hardware. Los kernels invitados y sus aplicaciones están en la banda de modo usuario real; solo el VMM está en modo kernel real. Una flecha baja del kernel de xv6 al VMM (csrw satp provoca un trap) y otra sube (el VMM emula y retorna)Tres máquinas virtuales, con Linux, Windows y xv6 como kernels invitados y dos aplicaciones cada una, sobre el VMM y el hardware. Los kernels invitados y sus aplicaciones están en la banda de modo usuario real; solo el VMM está en modo kernel real. Una flecha baja del kernel de xv6 al VMM (csrw satp provoca un trap) y otra sube (el VMM emula y retorna)
Solo el VMM corre en modo kernel. Cuando un kernel invitado ejecuta una instrucción privilegiada, el hardware hace un trap al VMM, que la emula sobre el estado virtual y retorna.

Sigamos un read() de un proceso invitado:

Paso Quién Qué pasa
1 Proceso invitado Ejecuta ecall, como siempre
2 Hardware → VMM Trap real: el VMM recibe el control
3 VMM Salta al manejador de traps del invitado, con la vCPU en “modo S virtual”
4 Kernel invitado Atiende read() y ejecuta sret
5 Hardware → VMM sret es privilegiada y el invitado está en modo usuario: otro trap
6 VMM Hace el retorno real al proceso

El VMM conoce ese manejador porque anotó la dirección cuando el invitado, al arrancar, intentó escribir stvec. Cada llamada al sistema (system call) cuesta dos traps extra.

En xv6. Cada hart ejecuta al arrancar kvminithart() en kernel/vm.c, que llama a w_satp(MAKE_SATP(kernel_pagetable)) entre dos sfence_vma(). En kernel/riscv.h, w_satp es una sola instrucción: csrw satp, %0. Si xv6 fuera un invitado en un VMM clásico, esa instrucción provocaría una excepción de instrucción ilegal; el VMM guardaría el valor en el satp virtual, prepararía la traducción real y retornaría a la instrucción siguiente.

Popek y Goldberg, y el problema de x86

Una instrucción es privilegiada si hace trap en modo usuario, y sensible si lee o cambia el estado de la máquina: el modo, la tabla de páginas, las interrupciones (interrupts). Popek y Goldberg (1974) mostraron que trap-and-emulate funciona si toda instrucción sensible es privilegiada; si no, el VMM no se entera de algunas.

RISC-V, MIPS e IBM 370 cumplen la condición; el x86 de 32 bits no. El ejemplo clásico es POPF: en modo usuario ignora en silencio el bit que habilita las interrupciones, sin trap. Hubo tres salidas: traducción binaria (VMware, 1999), que reescribe el código del kernel invitado antes de ejecutarlo; paravirtualización (Xen, 2003); y soporte de hardware (VT-x y AMD-V, 2005–2006), un modo de la CPU para el invitado.

Virtualizar la memoria

Con una VM hay tres tipos de dirección: la virtual del invitado (gVA), que traduce la tabla de páginas del kernel invitado; la física del invitado (GPA), la “memoria física” que él cree ver; y la física de la máquina (HPA), la RAM real. El VMM reparte la RAM entre las VMs y lleva, por VM, una tabla GPA → HPA.

Un proceso invitado usa las páginas virtuales 1, 2 y 5:

gVA 1 2 5
GPA (tabla del invitado) 4 7 2
HPA (tabla del VMM, aplicada a esa GPA) 3 11 9

La página 1 está, para el invitado, en la página física 4; y la página física 4 del invitado es, en realidad, el marco de página (page frame) 3 de la máquina. Pero la MMU sabe hacer una traducción. ¿Cómo llega de gVA a HPA?

Tablas de páginas sombra

La solución por software: el VMM combina las dos tablas en una tercera, la tabla de páginas sombra (shadow page table), que va directo de gVA a HPA: 1 → 3, 2 → 11, 5 → 9. El satp real apunta a la sombra; el que lee y escribe el invitado es virtual. Para que la sombra no quede vieja, el VMM marca como solo lectura las páginas con la tabla del invitado: cuando este modifica una PTE, hay un fallo que atiende el VMM, que actualiza ambas tablas.

Tres tablas lado a lado. La del invitado traduce gVA a GPA (1 a 4, 2 a 7, 5 a 2) y está marcada como solo lectura; la del VMM traduce GPA a HPA (2 a 9, 4 a 3, 7 a 11); la sombra traduce gVA a HPA (1 a 3, 2 a 11, 5 a 9). Flechas doradas siguen la página 1: 1 a 4 en el invitado, 4 a 3 en el VMM, 1 a 3 en la sombra. El satp virtual apunta a la tabla del invitado y el satp real, a la sombraTres tablas lado a lado. La del invitado traduce gVA a GPA (1 a 4, 2 a 7, 5 a 2) y está marcada como solo lectura; la del VMM traduce GPA a HPA (2 a 9, 4 a 3, 7 a 11); la sombra traduce gVA a HPA (1 a 3, 2 a 11, 5 a 9). Flechas doradas siguen la página 1: 1 a 4 en el invitado, 4 a 3 en el VMM, 1 a 3 en la sombra. El satp virtual apunta a la tabla del invitado y el satp real, a la sombra
La sombra combina las dos tablas en una sola traducción. La MMU solo recorre la sombra; la tabla del invitado queda protegida para que el VMM se entere de cada cambio.

Es caro. Cada escritura de una PTE del invitado es un trap (un fork escribe cientos); aparecen fallos de página (page faults) “ocultos” que el invitado nunca ve; hace falta una sombra por proceso invitado, y los bits A y D se sincronizan a mano.

Paginación anidada

La solución definitiva vino del hardware: la paginación anidada (nested paging). La MMU conoce las dos tablas y las recorre ella misma, así que el invitado cambia sus PTE y su satp sin traps. Intel la llama EPT y AMD, NPT (2007–2008). En RISC-V es la etapa G (G-stage): vsatp apunta a la tabla del invitado y hgatp, a la del VMM.

El precio se paga en los fallos de TLB. Con Sv39 en el invitado y en el VMM, el recorrido es “en dos dimensiones”:

  1. vsatp da la raíz del invitado como GPA: 3 accesos a la tabla del VMM para traducirla y 1 para leer la PTE de nivel 2.
  2. Esa PTE apunta a la tabla de nivel 1, otra GPA: 3 + 1.
  3. Lo mismo para el nivel 0: 3 + 1.
  4. La PTE de nivel 0 da la GPA del dato: 3 accesos más.

En total, 4 + 4 + 4 + 3 = 15 accesos para una traducción, contra 3 sin virtualización. Con n niveles en el invitado y m en el VMM son n(m+1)+m; con x86-64 (4 y 4), 4·5+4 = 24.

Grilla de cuatro filas (PTE de nivel 2, 1 y 0 del invitado, y el dato) por cuatro columnas (niveles 2, 1 y 0 de la tabla del VMM, y la lectura). Las celdas están numeradas del 1 al 16 en orden de acceso: en cada fila, tres accesos a la tabla del VMM y una lecturaGrilla de cuatro filas (PTE de nivel 2, 1 y 0 del invitado, y el dato) por cuatro columnas (niveles 2, 1 y 0 de la tabla del VMM, y la lectura). Las celdas están numeradas del 1 al 16 en orden de acceso: en cada fila, tres accesos a la tabla del VMM y una lectura
El recorrido en dos dimensiones con Sv39 en ambos lados: cada paso del invitado necesita antes un recorrido completo de la tabla del VMM. Las celdas 1 a 15 son la traducción; la 16 es el dato.

Idea clave. La TLB guarda la traducción final gVA → HPA. Con una tasa de aciertos alta, como vimos en la semana 7, el recorrido largo se paga muy pocas veces. Por eso ganó la paginación anidada: los fallos de TLB son raros; los traps de las tablas sombra no lo eran.

Paravirtualización

La paravirtualización abandona la transparencia a propósito: el kernel invitado sabe que es invitado y, en vez de ejecutar instrucciones que hay que atrapar, le pide al VMM lo que necesita con hiperllamadas (hypercalls). Así corría Xen antes de VT-x. También cierra la brecha de información: el VMM no sabe si el invitado está en su bucle ocioso (y le sigue dando CPU) o si va a poner en cero una página que el VMM ya limpió.

Hoy se paravirtualiza sobre todo la E/S: imitar un disco real registro por registro es lento, porque cada acceso es una salida al VMM. Con virtio, el invitado deja sus pedidos en colas en memoria compartida y avisa una vez.

En xv6. xv6 ya es un invitado paravirtual. El Makefile arranca QEMU con -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0, y kernel/virtio_disk.c es el driver de ese disco (“driver for qemu’s virtio disk device”). El driver arma descriptores en memoria y QEMU los atiende. Lo estudiaremos en la parte de persistencia.

Las máquinas virtuales hoy: KVM, QEMU y RISC-V

En Linux, KVM es un módulo del kernel (desde 2007) que usa el modo invitado del hardware: con KVM, Linux es el hipervisor. QEMU es un proceso de usuario que pone el resto: la memoria del invitado es memoria de ese proceso y cada vCPU es un hilo que llama ioctl(KVM_RUN); el código del invitado corre entonces directamente en la CPU. Cuando el invitado toca un dispositivo hay una salida de la VM (VM exit), QEMU emula el dispositivo y vuelve a entrar. Así funcionan casi todas las VMs de la nube.

Un proceso QEMU en modo usuario, con dispositivos emulados, la memoria del invitado y dos hilos vCPU, junto a otro proceso. Debajo, el kernel Linux con el módulo kvm, y abajo la CPU en modo invitado. Cuatro flechas numeradas forman un ciclo para el hilo vCPU 0: 1, ioctl(KVM_RUN) baja al kernel; 2, el kernel entra al modo invitado; 3, una salida de la VM vuelve al kernel; 4, el ioctl retorna a QEMU, que emula el dispositivo y repiteUn proceso QEMU en modo usuario, con dispositivos emulados, la memoria del invitado y dos hilos vCPU, junto a otro proceso. Debajo, el kernel Linux con el módulo kvm, y abajo la CPU en modo invitado. Cuatro flechas numeradas forman un ciclo para el hilo vCPU 0: 1, ioctl(KVM_RUN) baja al kernel; 2, el kernel entra al modo invitado; 3, una salida de la VM vuelve al kernel; 4, el ioctl retorna a QEMU, que emula el dispositivo y repite
Una VM con KVM es un proceso más. Cada vCPU es un hilo que repite el ciclo: entrar al invitado con KVM_RUN, correr directo en la CPU hasta una salida, emular en QEMU y volver a entrar.

En el curso usamos QEMU para correr xv6, pero sin KVM:

$ uname -m
aarch64
$ qemu-system-riscv64 -accel help
Accelerators supported in QEMU binary:
tcg
$ ls /dev/kvm
ls: cannot access '/dev/kvm': No such file or directory

KVM ejecuta el código del invitado en la CPU real, así que invitado y anfitrión deben tener la misma arquitectura. xv6 es RISC-V y las laptops del curso son x86-64 o arm64 (la salida es de una Mac con Apple Silicon). QEMU usa entonces TCG, un traductor binario dinámico. No interpreta instrucción por instrucción: toma un bloque básico de código RISC-V (una secuencia sin saltos), lo traduce una vez a código de la CPU anfitriona, lo guarda en una caché y lo ejecuta; la próxima vez reutiliza la traducción. Las cargas y guardados del invitado pasan por una TLB implementada en software, y csrw, ecall o sret se traducen a llamadas a funciones que emulan su efecto. Es mucho más rápido que un intérprete, pero más lento que KVM.

QEMU es un proceso con varios hilos. Con xv6 corriendo con -smp 3 y la opción -name xv6,debug-threads=on, que pone nombre a los hilos:

$ ps -L -o pid,lwp,pcpu,comm -p 437
  PID   LWP %CPU COMMAND
  437   437  0.1 qemu-system-ris
  437   438  0.0 qemu-system-ris
  437   439  6.5 CPU 0/TCG
  437   440  6.6 CPU 1/TCG
  437   441  6.3 CPU 2/TCG
  437   442  0.0 worker

Un proceso, seis hilos; tres son las harts de xv6.

RISC-V agregó virtualización por hardware con la extensión H, ratificada en 2021. El modo supervisor pasa a ser HS (kernel anfitrión e hipervisor) y aparecen dos modos virtuales, VS para el kernel invitado y VU para sus programas. En VS, un acceso a satp usa en su lugar vsatp: el csrw satp de xv6 funcionaría sin trap. xv6 no la usa, pero KVM sí, en anfitriones RISC-V.

Contenedores: aislar sin un segundo kernel

Un contenedor (container) se parece a una VM desde afuera, pero es un proceso normal del kernel anfitrión, al que el kernel le muestra una vista restringida del sistema. No hay hipervisor ni segundo kernel. Dos mecanismos de Linux lo hacen posible:

  • Los namespaces deciden qué puede ver: el proceso recibe su propia copia de un recurso global. Hay namespaces de pid (el primer proceso es el PID 1), mnt (su árbol de archivos), net, uts (su hostname), ipc, user, cgroup y time.
  • Los cgroups deciden cuánto puede usar: límites y contabilidad de CPU, memoria y E/S para un grupo de procesos.

Por eso arranca en milisegundos y no gasta memoria en otro kernel, pero no puede correr otro sistema operativo, y su aislamiento depende del kernel.

Dos pilas de capas lado a lado. Máquinas virtuales: hardware, hipervisor y dos VMs, cada una con su kernel (Linux y Windows), bibliotecas y aplicación. Contenedores: hardware, un solo kernel Linux, una capa de namespaces y cgroups, y dos contenedores con bibliotecas y aplicaciónDos pilas de capas lado a lado. Máquinas virtuales: hardware, hipervisor y dos VMs, cada una con su kernel (Linux y Windows), bibliotecas y aplicación. Contenedores: hardware, un solo kernel Linux, una capa de namespaces y cgroups, y dos contenedores con bibliotecas y aplicación
Cada VM trae su propio kernel sobre el hipervisor; los contenedores comparten el kernel del anfitrión, que les da una vista restringida del sistema.

Todo esto se observa dentro de sistemas-operativos:

$ uname -r
6.12.72-linuxkit
$ ls -l /proc/self/ns        # recortado: solo nombre y destino
mnt -> mnt:[4026533186]
net -> net:[4026533199]
pid -> pid:[4026533193]
user -> user:[4026531837]
uts -> uts:[4026533189]
$ cat /proc/self/cgroup
0::/
$ ps -e
  PID TTY          TIME CMD
    1 ?        00:00:00 bash
   11 ?        00:00:00 ps

En una Mac, uname -r no dice “Darwin”: el kernel es Linux, porque Docker Desktop corre una VM Linux liviana y los contenedores viven dentro de ella (en Windows pasa algo parecido con WSL2; en Linux nativo se ve el kernel de su propia computadora). Cada número de /proc/self/ns identifica un namespace; bash es el PID 1 porque en su namespace de PID no hay nadie más; y 0::/ dice que el contenedor ve la raíz de su cgroup.

Con dos contenedores vivos a la vez, A y B, y un proceso de la VM:

Namespace Contenedor A Contenedor B VM Linux
pid 4026533193 4026533470 4026531836
net 4026533199 4026533472 4026531840
uts 4026533189 4026533468 4026531838
user 4026531837 4026531837 4026531837

Los namespaces de PID, red y nombre de host son distintos. El de user es el mismo: Docker no crea uno por defecto, así que el root del contenedor es root para el kernel. Un sleep 60 en otro contenedor es el PID 1 visto desde adentro y el PID 49346 visto desde la VM: un proceso, dos números. Y con docker run --memory=256m --cpus=1.5, dentro del contenedor /sys/fs/cgroup/memory.max vale 268435456 y cpu.max vale 150000 100000 (150 ms de CPU por cada 100 ms).

El entorno del curso en macOS o Windows apila, entonces: una VM Linux, el contenedor sistemas-operativos (sin kernel propio), QEMU con TCG, el kernel xv6 y sus programas.

Hilos

Un hilo (thread) es un flujo de ejecución dentro de un proceso. Tiene su propio contador de programa, sus registros y su pila (stack), pero comparte con los demás hilos el espacio de direcciones (address space): código, variables globales y heap. Un proceso clásico tiene un solo hilo; con varios, el espacio de direcciones tiene varias pilas. El estado de cada hilo se guarda en un TCB (thread control block), como el de un proceso en su PCB, y cambiar entre hilos del mismo proceso no cambia la tabla de páginas.

Los hilos sirven para el paralelismo (como en el proyecto P4, pzip) y para que un hilo que espera E/S no detenga al programa. Con procesos, compartir datos exige pipes o memoria compartida explícita; con hilos, lo que uno escribe en el heap los demás lo ven de inmediato. Esa facilidad es la fuente de los problemas.

Compartido entre los hilos Propio de cada hilo
Código, variables globales, heap PC y registros
Archivos abiertos Pila (variables locales)
PID, directorio actual TID, errno, máscara de señales

“Propio” no es “protegido”: otro hilo puede leer una pila ajena si tiene un puntero a ella. stacks.c lo muestra; cada hilo anota la dirección de una variable local:

$ ./stacks 3
global       0xaaaad7ec0018
heap         0xaaab071ba2a0
main  local  0xfffff5aba3f0   pid 29  tid 29
hilo 0 local  0xffff9740e8cc   pid 29  tid 30
hilo 1 local  0xffff96bfe8cc   pid 29  tid 31   (a 8256 KiB del hilo 0)
hilo 2 local  0xffff963ee8cc   pid 29  tid 32   (a 8256 KiB del hilo 1)

La global y el heap tienen una dirección para todos; las locales están en pilas separadas por unos 8 MiB (ulimit -s) más una zona de guarda, aunque el número exacto depende de la máquina. Mismo PID, distinto TID: para Linux cada hilo es una tarea que se planifica aparte. Los hilos esperan en una barrera (pthread_barrier_wait) para estar vivos a la vez; sin ella, la biblioteca podría reutilizar la pila de un hilo ya terminado.

Espacio de direcciones de un proceso con main y tres hilos: arriba, cuatro pilas separadas por zonas de guarda, con las direcciones que imprime stacks; abajo, heap, globales y código, compartidos. A la derecha, los registros de cada hilo: el SP de cada uno apunta a su pila y todos los PC al mismo códigoEspacio de direcciones de un proceso con main y tres hilos: arriba, cuatro pilas separadas por zonas de guarda, con las direcciones que imprime stacks; abajo, heap, globales y código, compartidos. A la derecha, los registros de cada hilo: el SP de cada uno apunta a su pila y todos los PC al mismo código
Un espacio de direcciones, cuatro pilas. Cada hilo tiene su SP y su PC, pero el código, las globales y el heap son de todos.

La API de hilos

Los hilos POSIX (pthreads) se compilan con gcc -pthread:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);
int pthread_join(pthread_t thread, void **value_ptr);

pthread_create crea un hilo que ejecuta start_routine(arg); attr casi siempre es NULL. La función recibe y devuelve void *, así que se puede pasar un puntero a cualquier tipo. pthread_join espera a que el hilo termine y deja en *value_ptr lo que devolvió: es el wait() de los hilos. Ambas devuelven 0 si todo salió bien. Después de crear un hilo no hay orden garantizado: lo decide el planificador (scheduler).

En args.c, varios hilos suman partes de 1 + 2 + … + N. Cada uno recibe un struct con su rango y devuelve otro, en el heap, con su suma:

static void *sum_range(void *arg) {
    task_t *t = arg;                    // void * -> puntero al tipo real
    result_t *r = malloc(sizeof(*r));   // en el heap: sobrevive al hilo
    r->sum = 0;
    for (long i = t->from; i <= t->to; i++)
        r->sum += i;
    return r;
}

Con ./args 100 3, los hilos suman 561, 1650 y 2839 (sus líneas salen en cualquier orden) y main obtiene 5050. Dos errores clásicos: devolver un puntero a la pila del hilo (return &r con r local), que deja a join con una dirección inválida; y pasar &i, el contador del bucle, a todos los hilos, que leen la misma variable cuando quizás ya cambió. Por eso args.c usa un arreglo con un task_t por hilo.

Condiciones de carrera

race.c lanza varios hilos que incrementan un contador compartido:

static volatile long counter = 0;

static void *worker_nolock(void *arg) {
    for (long i = 0; i < loops; i++)
        counter++;
    return NULL;
}
$ ./race nolock 1 1000000
nolock  hilos=1  esperado=1000000  obtenido=1000000  OK   0.001 s
$ ./race nolock 4 1000000
nolock  hilos=4  esperado=4000000  obtenido=1928101  MAL  0.006 s

Con cuatro hilos se pierde más de la mitad, y el número cambia en cada ejecución. make asm compila counter.c con el compilador cruzado de RISC-V del contenedor y muestra por qué:

increment:
    lla   a4,.LANCHOR0
    ld    a5,0(a4)
    addi  a5,a5,1
    sd    a5,0(a4)
    ret

counter++ son tres instrucciones: cargar (ld), sumar (addi) y guardar (sd). Y cada hilo tiene su propio a5, porque los registros son parte del estado de cada hilo. Con dos hilos en una CPU y el contador en 7:

Paso Hilo 1 a5 de H1 Hilo 2 a5 de H2 counter
1 ld a5,0(a4) 7 7
2 addi a5,a5,1 8 7
interrupción: se guardan los registros de H1
3 ld a5,0(a4) 7 7
4 addi a5,a5,1 8 7
5 sd a5,0(a4) 8 8
interrupción: vuelve H1 con su a5 = 8
6 sd a5,0(a4) 8 8
Animación: el hilo 1 carga counter (7) en su a5 y le suma 1; una interrupción lo saca de la CPU. El hilo 2 carga 7, suma 1 y guarda 8. Vuelve el hilo 1 con su a5 en 8 y también guarda 8: counter terminó en 8 tras dos incrementosAnimación: el hilo 1 carga counter (7) en su a5 y le suma 1; una interrupción lo saca de la CPU. El hilo 2 carga 7, suma 1 y guarda 8. Vuelve el hilo 1 con su a5 en 8 y también guarda 8: counter terminó en 8 tras dos incrementos
El mismo intercalado de la tabla: los números junto a cada instrucción son sus pasos. Cada hilo tiene su propio a5, y el del hilo 1 se queda con un valor viejo.

Dos incrementos y el contador subió de 7 a 8: se perdió una actualización. Con varias CPU ni siquiera hace falta la interrupción: los dos ld pueden ocurrir en el mismo instante. volatile no lo arregla: solo obliga al compilador a ir a memoria en cada vuelta, pero siguen siendo tres instrucciones.

El vocabulario de esta parte del curso:

  • Condición de carrera (race condition): el resultado depende del orden en que se intercalan los hilos; el programa es no determinista.
  • Sección crítica (critical section): código que accede a un recurso compartido y que no deben ejecutar dos hilos a la vez. Aquí, counter++.
  • Exclusión mutua (mutual exclusion): si un hilo está en la sección crítica, ningún otro entra.
  • Atomicidad: un grupo de operaciones ocurre “todo o nada”; nadie ve un estado intermedio.

Idea clave. La concurrencia plantea dos problemas distintos: la atomicidad (que nadie se meta en medio), que se resuelve con locks, y la espera (no seguir hasta que otro haga algo), que se resuelve con variables de condición (condition variables).

Exclusión mutua con un mutex

Un mutex es un lock: la sección crítica va entre pthread_mutex_lock y pthread_mutex_unlock; si otro hilo tiene el lock, se espera.

static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// ...
pthread_mutex_lock(&lock);    // inicio de la sección crítica
counter++;
pthread_mutex_unlock(&lock);  // fin de la sección crítica

Con 4 hilos y diez millones de vueltas cada uno (./race <modo> 4 10000000):

Modo Obtenido (esperado 40 000 000) Tiempo
nolock 12 621 040 0.039 s
mutex 40 000 000 1.342 s
local 40 000 000 0.007 s

El mutex corrige el resultado, pero es unas 35 veces más lento: en cada vuelta los cuatro hilos se pelean por el lock. Sin competencia es barato (./race mutex 1 1000000 tarda unos 0.006 s). El modo local muestra la salida: cada hilo cuenta en una variable de su pila y toma el lock una vez al final. Primero correcto; después, compartir menos. Lo retomaremos en la semana 10. Los números cambiarán en su máquina.

Esperar: variables de condición

Para que un hilo espere a que otro haga algo están las variables de condición. En cond.c, main espera a que otro hilo prepare un dato:

// hilo que espera (main)
pthread_mutex_lock(&lock);
while (!ready)
    pthread_cond_wait(&ready_cv, &lock);  // suelta el lock y duerme
pthread_mutex_unlock(&lock);

// hilo que avisa (producer)
pthread_mutex_lock(&lock);
data = 42;
ready = 1;
pthread_cond_signal(&ready_cv);
pthread_mutex_unlock(&lock);

pthread_cond_wait se llama con el lock tomado: lo suelta y duerme en un solo paso, y al despertar lo vuelve a tomar. Lo que importa es el estado (ready); la variable de condición solo sirve para dormir y despertar. Se espera con while, no con if: al despertar, el estado puede haber cambiado de nuevo, y existen despertares espurios. Quien avisa cambia el estado y luego hace signal, con el lock tomado; un signal sin nadie esperando se pierde, pero el estado queda escrito.

Errores comunes: girar sobre una bandera sin lock (while (ready == 0) ;), olvidar inicializar el mutex, salir con un return sin unlock o proteger un dato con dos locks distintos. Para encontrar carreras está helgrind:

$ valgrind --tool=helgrind ./race nolock 2 1000
==13== Possible data race during read of size 8 at 0x128018 by thread #3
==13==    at 0x108BB0: worker_nolock (race.c:23)
...
==13== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 1 from 1)

Señala race.c:23, el counter++. Con mutex, reporta 0 errors.

Concurrencia en xv6

En xv6. xv6 no tiene hilos de usuario. Cada struct proc (kernel/proc.h) tiene un solo trapframe, un context y una pila de kernel (kstack): un proceso es un hilo. Pero el kernel sí es concurrente: el Makefile define CPUS := 3 y arranca QEMU con -smp 3, y en kernel/main.c las harts 1 y 2 esperan a que la 0 termine de inicializar, imprimen hart 1 starting y hart 2 starting, y desde ahí tres CPU ejecutan el kernel sobre las mismas estructuras.

El caso más parecido a nuestro contador es allocpid() en kernel/proc.c:

  acquire(&pid_lock);
  pid = nextpid;
  nextpid = nextpid + 1;
  release(&pid_lock);

nextpid = nextpid + 1 es counter++: sin pid_lock, dos fork() en dos harts podrían recibir el mismo PID. pid_lock es un struct spinlock, y el corazón de acquire() en kernel/spinlock.c es:

  while (__atomic_exchange_n(&lk->locked, 1, __ATOMIC_ACQUIRE) != 0)
    ;

__atomic_exchange_n se compila a amoswap.w.aq, una instrucción atómica de RISC-V que escribe 1 y devuelve el valor anterior en un solo paso. Si devolvió 1, otro tenía el lock y hay que reintentar, girando (spinning). Antes, push_off() apaga las interrupciones de esa CPU; release() hace lo inverso con __atomic_store_n(&lk->locked, 0, __ATOMIC_RELEASE). Por qué hace falta cada pieza es el tema de la semana 10.

Resumen

  • Un VMM virtualiza la máquina con ejecución directa limitada: el kernel invitado corre desprivilegiado y sus instrucciones privilegiadas se atrapan y emulan. Requiere que toda instrucción sensible sea privilegiada; x86 no lo cumplía y se resolvió con traducción binaria, paravirtualización y VT-x/AMD-V.
  • La memoria tiene tres niveles, gVA → GPA → HPA. Las tablas sombra los combinan por software con muchos traps; la paginación anidada lo hace en hardware, con fallos de TLB de hasta 15 accesos (Sv39) o 24 (x86-64).
  • La paravirtualización hace cooperar al invitado; hoy se usa sobre todo para E/S con virtio, como el disco de xv6.
  • KVM ejecuta al invitado en el hardware; nuestro QEMU emula RISC-V con TCG. Un contenedor es un proceso con namespaces y cgroups, sobre el mismo kernel.
  • Un hilo tiene PC, registros y pila propios, y comparte el resto del proceso. counter++ son tres instrucciones: sin exclusión mutua se pierden actualizaciones. Mutex para la atomicidad; variables de condición, con while, para esperar.
  • xv6 no tiene hilos de usuario, pero su kernel corre en varias harts y usa spinlocks.

Ejercicios

  1. Dos hilos ejecutan counter++ tres veces cada uno, desde 0. Con ld/addi/sd, construyan un intercalado que termine en 2. ¿Puede terminar en 1?
  2. Un VMM usa paginación anidada con Sv39; el invitado usa Sv48 (cuatro niveles). ¿Cuántos accesos cuesta un fallo de TLB en el peor caso? Dibujen la grilla.
  3. La tabla de un invitado traduce gVA 0 → GPA 6 y gVA 3 → GPA 1; la del VMM, GPA 1 → HPA 20 y GPA 6 → HPA 8. Escriban la tabla sombra. Si el invitado cambia gVA 3 → GPA 6, ¿qué hace el VMM?
  4. Agreguen a race.c un modo atomic con __atomic_fetch_add(&counter, 1, __ATOMIC_RELAXED). Midan con 1, 2 y 4 hilos y compárenlo con mutex y local.
  5. Quiten la barrera de stacks.c y hagan pthread_join de cada hilo justo después de crearlo. ¿Qué direcciones aparecen? ¿Por qué?
  6. Cambien el while de cond.c por un if. Describan un escenario con dos hilos que esperan y uno que avisa en el que el if falla.
  7. Comparen docker run --rm --pid=host sistemas-operativos ps -e | head con ps -e en un contenedor normal. ¿Quién es el PID 1 en cada caso?
  8. ¿Qué hardware y qué anfitrión harían falta para correr xv6 con KVM?