Source: http://sistop.gwolf.org/html/04_administracion_de_memoria.html
Timestamp: 2020-08-13 07:57:11+00:00

Document:
Sistemas Operativos — Administración de memoria
1 Funciones y operaciones del administrador de memoria
1.1 Espacio de direccionamiento
1.2 Hardware: de la unidad de manejo de memoria (MMU)
1.3 La memoria caché
1.4 El espacio en memoria de un proceso
1.5 Resolución de direcciones
2 Asignación de memoria contigua
2.1 Partición de la memoria
3.2 Intercambio parcial
3.3 Ejemplificando
4.1 Tamaño de la página
4.2 Almacenamiento de la tabla de páginas
4.3 Memoria compartida
5.1 Paginación sobre demanda
5.3 Reemplazo de páginas
5.4 Asignación de marcos
5.5 Hiperpaginación
6.1 Desbordamientos de buffer (buffer overflows)
6.2 Mecanismos de mitigación
6.3 Bibliotecas de ligado estático y ligado dinámico
6.4 Recuperación de datos del intercambio
7 Consideraciones de rendimiento
El único espacio de almacenamiento que el procesador puede utilizar directamente, más allá de los registros (que si bien le son internos y sumamente rápidos, pero de capacidad muy escasa) es la memoria física. Todas las arquitecturas de procesador tienen instrucciones para interactuar con la memoria, pero ninguna lo tiene para hacerlo con medios persistentes de almacenamiento, como las unidades de disco1. Cabe mencionar que cuando veamos en un texto referencia al almacenamiento primario siempre se referirá a la memoria, mientras que el almacenamiento secundario se refiere a los discos u otros medios de almacenamiento persistente.
Todos los programas que deseemos ejecutar deben cargarse a la memoria del sistema antes de ser utilizados. En esta unidad veremos cómo administra el sistema operativo a la memoria para permitir que varios procesos la compartan — Esta tarea debe preverse desde el proceso de compilación de nuestros programas (en particular, la fase de ligado). Hoy en día, además, casi todos los sistemas operativos emplean implementaciones que requieren de hardware especializado — La Unidad de Manejo de Memoria (MMU). Hablaremos de cómo se manejaban los sistemas multitarea antes de la universalización de los MMU, y qué rol juegan hoy en día.
En esta primer sección, veremos algunos conceptos base que iremos hilando y empleando en las secciones subsecuentes.
La memoria está estructurada como un arreglo direccionable de bytes. Esto es, al solicitar los contenidos de una dirección específica de memoria, el hardware nos entregará un byte (8 bits), y no menos. Si queremos hacer una operación sobre bits específicos, tenemos que solicitar y almacenar bytes enteros. En algunas arquitecturas, el tamaño de palabra es mayor — Por ejemplo, los procesadores Alpha incurrían en fallas de alineación si se solicitaba una dirección de memoria no alineada a 64 bits, y toda llamada a direcciones mal alineadas tenía que ser atrapada por el sistema operativo, re-alineada, y entregada.
Un procesador que soporta un espacio de direccionamiento de 16 bits puede referirse directamente a hasta bytes, esto es, a hasta 65,536 bytes (64K). Estos procesadores fueron comunes en las décadas de 1970 y 1980 — Los más conocidos incluyen al Intel 8080 y 8085, Zilog Z80, MOS 6502 y 6510, y Motorola 6800. Hay que recalcar que estos procesadores son reconocidos como procesadores de 8 bits, pero con espacio de direccionamiento de 16 bits. El procesador empleado en las primeras PC, el Intel 8086, manejaba un direccionamiento de 20 bits (hasta 1024KB), pero al ser una arquitectura real de 16 bits requería del empleo de segmentación para alcanzar toda su memoria.
Hoy en día, los procesadores dominantes son de 32 o 64 bits. Un procesador de 32 bits puede direccionar hasta 4,294,967,296 bytes (4GB), que está ya dentro de los parámetros de lo esperable hoy en día; una arquitectura de 32 bits sin extensiones adicionales no puede emplear más de 4GB RAM; a través de un mecanismo llamado PAE (Extensión de Direcciónes Físicas, Physical Address Extension) permite extender esto a rangos de hasta a cambio de un nivel más de indirección.
Un procesador de 64 bits podría direccionar hasta 18,446,744,073,709,551,616 bytes (16 Exabytes). Los procesadores comercialmente hoy en día no ofrecen esta capacidad de direccionamiento principalmente por un criterio económico: Al resultar tan poco probable que exista un sistema con estas capacidades, los chips actuales están limitados a entre y bits — 1 y 256 terabytes. Esta restricción debe seguir teniendo sentido económico por muchos años aún.
A lo largo de la historia de las computadoras ha sido necesario emplear más memoria de la que está directamente disponible — Por un lado, ofrecer a los procesos más espacio de lo que puede direccionar la arquitectura (hardware) que empleamos, por otro lado la abstracción de un espacio virtualmente ilimitado para realizar sus operaciones incluso cuando la memoria real es mucho menor a la solicitada, y por último, la ilusión de tener un bloque contiguo e ininterrumpido de memoria, cuando en realidad puede haber alta fragmentación.
Veremos cómo es que el MMU cubre estas necesidades, qué mecanismos emplea para lograrlo — Y qué debemos cuidar, incluso como programadores de aplicaciones de alto nivel, para aprovechar de la mejor manera estas funciones (y evitar, por el contrario, que nuestros programas se vuelvan lentos por no saber manejar la memoria) correctamente.
El MMU es también el encargado de verificar que un proceso no tenga acceso a leer o modificar los datos de otro — Si el sistema operativo tuviera que verificar que ninguna de las instrucciones ejecutadas por un programa resulta en una violación de seguridad, la penalización en velocidad sería demasiado severa2.
Una primer aproximación a la protección de acceso es a través de un registro base y un registro límite: Si la arquitectura ofrece dos registros del procesador que sólo pueden ser modificados por el sistema operativo (Esto es, el hardware define la modificación de dichos registros como una operación privilegiada que requiere estar ejecutando en modo supervisor), el MMU puede comparar cada acceso a memoria para verificar que esté en el rango permitido.
Por ejemplo, si a un proceso le fue asignado un espacio de memoria de 64K (65535 bytes) a partir de la dirección 504214 (492K), el registro base contendría 504214, y el registro límite 65535. Si hubiera una instrucción por parte de dicho proceso que solicitara una dirección menor a 504214 o mayor a 569749 (556K), el MMU lanzaría una excepción o trampa interrumpiendo el procesamiento, e indicando al sistema operativo que ocurrió una violación de segmento (segmentation fault)3. El sistema operativo entonces procedería terminando la ejecución del proceso, reclamando todos los recursos que tuviera asignados y notificando a su usuario.
Espacio de direcciones válidas para el proceso 3 definido por un registro base y un registro límite
Hay otro proceso que hoy en día asumimos como un hecho: La memoria caché. Si bien su manejo es (casi) transparente para el sistema operativo, es muy importante mantenerlo en mente.
Conforme el procesador va avanzando sobre las instrucciones de un programa (avanzando el registro de conteo de instrucción), se van produciendo accesos a memoria. Por un lado, tiene que buscar en memoria la siguiente instrucción a ejecutar. Por otro lado, estas instrucciones pueden requerirle uno o más operadores adicionales que deben ser traídos de la memoria. Por último, la instrucción puede requerir guardar su resultado en cierta dirección de memoria.
Hace años esto no era un problema — La velocidad del procesador estaba básicamente sincronizada con la del manejador de memoria, y el flujo podía mantenerse básicamente estable. Pero conforme los procesadores se fueron haciendo más rápido, y conforme se ha popularizado el procesamiento en paralelo, la memoria no ha podido acelerarse a la misma velocidad. La memoria de alta velocidad es demasiado cara, e incluso las distancias de unos pocos centímetros se van volviendo obstáculos insalvables por la velocidad máxima de los electrones viajando por pistas conductoras.
Cuando el procesador solicitó el contenido de una dirección de memoria y esta no está aún disponible, tiene que detener su ejecución (stall) hasta que los datos estén disponibles. El CPU no puede, a diferencia del sistema operativo, "congelar" todo y guardar el estado para atender a otro proceso: Para el procesador, la lista de instrucciones a ejecutar es estrictamente secuencial, y todo tiempo que requiere esperar a una transferencia de datos es tiempo perdido.
La respuesta para reducir esa espera es la memoria caché. Esta es memoria de alta velocidad, situada entre la memoria principal y el procesador propiamente, que guarda copias de las páginas que van siendo accesadas, partiendo del principio de la localidad de referencia:
Es probable que un recurso que fue empleado recientemente vuelva a ser empleado en un futuro cercano.
La probabilidad de que un recurso aún no requerido sea accesado es mucho mayor si fue requerido algún recurso cercano.
Localidad secuencial
Un recurso, y muy particularmente la memoria, tiende a ser requerido de forma secuencial.
Patrones de acceso a memoria, demostrando la localidad espacial / temporal (Silberschatz, p.350)
Basándonos en estos conceptos, vemos que cuando el procesador solicita al hardware determinada dirección de memoria, el hardware no sólo transfiere a la memoria caché el byte o palabra solicitado, sino que un bloque o página completo.
Cabe mencionar que hoy en día (particularmente desde que se detuvo la guerra de los Megahertz parte importante del diferencial en precios de los procesadores líderes en el mercado es la cantidad de cache de primero y segundo nivel con que cuentan.
Cuando un sistema operativo inicia un proceso, no se limita a volcar el archivo ejecutable a memoria, sino que tiene que proveer la estructura para que éste vaya guardando la información de estado relativa a su ejecución.
Es el nombre que recibe la imagen en memoria de las instrucciones a ser ejecutadas. Típicamente, la sección de texto ocupa las direcciones más bajas del espacio en memoria.
Espacio fijo preasignado para las variables globales. Este espacio es fijado en tiempo de compilación, y no puede cambiar (aunque los datos que carga sí cambian en el tiempo de vida del proceso)
Espacio de libres
(Heap) Espacio de memoria que se emplea para la asignación dinámica de memoria durante la ejecución del proceso. Este espacio se ubica por encima de la sección de datos, y crece hacia arriba.
Cuando el programa es escrito en lenguajes que requieren manejo manual de la memoria (como C), esta área es la que se maneja a través de las llamadas de la familia de malloc y free; en lenguajes con gestión automática, esta área es monitoreada por los recolectores de basura (volveremos a estos conceptos más adelante).
(Stack) Estructuras representando a la serie de funciones que han sido llamadas dentro del proceso, con sus parámetros, direcciones de retorno, variables locales, etc. La pila ocupa la parte más alta del espacio en memoria, y crece hacia abajo.
Regiones de la memoria para un proceso
Un programa compilado no emplea nombres simbólicos para las variables o funciones que llama4; el compilador, al convertir el programa a lenguaje máquina, las substituye por la dirección en memoria donde se encuentra.
Ahora bien, en los sistemas actuales, los procesos requieren coexistir con otros, para lo cual las direcciones indicadas en el texto del programa pueden requerir ser traducidas al lugar relativo al sitio de inicio del proceso en memoria — Esto es, resueltas. Podemos hablar de las siguientes tres estrategias de resolución:
El texto del programa tiene la dirección absoluta de los datos y funciones. Esto era muy común en las computadoras previas al multiprocesamiento; en la arquitectura compatible con PC, el formato ejecutable .COM es un volcado de memoria directo de un archivo objeto con las direcciones indicadas de forma absoluta. Esto lo podemos ver hoy principalmente en sistemas embebidos o de función específica.
Al cargarse a memoria el programa y antes de iniciar su ejecución, el cargador (componente del sistema operativo) actualiza las referncias a memoria dentro del texto para que apunten al lugar correcto — Claro está, esto depende de que el compilador indique dónde están todas las referencias a variables y funciones.
El programa nunca hace referencia a una ubicación absoluta de memoria, sino que lo hace siempre relativo a una base y un desplazamiento (offset). Esto permite que el proceso sea incluso reubicado en la memoria mientras está siendo ejecutado sin tener que sufrir cambios, pero requiere de hardware específico (como un MMU).
Esto es, los nombres simbólicos (por ejemplo, la variable llamada contador) para ser traducidos ya sea a ubicaciones en la memoria, pueden resolverse en tiempo de compilación (y quedar plasmada en el programa en disco con una ubicación explícita y definitiva: 510200), en tiempo de carga (sería guardada en el programa en disco como inicio + 5986 bytes, y el proceso de carga incluiría substituirla por la dirección resuelta a la suma del registro base, 504214, y el desplazamiento, 5986, esto es, 510200).
Por último, para emplear la resolución en tiempo de ejecución, se mantiene en las instrucciones a ser ejecutadas por el proceso la etiqueta relativa al módulo actual, inicio + 5986 bytes, y es resuelta cada vez que sea requerido.
Proceso de compilación y carga de un programa, indicando el tipo de resolución de direcciones (Silberschatz, p.281)
En los sistemas de ejecución en lotes, así como en las primeras computadoras personales, sólo un programa se ejecutaba a la vez, por lo que, más allá de la carga del programa y la satisfacción de alguna eventual llamada al sistema solicitando recursos, el sistema operativo no tenía que ocuparse de la asignación de memoria.
Al nacer los primeros sistemas operativos multitarea, se hizo necesario resolver cómo asignar el espacio en memoria a diferentes procesos.
La primer respuesta, claro está, es la más sencilla: Asignar a cada programa a ser ejecutado un bloque contiguo de memoria de un tamaño fijo. En tanto los programas permitieran la resolución de direcciones en tiempo de carga o de ejecución, podrían emplear este esquema.
El sistema operativo emplearía una región específica de la memoria del sistema (típicamente la región baja — Desde la dirección en memoria 0x00000000 hacia arriba), y una vez terminado el espacio necesario para el núcleo y sus estructuras, el sistema asigna espacio a cada uno de los procesos. Si la arquitectura en cuestión permite limitar los segmentos disponibles a cada uno de los procesos (por ejemplo, con los registros base y límite discutidos anteriormente), esto sería suficiente para alojar en memoria a varios procesos y evitar que interfieran entre sí.
Desde la perspectiva del sistema operativo, cada uno de los espacios asignados a un proceso es una partición. Cuando el sistema operativo inicia, toda la memoria disponible es vista como un sólo bloque, y conforme se van lanzando procesos, este bloque va siendo subdividido para satisfacer sus requisitos. Cada proceso debe indicar al sistema operativo cuánta memoria va a requerir a lo largo de su vida prevista.
La fragmentación comienza a aparecer cuando más procesos van siendo lanzados y van terminando: Al terminar la ejecución de un proceso, ya no tenemos sólo un bloque de espacio libre, sino dos. Potencialmente, cada programa que vaya terminando, irá devolviendo al sistema operativo un bloque de distinto tamaño, que quede entre dos procesos activos.
Si la computadora no tiene hardware específico que permita que los procesos resuelvan sus direcciones en tiempo de ejecución, el sistema operativo no puede reasignar los bloques existentes, y aunque pudiera hacerlo, mover un proceso entero en memoria puede resultar una operación cara en tiempo de procesamiento.
Al lanzarse un nuevo programa, el sistema operativo tiene tres estrategias según las cuales podría asignarle uno de los bloques disponibles:
Asigna al nuevo proceso el primer bloque que encuentre donde éste quepa. Este mecanismo es el más simple de implementar y el de más rápida ejecución.
El sistema busca entre todos los bloques disponibles cuál es el que mejor se ajusta al tamaño requerido por el nuevo proceso. Esto requiere la revisión completa de la lista de bloques; lleva a que los bloques remanentes, una vez que se ubicó al nuevo proceso, sean tan pequeños como sea posible (esto es, que haya menor desperdicio).
El sistema busca cuál es el bloque más grande disponible, y se lo asigna al nuevo proceso. Empleando una estrucura de datos como un montículo, esta operación puede ser incluso más rápida que la de primer espacio. Con este mecanismo se busca que los bloques que queden después de otorgarlos a un proceso sean tan grandes como sea posible, de cierto modo balanceando su tamaño.
La fragmentación externa se produce cuando hay muchos bloques libres entre bloques asignados a procesos; la fragmentación interna se refiere a la cantidad de memoria perdida por asuntos administrativos — Por ejemplo, si el sistema operativo maneja bloques de 512 bytes y un proceso requiere 768 bytes para su ejecución, el sistema le entregará dos bloques (1024 bytes), con lo cual desperdicia 256. En el peor de los casos, con un bloque de bytes, un proceso podría solicitar bytes de memoria, desperdiciando por fragmentación interna bytes.
Según análisis estadísticos (Silberschatz, p.289), por cada N bloques asignados, se perderán del órden de 0.5N bloques por fragmentación interna y externa.
2.1.2 Compactación
Un problema importante que va surgiendo como resultado de esta fragmentación es que el espacio total libre de memoria puede ser mucho mayor que lo que requiere un nuevo proceso, pero al estar fragmentada en muchos bloques, éste no encontrará una partición donde ser cargado.
Si los procesos emplean resolución de direcciones en tiempo de ejecución, cuando el sistema operativo comience a detectar un alto índice de fragmentación, puede lanzar una operación de compresión o compactación: Mover los contenidos en memoria de los procesos en ejecución para que ocupen espacios contiguos, permitiendo unificar varios bloques libres en uno solo.
Compactación de la memoria de procesos en ejecución
La compactación tiene un costo alto — Involucra mover prácticamente la totalidad de la memoria (probablemente más de una vez por bloque).
2.1.3 Intercambio (swap) con el almacenamiento secundario
Siguiendo de cierto modo la lógica requerida por la compactación encontramos los sistemas que utilizan intercambio (swap) entre la memoria primaria y secundaria. En estos sistemas, el sistema operativo puede comprometer más espacio de memoria del que tiene físicamente disponible. Cuando hay más procesos de los que caben en memoria, el sistema suspende a uno de ellos y almacena una copia de su imagen en memoria en almacenamiento secundario.
Hay algunas restricciones a observar previo a suspender un proceso. Un proceso que tiene abierto alguna operación de entrada/salida en curso. Una estrategia ante esto podría ser que todas las operaciones se realicen únicamente a buffers en el espacio del sistema operativo y éste las transfiera al espacio del proceso interesado.
Esta técnica se popularizó en los sistemas de escritorio hacia fines de los 1980 y principios de los 1990, en que las computadoras personales tenían típicamente entre 1 y 8MB de memoria.
Recordemos que las unidades de disco son del órden de decenas de miles de veces más lentas que la memoria, por lo que este proceso resulta muy caro — Por ejemplo, si la imagen en memoria de un proceso es de 100MB, bastante conservador hoy en día, y la tasa de transferencia sostenida al disco de 50MB/s, intercambiar un proceso al disco toma dos segundos. Cargarlo de vuelta a memoria toma otros dos segundos — Y a esto debe sumarse el tiempo de posicionamiento de la cabeza de lectura/escritura, especialmente si el espacio a emplear no es secuencial y contiguo. Resulta obvio por qué esta técnica ha caído en desuso conforme aumenta el tamaño de los procesos.
Al desarrollar un programa, el programador no ve a la memoria como un sólo arreglo plano, en el que todas las direcciones son iguales (si bien está consciente de que la realidad es así). El uso que damos a la memoria sigue una lógica de distintos segmentos: En vez de dar una dirección lineal, damos al procesador una dirección de segmento y un desplazamiento dentro de dicho segmento. Podemos tener segmentos de distintos tamaños presentes en memoria, y la resolución de direcciones de cada uno de ellos se realizará por mecanismos análogos al descrito en el apartado anterior (registro base y desplazamiento). Claro está, esto debe también hacerse con apoyo del MMU.
Una de las implementaciones más obvias y directas de un espacio de memoria segmentado es asignar un segmento distinto a cada una de las secciones mencionadas en la sección espacio en memoria de un proceso.
La segmentación también ayuda a incrementar la modularidad de un programa: Es muy común que las bibliotecas ligadas dinámicamente estén representadas en segmentos independientes.
Una de las principales ventajas del uso de segmentación es que nos permite pedir al MMU que cada uno de los segmentos tenga un distinto juego de permisos para el proceso en cuestíón: El sistema operativo puede indicar, por ejemplo, que el segmento de texto (el código del programa) sea de lectura y ejecución, mientras que la seción de datos es de lectura y escritura. De este modo podemos evitar que un error en la programación resulte en que datos proporcionados por el usuario o por el entorno modifiquen el código que está siendo ejecutado.5 Es más, Incluso, dado que el acceso de ejecución está limitado a sólo los segmentos cargados del disco por el sistema operativo, el atacante no podrá introducir código ejecutable tan fácilmente — Tendría que cargarlo como un segmento adicional con los permisos correspondientes.
Un uso muy común de la segmentación, particularmnete en los sistemas de los 1980s, era el de permitir que sólo ciertas regiones de un programa sean intercambiadas al disco: Si un programa está compuesto por porciones de código que nunca se ejecutarán aproximadamente al mismo tiempo en sucesión, puede separar su texto (e incluso los datos correspondientes) en diferentes segmentos.
A lo largo de la ejecución del programa, algunos de sus segmentos pueden no emplearse por largos periodos de tiempo. Estas páginas pueden ser enviadas al espacio de intercambio (swap) ya sea a solicitud del proceso o por iniciativa del sistema operativo.
En la sección donde presentamos el concepto de intercambio mencionamos que intercambiar un proceso completo resultaba demasaido caro. Cuando hablamos de un espacio de memoria segmentado, y muy particularmente cuando hablamos de bibliotecas de carga dinámica, la sobrecarga es mucho menor:
En primer término, podemos hablar de la cantidad de información que intercambiaremos: En un sistema que sólo maneja regiones contiguas de memoria, intercambiar un proceso significa mover toda su información al disco; cuando hablamos de intercambio en un sistema con segmentación, puede enviarse a disco cada uno de los segmentos por separado, según el sistema operativo lo juzgue necesario. Podría sacar de memoria a alguno de los segmentos, eligiendo no necesariamente al que más estorbe (esto es, el más grande), sino el que más probablemente no esté siendo utilizado: Emplear el principio de localidad de referencia para intercambiar al segmento menos recientemente utilizado (LRU, Least Recently Used).
Además de esto, si hablamos de un segmento de texto (sea el código programa base o alguna de las bibliotecas) y su acceso es de sólo lectura, una vez que éste fue copiado una vez al disco, ya no hace falta volver a hacerlo: Tenemos la certeza de que no será modificado por el proceso en ejecución, por lo que basta marcarlo como no presente en las tablas de segmentos en memoria para que cualquier acceso ocasione que el sistema operativo lo traiga de disco.
Por otro lado, si la biblioteca en cuestión reside en disco (antes de ser cargada) como una imagen directa de su representación en memoria, al sistema operativo le bastará identificar el archivo en cuestión al cargar el proceso; no hace falta siquiera cargarlo en la memoria principal y guardarlo al área de intercambio, puede quedar referido directamente al espacio en disco en que reside el archivo.
Claro está, el acceso a disco sigue siendo una fuerte penalización cada vez que un segmento tiene que ser cargado del disco (sea del sistema de archivos o del espacio de intercambio), pero este mecanismo reduce dicha penalización, haciendo más atractiva la flexibilidad del intercambio por segmentos.
A modo de ejemplo (Finkel, p.79), y conjuntando los conceptos presentados en esta sección, si un proceso tuviera la siguiente tabla de segmentos:
0 13426 26 RWX sí
1 2309 00 R sí
2 1000 23 WX sí
3 - 95 W no
4 10000 100 RWX sí
En la columna de permisos, R se refiere a lectura, W a escritura y X a ejecución.
Un segmento que ha sido enviado al espacio de intercambio (en este caso, el 3), deja de estar presente en memoria y, por tanto, no tiene ya dirección de inicio registrada.
Veamos el resultado de entregar al MMU las siguientes direcciones y tipos de acceso:
0-0 R 13426
2-17 W 1017
2-17 R Atrapada: Violación de seguridad
2-32 R Atrapada: Desplazamiento fuera de rango
3-72 W Atrapada: Segmento faltante
3-94 R Atrapada: Segmento faltante;
4-99 X 10099
7-25 X Atrapada: Segmento invalido
Cuando se atrapa una situación de excepción, el sistema operativo debe intervenir. Por ejemplo, la solicitud de un segmento inválido, de un desplazamiento mayor al tamaño del segmento, o de un tipo de acceso que no esté autorizado, típicamente llevan a la terminación del proceso, en tanto que una de segmento faltante (indicando un segmento que está en el espacio de intercambio) llevaría a la suspensión del proceso, lectura del segmento de disco a memoria, y una vez que éste estuviera listo, se permitiría continuación de la ejecución.
En caso de haber más de una excepción, como podemos verlo en la solicitud de lectura de la dirección 3-94, el sistema debe reaccionar primero a la más severa: Si como resultado de esa solicitud iniciara el proceso de carga del segmento, sólo para abortar la ejecución del proceso al detectarse la violación de tipo de acceso, sería un desperdicio injustificado de recursos.
La fragmentación externa y, por tanto, la necesidad de compactación pueden evitarse por completo empleando la paginación. Esta consiste en que cada proceso esté compuesto por una serie de páginas, dejando de requerir que la asignación sea de un área contigua de memoria. Claro está, esto requiere de mayor espacialización por parte del hardware, y mayor información relacionada a cada uno de los procesos: No nos basta ya con indicar dónde inicia y dónde termina el área de memoria de cada proceso, sino que debemos hacer un mapeo entre la ubicación real (física) y la presentada a cada uno de los procesos (lógica). La memoria se presentará a cada proceso como si fuera de su uso exclusivo.
La memoria física se divide en una serie de marcos (frames), todos ellos del mismo tamaño, y el espacio cada proceso se divide en una serie de páginas (pages), del mismo tamaño que los marcos. El MMU se se encarga del mapeo entre páginas y marcos a través de tablas de páginas. Las direcciones que maneja el CPU ya no son presentadas de forma absoluta, sino que como la combinación de un identificador de página y un desplazamiento — De forma similar a lo que presentamos al hablar de resolución de instrucciones en tiempo de ejecución.
El tamaño de los marcos (y, por tanto, las páginas) debe ser una potencia de 2, de modo que el MMU pueda discernir fácilmente la porción de una dirección de memoria que se refiere a la página del desplazamiento. El rango varía, según el hardware, entre los 512 bytes ( ) y 16MB ( ); al ser una potencia de 2, el MMU puede separar la dirección en memoria entre los primeros bits (referentes a la página) y los últimos bits (referentes al desplazamiento).
Página y desplazamiento, en un esquema de direccionamiento de 16 bits y páginas de 512 bytes
Para poder realizar este mapeo, el MMU requiere de una tabla de páginas (page table), que resuelve la relación entre páginas y marcos, convirtiendo la dirección lógica (aquella que conoce el proceso) en la dirección física (la ubicación en que realmente se encuentra en la memoria del sistema).
Esquema del proceso de paginación, ilustrando el rol del MMU
Podemos tomar como ejemplo para explicar este mecanismo el esquema presentado en el libro de Silberschatz, Galvin y Gagné. Este nos presenta un esquema minúsculo: Un espacio de direccionamiento de 32 bytes (5 bits), organizado en 8 páginas de 4 bytes cada una (esto es, la página es representada con los 3 bits más significativos de la dirección, y el desplazamiento con los 2 bits menos significativos).
Ejemplo (minúsculo) de paginación, con un espacio de direccionamiento de 32 bytes y páginas de 4 bytes
El proceso que se nos presenta tiene una visión de la memoria como la columna del lado izquierdo: Le parece que existen 4 páginas, y tiene sus datos distribuidos en órden desde la dirección 00000 (0) hasta la 01111 (15), aunque en realidad en el sistema éstas se encuentren desordenadas y desperdigadas.
Cuando el proceso quiere referirse a la letra f, lo hace indicando la dirección 00101 (5). De esta dirección, los tres bits más significativos (001, 1 — Y recordemos que para la computadora, lo natural es comenzar a contar por el 0) se refieren a la página número 1, y los dos bits menos significativos (01, 1) indican al desplazamiento dentro de ésta.
El MMU verifica en la tabla de páginas, y encuentra que la página 1 corresponde al marco número 6 (110), por lo que traduce la dirección lógica 00101 (5) a la física 11001 (26).
Podemos ver que la paginación resulta en una suerte de resolución de direcciones en tiempo de ejecución, pero con una base distinta para cada una de las páginas.
Ahora, si bien la fragmentación externa se resuelve al emplear paginación, seguiremos enfrentándonos con la fragmentación interna: Al dividirse la memoria en bloques de longitud preestablecida de bytes, un proceso en promedio desperdiciará (y, en el peor de los casos, hasta ). Multiplicando esto por el número de procesos que están en ejecución en todo momento en el sistema, para evitar que una proporción sensible de la memoria se pierda en fragmentación interna, podríamos vernos tentados a emplear un tamaño de página tan pequeño como fuera posible.
Sin embargo, la sobrecarga administrativa en que incurriríamos por gestionar demasiadas páginas pequeñas se vuelve una limitante en sentido opuesto:
Las transferencias entre unidades de disco y memoria son mucho más eficientes si pueden mantenerse como trechos contínuos. El controlador de disco puede responder a solicitudes de acceso directo a memoria (DMA) siempre que tanto los fragmentos en disco como en memoria sean continuos; fragmentar la memoria demasiado jugaría en contra de la eficiencia de estas solicitudes.
El bloque de control de proceso (PCB) incluye la información de memoria. Entre más páginas tenga un proceso (aunque estas fueran muy pequeñas), más grande es su PCB, y más información requerirá intercambiar en un cambio de contexto.
Estas consideraciones opuestas apuntan a que debemos mantener el tamaño de página más grande, y se regulan con las primeras expuestas en esta sección.
Hoy en día, el tamaño habitual de las páginas es de 4KB u 8KB ( o bytes). Hay algunos sistemas operativos que soportan múltiples tamaños de página — Por ejemplo, Solaris puede emplear páginas de 8KB y 4MB ( o bytes), dependiendo del tipo de información que se declare que almacenarán.
Algunos de los primeros equipos en manejar memoria paginada empleaban un conjunto especial de registros para representar la tabla de páginas. Esto era posible dado que eran sistemas de 16 bits, con páginas de 8KB ($213). Esto significa que en el sistema había únicamente 8 páginas posibles ( ), por lo que resultaba sensato dedicar un registro a cada una.
En los equipos actuales, mantener la tabla de páginas en registros resultaría claramente imposible: Teniendo un procesaador de 32 bits, e incluso si manejáramos un tamaño de página muy grande (digamos, 4MB), tendríamos 1024 páginas posibles6; con un tamaño de páginas mucho más común (4KB, bytes), la tabla de páginas llega a ocupar 5MB.7 Los registros son muy rápidos, sin embargo, son correspondientemente muy caros. El manejo de páginas más pequeñas (que es lo normal), y muy especialmente el uso de espacios de direccionamiento de 64 bits, harían prohibitivo este enfoque. Además, nuevamente, cada proceso tiene una tabla de páginas distinta — Se haría necesario hacer una transferencia de información muy grande en cada cambio de contexto.
Otra estrategia para enfrentar esta situación es almacenar la propia tabla de páginas en memoria, y apuntar al inicio de la tabla con un juego de registros especiales: el registro de base de la tabla de páginas (PTBR, Page Table Base Register) y el registro de longitud de la tabla de páginas (PTLR, Page Table Length Register),8 De esta manera, en el cambio de contexto sólo hay que cambiar dos registros, y además tenemos un espacio muy amplio para poder guardar las tablas de páginas que necesitemos. El problema con este mecanismo es la velocidad: Estaríamos penalizando a cada acceso a memoria con un acceso de memoria adicional — Si para resolver una dirección lógica a su correspondiente dirección física hace fala consultar la tabla de páginas en memoria, el tiempo efectivo de acceso a memoria se duplica.
4.2.1 El buffer de traducción adelantada (TLB)
La salida obvia a este dilema es el uso de un caché. Sin embargo, más que un caché genérico, el MMU utiliza un caché especializado en el tipo de información que maneja: El buffer de traducción adelantada o anticipada (Translation Lookaside Buffer, TLB). El TLB es una tabla asociativa (un hash) en memoria de alta velocidad, una suerte de registros residentes dentro del MMU, donde las llaves son las páginas y los valores son los marcos correspondientes. De este modo, las búsquedas se efectúan en tiempo constante.
El TLB típicamente tiene entre 64 y 1024 entradas. Cuando el procesador efectúa un acceso a memoria, si la página solicitada está en el TLB, el MMU tiene la dirección física de inmediato.9 En caso de no encontrarse la página en el TLB, el MMU lanza un fallo de página (page fault), con lo cual consulta de la memoria principal cuál es el marco correspondiente. Esta nueva entrada es agregada al TLB; por las propiedades de localidad de referencia que vimos anteriormente, la probabilidad de que las regiones más empleadas de la memoria durante un área específica de ejecución del programa sean cubiertas por relativamente pocas entradas del TLB son muy altas.
Como sea, dado que el TLB es limitado, es necesario explicitar un mecanismo que indique dónde guardar las nuevas entradas una vez que el TLB está lleno y se produce un fallo de página. Uno de los esquemas más comunes es emplear la entrada menos recientemente utilizada (LRU, Least Recently Used), nuevamente apelando a la localidad de referencia; esto tiene como consecuencia necesaria que debe haber un mecanismo que contabilice los accesos dentro del TLB (lo cual agrega tanto latencia como costo); otro mecanismo (con obvias desventajas) es el reemplazar una página al azar. Veremos con mayor detalle más adelante algunos de los mecanismos más empleados para este fin, comparando sus puntos a favor y en contra.
4.2.2 Subdividiendo la tabla de páginas
Incluso empleando TLBs, el espacio empleado por las páginas sigue siendo demasiado grande. Consideremos un escenario más frecuente que el propuesto anteriormente: Empleando un procesador con espacio de direccionamiento de 32 bits, y un tamaño de página estándar (4KB, $212), hablamos ya de 1,048,576 ( ) páginas. Si cada entrada de la página ocupa 40 bits10 (esto es, 5 bytes), cada proceso requeriría de 5MB (5 bytes por cada una de las páginas) sólamente para representar su mapeo de memoria. Esto, especialmente en procesos pequeños, resultaría más gravoso para el sistema que los beneficios obtenidos de la paginación.
Aprovechando que la mayor parte del espacio de direccionamiento de un proceso está típicamente vacío, se puede subdividir el identificador de página en dos (o más) niveles, por ejemplo, separando una dirección de 32 bits en una tabla externa de 10 bits, una tabla interna de 10 bits, y el desplazamiento de 12 bits.
Paginación en dos niveles: Una tabla externa de 10 bits, tablas intermedias de 10 bits, y marcos de 12 bits (esquema común para procesadores de 32 bits)
Este esquema funciona adecuadamente para equipos con direccionamiento de hasta 32 bits. Sin embargo, recordemos que cada nivel de páginas conlleva un acceso adicional a memoria en caso de fallo de página — Emplear paginación jerárquica con un nivel externo y uno interno implica que un fallo de página triplica (y no duplica, como sería con un esquema de paginación directo) el tiempo de acceso a memoria. Para obtener una tabla de páginas manejable bajo los parámetros aquí descritos en un sistema de 64 bits, tendríamos que septuplicar el tiempo de acceso (cinco accesos en cascada para fragmentos de 10 bits, y un tamaño de página de 14 bits, mas el acceso a la página destino).
Otra alternativa es el emplear funciones digestoras (hash functions)11 para mapear cada una de las páginas a un espacio muestral mucho más pequeño. Cada página es mapeada a una lista de correspondencias simples12.
Un esquema basado en funciones digestoras nos brinda características muy deseables: El tamaño de la tabla de páginas puede variar según crece el uso de memoria de un proceso (aunque esto requiera recalcular la tabla con diferentes parámetros) y el número de accesos a memoria en espacios tan grandes como el de un procesador de 64 bits se mantiene mucho más tratable. Sin embargo, por la alta frecuencia de accesos a esta tabla, debe elegirse un algoritmo digestor muy ágil para evitar que el tiempo que tome calcular la posición en la tabla resulte significativo frente a las alternativas.
Hay muchos escenarios en que diferentes procesos pueden beneficiarse de compartir áreas de su memoria. Uno de ellos es como mecanismo de comunicación entre procesos (IPC, Inter Process Communication), en que dos o más procesos pueden intercambiar estructuras de datos complejas sin incurrir en el costo de copiado que implicaría copiarlas a través del sistema operativo. Otro caso, mucho más frecuente, es el de compartir código.
Si un mismo programa es ejecutado varias veces, y dicho programa no emplea mecanismos de código auto-modificable, no tiene sentido que las páginas en que se representa cada una de dichas instancias ocupe un marco independiente — El sistema operativo puede asignar a páginas de diversos procesos el mismo conjunto de marcos, con lo cual puede aumentar la capacidad percibida de memoria.
Y si bien es muy común compartir los segmentos de texto de los diversos programas que están en un momento dado en ejecución en la computadora, este mecanismo es todavía más útil cuando hablamos de bibliotecas del sistema: Hay bibliotecas que son empleadas por una gran cantidad de programas13.
Uso de memoria compartida: Tres procesos comparten la memoria ocupada por el texto del programa (azul), difieren sólo en los datos.
Claro está, para ofrecer este modelo, el sistema operativo debe poder garantizar que las páginas correspondientes a las secciones de texto (el código del programa) sean de sólo lectura.
Un programa que está programado y compilado de forma que permita que todo su código sea de sólo lectura es reentrante, dado que posibilita que diversos procesos entren a su espacio en memoria sin tener que sincronizarse con otros procesos que lo estén empleando.
4.3.1 Copiar al escribir (Copy on Write, CoW)
En los sistemas Unix, el mecanismo más frecuentemente utilizado para crear un nuevo proceso es el empleo de la llamada al sistema fork(). Cuando es invocado por un proceso, el sistema operativo crea a un nuevo proceso idéntico al que lo llamó, diferenciándose únicamente en el valor entregado por la llamada a fork(). Si ocurre algún error, el sistema entrega un número negativo (indicando la causa del error). En caso de ser exitoso, El proceso nuevo (o proceso hijo) recibe el valor 0, mientras que el proceso preexistente (o proceso padre) recibe el PID (número identificador de proceso) del hijo. Esto es, es frecuente que veamos:
/* Soy el proceso hijo */
/* Ocurrio un error, no se creo un proceso hijo */
/* Soy el proceso padre */
/* La variable 'pid' tiene el PID del proceso hijo */
Este método es incluso utilizado normalmente para crear nuevos procesos, transfiriendo el ambiente (variables, por ejemplo, que incluyen cuál es la entrada y salida estándar de un proceso, esto es, a qué terminal están conectados, indispensable en un sistema multiusuario). Frecuentemente, la primer instrucción que ejecuta un proceso hijo es execve(), que carga a un nuevo programa sobre del actual y transfiere la ejecución a su primer instrucción.
Cuesta trabajo comprender el por qué de esta lógica si no es por el empleo de la memoria compartida: El costo de fork() en un sistema Unix es muy bajo, se limita a crear las estructuras necesarias en la memoria del núcleo. Tanto el proceso padre como el proceso hijo comparten todas sus páginas de memoria — Sin embargo, siendo dos procesos independientes, no deben poder modificarse más que por los canales explícitos de comunicación entre procesos.
Memoria de dos procesos inmediatamente después de la creación del proceso hijo por fork()
Esto ocurre así gracias a un mecanismo llamado Copiar al escribir (Copy on Write o CoW). Las páginas de memoria de ambos procesos son las mismas mientras sean sólo leídas. Sin embargo, si uno de los procesos modifica a cualquier dato en una de estas páginas, ésta se copia a un nuevo marco, y deja de ser una página compartida. El resto de las páginas seguirá siendo compartido.
Cuando el proceso hijo modifica información en la primer página de su memoria, se crea como una página nueva.
Incluso cuando se ejecutan nuevos programas a través de execve(), es posible que una buena parte de la memoria se mantenga compartida, por ejemplo, al refrirse a copias de bibliotecas de sistema.
Varios de los aspectos mencionados en la sección de paginación van conformando a lo que conocemos como memoria virtual: Vimos ya que en un sistema que emplea paginación, un proceso no conoce su dirección en memoria relativa a otros procesos, sino que trabajan con una idealización de la memoria, en la cual ocupan el espacio completo de direccionamiento, desde el cero hasta el límite lógico de la arquitectura, independientemente del tamaño físico de la memoria disponible.
Y si bien el modelo mencionado de paginación nos llevó a que los diferentes procesos pueden compartir regiones de memoria y direccionar más memoria de la físicamente disponible, no abordamos qué estrategia se emplearía cuando el total de páginas solicitadas por todos los procesos activos en el sistema superara el total de espacio físico. Es ahí donde entra en juego la memoria virtual: Para ofrecer a los procesos mayor espacio en memoria del que existe físicamente, el sistema emplea espacio en almacenamiento secundario (típicamente, disco duro), a través de un esquema de intercambio (swap).
Esquema general de la memoria, incorporando espacio en almacenamiento secundario, representando la memoria virtual
Es importante apuntar que la memoria virtual es gestionada de forma automática y transparente por el sistema operativo. No hablaríamos de memoria virtual, por ejemplo, si un proceso pide explícitamente intercambiar determinadas páginas.
Puesto de otra manera: Del mismo modo que la segmentación permitió hacer mucho más cómodo y útil al intercambio a través del intercambio parcial, permitiendo que continuara la ejecución del proceso incluso con ciertos segmentos intercambiados (swappeados) a disco, la memoria virtual lo hace aún más conveniente al aumentar la granularidad del intercambio: Ahora ya no sólo se enviarán a disco secciones lógicas completas del proceso (segmentos), sino que podrá hacerse página por página, aumentando muy fuertemente el rendimiento resultante. Veremos que, empleando memoria virtual, de cierto modo la memoria física se vuelve sólo una proyección parcial de la memoria lógica, potencialmente mucho mayor a ésta.
Técnicamente, cuando hablamos de memoria virtual, no nos referirmos ya a un intercambiador (swapper), sino que al paginador.
La memoria virtual entra en juego desde la carga misma del proceso. Tomemos en cuenta que casi siempre tenemos código durmiente: Código que sólo se emplea eventualmente, como el que responde ante una situación de excepción o el que se emplea sólo ante circunstancias particulares (por ejemplo, la exportación de un documento a determinados formatos, o la verificación de que no haya tareas pendientes antes de cerrar un programa). Y si bien a una computadora le sería imposible ejecutar código que no esté cargado en memoria,14 el código sí puede comenzar ejecutarse sin estar completamente en memoria: Basta con haber cargado la página donde están las instrucciones que comenzaremos a seguir.
La paginación sobre demanda significa que, para comenzar a ejecutar un proceso, el sistema operativo carga sólamente la porción necesaria para comenzar la ejecución, y que a lo largo de la ejecución de cada uno de los procesos, el paginador es flojo:15 Sólo carga a memoria las páginas cuando van a ser utilizadas. Al emplear un paginador flojo, las páginas que no sean requeridas nunca serán siquiera cargadas a memoria.
La estructura empleada por el MMU para implementar un paginador flojo es muy parecida a la descrita al hablar del buffer de tradución adelantada: La tabla de páginas incluirá un bit de validez, indicando para cada página del proceso si está presente o no en memoria. Si el proceso intenta emplear una página que esté marcada como no válida, esto causa un fallo de página, que lleva a que el sistema operativo lo suspenda y traiga a memoria la página solicitada:
Pasos que atraviesa la respuesta a un fallo de página
Verifica en el PCB si esta solicitud corresponde a una página que ya ha sido asignada a este proceso.
En caso de que la referencia sea inválida, se termina el proceso.
Procede a traer la página del disco a la memoria. El primer paso es buscar un marco disponible (por ejemplo, a través de una tabla de asignación de marcos)
Solicita al disco la lectura de la página en cuestión hacia el marco especificado
Una vez que finaliza la lectura de disco, modifica tanto al PCB como al TLB para indicar que la tabla está en memoria.
Termina la suspensión del proceso, continuando con la instrucción que desencadenó al fallo. El proceso puede continuar tal como si la página siempre hubiera estado presente en memoria.
Llevando este proceso al extremo, podemos hablar de un sistema de paginación puramente sobre demanda (pure demand paging): En un sistema así, ninguna página llegará al espacio de un proceso si no es a través de un fallo de página; un proceso, al iniciarse, comienza su ejecución sin ninguna página en memoria, y con el apuntador de siguiente instrucción del procesador apuntando a una dirección que no está en memoria. El sistema operativo responde cargando esta primer página, y conforme avanza el flujo del programa, el proceso irá ocupando el espacio real que empleará.
La paginación sobre demanda puede impactar fuertemente el rendimiento de un proceso. Hemos visto ya que un acceso a disco es varios miles de veces más lento que el acceso a memoria; podemos calcular el tiempo de acceso efectivo a memoria ( ) a partir de la probabilidad que en un proceso se presente un fallo de página ( ), conociendo el tiempo de acceso a memoria ( ) y el tiempo que toma atender a un fallo de página ( ):
Ahora bien, dado que ronda hoy en día entre los 10 y 200ns, mientras que está más bien cerca de los 8ms (la latencia típica de un disco duro es de 3ms, el tiempo de posicionamiento de cabeza de 5ms, y el tiempo de transferencia es de 0.05ms), para propósitos prácticos ignorar a . Con los valores presentados, seleccionando el mayor de los presentados, si sólo un acceso a memoria de cada 1000 ocasiona un fallo de página (esto es, ):
Esto es, en promedio, tendemos un tiempo efectivo de acceso a memoria 40 veces mayor a que si no empleáramos este mecanismo. Con estos mismos números, para mantener la degradación de rendimiento por acceso a memoria por debajo del 10%, tendríamos que reducir la probabiliad de fallos de página a .
Cabe mencionar que este impacto al rendimiento no necesariamente significa que una proporción relativamente alta de fallos de página para un proceso impacte negativamente a todo el sistema — El mecanismo de paginación sobre demanda permite, al no requerir que se tengan en memoria todas las páginas de un proceso, que haya más procesos activos en el mismo espacio en memoria, aumentando el grado de multiprogramación del equipo. De este modo, si un proceso se ve obligado a esperar por 8ms a que se resuelva un fallo de página, durante ese tiempo pueden seguirse ejecutando los demás procesos.
5.2.1 Acomodo de las páginas en disco
El cálculo recién presentado, además, asume que el acomodo de las páginas en disco es óptimo. Sin embargo, si para llegar a una página hay que resolver la dirección que ocupa en un sistema de archivos (posiblemente navegar una estructura de directorio), y si el espacio asignado a la memoria virtual es compartido con los archivos en disco, el rendimiento sufrirá adicionalmente.
Una de las principales deficiencias estructurales en este sentido de los sistemas de la familia Windows es que el espacio de almacenamiento se asigna en el espacio libre del sistema de archivos. Esto lleva a que, conforme crece la fragmentación del disco, la memoria virtual quede esparcida por todo el disco duro.
La generalidad de sistemas tipo Unix, en contraposición, reservan una partición de disco exclusivamente para la memoria virtual.
Si aprovechamos las características de la memoria virtual para aumentar el grado de multiprogramación, como vimos en la sección anterior, se nos presenta un problema: Al sobre-comprometer memoria, en determinado momento, los procesos que están en ejecución pueden caer en un patrón que requiera cargarse a memoria física páginas por un mayor uso de memoria que el que hay físicamente disponible.
Y si recordamos que uno de los objetivos del sistema operativo es otorgar a los usuarios la ilusión de una computadora dedicada a sus procesos, no sería aceptable terminar la ejecución de un proceso ya aceptado y cuyos requisitos han sido aprobados porque nos quedamos sin recursos. Se hace necesario encontrar una forma justa y adecuada de llevar a cabo un reemplazo de páginas que nos permita continuar satisfaciendo sus necesidades.
El reemplazo de páginas es una parte fundamental de la paginación sobre demanda, es la pieza que posibilita una verdadera separación entre memoria lógica y física.
El mecanismo básico a ejecutar es simple: Si todos los marcos están ocupados, debemos encontrar una página que podamos liberar (una página víctima) y grabarla al espacio de intercambio en el disco. Una vez que lo hayamos hecho, podemos emplear el espacio liberado para cargar una página nueva, y continuar con la ejecución del proceso.
Esto nos lleva a una doble transferencia al disco (una para grabar la página víctima y una para traer la página de reemplazo), y por tanto, a una doble demora.
Podemos, con un mínimo de burocracia adicional (aunque requiere de apoyo del MMU): implementar un mecanismo que disminuya la probabilidad de que tengamos que realizar esta doble transferencia: Agregar un bit de modificación o bit de página sucia (dirty bit) a la tabla de páginas. Este bit se marca como apagado siempre que se carga una página a memoria, y es automáticamente encendido cuando se realiza un acceso de escritura a dicha página.
Cuando el sistema operativo elige una página víctima, si su bit de página sucia está encendido, es necesario grabarla al disco, pero si está apagada, sabemos que la información en disco es idéntica a la que tenemos en memoria, y permite ahorrar la mitad del tiempo de transferencia.
Ahora bien, ¿cómo decidimos qué páginas remplazar marcándolas como víctimas cuando hace falta? Para esto tenemos que elegir un algoritmo de reemplazo de páginas. La característica que buscamos en este algoritmo es que, para una patrón de accesos dado, nos dé el menor número de fallos de página.
Esta sección está basada en los ejemplos presentados en los presentados en el libro Operating Systems Concepts Essentials (Silberschatz, Galvin y Gagné, 2011).
Al igual que como lo hicimos al describir los algoritmos de planificación de procesos, para analizar los que aquí presentaremos lo haremos sobre una cadena de referencia, esto es, sobre una representación de accesos solicitados. Estas cadenas modelan el comportamiento de un conjunto de procesos en el sistema, y, obviamente, diferentes comportamientos nos llevarán a diferentes resultados.
Hacer un volcado y trazado de ejecución en un sistema real puede dar una enorme cantidad de información, del órden de un millón de accesos por segundo. Para reducir esta información en un número más tratable, se puede simplificar basado en que no nos interesa el acceso independiente a cada dirección de memoria, sino que a cada página.
Además, varios accesos a direcciones de memoria en la misma página no causan efecto en el estado. Podemos tomar como un sólo acceso a todos aquellos que ocurren de forma consecutiva (esto es, sin llamar a ninguna otra página, no es necesario que sean en instrucciones consecutivas) a una misma página.
Para analizar a un algoritmo de reemplazo, si lo que buscamos es la cantidad de fallos de página producidos, además de la cadena de referencia, necesitamos saber cuántos marcos tiene nuestra computadora hipotética. Por ejemplo, si tuviéramos la cadena:
1, 4, 3, 4, 1, 2, 4, 2, 1, 3, 1, 4
Al recorrer esta cadena en una computadora con cuatro o más marcos, sólo nos encontraríamos con cuatro fallos (el fallo inicial que hace que se cargue por primera vez cada una de las páginas). Si, en el otro extremo, tuviéramos sólo un marco, nos encontraríamos con 12 fallos, dado que a cada solicitud tendríamos que reemplazar el único marco disponible. El rendimiento que evaluaríamos sería en los casos de que tuviéramos dos o tres marcos.
Un fenómeno interesante que se presenta con algunos algoritmos es la anomalía de Belady: Si bien la lógica indica que a mayor número de marcos disponibles tendremos una menor cantidad de fallos de página, con algunas de cadenas de referencia y bajo ciertos algoritmos puede haber una regresión, en que aumentar la cantidad de marcos también aumenta la cantidad de fallos.
Comportamiento del algoritmo FIFO que exhibe la anomalía de Belady al pasar de 3 a 4 marcos
Para los algoritmos que veremos a continuación, asumiremos una memoria con tres marcos, y con la siguiente cadena de referencia:
5.3.1 Primero en entrar, primero en salir (FIFO)
El algoritmo de más simple y obvia implementación es, nuevamente, el FIFO: Al cargar una página en memoria, se toma nota de en qué momento fue cargada, y cuando llegue el momento de reemplazar una página vieja, se elige la que haya sido cargada hace más tiempo.
Partiendo de un estado inicial en que nuestras tres páginas están vacías, necesariamente las tres primeras referencias a distintas páginas de memoria (7, 0, 1) causarán fallos de página. La siguiente (2) causará uno, pero la quinta referencia (0) puede ser satisfecha sin requerir una nueva transferencia.
Algoritmo FIFO de reemplazo de páginas
La principal ventaja de este algoritmo es, como ya lo hemos mencionado, la simplicidad, tanto para programarlo como para comprenderlo. Su implementación puede ser tan simple como una lista ligada circular, cada elemento que va recibiendo se agrega en el último elemento de la lista, y se "empuja" el apuntador para convertirlo en la cabeza. Su desventaja, claro está, es que no toma en cuenta a la historia de las últimas solicitudes, por lo que puede causar un bajo rendimiento. Todas las páginas tienen la misma probabilidad de ser reemplazadas, sin importar su frecuencia de uso.
Con las condiciones aquí presentadas, un esquema FIFO nos lleva a 15 fallos de página en un total de 20 accesos requeridos.
El algoritmo FIFO es vulnerable a la anomalía de Belady. Esto puede ilustrarse al pasar de 3 a 4 marcos con la cadena de referencia:
La prevalencia de cadenas que desencadenan la anomalía de Belady fue uno de los factores principales que llevaron al diseño de nuevos algoritmos de reemplazo de páginas.
5.3.2 Reemplazo de páginas óptimo (OPT, MIN)
Un segundo algoritmo, de interés casi puramente teórico, fue propuesto, y es típicamente conocido como OPT o MIN. Bajo este algoritmo, el enunciado será elegir como página víctima a aquella página que no vaya a ser utilizada por un tiempo máximo.
Algoritmo óptimo de reemplazo de páginas (OPT)
Si bien este algoritmo está demostrado como óptimo o mínimo, se mantiene como curiosidad teórica porque requiere conocimiento a priori de las necesidades del sistema — Y si esto es impracticable ya en los algoritmos que revisamos de despachadores, lo será mucho más con un recurso de reemplazo tan dinámico como la memoria.
Su principal utilidad reside en que nos brinda una cota mínima: Calculando el número de fallos que se presentan al seguir OPT, podemos ver qué tan cercano resulta otro algoritmo respecto al caso óptimo. Para esta cadena de referencia, y con tres páginas, tenemos un total de nueve fallos.
5.3.3 Menos recientemente utilizado (LRU)
Hemos visto este esquema en diversos mecanismos relacionados con la administración de memoria. Busca acercarse a OPT prediciendo cuándo será la próxima vez en que se emplee cada una de las páginas que tiene en memoria basado en la historia reciente de su ejecución.
Cuando necesita elegir una página víctima, LRU elige la página que no ha sido empleada hace más tiempo
Algoritmo reemplazo de páginas menos recientemente utilizadas (LRU)
Para nuestra cadena de referencia, LRU genera 12 fallos, en el punto medio entre OPT y FIFO.
Una observación interesante puede ser que para una cadena y su cadena espejo (invertida) , el resultado de evaluar por LRU es igual al de evaluar por OPT, y viceversa.
La principal debilidad de LRU es que para su implementación requiere apoyo en hardware16 sensiblemente más complejo que FIFO. Una implementación podría ser agregar un contador a cada uno de los marcos, actualizarlo siempre al hacer una referenciar a dicha página, y elegir como víctima a la página con un menor conteo. Este mecanismo tiene la desventaja de que, en presencia de una gran cantidad de páginas, tiene que recorrerlas a todas para buscar a la más envejecida.
Otro mecanismo es emplear una lista doblemente ligada con dos métodos de acceso: Lista y stack. Cada vez que se haga referencia a una página, se mueve a la cabeza del stack, y cada vez que se busque a una página víctima, se selecciona a aquella que esté en el extremo inferior del stack. Este mecanismo hace un poco más cara la actualización (pueden requerirse hasta seis modificaciones), pero encuentra a la página víctima en tiempo constante.
Se ha demostrado que LRU y OPT están libres de la anomalía de Belady, dado que, para marcos, las páginas que estarían en memoria son un subconjunto estricto de las que estarían con marcos.
5.3.4 Más frecuentemente utilizada (MFU) / Menos frecuentemente utilizada (LFU)
Estos dos algoritmos se basan en mantener un contador, como lo hace LRU, pero en vez de medir el tiempo, miden la cantidad de referencias que se han hecho a cada página.
MFU parte de la lógica que, si una página fue empleada muchas veces, probablemente vuelva a ser empleada muchas veces más; LFU parte de que una página que ha sido empleada pocas veces es probablemente una página recién cargada, y va a ser empleada en el futuro cercano.
Estos dos algoritmos son tan caros de implementar como LRU, y su rendimiento respecto a OPT no es tan cercana, por lo cual casi no son empleados.
5.3.5 Aproximaciones a LRU
Dada la complejidad que presenta la implementación de LRU en hardware, algunos sistemas implementan una aproximación a éste.
Esta es una aproximación bastante común. Consiste en que todas las entradas de la tabla de páginas tengan un bit adicional, al que llamaremos de referencia. Al iniciar la ejecución, todos los bits de referencia están apagados (0). Cada vez que se referencia a un marco, su bit de referencia se enciende.
El sistema operativo invoca periódicamente a que se apaguen nuevamente todos los bits de referencia. En caso de presentarse un fallo de página, se elige por FIFO sobre el subconjunto de marcos que no hayan sido referenciados en el periodo actual (esto es, entre todos aquellos para los cuales el bit de referencia sea 0).
Columna de referencia
Una mejoría casi trivial sobre la anterior consiste en agregar varios bits de referencia, conformándose como una columna: En vez de descartar su valor cada vez que transcurre el periodo determinado, el valor de la columna de referencia es recorrido a la derecha, descartando el bit más bajo. Por ejemplo, si nuestra implementación tuviera 4 bits, un marco que no ha sido empleado en los últimos 4 periodos tendría el valor 0000, mientras que un marco que sí ha sido referenciado los últimos cuatro periodos tendría 1111. Un marco que fue empleado hace cuatro y tres periodos, pero desde entonces no, tendría el 0011.
Cuando el sistema tenga que elegir a una nueva página víctima, lo hará de entre el conjunto que tenga un número más bajo.
La parte de mantenimiento de este algoritmo es muy simple; recorrer una serie de bits es una operación muy sencillo. Seleccionar el número más pequeño requiere una pequeña búsqueda, pero sigue resultando mucho más sencillo que LRU.
Segunda oportunidad (o reloj)
El algoritmo de la segunda oportunidad trabaja también basado en un bit de referencia y un recorrido tipo FIFO. La diferencia en este caso es que, al igual que hay eventos que encienden a este bit (efectuar una referencia al marco), también hay eventos que lo apagan:
Se mantiene un apuntador a la próxima víctima, y cuando el sistema requiera efectuar un reemplazo, éste verificará si el marco al que apunta tiene el bit de referencia encendido o apagado. En caso de estar apagado, el marco es seleccionado como víctima, pero en caso de estar encendido (indicando que fue utilizado recientemente), se le da una segunda oportunidad: El bit de referencia se apaga, el apuntador de víctima potencial avanza una posición, y vuelve a intentarlo.
A este algoritmo se le llama también de reloj porque puede implementarse como una lista ligada circular, y el apuntador puede ser visto como una manecilla. La manecilla avanza sobre la lista de marcos buscando uno con el bit de referencia apagado, y apagando a todos a su paso.
En el peor caso, el algoritmo de segunda oportunidad degenera en FIFO.
Segunda oportunidad mejorada
El bit de referencia puede amplairse con un bit de modificación, dándonos las siguientes combinaciones, en órden de preferencia:
No ha sido utilizado ni modificado recientemente. Candidato ideal para su reemplazo.
No ha sido utilizada recientemente, pero está modificada. No es tan buena opción, porque es necesario escribir la página a disco antes de reemplazarla, pero puede se elegida.
El marco está limpio, pero fue empleado recientemente, por lo que probablemente se vuelva a requerir pronto
Empleada recientemente y sucia — Sería necesario escribir la página a disco antes de reemplazar, y probablemente vuelva a ser requerida pronto. Hay que evitar reemplazarla.
La lógica para encontrar una página víctima es similar a la segunda oportunidad, pero busca reducir el costo de E/S. Esto puede requerir, sin embargo, dar hasta cuatro vueltas (por cada una de las listas) para elegir la página víctima.
5.3.6 Algoritmos con manejo de buffers
Un mecanismo que se emplea con cada vez mayor frecuencia es que el sistema no espere a enfrentarse a la necesidad de reemplazar un marco, sino que proactivamente busque tener siempre espacio vacío en memoria. Para hacerlo, conforme la carga lo permite, el sistema operativo busca las páginas sucias más proclives a ser paginadas a disco y va actualizando el disco (y marcándolas nuevamente como limpias). De este modo, cuando tenga que traer una página nueva del disco, siempre habrá espacio donde ubicarla sin tener que esperar a que se transfiera una para liberarla.
Abordando el problema prácticamente por el lado opuesto al del reemplazo de páginas, ¿cómo se asignan los marcos existentes a los procesos del sistema? Esto es, ¿qué esquemas podemos encontrar para que la asignación inicial (y, de ser posible, en el transcurso de la ejecución) sea adecuada?
Consideremos en primer término un esquema sencillo: Un sistema con 1024KB de memoria, compuesta de 256 páginas de 4096 bytes cada una, y basado en paginación puramente sobre demanda.
Si el sistema operativo ocupa 248KB, el primer paso será reservar las 62 páginas que éste requiere, y destinar las 194 páginas restantes para los procesos a ejecutar.
Conforme se van lanzando y comienzan a ejecutar los procesos, cada vez que uno de ellos genere un fallo de página, se le irá asignando uno de los marcos disponibles hasta ocupar que la memoria entera esté ocupada; claro está, cuando un proceso termine su ejecución, todos los marcos que tenía asignados volverán a la lista de marcos libres.
Una vez que la memoria esté completamente ocupada (esto es, que haya 194 páginas ocupadas por procesos), el siguiente fallo de página invocará a un algoritmo de reemplazo de página, que elegirá una de las 194.17
Este esquema, si bien es simple, al requerir una gran cantidad de fallos de página explícitos puede penalizar el rendimiento del sistema — El esquema puede resultar demasiado flojo, no le vendría mal ser un poco más ansioso y asignar, de inicio, un número determinado como mínimo utilizable de marcos.
5.4.1 Mínimo de marcos
Si un proceso tiene asignados demasiado pocos marcos, su rendimiento indudablemente se verá afectado. Hasta ahora hemos supuesto que cada instrucción puede causar un sólo fallo de página, pero la realidad es más compleja. Cada instrucción del procesador puede, dependiendo de la arquitectura, desencadenar varias solicitudes.
Todas las arquitecturas proporcionan instrucciones de referencia directa a memoria (instrucciones que permiten especificar una dirección de memoria para leer o escribir) — Esto significa que todas requerirán que, para que un proceso funcione adecuadamente, tenga por lo menos dos marcos asignados: En caso de que se le permitiera solo uno, si la instrucción ubicada en 0x00A2C8 solicita la carga de 0x043F00, esta causaría dos fallos: El primero, cargar al marco la página 0x043, y el segundo, cargar nuevamente la página 0x00A, necesario para leer la siguiente instrucción a ejecutar del programa (0x00A2CC, asumiendo palabras de 32 bits).
Algunas arquitecturas, además, permiten referencias indirectas a memoria, esto es, nuestra instrucción de carga puede solicitar la dirección que está referenciada en 0x043F00. El procesador tendría que recuperar esta dirección, y podría encontrarse con que hace referencia a una dirección en otra página (digamos, 0x010F80). Cada nivel de indirección que se permite aumenta en uno el número de páginas que debemos reservar como mínimo por proceso.
Algunas arquitecturas, particularmente las más antiguas,18, permiten que tanto los operandos de algunas instrucciones aritméticas como su resultado sean direcciones de memoria (y no operan estrictamente sobre los registros, como las arquitecturas RISC). En dichas arquitecturas, el mínimo debe también tener este factor en cuenta: Si en una sola instrucción podemos sumar dos direcciones de memoria y guardar el resultado en una adicional, el mínimo a reservar es de cuatro marcos: Uno para el flujo del programa, uno para el primer operando, uno para el segundo operando, y uno para el resultado.
5.4.2 Esquemas de asignación
Ahora, una vez establecido el número mínimo de marcos por proceso, ¿cómo podemos determinar el nivel deseable?
Partiendo de que el rendimiento de un proceso será mejor entre menos fallos de paginación cause, podríamos intentar otorgar a cada proceso el total de marcos que solicita — Pero esto tendría como resultado disminuir el grado de multiprogramación, y por tanto, reducir el uso efectivo total del procesador.
Otra alternativa es la asignación igualitaria: Se divide el total de espacio en memoria física entre todos los procesos en ejecución, en partes iguales. Esto es, volviendo a la computadora hipotética que presentamos al inicio de esta sección, si tenemos 4 procesos que requieren ser ejecutados, de los 194 marcos disponibles, el sistema asignará 48 marcos (192KB) a dos de los procesos y 49 (196KB) a los otros dos (es imposible asignar fracciones de marcos). De este modo, el espacio será compartido por igual.
La asignación igualitaria resulta ser un esquema deficiente para casi todas las distribuciones de procesos: Bajo este esquema, si es un gestor de bases de datos que puede estar empleando 2048KB (512 páginas) de memoria virtual (a pesar de que el sistema tiene sólo 1MB de memoria física) y es un lector de texto que está empleando un usuario, requiriendo apenas 112KB (28 páginas), con lo cual incluso dejaría algunos de sus marcos sin utilizar.
Un segundo esquema, que resuelve mejor esta situación, es la asignación proporcional: Dar a cada proceso una porción del espacio de memoria física proporcional a su uso de memoria virtual.
De tal suerte, si además de los procesos anteriores tenemos a empleando 560KB (140 páginas) y a con 320KB (80 páginas) de memoria virtual, el uso total de memoria virtual sería de páginas, esto es, el sistema tendría comprometido a través de la memoria virtual un sobreuso cercano a 4:1 sobre la memoria física19.
Cada proceso recibirá entonces , donde indica el espacio de memoria física que el proceso recibirá, la cantidad de memoria virtual que está empleando, y la cantidad total de marcos de memoria disponibles. De este modo, recibirá 130 marcos, 7, 35 y 20, proporcionalmente a su uso de memoria virtual.
Cabe apuntar que este mecanismo debe observar ciertos parámetros mínimos: Por un lado, si el mínimo de marcos definido para esta arquitectura es de 4, por más que entrara en ejecución un proceso de 32KB (8 páginas) o aumentara al doble el grado de multiprocesamiento, ningún proceso debe tener asignado menos del mínimo definido.
La asignación proporcional también debe cuidar no sobre-asignar recursos a un proceso obeso: es ya mucho más grande que todos los procesos del sistema. En caso de que esta creciera mucho más, por ejemplo, si multiplicara por 4 su uso de memoria virtual, esto llevaría a que se castigara desproporcionadamente a todos los demás procesos del sistema.
Por otro lado, este esquema ignora por completo las prioridades que hoy en día manejan todos los sistemas operativos; si quisiéramos que las consideraran, podría incluirse como factor la prioridad, multiplicando junto con .
El esquema de asignación proporcional sufre, sin embargo, cuando cambia el nivel de multiprogramación — Esto es, cuando se inicia un nuevo proceso o finaliza un proceso en ejecución, deben recalcularse los espacios en memoria física asignados a cada uno de los procesos restantes. Si finaliza un proceso, el problema es menor, pues sólo se asignan los marcos y puede esperarse a que se vayan poblando por paginación sobre demanda, pero si inicia uno nuevo, es necesario reducir de golpe la asignación de todos los demás procesos hasta abrir suficiente espacio para que quepa.
Por último, el esquema de la asignación proporcional también tiende a desperdiciar recursos: Si bien hay procesos que mantienen un patrón estable de actividad a lo largo de su ejecución, muchos otros pueden tener periodos de mucho menor requisitos. Por ejemplo, un proceso servidor de documentos pasa la mayor parte de su tiempo simplemente esperando solicitudes, y podría reducirse a un uso mínimo de memoria física, sin embargo, al solicitársele un documento, se le deberían poder asignar más marcos (para trabajar en una ráfaga) hasta que termine con su tarea. Veremos este punto en breve, al hablar del conjunto activo
5.4.3 Ámbitos del algoritmo de reemplazo de páginas
Para responder a los problemas que la anterior sección nos deja abiertos, se puede discutir el ámbito en que operará el algoritmo de reemplazo de páginas.
Mantenemos tan estable como sea posible el cálculo hecho por el esquema de asignación que estemos empleando. Esto significa que cuando se presente un fallo de página, las páginas que serán consideradas para su intercambio serán únicamente aquellas pertenecientes al mismo proceso que el que causó el fallo.
Un proceso tiene asignado su espacio de memoria física, y se mantendrá estable mientras el sistema operativo no tome alguna decisión por cambiarlo.
Los algoritmos de asignación determinan el espacio asignado a los procesos al ser inicializados, e influyen a los algoritmos de reemplazo (por ejemplo, dando mayor peso para ser elegidas como páginas víctima a aquellas que pertenezcan a un proceso que excede de su asignación en memoria física).
Los algoritmos de reemplazo de páginas operan sobre el espacio completo de memoria, y la asignación física de cada proceso puede variar según el estado del sistema momento a momento.
Reemplazo global con prioridad
Es un esquema mixto, en el que un proceso puede sobrepasar su límite siempre que le robe espacio en memoria física exclusivamente a procesos de prioridad inferior a él. Esto es consistente con el comportamiento de los algoritmos planificadores, que siempre dan preferencia a un proceso de mayor prioridad por sobre de uno de prioridad más baja.
El reemplazo local es más rígido y no permite aprovechar para mejorar el rendimiento los periodos de inactividad de algunos de los procesos. En contraposición, los esquemas basados en reemplazo global puede llevar a rendimiento inconsistente: Dado que la asignación de memoria física sale del control de cada proceso, puede que la misma sección de código presente tiempos de ejecución muy distintos si porciones importantes de su memoria fueron paginadas a disco.
Cuando (bajo un esquema de reemplazo local) un proceso tiene asignadas demasiado pocas páginas para llevar a cabo su trabajo y genera fallos de página con tal frecuencia que le imposibilita realizar trabajo real, o (bajo un esquema de reemplazo global) hay demasiados procesos en ejecución en el sistema y los constantes fallos y reemplazos hacen imposible a todos los procesos involucrados avanzar, se dice que estamos en estado de hiperpaginación.20
Hay varios escenarios que pueden llevarnos a la hiperpaginación, y su impacto es tan claro e identificable que prácticamente cualquier usuario de cómputo lo sabrá reconocer. Presentemos, pues, un escenario ejemplo en que las malas decisiones del sistema operativo pueden conducirlo a este estado.
Comencemos con un sistema que está con una carga media normal, con un esquema de reemplazo global de marcos. Se lanza un nuevo proceso, que como parte de su inicialización requiere poblar diversas estructuras a lo largo de su espacio de memoria virtual. Para hacerlo, lanza una serie de fallos de página, a las que el sistema operativo responde reemplazando a varios marcos pertenecientes a otros procesos.
Casualmente, a lo largo del periodo que toma esta inicialización (recordemos que puede parecer una eternidad: El disco es entre miles y millones de veces más lento que la memoria) algunos de estos procesos solicitan los espacios de memoria que acaban de ser enviados a disco, por lo cual lanzan nuevos fallos de página.
Cuando el sistema detecta que la utilización del procesador decrece, el sistema operativo puede aprovechar la situación para lanzar procesos de mantenimiento. Se lanzan estos procesos, reduciendo aún más el espacio de memoria física disponible para cada uno de los procesos preexistentes.
Se ha formado ya toda una cola de solicitudes de paginación, algunas veces contradictorias. El procesador tiene que comenzar a ejecutar NOOP (esto es, no tiene trabajo que ejecutar), porque la mayor parte del tiempo lo pasa en espera de un nuevo marco por parte del disco duro. El sistema completo avanza cada vez más lento.
Al aumentar demasiado el grado de multiprogramación, el uso del CPU cae abruptamente y caemos en la hiperpaginación (Silberschatz, p.349)
Los síntomas de la hiperpaginación son muy claros, y no son difíciles de detectar. ¿Qué estrategia puede emplear el sistema operativo una vez que se da cuenta que se presentó esta situación?
Una salida sería reducir el nivel de multiprogramación — Si la paginación se presentó debido a que los requisitos de memoria de los procesos actualmente en ejecución no pueden ser satisfechos con la memoria física disponible, el sistema puede seleccionar a uno (o más) de los procesos y suspenderlos por completo hasta que el sistema vuelva a un estado normal. Podría seleccionarse, por ejemplo, al proceso con menor prioridad, al que esté causando más cantidad de fallos, o al que esté ocupando más memoria.
5.5.1 Modelando el conjunto activo
Un pico en la cantidad de fallos de página no necesariamente significa que estamos cayendo en una situación de hiperpaginación — Muchas veces indica que el proceso cambió su atención de un conjunto de páginas a otro, o dicho de otro modo, que cambió el conjunto activo del proceso — Y resulta natural que, al cambiar el conjunto activo, el proceso accese de golpe una serie de páginas que no había tocado en cierto tiempo.
Los picos y valles en la cantidad de fallos de página de un proceso definen a su conjunto activo (Silberschatz, p.349)
El conjunto activo es, pues, la aproximación más clara a la localidad de referencia de un proceso dado: El conjunto de páginas sobre los que está iterando en un momento dado.
Idealmente, para evitar los problemas relacionados con la hiperpaginación, debemos asignar a cada proceso suficientes páginas como para que mantenga en memoria física su conjunto activo — Y si no nos es posible hacerlo, el proceso es un buen candidato para ser suspendido. Sin embargo, detectar con suficiente claridad como para efectuar este diagnóstico los cuál es el conjunto activo es una tarea muy compleja, que típicamente implica rastrear y verificar del órden de los últimos miles a decenas de miles de accesos a memoria.
Para una cobertura a mayor profundidad del material presentado en esta sección, sugiero fuertemente referirse a los siguientes dos textos:
Smashing The Stack For Fun And Profit (Aleph One, revista Phrack, 1996)
The Tao of Buffer Overflows (Enrique Sánchez, inédito, pero disponible en el sitio Web del curso)
Una de las funciones principales de los sistemas operativos que hemos recalcado a lo largo del curso es la de implementar protección entre los procesos pertenecientes a diferentes usuarios, o ejecutándose con distinto nivel de privilegios. Y si bien el enfoque general que estamos dando es el de analizar por separado subsistema por subsistema, al hablar de administración de memoria es necesario mencionar también las implicaciones de seguridad que del presente tema se pueden desprender.
En las computadoras de arquitectura von Neumann, todo dato a ser procesado (sean instrucciones o datos) debe pasar por la memoria, por el almacenamiento primario. Sólo desde ahí puede el procesador leer la información directamente.
A lo largo de la presente unidad hemos revisado que el MMU incluye ya desde el hardware el concepto de permisos, separando claramente las regiones de memoria donde se ubica el código del programa (y son, por tanto, ejecutables y de sólo lectura) de aquellas donde se encuentran los datos (de lectura y escritura). Esto, sin embargo, no nos pone a salvo de los desbordamientos de buffer (buffer overflows), errores de programación (típicamente, la falta de verificación de límites) que pueden convertirse en vulnerabilidades21.
6.1.1 La pila de llamadas (stack)
Recordando lo mencionado en la sección en que presentamos al espacio en memoria de un proceso, profundicemos un poco en cómo está estructurada la pila de llamadas (stack).
El stack es el mecanismo que brinda un sentido local a la representación del código estructurado. Está dividido en marcos (sin relación con el concepto de marcos empleado al hablar de memoria virtual); durante el periodo en que es el marco activo (esto es, cuando no se ha transferido el control a ninguna otra función), está delimitado por dos valores, mantenidos en registros:
Apuntador a la pila
(Stack pointer, SP) Apunta al final actual (dirección inferior) de la pila. En arquitecturas x86, emplea el registro ESP.
Apuntador del marco
(Frame pointer, FP, o Base local, LB) Apunta al inicio del marco actual, o lo que es lo mismo, al final del marco anterior. En arquitecturas x86, emplea el registro EBP; cuando se le pide al procesador que actúe sobre el stack (con las operaciones pushl o popl), lo hace sobre este registro.
A cada función a que va entrando la ejecución del proceso, se va creando un marco en el stack, que incluye:
Los argumentos recibidos por la función
La dirección de retorno al código que la invocó
Las variables locales creadas en la función
Con esto en mente, podemos en primer término analizar la traducción de una llamada a función en C a su equivalente en ensamblador, y en segundo término ver el marco del stack resultante:
void func(int a, int b, int c) {
Y lo que el código resultante en ensamblador efectúa es:
El procesador empuja (pushl) los tres argumentos al stack (ESP). La notación empleada ($1, $2, $3) indica que el número indicado se expresa de forma literal. Cada uno de estos tres valores restará 4 bytes (el tamaño de un valor entero en x86-32) a ESP.
En ensamblador, los nombres que demos a nuestras variables y funciones no significan nada. La llamada call no es lo que entenderíamos como una llamada a función en un lenguaje de alto nivel — Lo que hace es empujar al stack la dirección actual del apuntador a la siguiente instrucción, y cargar a éste la dirección en el fuente donde está dicha etiqueta (esto es, transferir la ejecución hacia allá).
Lo primero que hace la función al ser invocada es asegurarse de saber a dónde volver: empuja al stack el viejo apuntador al marco (EBP), y lo reemplaza (movl) por el actual. A esta ubicación se le llama SFP (Saved Frame Pointer, apuntador al marco grabado)
Por último, con subl, resta el espacio necesario para alojar las variables locales, buffer1 y buffer2. Notarán que, si bien éstas son de 5 y 10 bytes, está recorriendo 20 bytes — Esto porque, en la arquitectura x86-32, los accesos a memoria deben estar alineados a 32 bits.
Marco del stack con llamada a func(1,2,3) en x86-32
6.1.2 C y las funciones de manejo de cadenas
El lenguaje de programación C fue creado con el propósito de ser tan simple como sea posible, manteniéndose tan cerca del hardware como se pudiera, para que pudiera ser empleado como un lenguaje de programación para un sistema operativo portable. Y si bien en 1970 era visto como un lenguaje bastante de alto nivel, hoy en día es lo más bajo nivel en que programa la mayor parte de los desarrolladores del mundo.
C no tiene soporte nativo para cadenas de caracteres. El soporte es provisto a través de familias de funciones en la biblioteca estándar del lenguaje, que están siempre disponibles en cualquier implementación estándar de C. Las familias principales son strcat, strcpy, printf y gets. Estas funciones trabajan con cadenas que siguen la siguiente estructura:
Son arreglos de 1 o más caracteres (char, 8 bits)
Deben terminar con el byte de terminación NUL (\0)
El problema con estas funciones es que sólo algunas de las funciones derivadas implementan verificaciones de límites, y algunas son incluso capaces de crear cadenas ilegales (que no concluyan con el terminador \0).
El problema aparece cuando el programador no tiene el cuidado necesario al trabajar con datos de los cuales no tiene certeza. Por ejemplo, veamos el siguiente código vulnerable:
printf("Escribiste %s\n", buffer);
El problema con este código reside en el strcpy(buffer, argv[1]) — Dado que el código es recibido del usuario, no tenemos la certeza de que el argumento que recibamos (empleando argv[1]) quepa en el arreglo buffer[256]. Esto es, si llamamos al programa ejemplo con una cadena de 120 caracteres:
$ ./ejemplo1 `perl -e 'print "A" x 120'`
Escribiste: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
La ejecución resulta exitosa. Sin embargo, si lo invocamos con un parámetro demasiado largo para el arreglo:
$ ./ejemplo1 `perl -e 'print "A" x 500'`
6.1.3 De una falla a un ataque
En el ejemplo recién presentado, parecería que el sistema atrapó al error exitosamente y detuvo la ejecución — Pero no lo hizo: El Segmentation fault no fue generado al sobreescribir el buffer ni al intentar procesarlo, sino después de terminar de hacerlo: Al llegar nuestro código al return 0.
Estado de la memoria después del strcpy()
Para volver de una función a quien la invocó, incluso si dicha función es main(), lo que hace return es restaurar el viejo SFP y hacer que el apuntador a siguiente dirección brinque a la dirección que tiene en RET. Sin embargo, como podemos ver en el esquema, RET fue sobreescrito por la dirección 0x41414141 (AAAA). Dado que esa dirección no forma parte del espacio del proceso actual, se lanza una excepción por violación de segmento, y el proceso es terminado.
Ahora, lo que observamos implica que el código está demostrado vulnerable, pero no lo hemos explotado aún. El siguiente paso es, conociendo el acomodo exacto de la memoria, sobreescribir únicamente lo necesario para poder apoderarnos del flujo del programa — Esto es, sobreescribir RET con una dirección válida. Para esto, necesitamos conocer la longitud desde el inicio del buffer hasta donde terminan RET y SFP, en nuestro caso, 264 bytes (256 del buffer mas 4 de RET mas 4 de SFP).
Citando al texto de Enrique Sánchez,
¿Por qué ocurre un desbordamiento de stack? Imagina un vaso y una botella de cerveza. ¿Qué ocurre si sirves la botella completa en el vaso? Se va a derramar. Imagina que tu variable es el vaso, y la entrada del usuario es la cerveza. Puede ocurrir que el usuario sirva tanto líquido como el que cabe en el vaso, pero puede también seguir sirviendo hasta que se derrame. La cerveza se derramaría en todas direcciones, pero la memoria no crece de esa manera, es sólo un arreglo bidimensional, y sólo crece en una dirección.
Ahora, ¿qué más pasa cuando desbordas a un contenedor? El líquido sobrante va a mojar la botana, los papeles, la mesa, etc. En el caso de los papeles, destruirá cualquier cosa que hubieras apuntado (como el teléfono que acabas de anotar de esa linda chica). Cuando tu variable se desborde, ¿qué va a sobrescribir? Al EBP, al EIP, y lo que les siga, dependiendo de la función, y si es la última función, las variables de ambiente. Puede que el programa aborte y tu shell resulte inutilizado a causa de las variables sobreescritas.
Hay dos técnicas principales: Brincar a un punto determinado del programa, y brincar hacia dentro del stack. Veamos la primera:
Si el atacante está intentando burlar una validación simple de usuarios, como la siguiente:
if (valid_user(usr, pass)) {
Y detecta que valid_user() es susceptible a un desbordamiento, le bastaría con incrementar en 4 la dirección de retorno. La conversión de este if a ensamblador es, primero, brincar hacia la etiqueta valid_user, y (empleando al valor que ésta regrese en %EBX) ir a la siguiente instrucción, o brincar a la etiqueta FAIL. Esto puede hacerse con la instrucción BNE $0, %EBX, FAIL (Branch if Not Equal, Brinca si no es igual, que recibe como argumentos dos valores a ser comparados, en este caso el registro %EBX y el número 0, y la etiqueta destino, FAIL). Cambiar la dirección destino significa burlar la verificación.
Ahora, por otro lado, el atacante podría querer que el sistema haga algo más complejo — Que ejecute código arbitrario que él proporicione. Para esto, el ataque más frecuente es brincar hacia adentro del stack.
Ejecutando el código arbitrario inyectado al buffer
Para hacerlo, si en vez de proporcionar simplemente una cadena suficientemente grande para sobrepasar el buffer le diéramos una cadena con código ejecutable válido, y sobreescribiera la dirección de retorno con la dirección de su código dentro del buffer, tendría 256 bytes de espacio para especificar código arbitrario. Este código típicamente se llama shellcode, pues se emplea para obtener un shell (un intérprete de comandos) que ejecuta con los privilegios del proceso explotado.
Claro está, el mundo no se queda quieto. Una vez que estos mecanismos de ataque se dieron a conocer, comenzó un fuerte trabajo para crear mecanismos de mitigación de daños.
La principal y más importante medida es crear una cultura de programadores conscientes y prácticas seguras. Esto cruza necesariamente el no emplear funciones que no hagan verificación de límites. La desventaja de esto es que hace falta cambiar al factor humano, lo cual resulta prácticamente imposible de lograr con suficiente profundidad. Muchos desarrolladores esgrimen argumentos en contra de estas prácticas, como la pérdida de rendimiento que estas funciones requieren, y muchos otros sencillamente nunca se dieron por enterados de la necesidad de programar correctamente.
Por esto, se han ido creando diversos mecanismos automatizados de protección ante los desbordamientos de buffer. Ninguno de estos mecanismos es perfecto, pero sí ayudan a reducir los riesgos ante los atacantes menos persistentes o habilidosos.
6.2.1 Secciones de datos no ejecutables
Comenzamos la sección hablando de la protección que puede imponer el MMU por regiones, evitando la modificación de código ejecutable.
En la arquitectura x86, dominante en el mercado de computadoras personales desde hace muchos años, esta característica existía en varios procesadores basados en el modelo de segmentación de memoria, pero desapareció al cambiarse el modelo predominante por uno de memoria plana paginada, y fue hasta alrededor del 2001 en que fue introducida de vuelta, bajo los nombres bit NX (Never eXecute, Nunca ejecutar) o bit XD (eXecute Disable, Deshabilitar ejecución), como una característica particular de las extensiones PAE.
Empleando este mecanismo, el MMU puede evitar la ejecución de código en el área de stack, lo cual anula la posibilidad de brincar al stack. Esta protección desafortunadamente no es muy efectiva: Una vez que tiene acceso a un buffer vulnerable, el atacante puede brincar a libc, esto es, por ejemplo, proporcionar como parámetro el nombre de un programa a ejecutar, e indicar como retorno la dirección de la función system o execve de la libc.
Las secciones de datos no ejecutables son, pues, un obstáculo ante un atacante, aunque no representan una dificultad mucho mayor.
6.2.2 Aleatorización del espacio de direcciones
Otra técnica es que, en tiempo de carga y a cada ejecución, el proceso reciba diferentes direcciones base para sus diferentes áreas. Esto hace más difícil para el atacante poder indicar a qué dirección destino brincar.
Un atacante puede emplear varias técnicas para ayudarse a adivinar detalles acerca del acomodo en memoria de un proceso, y, con un buffer suficientemente grande, es común ver cadenas de NOP, esto es, una extensión grand de operaciones nulas, seguidas del shellcode, para aumentar las probabilidades de que el control se transfiera a un punto útil.
6.2.3 Empleo de canarios
Se llama canario a un valor aleatorio de protección22, insertado entre los buffers y la dirección de retorno, que es verificado antes de regresar de una función. Si se presentó un desbordamiento de buffer, el valor del canario será reemplazado por basura, y el sistema podrá detener la ejecución del proceso comprometido antes de que brinde privilegios elevados al atacante.
Marco de stack con un canario aleatorio protector de 12 bytes (qR'z2a&5f50s): Si este es sobreescrito por un buffer desbordado, se detendrá la ejecución del programa.
Un atacante tiene dos mecanismos ante un sistema que requiere del canario: Uno es el atacar no directamente a la función en cuestión, sino que al manejador de señales que es notificado de la anomalía, y otro es, ya que se tiene acceso a la memoria del proceso, averiguar el valor del canario. Esto requiere ataques bastante más sofisticados que los vistos en esta sección, pero definitivamente ya no fuera del alcance de los atacantes.
int datos[8000][8000];
/* Version correcta: El ciclo externo va sobre el */
/* indice externo, el ciclo interno va sobre el indice */
/* interno. El arreglo en memoria sera recorrido */
/* secuencialente. */
for (i=0; i<8000; i++) {
for (j=0; j<8000; j++) {
datos[i][j] += 1;
/* Si el ciclo externo es empleado para el indice interno */
/* y viceversa, el recorrido en memoria sera de "puntitos" */
/* distribuidos por todo el espacio que ocupa datos[], */
/* reemplazando a los contenidos del TLB una y otra vez, y */
/* con un fuerte impacto al rendimiento. */
datos[j][i] += 1;
The Grumpy Editor goes 64-bit — Experiencia del editor de Linux Weekly News al migrar a una arquitectura de 64 bits en 2004. Lo más interesante del artículo son los comentarios, ilustran buena parte de los pros y contras de una migración de 32 a 64 bits.
Using Valgrind to debug Xen Toolstacks — Revisar, no sé si el texto propiamente sea del nivel adecuado. En todo caso, buscar algo de Valgrind.
Less swap (blog de Jose Luis Rivas) — Controlando el "swappiness" de Linux vía vm.swappiness
Process memory usage, ejemplos de pmap en diferentes Unixes
20 Linux system monitoring tools, varias de ellas enfocadas a la memoria
Manual de pmap en NetBSD, con varias opciones más explícitas/completas que las de Linux
Understanding the Linux Virtual Memory Manager, por Mel Gorman (2004). Un libro bajo licencia libre, parte de la colección Bruce Perens' Open Source Series; aborda a profundidad los mecanismos y algoritmos relativos a la memoria empleados por el sistema operativo Linux. Entra en detalles técnicos con bastante profundidad, presentándolos poco a poco, por lo que no resulta demasiado complejo de leer. El primer tercio del libro (216 páginas) describe los mecanismos, y los dos tercios restantes siguen el código comentado. El libro está también disponible en formato HTML en
The art of picking Intel registers explica el uso de los principales registros en la arquitectura x86
Smashing the Stack for fun and profit, Aleph One, 1996: Uno de los primeros artículos publicados acerca de los buffers rebasados
Attacking the Windows 7/8 Address Space Randomization explica cómo puede burlarse la protección ALSR (aleatorización de direcciones) en Windows 7 y 8, logrando una dirección predecible de memoria hacia la cual brincar. Puede interesarles también leer la discusión en Slashdot una vez que esto fue hecho público.
1 Veremos en la unidad Sistemas de entrada y salida cómo se interactúa con estas
2 Y de hecho está demostrado que no puede garantizarse que una verificación estática sea suficientemente exhaustiva
3 ¿Por qué de segmento? Ver la sección segmentación, más adelante en esta misma unidad
4 Cuando se hace ligado dinámico a bibliotecas externas sí se mantiene la referencia por nombre, pero para los propósitos de esta sección, hablaremos de las referencias internas únicamente
5 Sin embargo, incluso bajo este esquema, dado que la pila de llamadas (stack) debe mantenerse como escribible, es común encontrar ataques que permiten modificar la dirección de retorno de una subrutina.
6 4MB es bytes;
7 , cada entrada con un mínimo de 20 bits para la página y 20 bits para el marco. ¡La tabla de páginas misma ocuparía 1280 páginas!
8 ¿Por qué necesitamos el segundo? Porque es prácticamente imposible que un proceso emplee su espacio de direccionamiento completo; al indicar el límite máximo de su tabla de páginas por medio del PTLR evitamos desperdiciar grandes cantidades de memoria indicando todo el espacio no utiilzado.
9 Indica Silberschatz (p.295) que el tiempo efectivo de acceso puede ser 10% superior al que tomaría sin emplear paginación.
10 20 bits identificando a la página y otros 20 bits identificando al marco; omitimos aquí la necesidad de alinear los accesos a memoria a bytes individuales, que lo aumentarían a 24
11 Una función digestora puede definirse como , una función que mapea o proyecta al conjunto en un conjunto mucho menor; una característica muy deseable de toda función hash es que la distribución resultante en resulte homogenea y tan poco dependiente de la secuencialidad de la entrada como sea posible.
12 A una lista y no a un valor único dado que una función digestora es necesariamente proclive a presentar colisiones; el sistema debe poder resolver dichas colisiones sin pérdida de información.
13 Algunos ejemplos sobresalientes podrían ser la libc o glibc, que proporciona las funcinoes estándar del lenguaje C y es, por tanto, requerida por casi todos los programas del sistema; los diferentes entornos gráficos (en los Unixes modernos, los principales son Qt y Gtk); bibliotecas para el manejo de cifrado (openssl), compresión (zlib), imágenes (libpng, libjpeg), etc.
14 Una computadora basada en la arquitectura von Neumann, como prácticamente todas las que conocemos hoy en día, no puede ver directamente más que a la memoria principal
15 En cómputo, muchos procesos pueden determinarse como ansiosos (eager), cuando buscan realizar todo el trabajo que puedan desde el inicio, o flojos (lazy), si buscan hacer el trabajo mínimo en un principio y diferir para más tarde tanto como sea posible
16 Dada la frecuencia con que se efectúan referencias a memoria, emplear un mecanismo puramente en software para actualizar las entradas de los marcos resultaría inaceptablemente lento
17 En realidad, dentro de la memoria del sistema operativo, al igual que la de cualquier otro proceso, hay regiones que deben mantenerse residentes y regiones que pueden paginarse. Podemos, simplificando, omitir por ahora esa complicación y asumir que el sistema operativo completo se mantendrá siempre en memoria
18 Aquellas diseñadas antes de que la velocidad del procesador se distanciara tanto del tiempo de acceso a memoria
19 Recordemos que de los 1024KB, o 256 páginas, que tiene nuestro sistema, descontando los 248KB, o 62 páginas, que ocupa el sistema operativo, nos quedan 194 páginas disponibles para los procesos
20 Una traducción literal del término thrashing, empleado en inglés para designar a este fenómeno, resulta más gráfica: Paliza
21 Citando a Theo de Raadt, autor principal del sistema operativo OpenBSD, todo error es una vulnerabilidad esperando a ser descubierta
22 Este uso proviene de la costumbre antigua de los mineros de tener un canario en una jaula en la mina. Como el canario es muy sensible ante la falta de oxígeno, si el canario moría servía como indicador a los mineros de que debían abandonar la mina de inmediato, antes de correr la misma suerte.

References: Resolución 
 resolución 
 resolución 
 resolución 
 resolución 
 resolución 
 resolución 
 resolución