Página siguiente Página anterior Índice general

2. Procesos y Manejo de Interrupciones

2.1 Estructura de Tareas y Tabla de Procesos

Cada proceso bajo Linux es dinámicamente asignado a una estructura struct task_struct. El número máximo de procesos que pueden ser creados bajo Linux está solamente limitado por la cantidad de memoria física presente, y es igual a (ver kernel/fork.c:fork_init()):


        /*
         * El número máximo por defecto de hilos es establecido
         * a un valor seguro: las estructuras de hilos pueden ocupar al
         * menos la mitad de la memoria.
         */
        max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 2;

lo cual, en la arquitectura IA32, básicamente significa num_physpages/4. Como ejemplo, en una máquina de 512M, puedes crear 32k de hilos. Esto es una mejora considerable sobre el límite de 4k-epsilon para los núcleos viejos (2.2 y anteriores). Es más, esto puede ser cambiado en tiempo de ejecución usando el KERN_MAX_THREADS sysctl(2), o simplemente usando la interfaz procfs para el ajuste del núcleo:


# cat /proc/sys/kernel/threads-max 
32764
# echo 100000 > /proc/sys/kernel/threads-max 
# cat /proc/sys/kernel/threads-max 
100000
# gdb -q vmlinux /proc/kcore
Core was generated by `BOOT_IMAGE=240ac18 ro root=306 video=matrox:vesa:0x118'.
#0  0x0 in ?? ()
(gdb) p max_threads
$1 = 100000

El conjunto de procesos en el sistema Linux está representado como una colección de estructuras struct task_struct, las cuales están enlazadas de dos formas:

  1. como una tabla hash, ordenados por el pid, y
  2. como una lista circular doblemente enlazada usando los punteros p->next_task y p->prev_task.

La tabla hash es llamada pidhash[] y está definida en include/linux/sched.h:


/* PID hashing. (¿no debería de ser dinámico?) */
#define PIDHASH_SZ (4096 >> 2)
extern struct task_struct *pidhash[PIDHASH_SZ];

#define pid_hashfn(x)   ((((x) >> 8) ^ (x)) & (PIDHASH_SZ - 1))

Las tareas son ordenadas por su valor pid y la posterior función de ordenación se supone que distribuye los elementos uniformemente en sus dominios de (0 a PID_MAX-1). La tabla hash es usada para encontrar rápidamente una tarea por su pid usando find_task_pid() dentro de include/linux/sched.h:


static inline struct task_struct *find_task_by_pid(int pid)
{
        struct task_struct *p, **htable = &pidhash[pid_hashfn(pid)];

        for(p = *htable; p && p->pid != pid; p = p->pidhash_next)
                ;

        return p;
}

Las tareas en cada lista ordenada (esto es, ordenadas por el mismo valor) son enlazadas por p->pidhash_next/pidhash_pprev el cual es usado por hash_pid() y unhash_pid() para insertar y quitar un proceso dado en la tabla hash. Esto es realizado bajo la protección del spinlock read/write (lectura/escritura) llamado tasklist_lock tomado para ESCRITURA.

La lista circular doblemente enlazada que usa p->next_task/prev_task es mantenida para que uno pueda ir fácilmente a través de todas las tareas del sistema. Esto es realizado por la macro for_each_task() desde include/linux/sched.h:


#define for_each_task(p) \
        for (p = &init_task ; (p = p->next_task) != &init_task ; )

Los usuarios de for_each_task() deberían de coger la tasklist_lock para LECTURA. Destacar que for_each_task() está usando init_task para marcar el principio (y el final) de la lista - esto es seguro porque la tarea vacía (pid 0) nunca existe.

Los modificadores de los procesos de la tabla hash y/o los enlaces de la tabla de procesos, notablemente fork(), exit() y ptrace(), deben de coger la tasklist_lock para ESCRITURA. El motivo por el que esto es interesante es porque los escritores deben de deshabilitar las interrupciones en la CPU local. El motivo para esto no es trivial: la función send_sigio() anda por la lista de tareas y entonces coge tasklist_lock para ESCRITURA, y esta es llamada desde kill_fasync() en el contexto de interrupciones. Este es el motivo por el que los escritores deben de deshabilitar las interrupciones mientras los lectores no lo necesitan.

Ahora que entendemos cómo las estructuras task_struct son enlazadas entre ellas, déjanos examinar los miembros de task_struct. Ellos se corresponden débilmente con los miembros de las estructuras de UNIX 'struct proc' y 'struct user' combinadas entre ellas.

Las otras versiones de UNIX separan la información del estado de las tareas en una parte, la cual deberá de ser mantenida en memoria residente durante todo el tiempo (llamada 'proc structure' la cual incluye el estado del proceso, información de planificación, etc.), y otra parte, la cual es solamente necesitada cuando el proceso está funcionando (llamada 'u_area' la cual incluye la tabla de descriptores de archivos, información sobre la cuota de disco etc.). El único motivo para este feo diseño es que la memoria era un recurso muy escaso. Los sistemas operativos modernos (bueno, sólo Linux por el momento, pero otros, como FreeBSD ( que parece que avanzan en esta dirección, hacia Linux) no necesitan tal separación y entonces mantienen el estado de procesos en una estructura de datos del núcleo residente en memoria durante todo el tiempo.

La estructura task_struct está declarada en include/linux/sched.h y es actualmente de un tamaño de 1680 bytes.

El campo de estado es declarado como:


volatile long state;    /* -1 no ejecutable, 0 ejecutable, >0 parado */

#define TASK_RUNNING            0
#define TASK_INTERRUPTIBLE      1
#define TASK_UNINTERRUPTIBLE    2
#define TASK_ZOMBIE             4
#define TASK_STOPPED            8
#define TASK_EXCLUSIVE          32

¿Por qué TASK_EXCLUSIVE está definido como 32 y no cómo 16? Porque 16 fue usado por TASK_SWAPPING y me olvidé de cambiar TASK_EXCLUSIVE cuando quité todas las referencias a TASK_SWAPPING (en algún sitio en 2.3.x).

La declaración volatile en p->statesignifica que puede ser modificada asincrónicamente (desde el manejador de interrupciones);

  1. TASK_RUNNING: significa que la tarea está "supuestamente" en la cola de ejecución. El motivo por lo que quizás no esté aún en la cola de ejecución es porque marcar una tarea como TASK_RUNNING y colocarla en la cola de ejecución no es atómico. Necesitarás mantener el spinlock read/write runqueue_lock en lectura para mirar en la cola de ejecución. Si lo haces, verás que cada tarea en la cola de ejecución está en el estado TASK_RUNNING. Sin embargo, la conversión no es verdad por los motivos explicados anteriormente. De una forma parecida, los controladores pueden marcarse a ellos mismos (o en realidad, en el contexto del proceso en el que están) como TASK_INTERRUPTIBLE (o TASK_UNINTERRUPTIBLE) y entonces llaman a schedule(), el cual entonces los quita de la cola de ejecución (a memos que exista una señal pendiente, en tal caso permanecen en la cola de ejecución).
  2. TASK_INTERRUPTIBLE: significa que la tarea está durmiendo pero que puede ser despertada por una señal o por la terminación de un cronómetro.
  3. TASK_UNINTERRUPTIBLE: lo mismo que TASK_INTERRUPTIBLE, excepto que no puede ser despertado.
  4. TASK_ZOMBIE: tareas que han terminado pero que no tienen su estado reflejado (para wait()-ed) por el padre (natural o por adopción).
  5. TASK_STOPPED: tarea que fue parada, por señales de control de trabajos o por ptrace(2).
  6. TASK_EXCLUSIVE: este no es un estado separado pero puede ser uno de TASK_INTERRUPTIBLE o TASK_UNINTERRUPTIBLE. Esto significa que cuando esta tarea está durmiendo o un una cola de espera con otras tareas, puede ser despertada sóla en vez de causar el problema de "movimiento general" despertando a todos los que están esperando.

Las banderas de las tareas contienen información sobre los estados de los procesos, los cuales no son mutuamente exclusivos:


unsigned long flags;    /* banderas para cada proceso, definidas abajo */
/*
 * Banderas para cada proceso
 */
#define PF_ALIGNWARN    0x00000001      /* Imprime mensajes de peligro de alineación */
                                        /* No implementada todavía, solo para 486 */
#define PF_STARTING     0x00000002      /* Durante la creación */
#define PF_EXITING      0x00000004      /* Durante la destrucción */
#define PF_FORKNOEXEC   0x00000040      /* Dividido pero no ejecutado */
#define PF_SUPERPRIV    0x00000100      /* Usados privilegios de super-usuario */
#define PF_DUMPCORE     0x00000200      /* Núcleo volcado */
#define PF_SIGNALED     0x00000400      /* Asesinado por una señal */
#define PF_MEMALLOC     0x00000800      /* Asignando memoria */
#define PF_VFORK        0x00001000      /* Despertar al padre en mm_release */
#define PF_USEDFPU      0x00100000      /* La tarea usó de FPU este quantum (SMP) */

Los campos p->has_cpu, p->processor, p->counter, p->priority, p->policy y p->rt_priority son referentes al planificador y serán mirados más tarde.

Los campos p->mm y p->active_mm apuntan respectivamente a la dirección del espacio del proceso descrita por la estructura mm_struct y al espacio activo de direcciones, si el proceso no tiene una verdadera (ej, hilos de núcleo). Esto ayuda a minimizar las descargas TLB en los intercambios del espacio de direcciones cuando la tarea es descargada. Por lo tanto, si nosotros estamos planificando el hilo del núcleo (el cual no tiene p->mm) entonces su next->active_mm será establecido al prev->active_mm de la tarea que fue descargada, la cual será la misma que prev->mm si prev->mm != NULL. El espacio de direcciones puede ser compartido entre hilos si la bandera CLONE_VM es pasada a las llamadas al sistema clone(2) o vfork(2).

Los campos p->exec_domain y p->personality se refieren a la personalidad de la tarea, esto es, la forma en que ciertas llamadas al sistema se comportan para emular la "personalidad" de tipos externos de UNIX.

El campo p->fs contiene información sobre el sistema de archivos, lo cual significa bajo Linux tres partes de información:

  1. dentry del directorio raíz y punto de montaje,
  2. dentry de un directorio raíz alternativo y punto de montaje,
  3. dentry del directorio de trabajo actual y punto de montaje.

Esta estructura también incluye un contador de referencia porque puede ser compartido entre tareas clonadas cuando la bandera CLONE_FS es pasada a la llamada al sistema clone(2).

El campo p->files contiene la tabla de descriptores de ficheros. Esto también puede ser compartido entre tareas, suministrando CLONE_FILES el cual es especificado con clone(2).

El campo p->sig contiene los manejadores de señales y puede ser compartido entre tareas clonadas por medio de CLONE_SIGHAND.

2.2 Creación y terminación de tareas e hilos del núcleo

Diferentes libros sobre sistemas operativos definen un "proceso" de diferentes formas, empezando por "instancia de un programa en ejecución" y finalizando con "lo que es producido por las llamadas del sistema clone(2) o fork(2)". Bajo Linux, hay tres clases de procesos:

El hilo vacío es creado en tiempo de compilación para la primera CPU; es entonces creado "manualmente" para cada CPU por medio de la función específica de la arquitectura fork_by_hand() en arch/i386/kernel/smpboot.c, el cual desenrolla la llamada al sistema fork(2) a mano (en algunas arquitecturas). Las tareas vacías comparten una estructura init_task pero tienen una estructura privada TSS, en la matriz de cada CPU init_tss. Todas las tareas vacías tienen pid = 0 y ninguna otra tarea puede compartir el pid, esto es, usar la bandera CLONE_PID en clone(2).

Los hilos del núcleo son creados usando la función kernel_thread() la cual invoca a la llamada al sistema clone(2) en modo núcleo. Los hilos del núcleo usualmente no tienen espacio de direcciones de usuario, esto es p->mm = NULL, porque ellos explicitamente hacen exit_mm(), ej. a través de la función daemonize(). Los hilos del núcleo siempre pueden acceder al espacio de direcciones del núcleo directamente. Ellos son asignados a números pid en el rango bajo. Funcionando en el anillo del procesador 0 (en x86) implica que los hilos del núcleo disfrutan de todos los privilegios de E/S y no pueden ser pre-desocupados por el planificador.

Las tareas de usuario son creadas por medio de las llamadas al sistema clone(2) o fork(2), las cuales internamente invocan a kernel/fork.c:do_fork().

Déjenos entender qué pasa cuando un proceso de usuario realiza una llamada al sistema fork(2). Como fork(2) es dependiente de la arquitectura debido a las diferentes formas de pasar la pila y registros de usuario, la actual función subyacente do_fork() que hace el trabajo es portable y está localizada en kernel/fork.c.

Los siguientes pasos son realizados:

  1. La variable local retval es establecida a -ENOMEM, ya que este es el valor al que errno debería de ser establecida si fork(2) falla al asignar una nueva estructura de tarea.
  2. Si CLONE_PID es establecido en clone_flags entonces devuelve un error (-EPERM), a menos que el llamante sea el hilo vacío (sólo durante el arranque). Por lo tanto, un hilo de un usuario normal no puede pasar CLONE_PID a clone(2) y esperar que tenga éxito. Para fork(2), es irrelevante que clone_flags sea establecido a SIFCHLD - esto es sólo relevante cuando do_fork() es invocado desde sys_clone() el cual pasa clone_flags desde el valor pedido desde el espacio de usuario.
  3. current->vfork_sem es inicializado (es más tarde limpiado en el hijo). Esto es usado por sys_vfork() (la llamada al sistema vfork(2) corresponde a clone_flags = CLONE_VFORK|CLONE_VM|SIGCHLD) para hacer que el padre duerma mientras el hijo hace mm_release(), por ejemplo como resultado de exec() (ejecutar) otro programa o exit(2).
  4. Una nueva estructura de tarea es asignada usando la macro dependiente de la arquitectura alloc_task_struct(). En x86 es justo un gfp a la prioridad GFP_KERNEL. Este es el primer motivo por el que la llamada fork(2) quizás duerma. Si la primera asignación falla, devolvemos -ENOMEM.
  5. Todos los valores de la actual estructura de tareas del proceso son copiadas en la nueva, usando un asignamiento de estructura *p = *current. ¿Quizás debería de ser reemplazada por un establecimiento de memoria? Más tarde, los campos que no deberían de ser heredados por el hijo son establecidos a los valores correctos.
  6. Un gran cierre del núcleo es tomado durante el resto del código ya que en otro caso sería no reentrante.
  7. Si el padre no tiene recursos de usuario (un concepto de UID, Linux es suficientemente flexible para hacer de ellos una cuestión mejor que un hecho), entonces verifica si el límite blando de usuario RLIMIT_NPROC ha sido excedido - si lo es, falla con -EAGAIN, si no, incrementa la cuenta de procesos con el uid dado p->user->count.
  8. Si el número de tareas a lo largo del sistema excede el valor de max_threads (recordar que es ajustable), falla con -EAGAIN.
  9. Si el binario que se ejecuta pertenece a un dominio modularizado de ejecución, incrementa el contador de referencia del correspondiente módulo.
  10. Si el binario que se ejecuta pertenece a un formato binario modularizado, incrementa el contador de referencia del módulo correspondiente.
  11. El hijo es marcado como 'no tiene que ser ejecutado' (p->did_exec = 0)
  12. El hijo es marcado como 'no intercambiable' (p->swappable = 0)
  13. EL hijo es puesto en el estado 'durmiendo no interrumpible', esto es p->state = TASK_UNINTERRUPTIBLE (POR HACER: ¿por qué es realizado esto? Creo que no se necesita - librarse de el, Linus confirma que no se necesita)
  14. Las p->flags del hijo son establecidas de acuerdo a los valores de clone_flags; para fork(2) limpias, esto será p->flags = PF_FORKNOEXEC.
  15. El pid del hijo p->pid es establecido usando el algoritmo rápido en kernel/fork.c:get_pid() (POR HACER: el spinlock lastpid_lock puede ser redundante ya que get_pid() siempre es llamado bajo un gran cierre del núcleo desde do_fork(), también quita los argumentos bandera de get_pid(), parche enviado a Alan el 20/06/2000 - mirar después).
  16. El resto del código en do_fork() inicializa el resto de la estructura de la tarea del hijo. Muy al final, la estructura de tarea del hijo es ordenada en la tabla hash pidhash y el hijo es despertado. (POR HACER: wake_up_process(p) establece p->state = TASK_RUNNING y añade el proceso a la cola de ejecución, entonces probablemente no necesita establecer p->state a TASK_RUNNING tempranamente en do_fork()). La parte interesante es establecer p->exit_signal a clone_flags & CSIGNAL, la cual para fork(2) significa justamente SIGCHLD y establece p->pdeath_signal a 0. La pdeath_signal es usada cuando un proceso 'olvida' el padre original (durante la muerte) y puede ser establecido/tomado por medio del comando PR_GET/SET_PDEATHSIG de la llamada al sistema prctl(2) (Tu quizás argumentes que la forma en la que el valor de pdeath_signal es devuelto a través de un argumento de un puntero del espacio de usuario en prctl(2) es un poco tonto - mea culpa, después de que Andries Brouwer actualizara la página man era muy tarde para arreglarlo ;)

Entonces las tareas son creadas. Hay varias formas para la terminación de tareas:

  1. haciendo la llamada al sistema exit(2);
  2. enviando un señal con la disposición por defecto de morir;
  3. siendo forzado a morir bajo ciertas excepciones;
  4. llamando bdflush(2) con func == 1 (esto es específico de Linux, para compatibilización de viejas distribuciones que todavía tienen la linea 'update' en /etc/inittab - hoy en día el trabajo de update es hecho por el hilo del núcleo kupdate).

Las funciones implementando llamadas al sistema bajo Linux son prefijadas con sys_, pero ellas son usualmente concernientes sólo al chequeo de argumentos o a formas específicas de la arquitectura de pasar alguna información y el trabajo actual es realizado por las funciones do_. Por lo tanto, es con sys_exit() el cual llama a do_exit() para hacer el trabajo. Aunque otras partes del núcleo a veces invocan a sys_exit() mientras que deberían realmente de llamar a do_exit().

La función do_exit() es encontrada en kernel/exit.c. Los puntos que destacar sobre do_exit() son;

2.3 Planificador Linux

El trabajo de un planificador es decidir el acceso a la actual CPU entre múltiples procesos. El planificador está implementado en el 'archivo principal del núcleo' kernel/sched.c. El archivo de cabeceras correspondiente include/linux/sched.h está incluido virtualmente (explícita o implicitamente) en todos los archivos de código fuente del núcleo.

Los campos de una estructura de tareas relevante a planificar incluyen:

EL algoritmo de planificación es simple, olvídate de la gran complejidad aparente de la función schedule(). La función es compleja porque implementa tres algoritmos de planificación en uno y también porque disimula los específicos de SMP.

Las aparentemente 'inservibles' etiquetas (gotos) en schedule() están allí con el propósito de generar el mejor código optimizado (para i386). También, destacar que el planificador (como la mayoría del núcleo) fue totalmente reescrito para el 2.4, entonces la discusión de más abajo no se aplica a los núcleos 2.2 o anteriores.

Déjanos mirar la función en detalle:

  1. Si current->active_mm == NULL entonces algo está mal. El actual proceso, incluso un hilo del núcleo (current->mm == NULL) debe de tener un p->active_mm válido durante todo el tiempo.
  2. Si hay algo que hacer en la cola de tareas tq_scheduler, entonces se procesa ahora. La cola de tareas suministra al núcleo un mecanismo para planificar la ejecución de las funciones más tarde. Lo miraremos en detalle en otra parte.
  3. Se inicializan las variables locales prev y this_cpu a las tareas y CPUs actuales, respectivamente.
  4. Se chequea si schedule() fue llamada desde el controlador de interrupciones (debido a un fallo) y provoca un pánico si ha sido así.
  5. Se quita el cierre global del núcleo.
  6. Si hay algún trabajo que hacer a través del mecanismo de softirq, se hace ahora.
  7. Se inicializa el puntero local struct schedule_data *sched_data para que apunte a cada CPU (alineado de la linea de antememoria para prevenir que la linea de antememoria salte) planificando el área de datos, el cual contiene el valor TSC de last_schedule y el puntero a la última estructura planificada (POR HACER: sched_data es usada sólo en SMP, ¿pero porqué inicializa también init_idle() en UP (monoprocesadores)?
  8. Es tomado el spinlock runqueue_lock. Destacar que usamos spin_lock_irq() porque en schedule() garantizamos que las interrupciones están habilitadas. Por esto, cuando abrimos runqueue_lock, podemos rehabilitarlas en vez de salvar/restaurar las eflags (variante spin_lock_irqsave/restore).
  9. Estado de tareas de la máquina: si la tarea está en el estado TASK_RUNNING entonces se deja sólo, si está en el estado TASK_INTERRUPTIBLE y hay una señal pendiente, es movido al estado TASK_RUNNING. En todos los otros casos es borrado de la cola de ejecución.
  10. next (mejor candidato para ser planificado) es establecido a la tarea vacía de esta CPU. En todo caso, la virtud de este candidato es establecida a un valor muy bajo (-1000), con la esperanza de que haya otro mejor que él.
  11. Si la tarea prev (actual) está en el estado TASK_RUNNING entonces la actual virtud es establecida a su virtud y es marcado como mejor candidato para ser planificado que la tarea vacía.
  12. Ahora la cola de ejecución es examinada y una virtud de cada proceso que puede ser planificado en esta CPU es comparado con el actual valor; el proceso con la virtud más alta gana. Ahora el concepto de "puede ser planificado en esta CPU" debe de ser clarificado: en UP, todos los procesos en la cola de ejecución son elegibles para ser planificados; en SMP, sólo los procesos que no estean corriendo en otra CPU son elegibles para ser planificados en esta CPU. La virtud es calculada por una función llamada goodness(), la cual trata los procesos en tiempo real haciendo sus virtudes muy altas (1000 + p->rt_priority), siendo mayor que 1000 se garantiza que no puede ganar otro proceso SCHED_OTHER; por lo tanto sólo compiten con los otros procesos en tiempo real que quizás tengan un mayor p->rt_priority. La función virtud devuelve 0 si la porción de tiempo del proceso (p->counter) se acabó. Para procesos que no son en tiempo real, el valor inicial de la virtud es establecido a p->counter - por este camino, el proceso tiene menos posibilidades para alcanzar la CPU si ya la tuvo por algún tiempo, esto es, los procesos interactivos son favorecidos más que el límite de impulsos de la CPU. La constante específica de la arquitectura PROC_CHANGE_PENALTY intenta implementar la "afinidad de cpu" (esto es, dar ventaja a un proceso en la misma CPU). También da una ligera ventaja a los procesos con mm apuntando al actual active_mm o a procesos sin espacio de direcciones (de usuario), esto es, hilos del núcleo.
  13. si el actual valor de la virtud es 0 entonces la lista entera de los procesos (¡no sólo los de la lista de ejecutables!) es examinada y sus prioridades dinámicas son recalculadas usando el simple algoritmo:

    
    recalculate:
            {
                    struct task_struct *p;
                    spin_unlock_irq(&runqueue_lock);
                    read_lock(&tasklist_lock);
                    for_each_task(p)
                            p->counter = (p->counter >> 1) + p->priority;
                    read_unlock(&tasklist_lock);
                    spin_lock_irq(&runqueue_lock);
            }
    

    Destacar que tiramos el runqueue_lock antes de recalcular. El motivo para esto es que vamos a través del conjunto entero de procesos; esto puede llevar un gran tiempo, durante el cual el schedule() puede ser llamado por otra CPU y seleccionar un proceso con la suficiente virtud para esta CPU, mientras que nosotros en esta CPU seremos obligados a recalcular. Muy bien, admitamos que esto es algo inconsistente porque mientras que nosotros (en esta CPU) estamos seleccionando un proceso con la mejor virtud, schedule() corriendo en otra CPU podría estar recalculando las prioridades dinámicas.
  14. Desde este punto, es cierto que next apunta a la tarea a ser planificada, por lo tanto debemos de inicializar next->has_cpu a 1 y next->processor a this_cpu. La runqueue_lock puede ahora ser abierta.
  15. Si estamos volviendo a la misma tarea (next == prev) entonces podemos simplemente readquirir un cierre global del núcleo y volver, esto es, saltar todos los niveles hardware (registros, pila, etc.) y el grupo relacionado con la VM (Memoria Virtual) (cambiar la página del directorio, recalcular active_mm etc.)
  16. La macro switch_to() es específica de la arquitectura. En i386, es concerniente con: a) manejo de la FPU (Unidad de Punto Flotante), b) manejo de la LDT, c) recargar los registros de segmento, d) manejo de TSS y e) recarga de los registros de depuración.

2.4 Implementación de la lista enlazada (de) Linux

Antes de ir a examinar las implementación de las colas de espera, debemos de informarnos con la implementación estándar de la lista doblemente enlazada Linux. Las colas de espera (igual que todo lo demás en Linux) hacen un uso fuerte de ellas y entonces son llamadas en la jerga "implementación list.h" porque el archivo más relevante es include/linux/list.h.

La estructura de datos fundamental aquí es struct list_head:


struct list_head {
        struct list_head *next, *prev;
};

#define LIST_HEAD_INIT(name) { &(name), &(name) }

#define LIST_HEAD(name) \
        struct list_head name = LIST_HEAD_INIT(name)

#define INIT_LIST_HEAD(ptr) do { \
        (ptr)->next = (ptr); (ptr)->prev = (ptr); \
} while (0)

#define list_entry(ptr, type, member) \
        ((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member)))

#define list_for_each(pos, head) \
        for (pos = (head)->next; pos != (head); pos = pos->next)

Las tres primeras macros son para inicializar un lista vacía apuntando los punteros next y prev a ellos mismos. Esto es obvio debido a las restricciones sintácticas de C, las cuales deberían de ser usadas aquí - por ejemplo, LIST_HEAD_INIT() puede ser usada para la inicialización de elementos de la estructura en la declaración, la segunda puede ser usada para la inicialización de las declaraciones de variables estáticas y la tercera puede ser usada dentro de la función.

La macro list_entry() da acceso individual a los elementos de la lista, por ejemplo (desde fs/file_table.c:fs_may_remount_ro()):


struct super_block {
   ...
   struct list_head s_files;
   ...
} *sb = &some_super_block;

struct file {
   ...
   struct list_head f_list;
   ...
} *file;

struct list_head *p;

for (p = sb->s_files.next; p != &sb->s_files; p = p->next) {
     struct file *file = list_entry(p, struct file, f_list);
     haz algo a 'file'
}

Un buen ejemplo del uso de la macro list_for_each() está en el planificador, donde andamos a través de la cola de ejecución buscando al proceso con la virtud más alta:


static LIST_HEAD(runqueue_head);
struct list_head *tmp;
struct task_struct *p;

list_for_each(tmp, &runqueue_head) {
    p = list_entry(tmp, struct task_struct, run_list);
    if (can_schedule(p)) {
        int weight = goodness(p, this_cpu, prev->active_mm);
        if (weight > c)
            c = weight, next = p;
    }
}

Aquí, p->run_list es declarada como struct list_head run_list dentro de la estructura task_struct y sirve como ancla de la lista. Quitando y añadiendo (al principio o al final de la lista) un elemento de la lista es hecho por las macros list_del()/list_add()/list_add_tail(). Los ejemplos siguientes están añadiendo y quitando una tarea de la cola de ejecución:


static inline void del_from_runqueue(struct task_struct * p)
{
        nr_running--;
        list_del(&p->run_list);
        p->run_list.next = NULL;
}

static inline void add_to_runqueue(struct task_struct * p)
{
        list_add(&p->run_list, &runqueue_head);
        nr_running++;
}

static inline void move_last_runqueue(struct task_struct * p)
{
        list_del(&p->run_list);
        list_add_tail(&p->run_list, &runqueue_head);
}

static inline void move_first_runqueue(struct task_struct * p)
{
        list_del(&p->run_list);
        list_add(&p->run_list, &runqueue_head);
}

2.5 Colas de espera

Cuando un proceso solicita el núcleo para hacer algo que es actualmente imposible pero que quizás sea posible más tarde, el proceso es puesto a dormir y es despertado cuando la solicitud tiene más probabilidades de ser satisfecha. Uno de los mecanismos del núcleo usados para esto es llamado 'cola de espera'.

La implementación de Linux nos permite despertar usando la bandera TASK_EXCLUSIVE. Con las colas de espera, también puedes usar una cola bien conocida y entonces simplificar sleep_on/sleep_on_timeout/interruptible_sleep_on/interruptible_sleep_on_timeout, o puedes definir tu propia cola de espera y usar add/remove_wait_queue para añadir y quitarte desde ella y wake_up/wake_up_interruptible para despertar cuando se necesite.

Un ejemplo del primer uso de las colas de espera es la interacción entre el asignador de páginas (en mm/page_alloc.c:__alloc_pages()) y el demonio del núcleo kswapd (en mm/vmscan.c:kswap()), por medio de la cola de espera kswapd_wait, declarada en mm/vmscan.c; el demonio kswapd duerme en esta cola, y es despertado cuando el asignador de páginas necesita liberar algunas páginas.

Un ejemplo del uso de una cola de espera autónoma es la interacción entre la solicitud de datos de un proceso de usuario a través de la llamada al sistema read(2) y el núcleo funcionando en el contexto de interrupción para suministrar los datos. Un manejador de interrupciones quizás se parezca a (drivers/char/rtc_interrupt() simplificado):


static DECLARE_WAIT_QUEUE_HEAD(rtc_wait);

void rtc_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
        spin_lock(&rtc_lock);       
        rtc_irq_data = CMOS_READ(RTC_INTR_FLAGS);
        spin_unlock(&rtc_lock);     
        wake_up_interruptible(&rtc_wait);
}

Por lo tanto, el manejador de interrupciones obtiene los datos leyendo desde algún puerto de E/S específico del dispositivo (la macro CMOS_READ() devuelve un par de outb/inb) y entonces despierta a quien esté durmiendo en la cola de espera rtc_wait.

Ahora, la llamada al sistema read(2) puede ser implementada como:


ssize_t rtc_read(struct file file, char *buf, size_t count, loff_t *ppos)
{
        DECLARE_WAITQUEUE(wait, current);
        unsigned long data;
        ssize_t retval;

        add_wait_queue(&rtc_wait, &wait);
        current->state = TASK_INTERRUPTIBLE;
        do {
                spin_lock_irq(&rtc_lock);
                data = rtc_irq_data;
                rtc_irq_data = 0;
                spin_unlock_irq(&rtc_lock);

                if (data != 0)
                        break;

                if (file->f_flags & O_NONBLOCK) {
                        retval = -EAGAIN;
                        goto out;
                }
                if (signal_pending(current)) {
                        retval = -ERESTARTSYS;
                        goto out;
                }
                schedule();
        } while(1);
        retval = put_user(data, (unsigned long *)buf);  
        if (!retval)
                retval = sizeof(unsigned long);

out:
        current->state = TASK_RUNNING;
        remove_wait_queue(&rtc_wait, &wait);
        return retval;
}

Lo que pasa en rtc_read() es esto:

  1. Declaramos un elemento de la cola de espera apuntando al contexto del proceso actual.
  2. Añadimos este elemento a la cola de espera rtc_wait
  3. Marcamos el actual contexto como TASK_INTERRUPTIBLE lo que significa que no será replanificado después de la próxima vez que duerma.
  4. Chequeamos si no hay datos disponibles; si los hay empezamos, copiamos los datos a la memoria intermedia del usuario, nos marcamos como TASK_RUNNING, nos quitamos de la cola de espera y regresamos.
  5. Si todavía no hay datos, chequeamos cuando el usuario especificó una E/S no bloqueante, y si es así entonces fallamos con EAGAIN (el cual es el mismo que EWOULDBLOCK)
  6. También chequeamos si hay alguna señal pendiente y si por lo tanto informamos a las "capas superiores" para reinicializar la llamada al sistema si es necesario. Por "si es necesario" yo entiendo los detalles de la disposición de la señal tal como están especificadas en la llamada al sistema sigaction(2).
  7. Entonces "salimos", esto es, nos dormimos, hasta que sea despertado por el manejador de interrupciones. Si no nos marcamos como TASK_INTERRUPTIBLE entonces el planificador nos podrá planificar tan pronto como los datos estean disponibles, causando así procesamiento no necesario.

Es también valioso apuntar que, usando una cola de espera, es bastante más fácil implementar la llamada al sistema poll(2):


static unsigned int rtc_poll(struct file *file, poll_table *wait)
{
        unsigned long l;

        poll_wait(file, &rtc_wait, wait);

        spin_lock_irq(&rtc_lock);
        l = rtc_irq_data;
        spin_unlock_irq(&rtc_lock);

        if (l != 0)
                return POLLIN | POLLRDNORM;
        return 0;
}

Todo el trabajo es realizado por la función independiente del dispositivo poll_wait() la cual hace las manipulaciones necesarias en la lista de espera; todo lo que necesitamos hacer es apuntarla a la cola de espera la cual es despertada por nuestro manejador de interrupciones específico del dispositivo.

2.6 Cronómetros del núcleo

Ahora déjanos poner nuestra atención en los cronómetros del núcleo. Los cronómetros del núcleo son usados para expedir la ejecución de una función particular (llamada 'manejador de cronómetros') en un tiempo especificado en el futuro. La estructura de datos principal es struct timer_list declarada en include/linux/timer.h:


struct timer_list {
        struct list_head list;
        unsigned long expires;
        unsigned long data;
        void (*function)(unsigned long);
        volatile int running;
};

El campo list es para enlazar con la lista interna, protegida por el spinlock timerlist_lock. El campo expires es el valor de jiffies cuando el manejador function debería de ser invocado con data pasado como parámetro. El campo running es usado en SMP para probar si el manejador de cronómetros está actualmente funcionando en otra CPU

Las funciones add_timer() y del_timer() añaden y quitan un cronómetro determinado de la lista. Cuando un cronómetro se termina, este es borrado automáticamente. Antes de que el cronómetro sea usado, DEBE de ser inicializado por medio de la función init_timer(). Y entonces es añadido, los campos function y expires deben de ser establecidos.

2.7 Bottom Halves

A veces es razonable partir la cantidad de trabajo para ser realizada dentro de un manejador de interrupciones en un trabajo inmediato (ej. agradecer la interrupción, actualizar las estadísticas, etc. ) y el trabajo que puede ser postpuesto para más tarde, cuando las interrupciones están habilitadas (ej, para realizar algún post-procesamiento sobre los datos, despertar a los procesos esperando por estos datos, etc).

Los bottom halves son el mecanismo más viejo para posponer la ejecución de una tarea del núcleo y están disponibles desde Linux 1.x. En Linux 2.0, un nuevo mecanismo fue añadido, llamado 'colas de tareas', las cuales serán el título de la siguiente sección.

Los bottom halves son serializados por el spinlock global_bh_lock, esto es, sólo puede haber un bottom half funcionando en cualquier CPU a la vez. De cualquier modo, cuando se intenta ejecutar el manejador, si no está disponible global_bh_lock, el bottom half es marcado (esto es planificado) para ejecución - por lo tanto el procesamiento puede continuar, en opuesto a un bucle ocupado en global_bh_lock.

Sólo puede haber 32 bottom halves registrados en total. Las funciones requeridas para manipular los bottom halves son las siguientes (todas exportadas a módulos);

Los bottom halves son tasklets globalmente cerrados, por lo tanto la pregunta "¿cúando es el manejador bottom half ejecutado?" es realmente "¿cuándo son los tasklets ejecutados?". Y la respuesta es, en dos sitios: a) en cada schedule() y b) en cada camino de retorno de interrupciones/llamadas al sistema en entry.S (POR HACER: entonces, el caso schedule() es realmente aburrido - parece añadir todavía otra interrupción muy muy lenta, ¿por qué no desembarazarse de la etiqueta handle_softirq de schedule() en su conjunto?).

2.8 Colas de Tareas

Las colas de tareas pueden ser entendidas como una extensión dinámica de los viejos bottom halves. En realidad, en el código fuente son a veces referidas como los "nuevos" bottom halves. Más específicamente, los viejos bottom halves discutidos en la sección anterior tienen estas limitaciones:

  1. Sólo hay un número fijo de ellos (32).
  2. Cada bottom half sólo puede estar asociado con una función de manejador.
  3. Los Bottom halves son consumidos con un spinlock mantenido, por lo tanto no pueden bloquear.

Por lo tanto, con las colas de tareas, un número arbitrario de funciones pueden ser encadenadas y procesadas una después de otra en un tiempo posterior. Uno crea una nueva cola de tareas usando la macro DECLARE_TASK_QUEUE() y encola la tarea en él usando la función queue_task(). La cola de tareas entonces puede ser procesada usando run_task_queue(). En vez de crear nuestra propia cola de tareas (y tener que consumirla manualmente) puedes usar una de las colas de tareas predefinidas en Linux las cuales son consumidas en puntos bien conocidos:

  1. tq_timer: la cola de tareas de cronómetros, funciona en cada interrupción del cronómetro y cuando se libera un dispositivo tty (cerrando o liberando un dispositivo de terminal medio abierto). Desde que el manejador de cronómetro funciona en el contexto de interrupción, la tarea tq_timer también funciona en el contexto de interrupción y de este modo tampoco puede bloquearse.
  2. tq_scheduler: la cola de tareas del planificador, consumida por el planificador (y también cuando se cierran dispositivos tty, como tq_timer). Como el planificador es ejecutado en el contexto de los procesos siendo re-planificados, las tareas tq_scheduler pueden hacer todo lo que quieran, esto es bloquear, usar los datos del contexto de los procesos (pero porque ellos quieren), etc .
  3. tq_immediate: esto es realmente un bottom half IMMEDIATE_BH, por lo tanto los controladores pueden queue_task(task, &tq_immediate) y entonces mark_bh(IMMEDIATE_BH) ser consumido en el contexto de interrupción.
  4. tq_disk: usado por un acceso de dispositivo de bloqueo de bajo nivel (y RAID) para empezar la actual petición. Esta cola de tareas es exportada a los módulos pero no debería de ser usada excepto para los propósitos especiales para los que fue diseñada.

A menos que un controlador use su propia cola de tareas, no necesita llamar a run_tasks_queues() para procesar la cola, excepto bajo ciertas circunstancias explicadas a continuación.

El motivo por el que la cola de tareas tq_timer/tq_scheduler no es consumida sólo en los sitios usuales sino en otras partes (cerrando un dispositivo tty, pero no el único ejemplo) se aclara si uno recuerda que el controlador puede planificar tareas en la cola, y estas tareas solo tienen sentido mientras una instancia particular del dispositivo sea todavía válida - lo cual usualmente significa hasta que la aplicación la cierre. Por lo tanto, el controlador quizás necesite llamar a run_task_queue() para encender las tareas que el (y alguno más) ha puesto en la cola, porque permitiéndoles funcionar en un tiempo posterior quizás no tenga sentido - esto es, las estructuras de datos relevantes quizás no hayan sido liberadas/reusadas por una instancia diferente. Este es el motivo por el que ves run_task_queue() en tq_timer y tq_scheduler en otros lugares más que el cronómetro de interrupciones y schedule() respectivamente.

2.9 Tasklets

Todavía no, estarán en una revisión futura

2.10 Softirqs

Todavía no, estarán en una revisión futura

2.11 ¿Cómo son las llamadas al sistema implementadas en la arquitectura i386?

Existen dos mecanismos bajo Linux para implementar las llamadas al sistema:

Los programas nativos de Linux utilizan int 0x80 mientras que los binarios de los distintos tipos de UNIX (Solaris, UnixWare 7 etc.) usan el mecanismo lcall7. El nombre 'lcall7' es históricamente engañoso porque también cubre lcall27 (ej. Solaris/x86), pero la función manejadora es llamada lcall7_func.

Cuando el sistema arranca, la función arch/i386/kernel/traps.c:trap_init() es llamada, la cual inicializa el IDT, por lo tanto el vector 0x80 (del tipo 15, dpl 3) apunta a la dirección de la entrada system_call desde arch/i386/kernel/entry.S.

Cuando una aplicación del espacio de usuario realiza una llamada del sistema, los argumentos son pasados a través de los registros y la aplicación ejecuta la instrucción 'int 0x80'. Esto causa un reajuste en el modo núcleo y el procesador salta al punto de entrada system_call en entry.S. Lo que esto hace es:

  1. Guarda los registros.
  2. Establece %ds y %es a KERNEL_DS, por lo tanto todas las referencias de datos (y segmentos extras) son hechas en el espacio de direcciones del núcleo.
  3. Si el valor de %eax es mayor que NR_syscalls (actualmente 256) fallará con el error ENOSYS.
  4. Si la tarea está siendo ptraced (tsk->ptrace & PF_TRACESYS), realiza un procesamiento especial. Esto es para soportar programas como strace (análogo a SVR4 truss(1)) o depuradores.
  5. Llamada sys_call_table+4*(syscall_number desde %eax). Esta tabla es inicializada en el mismo archivo (arch/i386/kernel/entry.S) para apuntar a los manejadores individuales de las llamadas al sistema, los cuales bajo Linux son (usualmente) prefijados con sys_, ej. sys_open, sys_exit, etc. Estos manejadores de las llamadas al sistema de C encontrarán sus argumentos en la pila donde SAVE_ALL las almacenó.
  6. Entra en el 'camino de retorno de la llamada al sistema'. Esta es una etiqueta separada porque es usada no sólo por int 0x80 sino también por lcall7, lcall27. Esto es, relacionado con el manejo de tasklets (incluyendo bottom halves), chequeando si un schedule() es necesitado (tsk->need_resched !=0), chequeando si hay señales pendientes y por lo tanto manejándolas.

Linux soporta hasta 6 argumentos para las llamadas al sistema. Ellas son pasadas en %ebx, %ecx, %edx, %esi, %edi (y %ebp usado temporalmente, ver _syscall6() en asm-i386/unistd.h). El número de llamadas al sistema es pasado a través de %eax.

2.12 Operaciones Atómicas

Hay dos tipos de operaciones atómicas: bitmaps (mapas de bits) y atomic_t. Los bitmaps son muy convenientes para mantener un concepto de unidades "asignadas" o "libres" para alguna colección grande donde cada unidad es identificada por algún número, por ejemplo tres inodos o tres bloques. Son ampliamente usados para un simple cierre, por ejemplo para suministrar acceso exclusivo para abrir un dispositivo. Un ejemplo de esto puede ser encontrado en arch/i386/kernel/microcode.c:


/*
 *  Bits en microcode_status. (31 bits de espacio para una futura expansión)
 */
#define MICROCODE_IS_OPEN       0       /* establece si el dispositivo está en uso */

static unsigned long microcode_status;

No hay necesidad de inicializar microcode_status a 0 ya que BSS es limpiado a cero explícitamente bajo Linux.


/*
 * Forzamos a un sólo usuario a la vez aquí con open/close. 
 */
static int microcode_open(struct inode *inode, struct file *file)
{
        if (!capable(CAP_SYS_RAWIO))
                return -EPERM;

        /* uno de cada vez, por favor */
        if (test_and_set_bit(MICROCODE_IS_OPEN, &microcode_status))
                return -EBUSY;

        MOD_INC_USE_COUNT;
        return 0;
}

Las operaciones en los bitmaps son:

Estas operaciones usan la macro LOCK_PREFIX, la cual en núcleos SMP evalúa la instrucción prefijo de cierre del bus y no hace nada en UP. Esto garantiza la atomicidad del acceso en el entorno SMP.

A veces las manipulaciones de bits no son convenientes, pero en cambio necesitamos realizar operaciones aritméticas - suma, resta, incremento decremento. Los casos típicos son cuentas de referencia (ej. para los inodos). Esta facilidad es suministrada por el tipo de datos atomic_t y las siguientes operaciones:

2.13 Spinlocks, Spinlocks Read-Write y Spinlocks Big-Reader

Desde los primeros días del soporte Linux (al principio de los 90, en el siglo XX), los desarrolladores se encararon con el clásico problema de acceder a datos compartidos entre los diferentes tipos de contexto (procesos de usuario vs interrupciones) y diferentes instancias del mismo contexto para múltiples cpus.

El soporte SMP fue añadido a Linux 1.3.42 el 15 de Noviembre de 1995 (el parche original fue hecho para el 1.3.37 en Octubre del mismo año).

Si la región crítica del código puede ser ejecutada por el contexto de los procesos y el contexto de las interrupciones, entonces la forma de protegerlo usando las instrucciones cli/sti en UP es:


unsigned long flags;

save_flags(flags);
cli();
/* código crítico */
restore_flags(flags);

Mientras que esto está bien en UP, obviamente no lo está usándolo en SMP porque la misma secuencia de código quizás sea ejecutada simultáneamente en otra cpu, y mientras cli() suministra protección contra las carreras con el contexto de interrupciones en cada CPU individualmente, no suministra protección contra todas las carreras entre los contextos funcionando en diferentes CPUs. Es aquí donde los spinlocks son útiles.

Hay tres tipos de spinlocks: vanilla (básico), read-write y spinlocks big-reader. Los spinlocks read-write deberían de ser usados cuando existe una tendencia natural de 'muchos lectores y pocos escritores'. Un ejemplo de esto es el acceso a las lista de sistemas de archivos registrados (ver fs/super.c). La lista es guardada por el spinlock read-write file_systems_lock porque uno necesita acceso exclusivo sólo cuando se está registrando/desregistrando un sistema de archivos, pero cualquier proceso puede leer el archivo /proc/filesystems o usar la llamada al sistema sysfs(2) para forzar un escaneo de sólo lectura de la lista file_systems. Esto lo hace sensible a usar spinlocks read-write. Con los spinlocks read-write, uno puede tener múltiples lectores a la vez pero sólo un escritor y no puede haber lectores mientras hay un escritor. Por el camino, sería bonito si nuevos lectores no isaran un cierre mientras hay un escritor intentando usar un cierre, esto es, si Linux pudiera distribuir correctamente la solución del hambre potencial del escritor por los múltiples lectores. Esto quiere significar que los lectores deben de ser bloqueados mientras exista un escritor intentando usar el cierre. Este no es actualmente el caso y no es obvio cuando debería de ser arreglado - el argumento para lo contrario es - los lectores usualmente ocupan el cierre por un instante de tiempo muy pequeño, por lo tanto, ¿ellos realmente deberían de tener hambre mientras el escritor usa el cierre para periodos potencialmente más largos?

Los spinlocks Big-reader son una forma de spinlocks read-write altamente optimizados para accesos de lectura muy ligeros, con una penalización para los escritores. Hay un número limitado de spinlocks big-reader - actualmente sólo existen dos, de los cuales uno es usado sólo en sparc64 (irq global) y el otro es usado para redes. En todos los demás casos donde el patrón de acceso no concuerda con ninguno de estos dos escenarios, se debería utilizar los spinlocks básicos. No puedes bloquear mientras mantienes algún tipo de spinlock.

Los Spinlocks vienen en tres tipos: plano, _irq() y _bh().

  1. spin_lock()/spin_unlock() plano: si conoces que las interrupciones están siempre deshabilitadas o si no compites con el contexto de interrupciones (ej. desde un manejador de interrupciones), entonces puedes utilizar este. No toca el estado de interrupción en la actual CPU.
  2. spin_lock_irq()/spin_unlock_irq(): si sabes que las interrupciones están siempre habilitadas entonces puedes usar esta versión, la cual simplemente deshabilita (en el cierre) y re-habilita (en el desbloqueo) las interrupciones en la actual CPU. Por ejemplo, rtc_read() usa spin_lock_irq(&rtc_lock) (las interrupciones están siempre habilitadas dentro de read()) mientras que rtc_interrupt() usa spin_lock(&rtc_lock) (las interrupciones están siempre deshabilitadas dentro del manejador de interrupciones). Nótese que rtc_read() usa spin_lock_irq() y no el más genérico spin_lock_irqsave() porque en la entrada a cualquier llamada al sistema las interrupciones están siempre habilitadas.
  3. spin_lock_irqsave()/spin_unlock_irqrestore(): la forma más fuerte, es usada cuando el estado de las interrupciones no es conocido, pero sólo si las interrupciones no importan nada, esto es, no hay punteros usándolo si nuestro manejador de interrupciones no ejecuta ningún código crítico.

El motivo por el que no puedes usar el spin_lock() plano si compites contra el manejador de interrupciones es porque si lo usas y después un interrupción viene en la misma CPU, el esperará ocupado por el bloqueo para siempre: el que tenga el bloqueo, habiendo sido interrumpido, no continuará hasta el que manejador de interrupciones vuelva.

El uso más común de un spinlock es para acceder a estructuras de datos compartidas entre el contexto de proceso de usuario y el manejador de interrupciones:


spinlock_t my_lock = SPIN_LOCK_UNLOCKED;

my_ioctl()
{
        spin_lock_irq(&my_lock);
        /* sección crítica */
        spin_unlock_irq(&my_lock);
}

my_irq_handler()
{
        spin_lock(&lock);
        /* sección crítica */
        spin_unlock(&lock);
}

Hay un par de cosas que destacar sobre este ejemplo:

  1. El contexto del proceso, representado aquí como un método típico de un controlador - ioctl() (argumentos y valores de retorno omitidos para una mayor claridad), deben de usar spin_lock_irq() porque conocen que las interrupciones están siempre habilitadas mientras se ejecuta un método de dispositivo ioctl().
  2. El contexto de interrupciones, representado aquí por my_irq_handler() (otra vez los argumentos son omitidos para una mayor claridad) pueden usar la forma spin_lock() plana porque las interrupciones están deshabilitadas dentro del manejador de interrupciones.

2.14 Semáforos y semáforos read/write

A veces, mientras se está accediendo a estructuras de datos compartidas, uno debe realizar operaciones que puedan bloquear, por ejemplo copiar datos al espacio de usuario. La directiva del cierre disponible para tales escenarios bajo Linux es llamada semáforo. Hay dos tipos de semáforos: básicos y semáforos read/write. Dependiendo del valor inicial del semáforo, pueden ser usados para exclusión mutua (valor inicial a 1) o para suministrar un tipo más sofisticado de acceso.

Los semáforos read-write difieren de los semáforos básicos de la misma forma que los spinlocks read-write difieren de los spinlocks básicos: uno puede tener múltiples lectores a la vez pero sólo un escritor y no puede haber lectores mientras hay escritores - esto es, el escritor bloquea a todos los lectores, y los nuevos lectores se bloquean mientras un escritor está esperando.

También, los semáforos básicos pueden ser interrumpidos - justamente usan las operaciones down/up_interruptible() en vez del down()/up() plano y chequean el valor devuelto desde down_interruptible(): no será cero si la operación fue interrumpida.

El uso de semáforos para exclusión mutua es ideal para situaciones donde una sección crítica de código quizás sea llamada por funciones de referencia desconocidas registradas por otros subsistemas/módulos, esto es, el llamante no conoce a priori cuando la función bloquea o no.

Un ejemplo simple del uso de semáforos está en la implementación de las llamadas al sistema gethostname(2)/sethostname(2) en kernel/sys.c.


asmlinkage long sys_sethostname(char *name, int len)
{
        int errno;

        if (!capable(CAP_SYS_ADMIN))
                return -EPERM;
        if (len < 0 || len > __NEW_UTS_LEN)
                return -EINVAL;
        down_write(&uts_sem);
        errno = -EFAULT;
        if (!copy_from_user(system_utsname.nodename, name, len)) {
                system_utsname.nodename[len] = 0;
                errno = 0;
        }
        up_write(&uts_sem);
        return errno;
}

asmlinkage long sys_gethostname(char *name, int len)
{
        int i, errno;

        if (len < 0)
                return -EINVAL;
        down_read(&uts_sem);
        i = 1 + strlen(system_utsname.nodename);
        if (i > len)
                i = len;
        errno = 0;
        if (copy_to_user(name, system_utsname.nodename, i))
                errno = -EFAULT;
        up_read(&uts_sem);
        return errno;
}

Los puntos a destacar en este ejemplo son:

  1. Las funciones quizás bloqueen mientras están copiando datos desde/al espacio de usuario en copy_from_user()/copy_to_user(). Entonces no pueden usar ninguna forma de spinlock aquí.
  2. El tipo de semáforo escogido es read-write en oposición al básico porque quizás existan un montón de peticiones concurrentes gethostname(2) las cuales no tienen que ser mutuamente exclusivas.

Aunque la implementación de Linux de los semáforos y los semáforos de read-write es muy sofisticada, existen posibles escenarios que uno puede pensar en los cuales no están todavía implementados, por ejemplo, no existe el concepto de semáforos read-write interrumpible. Eso es obvio porque no hay situaciones en el mundo real que requieran estos tipos exóticos de directivas.

2.15 Soporte del Núcleo para la Carga de Módulos

Linux es un sistema operativo monolítico y olvídate de todos los dichos modernos sobre algunas "ventajas" ofrecidas por los sistemas operativos basados en el diseño micro-núcleo, la verdad permanece (cita de Linus Torvalds):

... el paso de mensajes como la operación fundamental del SO es sólo un ejercicio de masturbación de la ciencia de la computación. Quizás suene bien, pero actualmente no tienes nada HECHO.

Entonces, Linux esta y siempre estará basado en un diseño monolítico, lo cual significa que todos los subsistemas funcionan en el mismo modo privilegiado y comparten el mismo espacio de direcciones, la comunicación entre ellos es realizada por medio de las llamadas usuales de funciones de C.

De cualquier modo, aunque la separación de la funcionalidad del núcleo en "procesos" separados realizada en los micro-núcleos es definitivamente una mala idea, separándolo en módulos del núcleo dinámicamente cargados bajo demanda es deseable en algunas circunstancias (ej, en máquinas con poca memoria o para núcleos de instalación, los cuales de otra forma pueden contener controladores de dispositivos ISA auto-probables que son mutuamente exclusivos). La decisión de cuando incluir soporte para la carga de módulos es hecha en tiempo de compilación y es determinada por la opción CONFIG_MODULES. El soporte para la auto-carga de módulos a través del mecanismo request_module() es una opción separada de compilación (CONFIG_KMOD).

Las siguientes funcionalidades pueden ser implementadas como módulos cargables bajo Linux:

  1. Controladores de dispositivos de bloque y de carácter, incluyendo los controladores de dispositivos misc.
  2. Disciplinas de linea de Terminal.
  3. Ficheros virtuales (regulares) en /proc y en devfs (ej. /dev/cpu/microcode vs /dev/misc/microcode).
  4. Formatos de Ficheros Binarios (ej. ELF, aout, etc).
  5. Dominios de Ejecución (ej. Linux, UnixWare7, Solaris, etc).
  6. Sistemas de ficheros.
  7. System V IPC.

Hay unas pocas cosas que no pueden ser implementadas como módulos bajo Linux (probablemente porque no tienen sentido el ser modularizadas):

  1. Algoritmos de planificación.
  2. Políticas de VM (Memoria Virtual).
  3. Antememoria intermedia, antememoria de páginas y otras antememoria.

Linux suministra varias llamadas al sistema para asistir en la carga de módulos:

  1. caddr_t create_module(const char *name, size_t size): asigna size bytes usando vmalloc() y mapea una estructura de un módulo al principio de este. Este nuevo módulo es enlazado en la cabecera de la lista por module_list. Sólo un proceso con CAP_SYS_MODULE puede llamar a esta llamada al sistema, otros verán como se les retorna EPERM.
  2. long init_module(const char *name, struct module *image): carga la imagen del módulo reasignado y motiva que la rutina de inicialización del módulo sea invocada. Sólo un proceso con CAP_SYS_MODULE puede llamar a esta llamada al sistema, otros verán como se les retorna EPERM.
  3. long delete_module(const char *name): intenta descargar el módulo. Si name == NULL, el intento es hecho para descargar todos los módulos no utilizados.
  4. long query_module(const char *name, int which, void *buf, size_t bufsize, size_t *ret): devuelve información sobre un módulo (o sobre todos los módulos).

La interfaz de comandos disponible a los usuarios consiste en:

Aparte de ser capaz de cargar un módulo manualmente usando insmod o modprobe, también es posible tener el módulo insertado automáticamente por el núcleo cuando una funcionalidad particular es requerida. La interface del núcleo para esto es la función llamada request_module(name) la cual es exportada a los módulos, por lo tanto los módulos también pueden cargar otros módulos. La request_module(name) internamente crea un hilo del núcleo el cual ejecuta el comando del espacio de usuario modprobe -s -k module_name, usando la interfaz estándar del núcleo exec_usermodehelper() (que es también exportado a los módulos). La función devuelve 0 si es exitosa, de cualquier forma no es usualmente válido chequear el código de retorno desde request_module(). En vez de esto, el idioma de programación es:


if (check_some_feature() == NULL)
    request_module(module);
if (check_some_feature() == NULL)
    return -ENODEV;

Por ejemplo, esto es realizado por fs/block_dev.c:get_blkfops() para cargar un módulo block-major-N cuando el intento es hecho para abrir un dispositivo de bloque con número mayor N. Obviamente, no existe tal módulo llamado block-major-N (los desarrolladores Linux solo escogen nombres sensibles para sus módulos) pero es mapeado al propio nombre del módulo usando el archivo /etc/modules.conf. De cualquier forma, para la mayoría de los números mayores bien conocidos (y otros tipos de módulos) los comandos modprobe/insmod conocen qué módulo real cargar sin necesitar una declaración explícita de un alias en /etc/modules.conf.

Un buen ejemplo de la carga de un módulo está dentro de la llamada del sistema mount(2). La llamada al sistema mount(2) acepta el tipo de sistema de archivos como una cadena fs/super.c:do_mount() la cual entonces pasa a fs/super.c:get_fs_type():


static struct file_system_type *get_fs_type(const char *name)
{
        struct file_system_type *fs;

        read_lock(&file_systems_lock);
        fs = *(find_filesystem(name));
        if (fs && !try_inc_mod_count(fs->owner))
                fs = NULL;
        read_unlock(&file_systems_lock);
        if (!fs && (request_module(name) == 0)) {
                read_lock(&file_systems_lock);
                fs = *(find_filesystem(name));
                if (fs && !try_inc_mod_count(fs->owner))
                        fs = NULL;
                read_unlock(&file_systems_lock);
        }
        return fs;
}

Hay que destacar unas pocas cosas en esta función:

  1. Primero intentamos encontrar el sistema de archivos con el nombre dado entre aquellos ya registrados. Esto es hecho bajo la protección de file_systems_lock tomado para lectura (ya que no estamos modificando la lista registrada de sistemas de archivos).
  2. Si tal sistema de archivos es encontrado intentamos coger una nueva referencia a él intentando incrementar la cuenta mantenida del módulo. Esto siempre devuelve 1 para sistemas de archivos enlazados dinámicamente o para módulos que actualmente no se han borrados. Si try_inc_mod_count() devuelve 0 entonces lo consideraremos un fallo - esto es, si el módulo está allí pero está siendo borrado, es tan bueno como si no estuviera allí en absoluto.
  3. Tiramos el file_systems_lock porque lo siguiente que vamos a hacer (request_module()) es una operación bloqueante, y entonces no podemos mantener un spinlock sobre el. Actualmente, en este caso específico, podríamos tirar file_systems_lock de cualquier forma, incluso si request_module() fuera garantizada para ser no bloqueante y la carga de módulos fuera ejecutada en el mismo contexto atómicamente. El motivo para esto es que la función de inicialización de módulos intentará llamar a register_filesystem(), la cual tomará el mismo spinlock read-write file_systems_lock para escritura.
  4. Si el intento de carga tiene éxito, entonces cogemos el spinlock file_systems_lock e intentamos situar el nuevamente registrado sistema de archivos en la lista. Nótese que esto es ligeramente erróneo porque es posible en un principio que un fallo en el comando modprobe pueda causar un volcado del núcleo después de cargar con éxito el módulo pedido, en tal caso request_module() fallará incluso aunque el nuevo sistema de archivos halla sido registrado, y todavía no lo encontrará get_fs_type().
  5. Si el sistema de archivos es encontrado y es capaz de obtener una referencia a el, la devolvemos. En otro caso devolvemos NULL.

Cuando un módulo es cargado en el núcleo, puede ser referido por cualquier símbolo que sea exportado como público por el núcleo usando la macro EXPORT_SYMBOL() o por otros módulos actualmente cargados. Si el módulo usa símbolos de otro módulo, es marcado como dependiente de ese módulo durante el recálculo de dependencias, realizado funcionando el comando depmod -a en el arranque (ej. después de instalar un nuevo núcleo).

Usualmente, uno debe comprobar el conjunto de los módulos con la versión de las interfaces del núcleo que usan, lo cual bajo Linux simplemente significa la "versión del núcleo" ya que no hay versionados especiales del mecanismo de interfaces del núcleo en general. De cualquier forma, hay una funcionalidad limitada llamada "versionamiento de módulos" o CONFIG_MODVERSIONS la cual nos permite eliminar el recompilamiento de módulos cuando cambiamos a un nuevo núcleo. Lo que pasa aquí es que la tabla de símbolos de núcleo es tratada de forma diferente para el acceso interno y para el acceso de los módulos. Los elementos de la parte pública (exportada) de la tabla de símbolos son construidos en la declaración de C de suma de control 32bit. Por lo tanto, en orden de resolver un símbolo usado por un módulo durante la carga, el cargador debe comprobar la representación total del símbolo que incluye la suma de control; será rechazada para cargar el módulo si estos símbolos difieren. Esto sólo pasa cuando el núcleo y el módulo son compilados con el versionamiento de módulos habilitado. Si ninguno de los dos usa los nombres originales de los símbolos el cargador simplemente intenta comprobar la versión del núcleo declarada por el módulo y el exportado por el núcleo y rechaza cargarlo si difieren.


Página siguiente Página anterior Índice general