P2 — Planificador por lotería en xv6-riscv
Publicación: semana 5 · Entrega: semana 8 · Reglas generales de entrega
y revisión: projects/README.md.
En este proyecto van a reemplazar el planificador (scheduler) de xv6 por un planificador por lotería (lottery scheduling). La idea viene del capítulo 9 de OSTEP: cada proceso tiene un número de tickets y, en cada quantum, se sortea un ticket ganador; el owner de ese ticket es el que corre. Un proceso con el doble de tickets que otro debería recibir, en promedio, el doble de CPU.
El enunciado es una adaptación a xv6-riscv del proyecto
scheduling-xv6-lottery
de OSTEP, que fue escrito para la versión x86 de xv6. Las ideas son las mismas;
cambian los nombres de algunos archivos y funciones.
Objetivos
- Leer y entender un planificador real:
scheduler(),sched(),yield()y el cambio de contexto (context switch) en xv6-riscv. - Agregar llamadas al sistema (system calls) que reciben y devuelven datos entre el espacio de usuario y el kernel de forma segura.
- Cambiar la política del planificador respetando la disciplina de locks del kernel.
- Comprobar con un experimento y un gráfico que la CPU se reparte en proporción a los tickets.
Versión de xv6
Todos trabajan sobre la misma versión de xv6-riscv, dentro del contenedor del
curso (env/run.sh):
git clone https://github.com/mit-pdos/xv6-riscv
cd xv6-riscv
git checkout -b p2 e93dabeb98431235c6e1a101cb96c73c923fdbc4
git config user.name "Nombre Apellido" # una sola vez por repositorio
git config user.email "correo@uni.pe"
make qemu # salir con Ctrl-a x
El contenedor se borra al salir, así que no guarda una configuración global de
git; git config sin --global la guarda dentro del repositorio
(.git/config), que sí persiste.
En esta versión algunas funciones del kernel tienen nombres distintos a los de
libros y tutoriales antiguos: kfork(), kexit() y kwait() (en vez de
fork(), exit() y wait() dentro del kernel), printk() para imprimir
desde el kernel y pause(n) como la llamada de usuario que duerme n ticks
(antes sleep).
Contexto: cómo planifica xv6-riscv
Procesos
Cada proceso es una struct proc (kernel/proc.h). Los campos que más
importan aquí:
state:UNUSED,USED,SLEEPING,RUNNABLE,RUNNINGoZOMBIE.pid,name,trapframe(registros de usuario guardados) ycontext(registros del kernel que guardaswtch()).lock: un spinlock por proceso. Hay que tenerlo tomado para leer o escribirstatey los demás campos marcados en el comentario destruct proc.
La tabla de procesos es el arreglo proc[NPROC] en kernel/proc.c
(NPROC = 64, en kernel/param.h). allocproc() busca una entrada UNUSED
y la inicializa; freeproc() la libera. kfork() crea el hijo con
allocproc() (que devuelve la entrada con su lock tomado), copia la
memoria y los registros del padre y lo deja RUNNABLE.
El planificador
Cada CPU, al terminar de arrancar, entra a scheduler() (kernel/proc.c) y
nunca sale. La versión original es round-robin: recorre la tabla, y para cada
proceso RUNNABLE toma su lock, lo marca RUNNING y llama a
swtch(&c->context, &p->context). El proceso corre hasta que vuelve al
planificador mediante sched(), que hace el swtch inverso. Noten que el
lock del proceso sigue tomado a través del swtch: lo suelta el propio
proceso (en forkret() o al volver de sched()), y lo vuelve a tomar antes
de regresar al planificador.
Interrupciones del temporizador
La CPU vuelve al planificador por dos motivos: el proceso se bloquea (por
ejemplo en sleep() esperando un disco o un pipe) o llega una interrupción
(interrupt) del temporizador.
timerinit()enkernel/start.cpide la primera interrupción.- Cada interrupción llega a
devintr()enkernel/trap.c, que llama aclockintr(). Esta incrementa el contador globalticks(solo la CPU 0) y programa la siguiente interrupción1000000ciclos después: en QEMU eso es un tick cada 0.1 s aproximadamente. devintr()devuelve 2 para el temporizador, y entoncesusertrap()(si el proceso estaba en modo usuario) okerneltrap()(si estaba en el kernel) llaman ayield().yield()marca al procesoRUNNABLEy llama asched().
Es decir: el quantum de xv6 es un tick. Un proceso que solo usa CPU vuelve al planificador una vez por tick, y en ese momento se sortea quién corre después.
Cómo se agrega una llamada al sistema
Ya lo practicaron en la semana 4 con getreadcount(). El recorrido de una
llamada es:
- El programa llama, por ejemplo, a
settickets(10). El stub generado poruser/usys.plpone el número de la llamada en el registroa7y ejecutaecall. - El hardware salta a
uservec(kernel/trampoline.S), que guarda los registros enp->trapframey llama ausertrap()(kernel/trap.c). usertrap()ve que la causa es una llamada al sistema y llama asyscall()(kernel/syscall.c), que leep->trapframe->a7, busca la función en el arreglosyscalls[]y guarda el valor de retorno enp->trapframe->a0.
Para una llamada nueva hay que tocar:
| Archivo | Qué agregar |
|---|---|
kernel/syscall.h |
#define SYS_settickets 23 (el siguiente número libre) |
kernel/syscall.c |
extern uint64 sys_settickets(void); y la entrada [SYS_settickets] = sys_settickets en syscalls[] |
kernel/sysproc.c |
la función sys_settickets() |
user/usys.pl |
entry("settickets"); |
user/user.h |
el prototipo int settickets(int); |
kernel/defs.h |
el prototipo de cualquier función nueva de proc.c que llamen desde otro archivo |
Los argumentos se leen con argint() y argaddr() (kernel/syscall.c).
Para escribir en memoria de usuario se usa siempre copyout()
(kernel/vm.c), que verifica que la dirección pertenezca al proceso y sea
escribible; nunca se escribe directamente en un puntero que viene de usuario.
Requisitos
1. int settickets(int number)
Fija el número de tickets del proceso que la llama.
- Devuelve 0 si tuvo éxito.
- Si
number < 1, devuelve -1 y no cambia los tickets. - No se exige un máximo. Los tests usan hasta 1000 tickets por proceso.
2. int getpinfo(struct pstat *ps)
Llena *ps con información de todas las entradas de la tabla de procesos.
- Devuelve 0 si tuvo éxito, y -1 si el puntero es nulo o inválido (fuera de la memoria del proceso, o en una página de solo lectura).
- La entrada
ide cada arreglo corresponde aproc[i].
La estructura se define en kernel/pstat.h. Usen exactamente el archivo
provisto (pstat.h): los tests dependen de su forma y el script de
pruebas lo sobrescribe con esta versión.
struct pstat {
int inuse[NPROC]; // 1 si esa entrada de la tabla de procesos está en uso, 0 si no
int tickets[NPROC]; // número de tickets del proceso
int pid[NPROC]; // PID del proceso
int ticks[NPROC]; // veces que el planificador eligió al proceso
};
inuse[i]es 1 siproc[i].state != UNUSED.ticks[i]cuenta cuántas veces el planificador eligió a ese proceso para correr (se incrementa enscheduler()justo al elegirlo). Para un proceso que solo usa CPU, esto coincide con los ticks del reloj que estuvo corriendo.- En
user/user.hbasta una declaración adelantada:struct pstat;y el prototipoint getpinfo(struct pstat *);. Los programas de usuario incluyenkernel/types.h,kernel/param.h,kernel/pstat.hyuser/user.h, en ese orden.
3. Tickets por defecto y herencia
- Todo proceso empieza con 1 ticket y 0 ticks. Esto incluye al primer
proceso (
userinit()), así queinityshtienen 1 ticket. - Con
fork(), el hijo hereda los tickets del padre (si el padre tiene 10, el hijo nace con 10). Sus ticks empiezan en 0.
4. Planificador por lotería
Reemplacen el recorrido round-robin de scheduler() por un sorteo:
- Sumar los tickets de todos los procesos
RUNNABLE. - Si la suma es 0, no hay nada que correr: hacer lo mismo que el código
original (
wfi) y volver a empezar. - Elegir un número
ganadoral azar entre 0 ytotal - 1. - Recorrer la tabla acumulando los tickets de los procesos
RUNNABLE; el primero en el que la suma acumulada supere aganadores el elegido. - Correrlo igual que el código original (
RUNNING,c->proc,swtch) e incrementar su contador de ticks. - Cuando el proceso devuelva la CPU, volver a sortear desde el paso 1. No sigan recorriendo la tabla desde donde estaban: eso vuelve a ser round-robin.
Disciplina de locks. El planificador corre a la vez en todas las CPUs, y mientras suma tickets otros procesos pueden despertar, terminar o ser elegidos por otra CPU.
- Lean
p->stateyp->ticketssolo conp->locktomado, y suéltenlo antes de pasar al siguiente proceso. No intenten tomar los 64 locks a la vez. - Al elegido, mantengan el lock tomado a través del
swtch, como el original. - Entre el paso 1 y el paso 4 la tabla puede cambiar. Si al recorrerla ningún
proceso alcanza al ganador, simplemente vuelvan a empezar. Usen un
uint64para la suma de tickets. - En
getpinfo(), copien primero los datos a unastruct pstatlocal (en la pila del kernel; ocupa 1 KB) tomando y soltando el lock de cada proceso, y recién al final llamen acopyout(), sin ningún spinlock tomado. settickets()modifica el proceso actual; tomenp->lockal escribirp->tickets, porque el planificador de otra CPU puede estar leyéndolo.
5. Números aleatorios en el kernel
xv6 no trae un generador de números aleatorios. Basta uno pseudoaleatorio sencillo, por ejemplo xorshift32:
static uint32 rand_state = 2463534242; // cualquier semilla distinta de 0
static uint32
rand(void)
{
uint32 x = rand_state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rand_state = x;
return x;
}
Otra opción es un generador lineal congruencial (LCG):
state = state * 1103515245 + 12345, usando los bits altos del resultado.
El estado rand_state es compartido por todas las CPUs. Protéjanlo con un
spinlock propio (inicializado con initlock() en procinit()), o usen un
estado por CPU dentro de struct cpu. rand() % total tiene un sesgo
mínimo que aquí no importa.
6. Varias CPUs
El código debe funcionar sin errores con cualquier número de CPUs
(make qemu usa 3 por defecto). Pero la proporción 3:2:1 solo se ve con
una CPU: con tres CPUs y tres procesos ocupados, cada uno tiene una CPU
para él solo. Por eso las mediciones se hacen con make qemu CPUS=1.
Archivos provistos
En esta carpeta:
| Archivo | Para qué |
|---|---|
pstat.h |
Copiar a kernel/pstat.h. No modificar. |
tickettest.c |
Copiar a user/. Prueba settickets() y getpinfo(): valores por defecto, errores, herencia y punteros inválidos. |
lotterytest.c |
Copiar a user/. Crea tres hijos con 30, 20 y 10 tickets que solo usan CPU, mide sus ticks durante una ventana de tiempo e imprime la proporción. |
test-p2.sh |
Script de pruebas públicas. |
Para agregar los programas a xv6, desde la raíz de su árbol de xv6-riscv
(CURSO es la carpeta de este repositorio):
cp CURSO/projects/p2-lottery/pstat.h kernel/pstat.h
cp CURSO/projects/p2-lottery/tickettest.c CURSO/projects/p2-lottery/lotterytest.c user/
y en el Makefile, dentro de la lista UPROGS, agreguen dos líneas:
UPROGS=\
$U/_cat\
...
$U/_sync\
$U/_tickettest\
$U/_lotterytest\
Luego make qemu CPUS=1 y, en la shell de xv6:
$ tickettest
tickettest: getpinfo devuelve 0: ok
...
tickettest: OK
tickettest: done
$ lotterytest
lotterytest: ventana 300 ticks, tickets 30:20:10
lotterytest: serie 20 12 6 2
lotterytest: serie 40 22 13 5
...
lotterytest: serie 300 157 103 41
lotterytest: tickets 30 ticks 157
lotterytest: tickets 20 ticks 103
lotterytest: tickets 10 ticks 41
lotterytest: total 301
lotterytest: esperado (por mil) 500 333 166
lotterytest: obtenido (por mil) 521 342 136
lotterytest: done
lotterytest tarda unos 30 segundos. Acepta dos argumentos opcionales:
lotterytest [ventana] [paso], la duración de la medición en ticks (300 por
defecto) y cada cuántos ticks imprimir una línea serie (20 por defecto).
Cada línea serie t a b c dice cuántas veces fue elegido cada hijo desde el
inicio de la medición hasta el tick t. Con esas líneas hacen el gráfico.
Cómo se evalúa
Pruebas públicas
CURSO/projects/p2-lottery/test-p2.sh ruta/a/su/xv6-riscv
Se puede correr desde la computadora (si no encuentra QEMU, se vuelve a lanzar
solo dentro del contenedor sistemas-operativos) o desde dentro del contenedor. Trabaja
sobre una copia temporal, así que no modifica su árbol. El script:
- Copia
pstat.h,tickettest.cylotterytest.c, y los agrega aUPROGSsi hace falta. - Compila. El
Makefilede xv6 usa-Werror: cualquier warning es un error. - Arranca xv6 sin interfaz gráfica con
CPUS=1y corretickettest. Cada líneaokoFAILcuenta como una prueba. - Corre
lotterytest 300y verifica que:- los hijos acumulen al menos 150 ticks en total;
- la fracción de CPU de cada hijo esté a ±0.100 de lo esperado (0.500, 0.333 y 0.167);
- el de 30 tickets reciba más CPU que el de 20, y este más que el de 10.
Con 300 ticks, un planificador correcto falla la prueba de proporción por mala suerte en menos de 2 de cada 1000 corridas. Si les falla por poco, vuelvan a correrla; si falla siempre, hay un error.
Con USERTESTS=1 el script además corre usertests -q, que tarda unos minutos.
Su planificador no debe romper ninguna prueba de xv6.
Pruebas privadas y revisión
Habrá pruebas privadas: otras proporciones de tickets, más procesos, procesos
que duermen y despiertan, y usertests -q con varias CPUs. Además se revisa el
código (en especial el manejo de locks y de punteros de usuario), el README.md
y el gráfico. Puede haber una defensa oral breve en el laboratorio.
Qué entregar
En la semana 8 se entregan:
-
El código. Hagan commits en su rama
p2y generen el parche contra la versión base:git add -A git commit -m "Planificador por loteria" git diff e93dabeb98431235c6e1a101cb96c73c923fdbc4 HEAD > p2.patchEl parche debe aplicarse limpio con
git applysobre esa versión y debe incluirkernel/pstat.h, los dos programas de prueba y los cambios en elMakefile. -
README.md: integrantes, qué funciona y qué no, y una explicación corta de su diseño: dónde guardan los tickets, cómo hacen el sorteo y qué locks toman enscheduler(),settickets()ygetpinfo(). -
El gráfico (PNG o PDF): ticks acumulados de cada uno de los tres hijos en función del tiempo, a partir de las líneas
seriedelotterytest, con dos o tres líneas de interpretación. Pueden hacerlo con Python, una hoja de cálculo o gnuplot fuera de xv6.
Calentamiento con gdb
Antes de tocar el planificador, sigan una llamada al sistema con gdb. Dentro
del contenedor abran dos terminales; lo más simple es tmux (Ctrl-b %
divide la pantalla, Ctrl-b o cambia de panel).
Terminal 1:
make qemu-gdb CPUS=1
QEMU se queda detenido esperando al depurador. Terminal 2, en la misma carpeta:
gdb-multiarch
El archivo .gdbinit que genera el Makefile conecta gdb a QEMU y carga los
símbolos de kernel/kernel. Luego:
(gdb) break syscall
(gdb) continue
(gdb) bt
(gdb) next
(gdb) next
(gdb) next
(gdb) p num
(gdb) p p->trapframe->a7
(gdb) p p->name
(gdb) p p->pid
btmuestra quesyscall()fue llamada porusertrap().- La primera llamada al sistema la hace
init(pid 1), y es la número 15 (SYS_openenkernel/syscall.h):initabre la consola. - El kernel se compila con
-O, así que en la primera línea desyscall()gdb puede decir<optimized out>paraponum. Avancen connexthasta pasarnum = p->trapframe->a7;, o usenp myproc()->trapframe->a7, que funciona en cualquier momento.
Para detenerse solo en su llamada nueva (usen el número que le asignaron a
SYS_settickets):
(gdb) delete
(gdb) break syscall if myproc()->trapframe->a7 == 23
(gdb) continue
y en la terminal 1 ejecuten tickettest. Con finish vuelven a usertrap()
y con p myproc()->trapframe->a0 ven el valor que recibirá el programa.
Preguntas para el calentamiento (no se entregan, pero pueden salir en la defensa oral):
- ¿Qué otras llamadas al sistema hace
initantes de lanzar la shell, y en qué orden? - ¿Por qué
syscall()puede leer los argumentos desdep->trapframey no de los registros de la CPU? - Pongan un breakpoint en
yieldy continúen: ¿desde dónde se llama cuando ningún programa hace llamadas al sistema?