Projeto de MC104 - SISTEMAS OPERACIONAIS

Comunicação entre Processos: Memória Compartilhada e Sinais

 

Objetivos do Projeto

Desenvolver um programa com base no Programa Exemplo de modo a explorar a Comunicação entre Processos utilizando o comparilhamento da memória. Dentre os objetivos deste projeto destacamos:

 

Aspectos Teóricos

A seguir são discutidos conceitos diretamente relacionados com o projeto e, deste modo, sua leitura é importante e será cobrada quando da apresentação. Caso o entendimento desses conceitos não se concretize procure reler e, se necessário, realizar pequenas experiências de programação até que ocorra o entendimento.

Este experimento enfoca um método primário para IPC, memória compartilhada. Antes de apresentar como é possível criar e remover esses tipos de construções na linguagem C, é interessante relembrar do comando ipcs, que pode ser usado para listar os recursos IPC alocados, e do comando ipcrm, que pode ser usado para remover os recursos IPC alocados. Procure mais informação sobre esses comandos usando o comando man.

Memória Compartilhada

Memória compartilhada é uma região de memória que pode ser acessada por mais de um processo. Por exemplo, quando se declara uma matriz de 1000 bytes em um programa, durante a execução desse programa, apenas suas instruções podem ter acesso à matriz. Com memória compartilhada, pode-se declarar um segmento de memória compartilhado de 1000 bytes e múltiplos processos podem ler e podem escrever neste local de memória. A vantagem primária da memória compartilhada é que um programa a vê exatamente igual à memória normal. Além disso, ler e escrever na memória compartilhada torna o acesso aos dados rápido em comparação com outros mecanismos IPC.

Usar memória compartilhada é relativamente simples. Semelhante às filas de mensagens, cada segmento de memória compartilhada tem uma identificação associada (chave), que o identifica de forma única. Todo processo que deseja ter acesso ao segmento de memória compartilhado de maneira exclusiva precisa saber esta identificação.

O algoritmo seguinte ilustra como usar a memória compartilhada: a. use shmat(...) para obter o ponteiro necessário para se acessar a memória compartilhada; b. use shmat(...) para obter o ponteiro necessário para se acessar a memória compartilhada; c. use a memória compartilhada com o ponteiro; d. use shmdt(...) para desassociar do segmento e e. use shmctl(...) para remover o segmento do sistema.

As vantagens da memória compartilhada já foram declaradas acima. Porém, há várias desvantagens no uso de memória compartilhada. Usando filas de mensagens, o sistema operacional controla tudo que é necessário na interação da troca, porém, com memória compartilhada, a aplicação tem que implementar todo o controle, desassociando, alertando, etc... tudo deve ser manipulado pela aplicação. Isto pode, tipicamente, exigir muito código extra.

Em geral, memória compartilhada é usada quando muitos dados precisam ser transferidos em um período pequeno de tempo.

Sinais (Signals)

Sinais já foram utilizados no experimento sobre multi-programação, embora não tenham sido explorados. A chamada de sistema kill(...), que também é um comando interpretado pela shell, foi introduzida como um método para terminar um processo. Na verdade, kill(...) é o método pelo qual um sinal pode ser enviado. Há uma variedade de sinais diferentes em um sistema baseado em Unix. Os tipos de sinais disponíveis podem ser listados usando o comando kill -l . A seguir é apresentada uma lista de alguns sinais usados pelo System V e para que eles são usados:

Além disso, a maioria dos SOs Unix fornecem dois sinais de usuários para um processo usar como quiser:

Um processo pode tratar o recebimento de um sinal de diferentes maneiras: pode ignorar o sinal, capturar o sinal, bloquear o sinal ou usar a ação default. Vários sinais de sistema não podem ser bloqueados, ignorados e nem capturados, tal como o SIGKILL. Para estes sinais a ação default é sempre usada. Quando um sinal é ignorado, ele é jogado fora e o processo não realiza ação alguma. Quando um sinal é capturado, uma função fornecida pelo usuário, chamada de manipulador de sinais, é executada assincronamente ao restante do programa. Quando um sinal é bloqueado, este é armazenado até ser desbloqueado; neste momento, a ação estabelecida é realizada imediatamente.

A execução assíncrona do manipulador de sinais ocasiona a interrupção da execução do processo (algo parecido com um time slice) e o início da rotina indicada como manipulador. Ao seu término, continua-se a execução do processo a partir do ponto em que foi interrompida.

NOTA: No System V, sinais não são enfileirados. Se dois sinais são recebidos em seguida, antes que o manipulador possa ser executado, o processo recebe apenas o primeiro sinal e o segundo será perdido (POSIX.1b introduz o conceito de sinais enfileirados).

A natureza assíncrona dos sinais introduz um grande conjunto de novos problemas. Por exemplo, o que acontece se um sinal é recebido enquanto um programa está no manipulador de sinais. Isto depende da versão de Unix que se está usando.

As seguintes funções podem ser usadas quando se trabalha com sinais. Não se esqueça de ver as respectivas páginas para mais informação:

Conjuntos de sinais foram introduzidos pelo Unix System V como um método para lidar com sinais. Um conjunto de sinais é simplesmente um grupo de sinais sobre o qual uma operação pode ser executada. O conceito básico atrás de conjuntos de sinais é usar sigemptyset(...), sigfillset(...), sigaddset(...) e sigdelset(...) para construir um conjunto de sinais com os sinais desejados. Então sigprocmask(...) é usado para bloquear ou desbloquear aquele conjunto de sinais. sigaction(...) também usa um conjunto de sinais para determinar que ação levar em consideração quando um sinal é recebido enquanto um manipulador de sinais está sendo executado.

Por exemplo, para bloquear as funções SIGUSR1 e SIGUSR2, o seguinte segmento de código seria usado: (Lembre-se que, para usar estas chamadas deve-se incluir o arquivo de cabeçalho correto; confira nas páginas do manual, caso esteja incerto do que incluir).

sigset_t block_mask;

sigemptyset( &block_mask );
sigaddset( &block_mask, SIGUSR1 );
sigaddset( &block_mask, SIGUSR2 );
sigprocmask( SIG_BLOCK, &block_mask, NULL );

Sinais e chamadas de sistema podem causar dores de cabeça se não usados corretamente. Há dois tipos de chamadas de sistema "definidas" no Unix: as lentas e as rápidas. Chamadas de sistema rápidas são aquelas que são completadas em um pequeno período de tempo e não podem ser interrompidas. Exemplos destas são requisições de I/O no disco. Chamadas de sistema lentas, às vezes, levam um período grande de tempo para serem completadas e podem ser interrompidas. Um exemplo disto é I/O para um terminal. Se um sinal é recebido enquanto uma chamada de sistema lenta está bloqueada, tal como ocorre quando msgrcv(...) bloqueia enquanto espera por uma mensagem a ser colocada na fila, a ação "default" é para a chamada de sistema ser interrompida. Quando isto acontece, é devolvido -1 e "errno" recebe o valor EINTR, indicando que a chamada foi interrompida.

Esteja seguro que qualquer chamada de sistema que possa ser interrompida seja reiniciada corretamente! Com os manipuladores de sinais do System V, um flag especial pode ser fixado e que permitirá reiniciar chamadas de sistema interrompidas automaticamente. Veja a página de manual de sigaction(...) para mais informação.

 

Entendendo o Programa Exemplo

Da mesma maneira que ocorreu nos experimentos prévios, será realizada uma medição de tempo para perceber o desempenho em tempo real do mecanismo de sinais. Neste caso, tenta-se medir a latência do envio de sinais entre dois processos. Este é o tempo que leva do instante que um sinal é enviado até o instante em que é recebido. Serão iniciados dois processos; ambos terão acesso a uma região de memória compartilhada. De acordo com um sinal enviado entre eles, os tempos medidos serão escritos na região de memória compartilhada. Depois de um número especificado de repetições, serão calculados os resultados.

O seguinte esquema é usado para se ter acesso à memória compartilhada. Ele necessita ser repetido em cada (e todo) processo em que se deseja ter acesso à memória compartilhada:

A seguir está a estrutura de memória compartilhada que será usada neste experimento para armazenar a informação gerada pelos filhos. Considerando que os filhos estarão tendo acesso a partes separadas da estrutura, não será necessário ter exclusão mútua para a estrutura.

  typedef struct {
    pid_t child_pid[2];
    struct timeval timings[NO_OF_ITERATIONS][2];
  } time_stats_t;

As declarações acima criarão um conjunto de sinais vazio usando a chamada sigemptyset(...). O sinal SIGUSR1 será incluído ao conjunto usando a chamada sigeaddset(...). Finalmente, sigprocmask(...) criará a máscara para este processo, a partir da união de todos os sinais que estão atualmente bloqueados e o sinal em sigset, isto é, SIGUSR1.

Em seguida, os dois filhos são inicializados. Ambos executarão o mesmo bloco de código. Eles criarão um conjunto de sinais para uso pela chamada sigsuspend(...), estabelecerão o manipulador de sinais para ser chamado quando SIGUSR1 for recebido, e chamarão sigsuspend(...) um número de vezes igual a NO_OF_ITERATIONS. Cada vez que se passa pelo loop, um sinal será recebido e o manipulador de sinais será chamado. A máscara de sinais final bloqueia todos os sinais exceto SIGUSR1, o que faz sentido, pois é esse o sinal esperado. O código para instalação do manipulador de sinais é mostrado a seguir:

  struct sigaction act;
  ...
  act.sa_handler = &SigHandler;
  sigfillset( &act.sa_mask );
  act.sa_flags = 0;
  if( sigaction( SIGUSR1, &act, NULL ) == -1 ) {
    fprintf(stderr,"Filho impossibilitado de estabelecer o manipulador de sinais!\n");
    exit(1);
  }

A variável do manipulador act.sa_handler é fixada para a função a ser chamada quando um sinal for recebido, no caso SigHandler. "act" é uma estrutura do tipo sigaction que contém a informação necessária para criar um manipulador de sinais. Depois, "act.sa_mask" é definida de forma a conter todos os sinais. Estes são os sinais que serão bloqueados durante a execução do manipulador de sinais. Neste caso, deseja-se bloquear todos os sinais durante a execução do manipulador de sinais. Nenhum flag é usado e, assim, "act.sa_flags" é fixado em 0. Finalmente, a chamada para "sigaction(...)" é feita. Isto causará a função "SigHandler(...)" ser chamada toda vez que um sinal SIGUSR1 seja recebido e não esteja bloqueado.

O último pedaço de código para examinar é o manipulador de sinais, que é relativamente pequeno e pode ser apresentado por completo.

  void SigHandler(int sig)  {
    static int i = 0;
    gettimeofday (&g_time_stats->timings[i++][g_child_no% 2], NULL);
    kill (g_time_stats->child_pid[g_child_no% 2], SIGUSR1);
  }

Note o formato da função, ela não retorna coisa alguma e aceita um parâmetro inteiro que corresponde a um sinal, o sinal que foi enviado. Deste modo, um manipulador de sinais pode manipular diferentes sinais usando este parâmetro e um comando switch. A variável estática i mantém controle do número de vezes que o manipulador de sinais foi chamado. O manipulador de sinais executa duas operações: armazena o valor da tomada de tempo na matriz "g_time_stats->timings". Note que i é incrementado dentro desta chamada. Depois, envia um sinal ao irmão.

O último item, que faz tudo trabalhar, é um comando no programa principal. E, assim, os filhos executarão e ambos alcançarão um estado bloqueado esperando por SIGUSR1. Quando um ou outro receber um sinal, o manipulador de sinais será executado. Este enviará um sinal ao seu irmão, ativando seu manipulador de sinais que mandará de volta um sinal. Tudo que é necessário é que o pai começe a ação. Assim, uma única chamada kill(...) no pai começa toda a " diversão "!

Sinais constituem um dos conceitos mais difíceis e mais enganadores de se entender no Unix. Se tiver problemas, realize experiências, que as coisas ficarão mais fáceis...

 

 

Descrição do Projeto

Cada projeto constitui uma atividade que precisa ser completada através de duas tarefas básicas. A primeira se refere à compilação e entendimento do programa exemplo que trata de assuntos cobertos em sala de aula e na teoria. A segunda se refere à implementação de uma modificação sobre o exemplo e/ou a geração de um novo código fonte.

No tocante a primeira tarefa, executar o programa exemplo 10 vezes. Procure carregar o computador a cada execução, ou seja, aumentar a sua carga através da execução de um número maior de programas e veja se os resultados são consistentes. Explique o que fez para aumentar a carga do computador e apresente os dez resultados obtidos. Analise os resultados que recebe destas execuções. Tente entender o que está acontecendo dentro da máquina!

Para a apresentação dos resultados do programa exemplo, crie um quadro semelhante ao apresentado em seguida. Analise os resultados e tente achar um padrão.

Execução

Médio (mseg)

Máximo (mseg)

1

0.003

0.278

2

0.003

0.519

3

0.003

0.332

4

0.004

42.705

0.004

40.068

10

0.003

22.953

Como uma primeira alteração do programa exemplo, no manipulador de sinais original, inclua uma dormência que pode variar a cada execução: faça isto entre a medida de tempo e o envio do próximo sinal. Execute o programa modificado dez vezes. Para cada execução varie o tempo de dormência, desde que seja um múltiplo de 20 microsegundos. Crie um quadro adequado para apresentar os resultados. Analise os resultados obtidos e explique as diferenças.

Como uma segunda alteração do programa exemplo (desfaça a alteração anterior), no laço em que sigsuspend(...) é chamado NO_OF_ITERATIONS vezes, inclua uma dormência que pode variar a cada execução: faça isto entre a instrução "for" e a invocação da chamada sigsuspend(...). Execute o programa modificado dez vezes. Para cada execução varie o tempo de dormência, desde que seja um múltiplo de 20 microsegundos. Crie um quadro adequado para apresentar os resultados. Analise os resultados obtidos e explique as diferenças.

Por fim, altere os programas Cliente e Servidor de Ordenação de Vetor de Números Inteiros gerados no Projeto #2 para realizarem a comunicação (IPC) através de memória compartilhada, ao invés de usarem filas de mensagem. Execute o programa modificado dez vezes. Compare o desempenho da versão Cliente/Servidor que usa filas de mensagens com a nova versão que usa memória compartilhada. Crie um quadro adequado para apresentar os resultados. Analise os resultados obtidos e explique as diferenças.

Resumo das Tarefas que devem ser contempladas neste Projeto:

 


Luís Fernando Faina - faina@facom.ufu.br
Last modified: Wed Aug 30 18:18:39 2006