Sistemas Operacionais, 10 semestre/2004

Experimento #3

 

Código Fonte do Programa Exemplo

1. Introdução

IPC (Inter-Process Communication), ou comunicação entre processos, consiste de um conjunto de métodos que permitem que processos no Sistema Operacional Unix possam se comunicar.

Como existem muitas versões de Unix, existem métodos diferentes para realização de IPC. A variante Berkley do Unix usa sockets. A variante da AT&T, o System V, usa filas de mensagens e compartilhamento de memória. Além disso, no POSIX.1b, filas de mensagens e memória compartilhada são definidas de modo diferente que no System V. Pipes constituem outro tipo de mecanismo para IPC. Até mesmo um arquivo pode ser usado para comunicação entre processos. Apesar da variedade de métodos diferentes para IPC, é importante entender e perceber como processos podem se comunicar.

Neste experimento são exploradas outras chamadas para mecanismos de IPC existentes no System V, ou seja, memória compartilhada. Além disso, são explorados sinais.

Sinais permitem que um único processo execute código de uma maneira assíncrona. Em outras palavras, a ordem de execução das instruções dentro de um programa não é o maior determinante para quais instruções serão executadas; pode ocorrer do fluxo de execução do processo ser interrompido para a execução de uma rotina associada a um sinal, quando da chegada deste; como o sinal pode chegar a qualquer momento, a rotina não tem um ponto determinado no programa para ser executada.

Sinais são usados para indicar durante a execução de um programa que alguma ação precisa ser feita, independentemente da região do programa que esteja em execução. Em programas simples, sinais são fáceis de trabalhar e relativamente fáceis de entender. Porém, em programas grandes, com sinais de múltiplos processos e múltiplas interações, trabalhar com sinais pode ser difícil . O comportamento assíncrono de sinais pode causar erros que não são reproduzíveis, são difíceis de localizar e corrigir.

Este exercício foi definido a partir dos experimentos existentes em http://www.rt.db.erau.edu/experiments/unix-rt que pertencem ao Laboratório Embry-Riddle de tempo-real.

 

2. Objetivos

A seguir estão os objetivos deste experimento com relação ao aluno:

 

3. Teoria

A seguir são discutidos conceitos diretamente relacionados com o experimento, 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, lembrar 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. Atenção: essa região de memória não é parte do processo (não constitui espaço local ou global), está fora de processo e só pode ser acessada através de pedidos ao SO. 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 processo a acessa de uma maneira semelhante a como se acessa memória normal, através de ponteiros. Além disso, ler e escrever na memória compartilhada torna o acesso a 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. Todo processo que deseja ter acesso ao segmento de memória compartilhado precisa saber esta identificação.

A seguinte seqüência ilustra como usar a memória compartilhada:

As vantagens da memória compartilhada já foram citadas acima. Porém, há várias desvantagens no uso de memória compartilhada. Usando filas de mensagens, o SO 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, removendo, etc. Isto acaba por 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

Sinais já foram utilizados no experimento #1, embora não tenham sido citados ou explicados. A chamada kill (), que também é um comando a nível de shell, foi introduzida como um método para terminar um processo. Há uma variedade de sinais diferentes em um sistema 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:

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

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 padrão. Vários sinais de sistema não podem ser bloqueados, tal como o SIGKILL, que não pode ser ignorado ou capturado. Para estes sinais a ação padrão é sempre usada.

Quando um sinal é ignorado, este é 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 (padrão ou fornecida pelo usuário) é realizada imediatamente.

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

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.

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á sendo usada.

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.

Sinais e chamadas de sistema podem causar dores de cabeça se não usados corretamente.

Há dois tipos de chamadas de sistema 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 pedidos de I/O no disco. Chamadas de sistema lentas, às vezes, levam um período grande de tempo para ser completadas e podem ser interrompidas. I/O para um terminal é um exemplo.

Se um sinal é recebido enquanto uma chamada de sistema lenta está bloqueada, tal como ocorre quando msgrcv () bloqueia o processo, enquanto espera por uma mensagem a ser colocada na fila, a ação padrão é 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 pode 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.

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, o tempo medido será escrito 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 processo em que se deseja ter acesso a memória compartilhada:

    1. Defina uma estrutura para a informação que será armazenada na memória compartilhada.
    1. Crie um ponteiro que seja do mesmo tipo que a estrutura definida em (1).
    1. Crie o segmento de memória compartilhada e faça a associação.
    1. Armazene o endereço devolvido pela associação no ponteiro criado em (2).
    1. Realize o acesso à memória compartilhada através do ponteiro de uma maneira normal.

A seguir está a estrutura de memória compartilhada que é 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. Se, por acaso, você não sabe o que é exclusão mútua, procure seu professor.

typedef struct {

pid_t child_pid[2];

struct timeval timings[NO_OF_ITERATIONS][2];

} time_stats_t;

Para manter a integridade sobre a memória compartilhada definida de acordo com a estrutura acima, para as tomadas de tempo (timings), o primeiro filho terá acesso à matriz só a elementos que estão na coluna 0 e o segundo filho, àqueles da coluna 1.

O primeiro grupo de chamadas de sinais é achado onde o pai estabelece sua máscara de sinais. Lembre-se, que a máscara de sinais estabelecida pelo pai será herdada pelos filhos através da chamada fork(). As chamadas seguintes instalarão uma máscara de sinais que bloqueia a união da máscara atual (que está vazia) e SIGUSR1. O ponto importante a notar aqui é que SIGUSR1 é bloqueado pelos filhos e pelo pai. Assim, até que seja desbloqueado, o sinal SIGUSR1 não é entregue!

sigemptyset (&sigset);

sigaddset (&sigset, SIGUSR1);

sigprocmask (SIG_BLOCK, &sigset, NULL);

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 o processo em execução, 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. 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:

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 fixa 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. O conjunto act.sa_mask é fixado para 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 a qualquer hora que um sinal SIGUSR1 seja recebido, desde que 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)

{

estatic 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. Assim, os filhos serão executados e ambos alcançarão um estado bloqueado esperando por SIGUSR1. Quando um ou outro recebe um sinal, o manipulador de sinais deste é 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 comece a ação. Então 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.

4. Resultado

Cada experimento constitui uma atividade que precisa de ser completada através de duas tarefas básicas. A primeira se refere à compilação e entendimento de um programa exemplo que trata de assuntos relacionados com aqueles cobertos em sala de aula e na teoria. A segunda se refere à implementação de uma modificação sobre o exemplo.

Cada trabalho de programação deve ser acompanhado de um relatório com as seguintes partes obrigatórias:

A entrega de cada trabalho deve ocorrer através do envio de um e-mail "Encaminhando programa 3", de acordo com o cronograma previamente estabelecido. A data e hora limites correspondem à segunda-feira às 24:00, da semana marcada para entrega e apresentação. Anexos a esse e-mail devem constar:

Solicita-se que, se usado um compactador de arquivos, que este seja o zip.

A falta de qualquer elemento no e-mail ou a perda da data de entrega implica na perda da nota correspondente. Somente duas exceções serão consideradas, o fechamento do laboratório durante o período disponibilizado para a realização do experimento, ou problema de doença avisado com antecedência mínima de dois dias antes da data da entrega.

Laboratório cheio, quedas de máquinas, falta de linha telefônica, problemas pessoais ou "blackouts" não serão aceitos como desculpas por atrasos. Por isso, recomenda-se fortemente que o início do trabalho ocorra o mais rapidamente possível.

Tarefas

A primeira parte da tarefa é compilar e executar as instruções disponíveis no programa exemplo.

Executar o programa 10 (dez) 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 o desvio aumenta. 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édia (seg)

Máxima (seg)

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

 

Altere o programa exemplo de maneira que durante sua execução existam dois processos filhos cooperando como se segue:

Certifique-se que os p-ids dos processos fiquem visíveis aos demais processos interessados. Para isso, use o mesmo artifício usado no programa exemplo (colocá-los no segmento de memória compartilhada).

Execute o programa modificado dez vezes. Para cada execução varie o tempo de dormência, desde que seja um múltiplo de 20 mili segundos. Crie um quadro adequado para apresentar os resultados em unidades de micro segundos. Analise os resultados obtidos e explique as diferenças.

5. Apresentação

O resultado do experimento será apresentado em sala no dia de aula prática da semana marcada para a entrega, com a presença obrigatória de todos os alunos, de acordo com o cronograma previamente estabelecido.

Serão escolhidos dois alunos para a apresentação e discussão do resultado. A critério do professor pode, inclusive, ocorrer o convite a qualquer dos alunos não escolhidos para que façam essa apresentação.

Todos os alunos que completaram o experimento devem preparar para a apresentação:

Durante a apresentação deverão ser respondidas perguntas do Professor e de colegas.