Preempção e compartilhamento de tempo

Este projeto faz parte do PPOS v2.

Até agora, nosso sistema suporta apenas tarefas cooperativas, que precisam informar ao despachante quando não precisam mais da CPU, usando a chamada task_yield. O objetivo deste projeto é adicionar preempção por tempo ao sistema. Com essa modificação, nosso sistema passará a suportar tarefas preemptivas, que podem se alternar no uso do processador sem a necessidade de solicitar trocas ao despachante.

Preempção

Em sistemas de tempo compartilhado (time-sharing), cada tarefa de usuário recebe uma pequena fatia de tempo de processador, denominada quantum. Valores típicos de quantum estão entre 1 ms e 100 ms. Ao acabar seu quantum, o despachante é ativado, a tarefa em execução retorna à fila de prontas e cede lugar à próxima tarefa da fila de prontas.

Em um sistema real, a implementação da preempção por tempo tem como base as interrupções geradas pelo temporizador programável do hardware. Esse temporizador é programado para gerar uma interrupção a cada 1 milissegundo, que é tratada por um interrupt handler (tratador de interrupção) ou ISR (Interrupt Service Routine); essas ativações periódicas do tratador de interrupção são normalmente chamadas de ticks do relógio.

Quando uma tarefa recebe o processador, o dispatcher ajusta um contador de ticks que essa tarefa pode usar, ou seja, seu quantum definido em número de ticks. A cada tick, esse contador deve ser decrementado; quando ele chegar a zero, o processador deve ser devolvido ao dispatcher e a tarefa volta à fila de prontas. A figura a seguir ilustra esse conceito:

Compartilhamento de tempo

Emulação do temporizador

Como um processo UNIX não tem permissão de acesso aos temporizadores e interrupções do hardware, em nosso projeto eles são emulados através de temporizadores e sinais UNIX. Isso é feito através das seguintes funções, definidas no arquivo hardware/cpu.h do projeto:

Implementação

O mecanismo a ser implementado pode ser resumido nos seguintes passos:

  1. Durante a inicialização do sistema (na função kernel/time.c:time_init), um temporizador deve ser programado para disparar a cada 1 milissegundo (1 tick);
  2. Os disparos desse temporizador devem ser tratados por uma rotina de tratamento de ticks, a ser definida em kernel/time.c;
  3. Ao ganhar o processador, cada tarefa recebe um quantum de 10 ticks de relógio (experimente com diferentes tamanhos de quantum para ver seu efeito);
  4. Ao ser acionada, a rotina de tratamento de ticks de relógio deve decrementar o contador de quantum da tarefa corrente, se for uma tarefa de usuário;
  5. Se o contador de quantum chegar a zero, a tarefa em execução deve voltar à fila de prontas e o controle do processador deve voltar ao dispatcher.

Sua implementação deve funcionar com o código de teste test/pingpong-preempcao.c e deve gerar um resultado similar ao presente no arquivo pingpong-preempcao.txt.

A rotina de tratamento de ticks de relógio é crítica, pois vai ser executada com muita frequência (1000× por segundo). Essa rotina deve ser pequena e rápida, para não prejudicar o desempenho do sistema e garantir sua estabilidade.

Arquivos

Os seguintes arquivos são relevantes para este projeto:

Condições de disputa

É importante evitar preempções dentro do dispatcher ou de funções do nosso sistema, pois estas podem ter resultados imprevisíveis, como condições de disputa e instabilidade. Pode-se controlar a ocorrência de preempções de várias formas. Uma forma básica de implementar esse controle usa o conceito de tarefa de sistema:

Uma solução mais “radical” para esse problema consiste em impedir completamente as preempções enquanto a execução estiver dentro das funções do núcleo. Uma forma simples de obter isso consiste em definir um flag global que seja TRUE quando uma tarefa de usuário estiver executando seu próprio código e FALSE quando a execução estiver dentro de uma função do sistema (task_create, task_switch, etc). Esse flag deve ser testado pela rotina de tratamento de ticks de relógio e precisa ser ligado/desligado explicitamente em cada função.

Versões mais antigas do núcleo Linux possuíam uma trava global chamada The Big Kernel Lock para fazer esse controle.

Sinais e Temporizadores UNIX

Para a emulação de interrupções e temporizadores de hardware, o PPOS usa sinais e temporizadores UNIX/POSIX. O mecanismo de sinais do UNIX é similar às interrupções (IRQs) geradas pelo hardware: ao receber um sinal, um processo desvia sua execução para uma função que ele previamente registrou no sistema operacional.

A página de manual signal (seção 7) relaciona os principais sinais disponíveis em um sistema UNIX e as ações que cada sinal pode desencadear no processo que o recebe. Através da chamada de sistema sigaction é possível registrar uma função de tratamento para um determinado sinal (signal handler function).

Um exemplo do uso de sinais está no arquivo signal.c. Nele, uma função é registrada para tratar o sinal SIGINT, que corresponde ao Control-C do teclado. Analise atentamente seu código, execute-o e observe seu comportamento.

Para simular as interrupções de relógio do hardware, é usado o do mecanismo de sinais (para implementar a preempção) e de temporizadores UNIX (para gerar os ticks de relógio). O UNIX permite definir temporizadores através das chamadas de sistema getitimer e setitimer. Ao disparar, um temporizador gera um sinal para o processo, que pode ser capturado por uma função tratadora previamente registrada por ele. O arquivo timer.c apresenta um exemplo de uso do temporizador.

GDB e sinais

Por default, o depurador GDB interrompe a depuração a cada sinal recebido pelo processo, o que torna inviável usá-lo para depurar nosso projeto, pois ele recebe 1.000 sinais por segundo do temporizador virtual. Para resolver esse problema, basta configurar o GDB para ignorar os sinais UNIX gerados pelo hardware virtual, incluindo o conteúdo abaixo no arquivo $HOME/.gdbinit:

.gdbinit
handle SIG34 nostop noprint
handle SIG35 nostop noprint
handle SIG36 nostop noprint
handle SIG37 nostop noprint

Outras informações