Apuntes · Capítulo 15Distribuidos

LFS, SSDs, integridad y sistemas distribuidos

LFS, SSDs, integridad y sistemas distribuidos

En la semana 14 vimos cómo FFS ubica inodos y datos en lugares fijos y cómo el journaling lo protege de un apagón. Este capítulo cierra la parte de persistencia con tres preguntas. ¿Qué pasa si, en vez de escribir cada bloque en su lugar, escribimos todo al final de un log? ¿Cómo funciona una SSD, que no puede sobrescribir y por dentro hace justamente eso? ¿Y qué hacemos cuando el disco devuelve datos equivocados sin avisar?

La segunda mitad pasa a los sistemas distribuidos: comunicar máquinas por una red que pierde mensajes, y cómo NFS y AFS comparten archivos.

Ruta de lectura.

  • Antes de clase (15–20 min): la primera parte de «LFS: un sistema de archivos que solo agrega» (hasta «¿Qué tan grande debe ser un segmento?»), «Bancos, bloques y páginas», de «SSDs: flash por dentro», y «Comunicación no confiable».
  • En clase: sesión A, «LFS: un sistema de archivos que solo agrega», «SSDs: flash por dentro» e «Integridad de los datos»; sesión B, «RPC», «NFS: un servidor sin estado» y «AFS: archivos completos y callbacks».
  • Después de clase: repasar el «Resumen» y resolver los «Ejercicios». Para ampliar: OSTEP cap. 43 a 45 y 48 a 50, «Escrituras mal dirigidas y escrituras perdidas» y «Scrubbing».

Lectura. OSTEP cap. 43–45 (LFS, SSDs, integridad) y cap. 48–50 (sistemas distribuidos, NFS y AFS). El código está en code/week-15; make test corre todos los ejemplos.

LFS: un sistema de archivos que solo agrega

A comienzos de los 90, Rosenblum y Ousterhout notaron que la caché absorbía casi todas las lecturas, así que al disco llegaban sobre todo escrituras; que el acceso secuencial era cada vez más rápido que el aleatorio (semana 12); y que crear un archivo pequeño en FFS escribe inodo, bitmap, directorio y datos, cada uno en su lugar fijo.

Su propuesta, el Log-structured File System (LFS), es nunca sobrescribir nada. Datos y metadatos se juntan en memoria en un segmento que, al llenarse, se escribe al final del log con una sola escritura grande y secuencial.

¿Qué tan grande debe ser un segmento?

Cada escritura paga un posicionamiento (búsqueda más rotación), T_pos, y luego transfiere a la tasa pico R_pico. Escribir D MB toma T = T_pos + D / R_pico, y la tasa efectiva es D / T. Si queremos que la tasa efectiva sea una fracción F de la pico, despejando:

D = F / (1 - F) × R_pico × T_pos

Con T_pos = 10 ms, R_pico = 100 MB/s y F = 0,9: D = 9 × 100 MB/s × 0,01 s = 9 MB. Para el 95 % hacen falta 19 MB, y para el 99 %, 99 MB: cada punto cerca del máximo cuesta mucho más buffer.

El mapa de inodos

Si nada se sobrescribe, cada vez que un archivo cambia su inodo se escribe en una dirección nueva. ¿Cómo encontramos el inodo número k? LFS agrega una indirección: el mapa de inodos (inode map, imap), que traduce número de inodo a la dirección de su versión más reciente. El imap también se escribe en el log, en pedazos, junto a los inodos que cambiaron.

El punto de partida fijo es la región de checkpoint (checkpoint region, CR): un bloque en una dirección conocida que apunta a los pedazos más recientes del imap. Se actualiza cada tanto (unos 30 segundos), no en cada escritura.

Un ejemplo: el archivo k tiene un bloque D en la dirección A0, su inodo en A1 y el pedazo del imap que lo contiene en A2. El programa sobrescribe ese bloque. LFS agrega al log D' (A3), un inodo nuevo que apunta a A3 (A4) y un pedazo de imap nuevo con k → A4 (A5). Lo que está en A0, A1 y A2 queda muerto. Cuando toca actualizar la CR, esta pasa a apuntar a A5.

Animación: D', el inodo nuevo de k y un pedazo de imap nuevo se juntan en un segmento en memoria y caen juntos al final del log, en A3, A4 y A5; D, el inodo viejo y el imap viejo en A0, A1 y A2 se vuelven grises; el imap en memoria cambia de A1 a A4 y al final la flecha de la CR pasa del imap viejo al nuevoAnimación: D', el inodo nuevo de k y un pedazo de imap nuevo se juntan en un segmento en memoria y caen juntos al final del log, en A3, A4 y A5; D, el inodo viejo y el imap viejo en A0, A1 y A2 se vuelven grises; el imap en memoria cambia de A1 a A4 y al final la flecha de la CR pasa del imap viejo al nuevo
Sobrescribir un bloque en LFS: todo lo nuevo va al final del log en una sola escritura y el imap registra dónde quedó el inodo.

Para leer, LFS carga todo el imap en memoria al montar; luego busca el inodo en el imap, lo lee y sigue sus punteros como FFS.

En xv6. xv6, como FFS, guarda los inodos en un arreglo en posiciones fijas. En kernel/fs.h, #define IBLOCK(i, sb) ((i) / IPB + sb.inodestart) calcula el bloque de un inodo con una división. Por eso no necesita imap: un inodo nunca se mueve. LFS necesita el imap justamente porque sus inodos cambian de lugar en cada escritura.

Esto responde la primera pregunta de repaso: FFS no necesita imap porque la ubicación de cada inodo es una función fija de su número; en LFS esa ubicación cambia cada vez que el inodo se reescribe, y el imap es la tabla que la registra.

El imap tiene otro efecto: como un directorio guarda (nombre, número de inodo) y no direcciones, mover un inodo no obliga a reescribir el directorio. Sin esa indirección, cada cambio se propagaría por los directorios hasta la raíz.

Limpieza

Como LFS nunca sobrescribe, el disco se llena de versiones viejas. El limpiador (cleaner) trabaja por segmentos: lee M segmentos, se queda con los bloques vivos, los escribe compactados en N < M segmentos nuevos y deja libres los M viejos. Así siempre hay espacio contiguo para escribir rápido.

Para saber si un bloque está vivo, cada segmento empieza con un resumen del segmento (segment summary) que dice, para cada bloque, de qué inodo es y en qué posición del archivo. Para el bloque en la dirección A, el limpiador lee del resumen “inodo k, bloque 2”, busca k en el imap, lee el inodo y mira su puntero 2. Si apunta a A, el bloque está vivo; si apunta a otro lado, es basura. Un atajo: guardar un número de versión del archivo en el imap y en el resumen; si no coinciden (el archivo se borró o se truncó), el bloque es basura sin leer el inodo.

¿Qué segmentos limpiar? La propuesta original separa segmentos calientes (se sobrescriben seguido) de fríos, y limpia antes los fríos: a los calientes conviene darles tiempo para que acumulen más basura.

Recuperación ante fallos

LFS guarda dos CR, una en cada extremo del disco, y las alterna. Cada una lleva una marca de tiempo al inicio y otra al final; si no coinciden, quedó a medias y se usa la otra. Después de un reinicio, LFS hace roll forward: desde la última CR válida, recorre el log y recupera los segmentos escritos después.

En xv6. El log de xv6 (kernel/log.c) no es LFS: es un journal de redo. Los bloques de una transacción se escriben primero en el log y, después del commit, install_trans los copia a su lugar fijo: bread(log.dev, log.lh.block[tail]), memmove y bwrite(dbuf). El log es temporal y los datos terminan escritos dos veces. En LFS el log es el sistema de archivos y cada dato se escribe una sola vez.

La idea de escribir en un lugar nuevo (copy-on-write) sobrevivió en WAFL, ZFS, btrfs y, como veremos, dentro de cada SSD.

SSDs: flash por dentro

Bancos, bloques y páginas

La memoria flash NAND guarda bits como carga eléctrica en celdas: 1 bit por celda (SLC), 2 (MLC) o 3 (TLC). Más bits por celda dan más capacidad, pero menos velocidad y menos duración. Un chip se organiza en bancos que trabajan en paralelo; cada banco tiene bloques grandes (típicamente 128–256 KB), y cada bloque tiene páginas de unos 4 KB. Ojo con el nombre: un bloque flash no es un bloque del sistema de archivos, es la unidad de borrado.

La flash tiene tres operaciones:

Leer página Programar página Borrar bloque
SLC 25 µs 200–300 µs 1,5–2 ms
MLC 50 µs 600–900 µs ~3 ms
TLC ~75 µs ~900–1350 µs ~4,5 ms

Leer es rápido y no depende de la posición. Programar solo cambia 1s por 0s, así que la página tiene que estar borrada. Borrar pone todo el bloque en 1s: es lento y desgasta (un bloque MLC aguanta unos 10 000 ciclos; uno SLC, unos 100 000). Por eso no se puede sobrescribir en el lugar: cambiar una página obligaría a copiar las demás del bloque, borrarlo y reprogramarlo.

El FTL

Hacia afuera, la SSD es un disco común que lee y escribe bloques de 4 KB. La traducción la hace el FTL (flash translation layer), que corre en el procesador de la SSD. Un FTL de mapeo directo (página lógica N = física N) tendría que borrar y reprogramar un bloque entero en cada sobrescritura.

Una SSD abierta: a la izquierda el chip de DRAM, en el centro el procesador, a la derecha el encapsulado de flash NAND y abajo el conector SATA
Una SSD por dentro: la flash guarda los datos, el procesador ejecuta el FTL y la DRAM guarda la tabla de mapeo. Foto: Michael Bemmerl, CC BY 3.0 de, Wikimedia Commons.

Los FTL reales son log-structured: cada escritura va a la siguiente página libre del bloque activo, y una tabla de mapeo en memoria anota dónde quedó cada página lógica. Si el sistema escribe las páginas lógicas 100, 101, 2000 y 2001, van a las físicas 0, 1, 2 y 3. Si después sobrescribe 100 y 101, los datos nuevos van a las físicas 4 y 5, la tabla se actualiza, y las páginas 0 y 1 quedan muertas. Es LFS otra vez, dentro del disco.

Recolección de basura y amplificación de escritura

Las páginas muertas hay que recuperarlas, pero solo se puede borrar un bloque entero. El recolector de basura (garbage collector, GC) elige un bloque, copia sus páginas vivas al final del log, actualiza la tabla y borra el bloque. En el ejemplo, el bloque 0 tiene muertas las páginas 0 y 1 y vivas la 2 y la 3: el GC copia 2000 y 2001 a las físicas 6 y 7 y borra el bloque 0.

Animación: dos bloques flash de cuatro páginas y una tabla de mapeo. Las páginas lógicas 100, 101, 2000 y 2001 llenan el bloque 0; al sobrescribir 100 y 101 van a las páginas 4 y 5 y las 0 y 1 quedan tachadas; el recolector copia 2000 y 2001 a las páginas 6 y 7 y borra el bloque 0. Un contador termina en 2 escrituras pedidas, 4 programadas: WA = 2Animación: dos bloques flash de cuatro páginas y una tabla de mapeo. Las páginas lógicas 100, 101, 2000 y 2001 llenan el bloque 0; al sobrescribir 100 y 101 van a las páginas 4 y 5 y las 0 y 1 quedan tachadas; el recolector copia 2000 y 2001 a las páginas 6 y 7 y borra el bloque 0. Un contador termina en 2 escrituras pedidas, 4 programadas: WA = 2
El FTL nunca sobrescribe: escribe en la siguiente página libre y actualiza la tabla. Para borrar un bloque, el recolector primero copia lo que sigue vivo.

Esas copias son escrituras extra. La amplificación de escritura (write amplification) las mide:

WA = páginas programadas en la flash / páginas que pidió escribir el sistema

En el ejemplo, el sistema escribió 2 páginas (las nuevas versiones de 100 y 101) y la flash programó 4 (esas dos más las dos copias del GC): WA = 2.

El programa ftl.c simula un FTL log-structured de 128 bloques de 32 páginas con un GC greedy: cuando quedan menos de dos bloques libres, recolecta el que tiene menos páginas vivas. Llena una fracción de la capacidad y luego hace 20 sobrescrituras al azar por página:

$ for u in 50 70 90; do ./ftl $u; done
ocupación 50%: escrituras del host 40960, copias del GC 9436
  amplificación de escritura = 1.23
ocupación 70%: escrituras del host 57340, copias del GC 48265
  amplificación de escritura = 1.84
ocupación 90%: escrituras del host 73720, copias del GC 305283
  amplificación de escritura = 5.14

Esto responde la segunda pregunta de repaso. Con el disco casi lleno, hasta el bloque “más vacío” tiene casi todas sus páginas vivas: para liberar una página hay que copiar varias, y al 90 % cada escritura cuesta más de cinco en la flash. Por eso las SSD esconden capacidad extra (sobreaprovisionamiento) y existe TRIM: el sistema de archivos avisa qué bloques borró y el GC deja de copiarlos.

El tamaño de la tabla de mapeo

Con mapeo por página, una SSD de 1 TB con páginas de 4 KB tiene 2⁴⁰ / 2¹² = 2²⁸ páginas. Con 4 bytes por entrada, la tabla ocupa 2²⁸ × 4 = 2³⁰ bytes: 1 GB de DRAM solo para la tabla.

El mapeo por bloque usa una entrada por bloque de 256 KB (64 veces menos), pero sobrescribir 4 KB obliga a copiar el bloque entero. Los FTL híbridos mapean por página unos pocos bloques de log y el resto por bloque. Otra opción es dejar la tabla en la flash y cachear en DRAM la parte que se usa, como una TLB.

Nivelación de desgaste

Un bloque con datos fríos nunca tiene basura, así que el GC nunca lo borra, mientras los bloques calientes se gastan. La nivelación de desgaste (wear leveling) mueve cada tanto los datos fríos para que esos bloques entren en la rotación. Con ./ftl 90 hot (80 % de las escrituras al 20 % de las páginas), nuestro FTL, que no nivela, termina con bloques de 71 y de 124 borrados.

Integridad de los datos

Los discos fallan a medias

El RAID de la semana 13 supone fallas fail-stop: un disco funciona o está muerto. Los discos reales también fallan de a un bloque:

  • Error latente de sector (latent sector error, LSE): un sector se dañó, por un roce del cabezal o por radiación. El ECC del disco detecta que los bits no son válidos y el disco devuelve un error al leer.
  • Corrupción silenciosa: el disco devuelve datos incorrectos sin avisar. Causas: firmware con errores, un bus que voltea bits, escrituras que terminan en el lugar equivocado.

Un estudio de Bairavasundaram y otros sobre 1,5 millones de discos durante unos tres años encontró al menos un LSE en el 9,40 % de los discos baratos (SATA) y el 1,40 % de los caros (SCSI/FC), y corrupción en el 0,50 % y el 0,05 %. Con miles de discos, eso pasa todas las semanas.

Errores latentes

Los LSE son fáciles de detectar: el disco avisa. Para recuperarse se usa la redundancia conocida: el espejo o la paridad. El caso feo es un LSE durante la reconstrucción de un disco caído en RAID 4/5; por eso existen esquemas con dos paridades, como RAID-6.

En xv6. Cuando el disco virtio termina un pedido, virtio_disk_intr en kernel/virtio_disk.c revisa el byte de estado que escribió el dispositivo: if (disk.info[id].status != 0) panic("virtio_disk_intr status");. Ante un error de E/S, xv6 no reintenta ni tiene otra copia: detiene el sistema.

Checksums

La corrupción silenciosa necesita otro mecanismo, porque el disco no avisa. La herramienta es el checksum (suma de verificación): una función que, a partir de un bloque de datos, calcula unos pocos bytes que lo resumen. Al escribir un bloque se guarda su checksum; al leerlo se recalcula y se compara. Si no coinciden, el sistema sabe que el dato está mal y, si tiene redundancia, lee la otra copia y repara el bloque.

Algunas funciones comunes, de la más barata a la más robusta:

  • XOR de todos los bytes. No detecta dos cambios en el mismo bit de bytes distintos, porque se cancelan.
  • Suma módulo 256. No detecta bytes reordenados.
  • Fletcher: dos sumas, s1 = s1 + d y s2 = s2 + s1 (módulo 255). Como s2 acumula s1, depende de la posición de cada byte. Adler-32 es una variante.
  • CRC: trata los datos como un polinomio y guarda el resto de dividirlo por un polinomio fijo. Detecta muy bien ráfagas de errores; lo usan Ethernet, zip y muchos sistemas de archivos.

Un ejemplo con los datos 1, 2, 3, 4 y los mismos datos al revés:

d s1 s2 d s1 s2
1 1 1 4 4 4
2 3 4 3 7 11
3 6 10 2 9 20
4 10 20 1 10 30

La suma da 10 en los dos casos y el XOR da 4 en los dos: ninguno nota que los bytes se reordenaron. Fletcher da (10, 20) y (10, 30): sí lo nota. En C, la versión de 16 bits cabe en unas líneas:

static uint32_t fletcher16(const uint8_t *d, size_t n) {
    uint32_t s1 = 0, s2 = 0;
    for (size_t i = 0; i < n; i++) {
        s1 = (s1 + d[i]) % 255;
        s2 = (s2 + s1) % 255;
    }
    return (s2 << 8) | s1;
}

El programa checksum.c calcula XOR, Fletcher-16, Adler-32 y CRC-32 de un archivo y prueba tres daños en una copia en memoria:

$ seq 1 1000 > num.txt
$ ./checksum num.txt 100
...
2. El mismo bit volteado en dos bytes seguidos
  XOR-8        00000031 -> 00000031  NO DETECTADO
  Fletcher-16  000084b9 -> 000080b9  detectado
  Adler-32     9e0f7a5c -> 9e0b7a5c  detectado
  CRC-32       8dc4565d -> 910792e7  detectado

Con un solo bit volteado, los cuatro detectan el cambio. XOR falla cuando dos cambios se cancelan; los otros tres no. Ningún checksum es perfecto: hay 2³²⁷⁶⁸ bloques posibles de 4 KB y solo 2³² valores de CRC-32, así que existen colisiones. Lo que se busca es que los errores comunes no las produzcan.

Idea clave. Un checksum no corrige nada: solo convierte un error silencioso en uno detectado. Para corregir hace falta redundancia, igual que con un LSE.

Escrituras mal dirigidas y escrituras perdidas

Dos fallas engañan a un checksum guardado junto al dato:

  • Escritura mal dirigida (misdirected write): datos correctos en el bloque equivocado; su checksum viaja con ellos y calza. Solución: guardar también un identificador físico (disco, bloque). Si el bloque 7 dice “soy el bloque 4”, está mal.
  • Escritura perdida (lost write): el disco dice que escribió, pero no lo hizo. El dato viejo y su checksum viejo son coherentes entre sí. Soluciones: verificar al escribir (releer, duplicando las E/S) o guardar el checksum en otro lugar, como el inodo o el bloque que apunta al dato (ZFS).

Scrubbing

Un checksum solo se revisa al leer, y lo que nadie lee en meses podría dañarse en todas sus copias. El scrubbing recorre todo el disco periódicamente, verifica cada checksum y repara con la copia buena. Cuesta poco: 8 bytes por bloque de 4 KB son menos del 0,2 % del espacio. scrub.c guarda un CRC-32 por bloque de 4 KB en un archivo aparte y luego los verifica:

$ seq 1 5000 > datos.txt
$ ./scrub sign datos.txt
6 bloques firmados en datos.txt.sum
$ printf 'X' | dd of=datos.txt bs=1 seek=9000 conv=notrunc status=none
$ ./scrub check datos.txt
bloque 2: CORRUPTO (guardado ff9551ad, leído 6f6d3f31)
6 bloques revisados, 1 con errores

El byte 9000 está en el bloque 2 (bytes 8192 a 12287), y es el único que falla.

Seis bloques de datos.txt con sus rangos de bytes; una flecha marca el byte 9000 dentro del bloque 2. Debajo, el CRC guardado en datos.txt.sum y el recalculado coinciden en todos los bloques menos en el 2, donde son ff9551ad y 6f6d3f31Seis bloques de datos.txt con sus rangos de bytes; una flecha marca el byte 9000 dentro del bloque 2. Debajo, el CRC guardado en datos.txt.sum y el recalculado coinciden en todos los bloques menos en el 2, donde son ff9551ad y 6f6d3f31
scrub check recalcula el CRC de cada bloque y lo compara con el guardado. Un byte cambiado basta para que el CRC del bloque 2 no coincida.

En xv6. bread y bwrite en kernel/bio.c no calculan ni guardan checksums, y struct buf (kernel/buf.h) no tiene un campo para uno. xv6 confía en que el disco devuelve lo que se escribió.

Comunicación no confiable

En un sistema distribuido, muchas máquinas cooperan por una red, y con miles de máquinas siempre hay alguna fallando. La red pierde mensajes (un router con el buffer lleno descarta paquetes), los corrompe, los duplica o los retrasa.

UDP refleja esa realidad: se envía un datagrama y nadie garantiza que llegue (trae un checksum, así que al menos no entrega datos corruptos). Encima se construye confiabilidad con cuatro piezas: el receptor confirma cada mensaje (ack); si el ack no llega a tiempo (timeout), el emisor reintenta; y si lo perdido era el ack, el receptor recibe un duplicado, que reconoce por su número de secuencia y no vuelve a procesar. TCP hace todo esto por dentro.

RPC

Una llamada a procedimiento remoto (RPC) hace que llamar a una función en otra máquina parezca local. Un compilador genera stubs: el del cliente empaqueta los argumentos (marshaling) y espera la respuesta; el del servidor desempaqueta y llama a la función. Un runtime se encarga de timeouts, reintentos y secuencias.

udprpc.c es un RPC mínimo: fork() crea un servidor con una operación ADD(n) que suma a un contador, y ambos hablan por 127.0.0.1. El servidor descarta al azar pedidos y respuestas; el cliente espera 50 ms (SO_RCVTIMEO) y reintenta:

$ ./udprpc 200 20
llamadas ADD: 200  reintentos: 120  dedup: no
contador esperado: 200  contador en el servidor: 253  <-- ¡sumas duplicadas!
$ ./udprpc 200 20 dedup
llamadas ADD: 200  reintentos: 120  dedup: sí
contador esperado: 200  contador en el servidor: 200

Reintentar tras un pedido perdido es inofensivo; tras una respuesta perdida, el servidor suma otra vez: ADD no es idempotente. Con dedup, el servidor recuerda la última secuencia y su respuesta y no re-ejecuta duplicados. Funciona, pero el servidor ahora tiene estado, que se pierde si se reinicia.

NFS: un servidor sin estado

En NFS (Sun, versión 2, años 80), los programas usan open, read y write como siempre, y un sistema de archivos del cliente, en el kernel, los convierte en mensajes a un servidor. Es un protocolo abierto: cualquiera puede implementar un cliente o un servidor.

La meta fue recuperarse rápido y sin complicaciones de una caída del servidor. Si open devolviera un descriptor guardado en el servidor, tras un reinicio read(fd) no significaría nada. NFSv2 eligió un servidor sin estado (stateless): cada pedido trae todo lo necesario para atenderlo.

La pieza central es el file handle: (volumen, número de inodo, número de generación). La generación cambia cuando un inodo se reutiliza, así un file handle viejo no termina abriendo otro archivo. Algunos mensajes del protocolo:

Mensaje Lleva
LOOKUP file handle del directorio, nombre
GETATTR file handle
READ file handle, offset, cantidad
WRITE file handle, offset, cantidad, datos
CREATE, MKDIR, REMOVE file handle del directorio, nombre

open("/foo") envía LOOKUP(fh de la raíz, "foo") y recibe el file handle. Es el cliente el que guarda el descriptor, el file handle y el offset; un read(fd, buf, 4096) se convierte en READ(fh, offset, 4096). close solo libera la entrada local.

Animación: el programa llama a open("/foo"), dos veces a read(3, buf, 4096) y a close(3). El cliente NFS convierte open en LOOKUP(fh raíz, "foo"), que devuelve el fh (1, 27, 4), y cada read en un READ con el fh y el offset: 0 y luego 4096. Abajo, el estado en el cliente pasa de offset 0 a 4096 y a 8192, y close lo borra sin enviar nada; el estado en el servidor es ninguno todo el tiempoAnimación: el programa llama a open("/foo"), dos veces a read(3, buf, 4096) y a close(3). El cliente NFS convierte open en LOOKUP(fh raíz, "foo"), que devuelve el fh (1, 27, 4), y cada read en un READ con el fh y el offset: 0 y luego 4096. Abajo, el estado en el cliente pasa de offset 0 a 4096 y a 8192, y close lo borra sin enviar nada; el estado en el servidor es ninguno todo el tiempo
El descriptor y el offset viven en el cliente. Cada pedido lleva el fh y el offset, así que al servidor no le hace falta recordar nada entre uno y otro.

Idempotencia

Como cada pedido trae el offset absoluto, repetir un WRITE(fh, 8192, "hola") deja el archivo igual que hacerlo una sola vez. Una operación así es idempotente. READ, WRITE, GETATTR y LOOKUP lo son.

Animación: dos diagramas de mensajes lado a lado. En ambos, el cliente envía un pedido, el servidor lo ejecuta, la respuesta se pierde, vence el timeout y el cliente reintenta. A la izquierda, ADD(1) deja el contador en 2; a la derecha, WRITE en el offset 8192 deja el mismo bloque que la primera vezAnimación: dos diagramas de mensajes lado a lado. En ambos, el cliente envía un pedido, el servidor lo ejecuta, la respuesta se pierde, vence el timeout y el cliente reintenta. A la izquierda, ADD(1) deja el contador en 2; a la derecha, WRITE en el offset 8192 deja el mismo bloque que la primera vez
La misma falla, dos operaciones. Reintentar ADD(1) suma dos veces; reintentar un WRITE con offset absoluto no cambia nada.

Esto responde la tercera pregunta de repaso. Desde el cliente, tres fallas se ven igual: se perdió el pedido, se perdió la respuesta, o el servidor se cayó (antes o después de ejecutar). Si todas las operaciones son idempotentes, el cliente tiene un solo remedio para las tres: reintentar. No necesita saber cuál ocurrió, y el servidor no necesita la tabla de secuencias que usamos en udprpc. Algunas operaciones son más difíciles: un MKDIR repetido responde “ya existe” aunque el primero sí funcionó.

La caché del cliente

Para no cruzar la red en cada read, el cliente guarda bloques en memoria. Eso trae dos problemas de consistencia:

  • Visibilidad: si C2 escribe y deja los bloques en su caché, C3 lee la versión vieja del servidor. NFS usa flush-on-close: al cerrar, el cliente envía todo lo que modificó.
  • Caché vencida: C1 sigue usando la versión vieja que tenía cacheada. Antes de usarla, NFS envía GETATTR para comparar la fecha de modificación; para no saturar el servidor, se agregó una caché de atributos de unos 3 segundos, durante la cual se pueden leer datos viejos.

El servidor, en cambio, no puede responder WRITE antes de llevarlo a almacenamiento estable: si confirma el bloque b desde memoria y se cae, el cliente no lo reintenta y el archivo queda con el valor viejo en medio. Por eso los servidores NFS usan memoria con batería o sistemas como WAFL.

AFS: archivos completos y callbacks

AFS (Carnegie Mellon, años 80) buscó que un servidor atendiera muchos clientes. Desde la primera versión cachea archivos completos en el disco local: al abrir se trae el archivo entero, se trabaja localmente y al cerrar, si cambió, se devuelve. Pero al reabrir, el cliente preguntaba “¿cambió?” (TestAuth) y el servidor recorría rutas completas: aguantaba unos 20 clientes.

AFSv2 introdujo dos cambios:

  • Callbacks: al entregar un archivo, el servidor promete avisar al cliente si alguien lo modifica. El cliente ya no pregunta; usa su copia hasta recibir un aviso. Es el paso de polling a interrupción.
  • FID (volumen, archivo, generación) en lugar de rutas: el cliente recorre los directorios y los guarda en su caché.

Un servidor pasó a atender unos 50 clientes. El precio es que ahora el servidor tiene estado: sabe qué clientes tienen copia de cada archivo.

Un ejemplo: C1 y C2 abren F y reciben v1; el servidor anota callbacks para los dos. C2 escribe v2 y cierra, enviando F al servidor, que rompe el callback de C1. Si C1 todavía tiene F abierto, sigue leyendo v1 hasta cerrarlo; el próximo open trae v2.

Animación: tres líneas de vida, C1, el servidor y C2. C1 abre F y recibe v1; el servidor anota un callback para C1. C2 abre F y recibe v1; los callbacks son C1 y C2. C2 escribe v2 y cierra, y el servidor guarda v2. El servidor envía a C1 "romper callback de F" y la copia de C1 queda inválida; el próximo open de C1 trae v2Animación: tres líneas de vida, C1, el servidor y C2. C1 abre F y recibe v1; el servidor anota un callback para C1. C2 abre F y recibe v1; los callbacks son C1 y C2. C2 escribe v2 y cierra, y el servidor guarda v2. El servidor envía a C1 "romper callback de F" y la copia de C1 queda inválida; el próximo open de C1 trae v2
El servidor recuerda quién tiene cada archivo y avisa cuando cambia. Mientras no llegue un aviso, el cliente usa su copia sin preguntar.

Consistencia y fallos

En AFS, lo escrito no se ve en otras máquinas hasta el close. Si dos clientes modifican el mismo archivo, gana el último en cerrar (last writer wins): el archivo queda entero de uno o del otro, mientras que en NFS puede mezclar bloques.

Los callbacks complican la recuperación: si un cliente o el servidor se reinicia, pudo perderse un aviso, y los clientes deben revalidar su caché.

NFSv2 AFSv2
Qué se cachea bloques, en memoria archivos completos, en disco
¿Cambió? GETATTR (caché de 3 s) callback
Visibilidad al cerrar al cerrar
Escrituras simultáneas bloques mezclados gana el último en cerrar
Servidor sin estado con estado
Tras una caída reintentar revalidar cachés

Resumen

  • LFS escribe todo en segmentos al final del log; como los inodos se mueven, necesita un imap, una CR fija y un limpiador que use el resumen del segmento.
  • Una SSD no puede sobrescribir: su FTL es un LFS con tabla de mapeo, GC y nivelación de desgaste. La amplificación de escritura se dispara con el disco casi lleno.
  • Los LSE se detectan solos; la corrupción silenciosa necesita checksums, un identificador físico, el checksum en otro lugar para las escrituras perdidas y scrubbing.
  • Sobre una red que pierde mensajes: acks, timeouts, reintentos y secuencias; RPC los esconde detrás de una llamada.
  • NFS: servidor sin estado y operaciones idempotentes; flush-on-close y GETATTR. AFS: archivos completos, callbacks, gana el último en cerrar.

Ejercicios

  1. Con T_pos = 8 ms y R_pico = 200 MB/s, ¿de qué tamaño debe ser un segmento de LFS para alcanzar el 90 % de la tasa pico? ¿Y el 95 %?
  2. En LFS, un programa crea el archivo /a y escribe un bloque. Enumeren todo lo que LFS agrega al log (datos, inodos, directorio, pedazos de imap). Comparen con lo que escribiría xv6 en su log y luego en los lugares fijos.
  3. Ejecuten ./ftl con ocupaciones de 30 % a 90 % en pasos de 10 y grafiquen la amplificación de escritura. Modifiquen gc() en ftl.c para que elija un bloque al azar en vez del que tiene menos páginas vivas. ¿Cómo cambia la curva?
  4. Calculen a mano la suma, el XOR y Fletcher de los datos 0, 1, 2, 3. Encuentren otros cuatro bytes distintos con el mismo Fletcher. Verifiquen con checksum.py -D 0,1,2,3 -c.
  5. Modifiquen scrub.c para que guarde, junto a cada CRC, el número de bloque, y que check informe si el bloque leído tiene el identificador de otro bloque. ¿Qué falla de las vistas en el capítulo detecta este cambio?
  6. En udprpc.c, cambien ADD por una operación SET(x) que asigna el contador. Corran ./udprpc 200 20 sin dedup. ¿Hay valores incorrectos? ¿Por qué?
  7. Dos clientes NFS abren el mismo archivo de 3 bloques y escriben los 3 bloques a la vez, intercalando los WRITE. Describan los posibles contenidos finales. Repitan el análisis con AFS.