Visão Geral do Escalonamento
Em sistemas com mais processos do que núcleos de CPU, é necessário compartilhar o tempo de processamento entre eles. O objetivo é que esse compartilhamento seja transparente para os processos de usuário. Uma técnica comum é multiplexar os processos nos núcleos disponíveis, criando a ilusão de que cada processo tem acesso exclusivo à CPU.
Quando o Xv6 Realiza a Troca de Processos?
A troca ocorre em duas situações principais:
- Troca Cooperativa: Quando um processo está aguardando a conclusão de uma operação de E/S em dispositivo ou pipe, aguardando o término de um processo filho, ou quando está em uma chamada de sistema
sleep. O mecanismo desleepewakeupé utilizado. - Troca Preemptiva: Para lidar com processos que realizam cálculos longos sem dormir, o Xv6 utiliza interrupções de temporizador para forçar periodicamente a troca de contexto.
Desafios do Escalonamento
A implementação do escalonamento envolve superar vários desafios:
- Como efetuar a troca entre processos? O código de troca de contexto (
context switch) é uma das partes menos transparentes do Xv6. - Como forçar a troca de forma transparente para o processo de usuário? O Xv6 utiliza interrupções de temporizador para acionar a troca.
- Como evitar condições de corrida ao selecionar processos para execução, já que todos os núcleos compartilham o mesmo conjunto de processos? Bloqueios (locks) são essenciais.
- Quando um processo termina, ele deve liberar seus recursos (como memória), mas não pode fazê-lo completamente por si mesmo, pois precisa usar sua própria pilha de kernel.
- Cada núcleo de CPU precisa rastrear qual processo está executando, garantindo que chamadas de sistema afetem o estado correto.
- O mecanismo de
sleeppermite a um processo liberar a CPU, ewakeuppermite reativar outro processo.
A implementação do Xv6 busca simplicidade, mas o código resultante ainda é complexo devido a esses desafios.
Troca de Contexto
O processo de troca de um processo de usuário para outro envolve os seguintes passos:
- O processo de usuário (ex.: shell) inicia uma chamada de sistema ou sofre uma interrupção.
- Controle é transferido para o thread de kernel do processo antigo.
- Ocorre uma troca de contexto para o thread de escalonador (scheduler thread) do CPU atual.
- O escalonador realiza outra troca de contexto para o thread de kernel do novo processo (ex.: cat).
- Uma instrução de retorno de
trapleva o controle de volta para o processo de usuário (cat).
Cada CPU tem um thread de escalonador dedicado. A troca de contexto salva os registradores da thread antiga e restaura os da nova. Isso inclui o ponteiro de pilha (sp) e o contador de programa, efetivamente mudando a thread em execução.
Função swtch()
A função swtch é responsável por trocar threads de kernel. Ela salva e restaura os 32 registradores da arquitetura RISC-V, que formam o contexto da thread.
Fluxo de operação:
- Quando um processo precisa liberar a CPU, sua thread de kernel chama
swtch. swtchsalva o contexto atual na estruturacontextdo processo (p->context).- Em seguida, restaura o contexto previamente salvo do escalonador, que reside em
cpu->scheduler_context. - Apenas os registradores callee-saved são salvos explicitamente; os caller-saved já foram empilhados automaticamente pelos chamadores.
- O registrador
pcnão é salvo diretamente. Em vez disso, ora(endereço de retorno) é salvo. Ao restaurar orade uma nova thread e retornar, a execução continua no ponto onde essa nova thread havia chamadoswtchanteriormente.
O Escalonador do Xv6
A função scheduler (kernel/proc.c) é o coração do escalonamento. Ela é executada por um loop infinito em cada CPU.
Exemplo de Código Simplificado do Escalonador
void scheduler(void) {
struct proc *p;
struct cpu *c = mycpu();
c->scheduler_thread = ...; // Inicialização da thread do escalonador
for(;;) {
// Habilita interrupções para permitir a troca de contexto
intr_on();
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
// Mude o estado para RUNNING, associe ao CPU
p->state = RUNNING;
c->current_proc = p;
// Troca de contexto: de CPU->scheduler_ctx para p->context
swtch(&c->scheduler_ctx, &p->context);
// Retornamos aqui quando o processo nos devolve a CPU
c->current_proc = 0;
}
release(&p->lock);
}
}
}
Passagem da CPU para o Escalonador
Quando um processo precisa dormir ou é finalizado, ele segue um caminho específico:
- Adquire o
lockdo processo (p->lock). - Atualiza seu estado (para
SLEEPING,ZOMBIE, etc.). - Chama a função
sched().
A função sched realiza verificações de segurança e então invoca swtch(&p->context, &mycpu()->scheduler_ctx). Isso salva o contexto do processo atual e restaura o contexto do escalonador, retornando a execução para o ponto dentro do loop infinito do scheduler.
O Bloqueio p->lock: O Xv6 mantém p->lock no momento da chamada a swtch. O controle do bloqueio é, em essência, passado para o código que será executado após a troca. Isso é necessário para proteger os campos state e context do processo durante a transição crítica.
Caso Especial: Criação de Novo Processo
Ao alocar uma nova estrutura de processo (allocproc), o endereço de retorno (ra) de seu contexto é apontado para forkret. Na primeira vez que swtch é chamado para este processo, a execução salta para forkret, cuja principal função é liberar p->lock.
Acessando Informações da CPU e do Processo
As informações de cada CPU são armazenadas em um array global cpus[]. O identificador único de cada CPU (seu hart id no RISC-V) é armazenado no registrador tp para facilitar o acesso.
Funções de Acesso
struct cpu* mycpu(void) {
// Obtém o ID do hart (núcleo) atual de 'tp'
int id = get_hartid();
// Usa o ID para indexar o array global
return &cpus[id];
}
struct proc* myproc(void) {
// Desabilita interrupções para obter um ponteiro consistente
push_off();
struct proc *p = mycpu()->current_proc;
pop_off();
return p;
}
Atenção: Os valores retornados por essas funções são frágeis. Se uma interrupção de temporizador ocorrer e causar uma troca de processo, o ponteiro retornado anteriormente pode se tornar inválido. Por isso, o Xv6 exige que as interrupções sejam desabilitadas ao usar o struct cpu retornado.
Sincronização com sleep e wakeup
Esses mecanismos permitem que um processo espere por um evento sem desperdiçar ciclos de CPU.
Implementação Ineficiente (Sem Sincronização)
// Exemplo de semáforo simples (não presente no Xv6)
struct semaphore {
struct spinlock lock;
int count;
};
void V_semaforo(struct semaphore *s) {
acquire(&s->lock);
s->count++;
release(&s->lock);
}
void P_semaforo(struct semaphore *s) {
while (s->count == 0) {
// Espera ativa (busy-wait) - ineficiente!
}
acquire(&s->lock);
s->count--;
release(&s->lock);
}
O problema é a espera ativa no while. Precisamos de uma forma de ceder a CPU.
Primeira Tenta com sleep/wakeup
void V_semaforo(struct semaphore *s) {
acquire(&s->lock);
s->count++;
wakeup(s); // Acorda processos esperando neste semáforo
release(&s->lock);
}
void P_semaforo(struct semaphore *s) {
while (s->count == 0) {
sleep(s); // Dorme até ser acordado
}
acquire(&s->lock);
s->count--;
release(&s->lock);
}
Novo Problema (Condição de Corrida): Se P verificar count==0, mas antes de chamar sleep, V for executado em outro CPU, incrementando count e chamando wakeup, P não será acordado porque ainda não está dormindo. Quando finalmente chamar sleep, ninguém irá acordá-lo novamente.
Adicionando um Bloqueio de Condição
void V_semaforo(struct semaphore *s) {
acquire(&s->lock);
s->count++;
wakeup(s);
release(&s->lock);
}
void P_semaforo(struct semaphore *s) {
acquire(&s->lock); // Adquire o lock antes de verificar a condição
while (s->count == 0) {
sleep(s); // Problema: dorme segurando o lock -> Deadlock!
}
s->count--;
release(&s->lock);
}
Problema: P adormece segurando s->lock, impedindo que V o adquira para alterar count e acordá-lo. Resultado: deadlock.
Solução Final do Xv6
A função sleep no Xv6 aceita um segundo argumento: o bloqueio de condição (condition lock). sleep liberará este bloqueio de forma atômica (junto com a ação de dormir). Isso resolve o problema, pois V só poderá adquirir o bloqueio de condição após P ter efetivamente adormecido.
void V_semaforo(struct semaphore *s) {
acquire(&s->lock);
s->count++;
wakeup(s);
release(&s->lock);
}
void P_semaforo(struct semaphore *s) {
acquire(&s->lock);
while (s->count == 0) {
// Passa o bloqueio de condição. sleep o libera antes de dormir.
sleep(s, &s->lock);
}
s->count--;
release(&s->lock);
}
Na prática, a chamada sleep(chan, lk) faz o seguinte:
- Adquire o
p->lockdo processo atual. - Libera o bloqueio de condição
lk. - Marca o estado do processo como
SLEEPINGe armazena o canalchan. - Chama
sched()para liberar a CPU (o que, internamente, liberaráp->lockdurante a troca de contexto).
wakeup(chan) percorre a tabela de processos, adquire p->lock para cada um, e se encontrar um processo em estado SLEEPING com o mesmo canal chan, muda seu estado para RUNNABLE.
Exemplo: Pipes no Xv6
Pipes usam sleep e wakeup para sincronização entre leitores e escritores.
struct pipe {
struct spinlock lock;
char data[PIPESIZE];
uint nread; // Próximo byte a ser lido
uint nwrite; // Próximo byte a ser escrito
int readopen;
int writeopen;
};
Sincronização na Escrita (pipewrite)
- Adquire
pipe->lock. - Enquanto o buffer estiver cheio (
nwrite - nread == PIPESIZE):- Chama
wakeup(&pipe->nread)para acordar possíveis leitores. - Chama
sleep(&pipe->nwrite, &pipe->lock)para liberar a CPU e o lock, esperando por espaço.
- Chama
- Escreve um byte, incrementa
nwrite. - Libera
pipe->lock.
Sincronização na Leitura (piperead)
- Adquire
pipe->lock. - Enquanto o buffer estiver vazio (
nwrite == nread) e houver escritores:- Chama
sleep(&pipe->nread, &pipe->lock)para esperar dados.
- Chama
- Lê bytes do buffer, incrementa
nread. - Chama
wakeup(&pipe->nwrite)para acordar escritores que possam estar esperando por espaço. - Libera
pipe->lock.
Encerramento de Processos (exit, wait, kill)
exit e wait
Quando um processo filho chama exit:
- Libera alguns de seus recursos (arquivos, memória de usuário).
- Recoloca seus processos filhos sob o processo
init. - Muda seu estado para
ZOMBIE. - Chama
wakeupno canal de seu processo pai (para acordar umwaitbloqueado). - Cede a CPU finalmente via
sched().
O processo pai, ao chamar wait:
- Varre a tabela de processos em busca de um filho com estado
ZOMBIE. - Se encontrar, coleta seu código de saída, libera a estrutura do processo e retorna o PID do filho.
- Se não encontrar, mas existirem filhos vivos, adormece (usando um
lockde condição chamadowait_lock) até ser acordado por umexitde filho.
A chave é que exit e wait coordenam-se através de wakeup e sleep, usando o wait_lock como bloqueio de condição para evitar perda de notificações.
kill
A chamada kill não termina um processo diretamente. Ela apenas marca o processo alvo (p->killed = 1). O processo terminará em breve porque:
- Se estiver rodando em espaço de usuário, a próxima interrupção ou chamada de sistema o levará ao kernel, onde
usertrapverificará a flagp->killede chamaráexit. - Se estiver dormindo,
killchamaráwakeuppara acordá-lo. O código que envolve osleepdo processo vítima deve, idealmente, verificarp->killedem seu loop e chamarexitse estiver definida. Nem todos os loops do Xv6 fazem essa verificação, o que pode atrasar o término.
Sobre os Bloqueios de Processo
O p->lock é um dos bloqueios mais críticos e multifuncionais do Xv6. Suas responsabilidades incluem:
- Proteger campos críticos como
p->state,p->chan,p->killed,p->xstateep->pid. - Garantir que o processo não seja considerado
RUNNABLEe escalonado antes de seucontextestar completamente salvo. - Prevenir que um processo
ZOMBIEseja coletado pelowaitantes de ter liberado todos os seus recursos no kernel. - Sincronizar ações entre
sleep,wakeupekill.
O campo p->parent é protegido pelo wait_lock global, e não pelo p->lock.
Considerações Finais
O escalonador do Xv6 implementa uma política simples de round-robin. Sistemas operacionais reais usam políticas muito mais complexas, considerando prioridades, justiça e throughput, o que pode levar a fenômenos como priority inversion e convoy effects.
Embora sleep/wakeup sejam simples e eficazes, outros sistemas operacionais usam abstrações como filas de espera (wait queues) e variáveis de condição (condition variables) para gerenciar a sincronização de forma mais explícita e, potencialmente, mais eficiente, evitando varreduras da tabela de processos.