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:
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->state
significa que
puede ser modificada asincrónicamente (desde el manejador de
interrupciones);
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). TASK_INTERRUPTIBLE
, excepto que no puede ser
despertado.wait()
-ed) por el padre (natural o por
adopción).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:
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
.
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:
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.
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.
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).
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
.
*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.
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
.
-EAGAIN
.
p->did_exec = 0
)
p->swappable = 0
)
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)
p->flags
del hijo son establecidas de acuerdo a los
valores de clone_flags; para fork(2) limpias, esto será
p->flags = PF_FORKNOEXEC
.
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).
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:
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;
schedule()
al final, el cual nunca regresa.
TASK_ZOMBIE
.
current->pdeath_signal
, si
no 0.
current->exit_signal
, el cual
es usualmente igual a SIGCHLD
.
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:
p->need_resched
: este campo es establecido si
schedule()
debería de ser llamado en la 'siguiente
oportunidad'.
p->counter
: número de ticks de reloj que quedan en esta
porción de tiempo del planificador, decrementada por un
cronómetro. Cuando este campo se convierte a un valor menor o
igual a cero, es reinicializado a 0 y p->need_resched
es establecido. Esto también es llamado a veces 'prioridad
dinámica' de un proceso porque puede cambiarse a si mismo.
p->priority
: la prioridad estática del proceso, sólo
cambiada a través de bien conocidas llamadas al sistema como
nice(2), POSIX.1b sched_setparam(2) o
4.4BSD/SVR4 setpriority(2).
p->rt_priority
: prioridad en tiempo real.
p->policy
: la política de planificación, específica a la
clase de planificación que pertenece la tarea. Las tareas pueden
cambiar su clase de planificación usando la llamada al sistema
sched_setscheduler(2). Los valores válidos son
SCHED_OTHER
(proceso UNIX tradicional),
SCHED_FIFO
(proceso FIFO en tiempo real POSIX.1b) y
SCHED_RR
(proceso en tiempo real round-robin POSIX).
Uno puede también SCHED_YIELD
a alguno de esos
valores para significar que el proceso decidió dejar la CPU, por
ejemplo llamando a la llamada al sistema sched_yield(2).
Un proceso FIFO en tiempo real funcionará hasta que: a) se
bloquee en una E/S, b) explícitamente deje la CPU, o c) es
predesocupado por otro proceso de tiempo real con un valor más
alto de p->rt_priority
. SCHED_RR
es el mismo
que SCHED_FIFO
, excepto que cuando su porción de tiempo
acaba vuelve al final de la cola de ejecutables.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:
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.
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.
prev
y
this_cpu
a las tareas y CPUs actuales, respectivamente.
schedule()
fue llamada desde el
controlador de interrupciones (debido a un fallo) y provoca un pánico
si ha sido así.
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)?
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
).
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.
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.
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.
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.
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.
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.
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.)
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.
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);
}
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:
rtc_wait
TASK_INTERRUPTIBLE
lo
que significa que no será replanificado después de la próxima
vez que duerma.
TASK_RUNNING
, nos quitamos de la cola de espera y
regresamos.
EAGAIN
(el cual es el mismo que EWOULDBLOCK
)
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.
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.
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);
void init_bh(int nr, void (*routine)(void))
: instala un
manejador de bottom half apuntado por el argumento
routine
en el slot nr
. El slot debe de estar
numerado en include/linux/interrupt.h
en la forma
XXXX_BH
, ej. TIMER_BH
o TQUEUE_BH
.
Típicamente, una rutina de inicialización del subsistema
(init_module()
para los módulos) instala el
bottom half requerido usando esta función.
void remove_bh(int nr)
: hace lo opuesto de
init_bh()
, esto es, desinstala el bottom half
instalado en el slot nr
. No hay chequeos de errores
realizados aquí, por lo tanto, como ejemplo
remove_bh(32)
rompe el sistema. Típicamente, una
rutina de limpieza del subsistema (cleanup_module()
para los módulos) usa esta función para liberar el slot, que
puede ser reusado por algún otro subsistema. (POR HACER: ¿no
sería bonito tener una lista /proc/bottom_halves
con
todos los bottom halves en el sistema? Esto significa que
global_bh_lock
deberían hacer lecturas/escrituras,
obviamente).
void mark_bh(int nr)
: marca el bottom half en el slot
nr
para ejecución. Típicamente, un manejador de
interrupciones marcará este bottom half (¡de aquí el nombre!)
para ejecución en un "tiempo seguro".
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?).
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:
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:
tq_timer
también funciona en el contexto de interrupción y de este modo
tampoco puede bloquearse.
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 .
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.
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.
Todavía no, estarán en una revisión futura
Todavía no, estarán en una revisión futura
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:
NR_syscalls
(actualmente 256) fallará con el error ENOSYS
.
tsk->ptrace &
PF_TRACESYS
), realiza un procesamiento especial. Esto es
para soportar programas como strace (análogo a SVR4
truss(1)) o depuradores.
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ó.
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.
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, µcode_status))
return -EBUSY;
MOD_INC_USE_COUNT;
return 0;
}
Las operaciones en los bitmaps son:
nr
en el bitmap apuntado por addr
.
nr
en el bitmap apuntado por addr
.
nr
(si está establecido limpia, si está limpio establece) en
en el bitmap apuntado por addr
.
nr
y devuelve el
viejo valor del bit.
nr
y devuelve el viejo
valor del bit.
nr
y devuelve el viejo
valor del bit.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:
atomic_t
v
.
atomic_t
v
al entero i
.
i
al valor de la variable atómica apuntado
por v
.
i
del valor de la variable atómica apuntada
por v
.
i
del valor de la variable atómica
apuntada por v
; devuelve 1 si el nuevo valor es 0,
devuelve 0 en otro caso.
i
a v
y devuelve 1 si el
resultado es negativo. Devuelve 0 si el resultado es mayor o
igual a 0. Esta operación es usada para implementar semáforos.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()
.
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.
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.
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:
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()
.
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.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:
copy_from_user()/copy_to_user()
. Entonces no
pueden usar ninguna forma de spinlock aquí.
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.
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:
/proc
y en devfs
(ej. /dev/cpu/microcode
vs /dev/misc/microcode
).
Hay unas pocas cosas que no pueden ser implementadas como módulos bajo Linux (probablemente porque no tienen sentido el ser modularizadas):
Linux suministra varias llamadas al sistema para asistir en la carga de módulos:
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
.
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
.
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.
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:
file_systems_lock
tomado para lectura
(ya que no estamos modificando la lista registrada de
sistemas de archivos).
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.
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.
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()
.
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.