Hablar con dispositivos lentos: bloqueo, interrupciones y colas
Por que una llamada al sistema cuesta cientos de nanosegundos, interrupciones frente a sondeo, disponibilidad frente a finalizacion y por que leer un archivo byte a byte es miles de veces mas lento.
Un procesador ejecuta una instruccion en una fraccion de nanosegundo. Un disco responde en una decima de milisegundo. Es una brecha de seis ordenes de magnitud entre el dispositivo que gestiona el sistema operativo y lo que gestiona, y practicamente cada decision de diseno en la entrada y salida existe para esconderla.
CPU register: ~0.3 ns
L1 cache: ~1 ns
RAM: ~30 ns
SSD: ~100 000 ns
HDD: ~8 000 000 ns
SSD / RAM = 3000xLa brecha que existe para que la esconda el subsistema de entrada y salida.El modelo mas simple es la entrada y salida bloqueante. El proceso llama a read(), el kernel envia la peticion al dispositivo y el hilo que llamo sale de la cola de ejecucion y se duerme. No esta girando ni recibe procesador: no cuesta nada hasta que el dispositivo termina. Entonces el dispositivo lanza una interrupcion, el kernel despierta el hilo y read() devuelve los datos. Desde el punto de vista del programa, una lectura bloqueante es una linea de codigo. Desde el punto de vista del sistema es un cambio de contexto de ida y otro de vuelta, mas todo lo que tardo el dispositivo.
La alternativa a la interrupcion es el sondeo: el procesador pregunta una y otra vez al dispositivo si ha terminado. Suena claramente peor y normalmente lo es, pero no siempre. Una interrupcion no es gratis: el procesador debe detenerse, guardar estado, ejecutar el manejador, y el manejador destruye la utilidad de la cache de instrucciones y del predictor de saltos. Si un dispositivo completa una peticion cada pocos microsegundos, la tasa de interrupciones se vuelve el cuello de botella y la maquina pasa el tiempo entrando y saliendo de manejadores. Por eso se elige por escala de tiempo: interrupciones para dispositivos lentos e impredecibles como discos y teclados, sondeo para los rapidos donde la espera es corta y conocida. Las tarjetas de red bajo carga alta son el caso clasico, y por eso pasan solas de interrupciones a sondeo cuando sube la tasa de paquetes.
polling: 10 000 000 spins/s, 1 core busy
interrupt: no cost while the device is idle
slow device, 0.1 ms -> interrupts win
fast device, 40 000 iops -> interrupts cost 4 ms/sEl compromiso, en numeros. Ninguna de las dos respuestas es correcta en general; decide la escala de tiempo.Y ahora el coste que sorprende. Una llamada a read() no es una llamada a funcion. Es una transicion controlada al kernel: el procesador cambia a modo kernel, se guardan los registros, se validan los argumentos, se comprueban los permisos y al volver se revisan senales pendientes y puede haber un cambio de planificacion. Todo eso ocurre antes de que el kernel haya leido un solo byte de donde sea. Medido en un procesador moderno, una llamada al sistema vacia cuesta entre 100 y 300 nanosegundos, frente a unos 2 nanosegundos de una llamada dentro del mismo proceso. Ese es el numero que explica buena parte de los problemas reales de rendimiento.
size_t n = read(fd, buf, 4096);
call: ~2 ns
read(4096): ~200 ns
read(1): ~1000 ns
4096 calls instead of 1 -> 4000xLos mismos bytes entregados en una llamada y en cuatro mil llamadas.Junta esos dos numeros y un consejo conocido deja de ser una cuestion de estilo. Un programa que lee un archivo de 1 MiB byte a byte hace 1048576 llamadas al sistema. A 200 nanosegundos cada una son unos 0,21 segundos de puro coste de llamada, antes de tocar un solo byte de datos, y ese es el caso optimista en el que cada byte ya esta en la cache de paginas. El mismo archivo leido en bloques de 4 KiB necesita 256 llamadas y unos 51 microsegundos de coste. Leer en bloques de 64 KiB necesita 16 llamadas. La relacion entre la version byte a byte y la version por bloques se mide en miles, y nada de eso tiene que ver con la velocidad del disco.
Cuando un hilo debe esperar a muchos dispositivos a la vez, la lectura bloqueante no sirve: la primera llamada se bloquea y los otros diez sockets no se miran nunca. La respuesta antigua eran select() y poll(), que entregan al kernel una lista de descriptores y preguntan cuales tienen datos. Ambos son lineales en el numero de descriptores, asi que con diez mil conexiones cada llamada recorre la lista entera. epoll fue el arreglo: los descriptores se registran una vez y el kernel mantiene los interesantes en una lista de listos. El programa se bloquea una vez en epoll_wait y recibe solo los sockets que de verdad tienen algo.
io_uring se construyo justo para eliminar ese paso. En lugar de registrar interes y luego emitir una llamada al sistema por operacion, el programa escribe una peticion en una cola de envio que vive en memoria compartida, y el kernel escribe el resultado en una cola de finalizacion. Una sola llamada al sistema puede enviar un lote de docenas de lecturas o, con el modo de sondeo activado, ninguna. Este es un modelo de finalizacion: el resultado aparece en la cola de finalizacion cuando la lectura ya ha ocurrido. La diferencia importa sobre todo con muchas operaciones pequenas, porque convierte N llamadas al sistema en aproximadamente una.
/* readiness model */
for (;;) {
n = epoll_wait(ep, evs, 128, -1);
for (i = 0; i < n; i++) read(evs[i].data.fd, buf, 4096);
}
/* completion model */
for (;;) {
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_submit(&ring);
}Dos modelos en paralelo. El primero pregunta si puede leer; el segundo encarga la lectura y recoge la respuesta.La diferencia practica entre ellos no es filosofica. En el modelo de disponibilidad cada evento cuesta una llamada al sistema para esperar mas otra para leer. En el modelo de finalizacion la lectura se encola en memoria y no cuesta ninguna transicion. Para un servidor que hace decenas de miles de operaciones pequenas por segundo esa es la diferencia entre pasar casi todo el tiempo en el kernel y pasar casi todo el tiempo en trabajo real. Tambien explica por que los numeros cambian tanto con la profundidad de cola: una operacion en vuelo nunca puede llenar un dispositivo rapido.
epoll: 1 submit per batch
io_uring: 1 submit for N reads
queue depth 1: 1 000 000 iops
queue depth 32: 3 000 000 iopsLa misma interfaz con distintas profundidades de cola. Las colas profundas son lo que hace utiles a los dispositivos rapidos.El almacenamiento en bufer es el nombre general de todos los sitios donde se copian datos. El kernel mantiene una cache de paginas, asi que una segunda lectura del mismo archivo nunca llega al disco. La biblioteca de C mantiene un bufer en espacio de usuario para stdio, asi que fgetc() normalmente no llama al kernel en absoluto. Ambas son la misma idea: agrupar la operacion lenta y repartir su coste. Ambas tienen tambien el mismo precio: lo que escribiste no esta necesariamente todavia en el dispositivo.
Las reglas practicas que se siguen. Nunca emitas una operacion pequena donde baste una grande: el tamano de bloque es la palanca mas grande, y los bloques mayores ganan hasta que dejan de caber en cache. No llames a fsync en cada escritura si la correccion no lo exige de verdad, y cuando lo exija, agrupa. Cuando un hilo deba esperar a muchos dispositivos, usa notificacion de disponibilidad o de finalizacion en lugar de un hilo por conexion, porque un hilo cuesta una pila y un cambio de contexto. Y al medir, mide el tiempo de sistema por separado del de usuario: un programa cuyo tiempo de sistema aplasta al de usuario esta hablando con el kernel demasiadas veces.
Práctica
Un proceso llama a read() sobre un archivo cuya pagina no esta en la cache de paginas. Ordena lo que ocurre, desde que el que llama entra en el kernel hasta que read() devuelve los datos.
- 1el kernel valida los argumentos, comprueba los permisos y busca la pagina en la cache de paginas
- 2el manejador de la interrupcion despierta al hilo que esperaba
- 3el dispositivo transfiere los datos y lanza una interrupcion
- 4el proceso ejecuta la llamada al sistema y el procesador cambia a modo kernel
- 5la llamada al sistema vuelve y el hilo continua con su bufer lleno
- 6la peticion se envia al dispositivo y el hilo que llamo se duerme
Pistas
- El dispositivo nunca llama al proceso directamente: solo senala al procesador, y el procesador ejecuta una rutina que pertenece al kernel.
- El proceso no pierde su sitio: se le aparca antes de la transferencia y se le recoge de nuevo solo cuando los datos han llegado a su bufer.
Dos programas leen el mismo archivo de 1 MiB, ya en la cache de paginas. El primero llama a read() una vez por byte, el segundo una vez por cada 4096 bytes. Reescribe el primero para que lea en bloques de 4096 bytes.
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
- Compara los dos programas sentencia a sentencia: la unica diferencia es cuantas veces el cuerpo del bucle entra en el kernel.
- El coste que domina aqui es por llamada y no por byte, asi que cuenta primero las llamadas y solo despues mira las cantidades de bytes.
Empareja cada mecanismo de entrada y salida con lo que hace en realidad.
Pistas
- Separa por quien espera: en un grupo el programa se aparca hasta que la operacion termina, en el otro solo pregunta si la operacion podria empezar ahora.
- Las dos interfaces modernas se diferencian en lo que significa la notificacion: una dice que el socket esta listo para leerse, la otra dice que la lectura ya se ha hecho.
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.