Ya hemos hablado anteriormente sobre los conceptos básicos de la programación de módulos para el Kernel de Linux y sobre los sistemas básicos implicados en el arranque y la gestión de memoria del sistema. Hoy vamos a introducirnos en la gestión de procesos.
En esta nueva entrega de la serie, vamos a adentrarnos en los cambios de contexto, interrupciones y timers. Ya hicimos una pequeña introducción a los Jiffies y como se calculan los loops_per_jiffy
en el post anterior.
Hoy vamos a aprender como y por qué se usan antes de adentrarnos en el apasionante mundo de la concurrencia a nivel de Kernel donde su uso es habitual. Entender los conceptos del contexto de procesos, las interrupciones y los timers es fundamental para la tarea que nos ocupa en esta serie.
Contexto de Procesos y Contexto de Interrupciones
El Kernel funciona combinando los contexto de procesos y contexto de interrupciones. La parte del código del Kernel que es invocada por las llamadas al sistema que se producen en las aplicaciones se ejecutan en el contexto de procesos.
Por otro lado, los manejadores de interrupciones se ejecutan de forma asíncrona en el contexto de interrupciones. Ambos contextos no están vinculados entre si de forma alguna. El código del Kernel que se ejecuta en en contexto de procesos es interrumpible y está sujeto a asignaciones dependientes de diferentes algoritmos.
El del contexto de interrupciones es siempre ejecutado hasta el final y por lo tanto es no interrumpible. A causa de esto, existen restricciones a lo que se puede hacer desde el contexto de interrupciones. Las restricciones para el código en ejecución desde el contexto de interrupción son las siguientes:
-
Dormirse o renunciar al procesador ya adquirido
-
Adquirir un mutex
-
Realizar tareas de consumición de tiempo
-
Acceder a la memoria virtual del espacio de usuario
¿Por qué es necesario?
El contexto de interrupciones es necesario por la misma naturaleza del sistema de E/S y por la disparidad de velocidad de acceso a datos entre los diferentes componentes de la máquina, sobre todo entre los dispositivos de E/S y el procesador. Cuando un dispositivo de E/S tiene algo que decir, demanda la atención del procesador (ciclos de CPU) mediante el envío de ciertas señales de hardware de forma asíncrona.
Esas señales de hardware son conocidas como interrucpiones. Cada dispositivo está asociado a un identificador único llamado IRQ (del Inglés Interrupt Request). Cuando el procesador detecta que se ha generado una interrupción en un IRQ, detiene de forma abrupta lo que esté ejecutando e invoca una rutina de interrupción o IRS (del Inglés Interrupt Routine Service) registrada para la IRQ correspondiente.
Las rutinas de interrupción se ejecutan en el contexto de interrupciones.
Timers del Kernel
El funcionamiento de gran parte del Kernel depende de forma crítica del paso del tiempo y de su medición. El Kernel hace uso de diferentes temporizadores provistos por el hardware que proveen de servicios dependientes del tiempo como busy-waiting y sleep-waiting (estar en espera y echarse a dormir).
Mientras un proceso se encuentra en espera consume ciclos de CPU mientras que cuando se echa a dormir, renuncia al procesador (o núcleo) que puede ser reclamado por otro proceso. Huelga decir que la primera solo se usa en casos muy concretos en las que la segunda no está disponible.
El Kernel también facilita la planificación de funciones que requieren ser ejecutadas tras un retraso de tiempo específico (scheduling).
HZ y Jiffies
Los temporizadores del sistema interrumpen al procesador a frecuencias programables (por lo tanto definibles). Esta frecuencia o número de ticks por segundo (ticks per second) del temporizador está definido en la variable del Kernel HZ
.
La elección de un valor para el HZ
es motivo de estudio y de debate. Los valores altos dan como resultado gran granularidad del temporizador y por lo tanto, una mejor planificación (scheduling). Sin embargo también producen un consumo mayor de energía debido a que la máquina usa más ciclos en el contexto de interrupciones.
En la configuración del Kernel por defecto viene fijado en 100 en la mayoría de las distribuciones y puede ser ajustado a 250 300 y 1000. Un valor de 1000 esta recomendado para sistemas de escritorio y otros donde la respuesta a eventos debe de ser rápida e interactiva.
El valor del HZ
o frecuencia del temporizador depende también de la arquitectura y de la versión del Kernel, consulta la documentación de tu distribución para comprobar cual es la configuración por defecto en tu sistema y por qué.
Cómo ya explicamos anteriormente, la variable jiffies
alberga el número de veces que el reloj del sistema ha pasado por un ciclo desde que se arrancó el sistema. El Kernel incrementa el valor de la variable jiffies
HZ
veces por segundo.
Por lo tanto, en un Kernel con 100HZ un jiffy
equivale una duración de diez milisegundos mientras que en un Kernel con 1000HZ un jiffy
equivaldría a una duración de un milisegundo. Si quisiéramos definir una espera de digamos cuatro segundos, podríamos hacerlo de la siguiente manera:
unsigned long timeout = jiffies + (4*HZ);Estaríamos definiendo que
timeout
es una variable de tipo entero largo sin signo y que su valor es el actual valor de la variable jiffies
más el incremento de cuatro segundos de frecuencia del temporizador (jiffies + (4*HZ)
).
Para comparar el valor actual de la variable jiffies
podemos utilizar la macro time_after
:
if (time_after(jiffies, timeout)) {
...
}
La variable jiffies
se define como volatile
lo que hace que el compilador no optimice el acceso a la misma. De esta forma se asegura que la variable es actualizada por el temporizador en cada tick.
Para convertir de jiffies
a segundos, tan solo hay que dividir el número de jiffies
entre la frecuencia del temporizador (HZ
).
Algunas consideraciones
La variable de 32 bits jiffies
se desborda en aproximadamente cincuenta días sin apagar la máquina asumiendo una frecuencia del temporizador de 1000HZ. Como muchos sistemas permanecen uptime mucho más que eso, el Kernel provee una variable llamada jiffies_64
que alberga una versión sin signo de 64 bits.
El enlazador posiciona jiffies_64
de tal forma que sus últimos 32 bits concuerdan con jiffies
. En máquinas de 32 bits, el compilador necesita dos instrucciones para asignar un entero sin signo de 64 bits dentro de otra variable, por lo que el acceso a jiffies_64
no es atómico. Para solucionar ese problema el Kernel proporciona la función get_jiffies_64()
.
Delays
En términos del Kernel, los delays del orden de jiffies
están considerados de larga duración. Una forma de conseguir delays largos es usar busy-looping:
unsigned long timeout = jiffies + HZ;
while (time_before(jiffies, timeout))
continue;
En el ejemplo anterior nuestro código esperará un segundo sin hacer uso del procesador e impidiendo que otros usen el procesador para algo útil. Por supuesto existen formas más eficientes de crear delays:
unsigned long timeout = jiffies + HZ;
schedule_timeout(timeout);
En este último ejemplo hemos hecho uso de la función schedule_timeout
que permite que otras partes del kernel se ejecuten mientras se espera a que finalice el delay.
Bien sea desde el espacio de usuario o desde el espacio de kernel es complicado conseguir un control más preciso sobre los tiempos de espera que la granularidad de HZ
porque los segmentos de tiempo de los procesos son actualizados por el planificador del Kernel solo durante los ticks del temporizador.
Incluso si tu proceso se ha planificado para que se ejecute después de un timeout específico, el planificador puede decidir elegir otro proceso de la cola de procesos basándose en prioridades. Otras dos funciones que facilitan sleep-waiting son wait_event_timeout()
y msleep()
. Ambas se implementan internamente con ayuda de schedule_timeout()
.
wait_sleep_timeout()
se usa cuando queremos reiniciar la ejecución del código cuando una condición se convierte en verdadera o si se consume un tiempo definido de espera. Por otro lado, msleep()
se utiliza para mandar el código a dormir por un tiempo específico definido.
Los delays largos pueden usarse solo en el contexto de procesos puesto que no se permite usar schedule()
y sleep-waiting desde el contexto de interrupciones. Usar busy-waiting desde el contexto de interrupciones si es posible pero usar periodos largos está considerado un pecado capital.
El Kernel nos provee de una API para ejecutar funciones en cualquier punto en el futuro. Podemos definir temporizadores de forma dinámica con init_timer()
o estática con DEFINE_TIMER()
. Después podemos añadir la función y los parámetros de nuestra función y registrarla usando add_timer()
:
#include <linux/timer.h>;
struct timer_list the_timer;
init_timer(&the_timer);
the_timer.expire = jiffies + 2*HZ;
the_timer.function = timer_func; /* función definida en otro sitio */
the_timer.data = timer_func_parameters; /* parámetros definidos en otro sitio */
add_timer(&the_timer);
Las aplicaciones que se ejecuta en el espacio de usuario y usan funciones como clock_settime() setitimer() clock_gettime()
y getitimer()
acceden a los servicios de temporizadores del Kernel. Para saber más sobre esto revisa el código del archivo kernel/timer.c.
En términos del Kernel, delays de menos de un jiffy son de corta duración. Este tipo de delays son usados tanto en contexto de procesos como en contexto de interrupciones. Los delays cortos están implementados utilizando una solución de busy-wait.
Las funciones de la API del Kernel que los implementan son mdelay()
, udelay()
y ndelay()
que soportan milisegundos, microsegundos y nanosegundos respectivamente. Su implementación es dependiente de la arquitectura y pueden no estar disponibles en todas las plataformas.
Los delays cortos usan la variable loops_per_jiffy
(ver el post anterior de la serie) para decidir el número de veces que necesitan iterar en un busy-loop.
Un ejemplo de su uso podemos verlo en la línea 196 del archivo drivers/usb/host/ehci-hcd.c donde para lograr un delay de un microsegundo durante el proceso de negociación, el controlador del host USB llama a udelay()
que hace uso de forma interna de la variable loops_per_jiffy
.
RTC (Real Time Clock)
El RTC controla el tiempo absoluto en la memoria no volátil. En las máquinas basadas en x86, los registros RTC constituyen una pocas posiciones de un pequeño segmento de memoria CMOS (Complementary Metal Oxide Semiconductor) alimentada por una pila. El RTC puede usarse para:
-
Consultar y fijar el reloj absoluto y generar interrupciones durante sus actualizaciones
-
Generar interrupciones periódicas con frecuencias en el rango de los 2 a los 8192 Herzios.
-
Fijar alarmas
Bastantes aplicaciones necesitan el concepto de tiempo absoluto ya que los jiffies
son relativos al tiempo desde que el sistema arrancó y no contienen un valor de tiempo absoluto. El Kernel guarda el valor del RTC en la variable xtime
durante el arranque. Y vuelve a escribirlo de vuelta al apagar el sistema.
Podemos usar do_gettimeofday()
para leer el tiempo absoluto con la mayor resolución soportada por el hardware:
#include <linux/time.h>
static struct timeval curr_time;
do_gettimeofday(&curr_time);
the_timestamp = cpu_to_le32(curr_time.tv_sec);
Existen numerosas funciones en espacio de usuario que acceden al tiempo absoluto, entre ellas, time()
, localtime()
, mktime()
y gettimeofday()
.
Otra forma de hacer uso del RTC desde el espacio de usuario es a través del dispositivo /dev/rtc pero solo puede ser accedido por un proceso a la vez.
Conclusión
Hoy hemos aprendido un poco sobre el modo en el que Linux trabaja con los temporizadores y el planificador de procesos. Hemos visto las diferencias entre contexto de procesos y contexto de interrupciones. Además hemos visto que es una interrupción y por que son necesarias.
En la próxima entrega nos adentraremos de lleno en el apasionante mundo de la concurrencia a nivel de Kernel y pondremos en práctica todo lo aprendido hasta ahora.
Más en Genbeta Dev | Programación a pecho descubierto (Linux Kernel)