Sinais Unix e Eventos do Sistema Operacional

[440] Sinais Unix e Eventos do Sistema Operacional

Se o sinal chega no meio de um malloc e o manipulador também aloca, a estrutura interna se corrompe — por isso o manipulador só liga uma flag e sai. Daí saem o volatile sig_atomic_t que não é enfeite, o Ctrl+C capturado a tempo, e o encerramento gracioso que um servidor deve ao sistema.
Linguagem C

13 min de leitura

Chegamos à última aula de conteúdo antes do projeto final. Ao longo da Fase 8, aprendemos a criar processos, threads e conexões de rede — formas de o nosso programa agir sobre o mundo. Hoje aprendemos a forma como o mundo avisa o nosso programa de que algo aconteceu: os sinais. Um sinal é uma notificação assíncrona que o sistema operacional envia a um processo para informá-lo de um evento — desde o Ctrl+C que você aperta para interromper um programa até o aviso de que um processo filho terminou. Dominar os sinais completa nosso repertório de programação de sistemas e ensina uma forma de pensar sobre eventos que chegam a qualquer momento, fora do fluxo previsível do código.

O que é um sinal

Um sinal é uma mensagem curta e padronizada que o sistema operacional (ou outro processo) envia a um processo para notificá-lo de um evento. Você já enviou sinais sem saber: quando aperta Ctrl+C num programa rodando no terminal, você está enviando o sinal SIGINT (interrupt) a ele, pedindo que ele pare. Cada sinal tem um nome e um número, e um significado padronizado. Alguns dos mais comuns: SIGINT (interrupção, o Ctrl+C), SIGTERM (pedido educado de término), SIGKILL (término forçado e imediato, que não pode ser ignorado), SIGSEGV (violação de memória — o segmentation fault que encontramos ao desreferenciar ponteiros inválidos!), e SIGCHLD (um processo filho mudou de estado, por exemplo terminou).

A natureza dos sinais é assíncrona: eles podem chegar a qualquer momento, interrompendo o fluxo normal do programa. Isso os torna conceitualmente diferentes de tudo que vimos — não é o seu código que decide quando um sinal chega, é o mundo externo. Essa assincronia é tanto o poder quanto a delicadeza dos sinais.

O comportamento padrão e como mudá-lo

Cada sinal tem um comportamento padrão — o que acontece se o programa não fizer nada especial a respeito. Para a maioria dos sinais, o padrão é terminar o processo (é por isso que Ctrl+C encerra programas). Mas você pode capturar a maioria dos sinais e definir sua própria reação — uma função chamada manipulador de sinal (signal handler) que roda quando o sinal chega. Isso permite, por exemplo, fazer um programa fazer uma limpeza antes de sair ao receber Ctrl+C, em vez de simplesmente morrer.

A forma moderna e recomendada de registrar um manipulador é a função sigaction, mas há também a mais simples signal, suficiente para exemplos didáticos. Veja um programa que captura o Ctrl+C:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t executando = 1; // flag segura para sinais

// o manipulador: roda quando SIGINT (Ctrl+C) chega
void tratar_sigint(int sinal) {
    (void)sinal; // ignoramos o número do sinal aqui
    executando = 0; // apenas altera a flag, saindo do laço principal
}

int main(void) {
    signal(SIGINT, tratar_sigint); // registra o manipulador para Ctrl+C

    printf("Rodando... aperte Ctrl+C para parar.\n");
    while (executando) {
        printf("Trabalhando...\n");
        sleep(1);
    }

    printf("\nRecebido Ctrl+C. Limpando e saindo com elegância.\n");
    // aqui você faria a limpeza: liberar memória, fechar arquivos, etc.
    return 0;
}

Acompanhe. A função signal(SIGINT, tratar_sigint) diz ao sistema: "quando este processo receber SIGINT, chame tratar_sigint em vez do comportamento padrão (terminar)". Quando você aperta Ctrl+C, o fluxo normal é interrompido, tratar_sigint roda (apenas mudando a flag executando para 0), e o programa retoma de onde parou — na próxima verificação do while, o laço termina, e o programa faz sua limpeza antes de sair com elegância. Sem o manipulador, o Ctrl+C mataria o programa na hora, sem chance de limpeza. Com ele, você controla como o programa responde ao evento.

A regra de ouro: manipuladores devem ser mínimos

Há uma restrição crucial sobre o que um manipulador de sinal pode fazer, e ela reaparece da nossa discussão sobre concorrência. Como um sinal pode interromper o programa a qualquer momento — inclusive no meio de uma operação —, o manipulador roda num contexto delicado, similar ao de uma thread concorrente. Se o sinal interrompe o programa no meio de um malloc, e o manipulador também chama malloc, as estruturas internas podem ficar corrompidas. Por isso, a regra é: manipuladores de sinal devem fazer o mínimo possível. O padrão seguro e idiomático, mostrado acima, é o manipulador apenas setar uma flag (uma variável do tipo volatile sig_atomic_t, que é seguro modificar num sinal), e deixar o trabalho real para o fluxo principal, que checa a flag. Fazer operações complexas — alocar memória, chamar printf, manipular estruturas de dados — dentro de um manipulador é perigoso e fonte de bugs sutis. Mínimo é seguro; complexo é traiçoeiro.

O volatile sig_atomic_t da flag não é decorativo: volatile impede o compilador de otimizar de formas que ignorariam a mudança feita pelo sinal, e sig_atomic_t garante que a leitura/escrita da variável seja uma operação indivisível, segura de fazer num manipulador. É a mesma preocupação com atomicidade e visibilidade que vimos na concorrência, agora no contexto dos sinais.

Enviando sinais e a conexão com processos

Sinais não vêm só do teclado — um processo pode enviar um sinal a outro (ou a si mesmo) com a função kill (o nome é enganoso; ela envia qualquer sinal, não só o de término). Isso conecta com a aula de processos: o sinal SIGCHLD, por exemplo, é enviado ao pai quando um filho termina, e um programa sofisticado poderia capturá-lo para recolher os filhos (com wait) de forma assíncrona, evitando os processos zumbis que discutimos. Ferramentas do sistema, como o comando kill do terminal, usam esse mecanismo para controlar processos. Os sinais são, assim, uma peça do quebra-cabeça maior da programação de sistemas — o canal pelo qual o sistema operacional e os processos coordenam eventos entre si.

Um uso prático: encerramento gracioso

O uso mais valioso dos sinais no dia a dia é o encerramento gracioso (graceful shutdown). Servidores e programas de longa duração precisam, ao serem solicitados a parar (via Ctrl+C ou SIGTERM), fazer uma limpeza ordenada antes de sair: terminar de atender requisições em andamento, salvar dados, fechar conexões e arquivos, liberar memória. Capturar SIGINT e SIGTERM e usar a flag para sair limpamente do laço principal — como no exemplo — é o padrão que torna um programa "bem-comportado" perante o sistema. Sem isso, o programa morreria abruptamente, possivelmente deixando dados corrompidos ou recursos não liberados. Com isso, ele se despede com dignidade. Esse padrão amarra os sinais a toda a disciplina de gerenciamento de recursos que cultivamos no curso.

Fechando a Fase 8

Com os sinais, encerramos a fase de programação de sistemas — e o conteúdo técnico do curso. Você percorreu o território onde o C conversa diretamente com o sistema operacional: criou processos com fork/exec/wait, threads com pthreads, aprendeu a sincronizá-las com mutexes, abriu conexões de rede com sockets, e agora respondeu a eventos assíncronos com sinais. Esse é o domínio que faz do C a linguagem dos sistemas operacionais, dos servidores, dos programas que formam a espinha dorsal da computação. Você tocou o metal. Na próxima e última aula, colocaremos tudo isso — e tudo o que construímos ao longo do ano — à prova num projeto capstone: uma aplicação real, do zero, que integra as habilidades da jornada inteira, seguida de uma retrospectiva de tudo que você conquistou.

Fontes e leituras recomendadas

  • The Linux Programming Interface, Michael Kerrisk — capítulos abrangentes sobre sinais
  • Advanced Programming in the UNIX Environment (APUE), Stevens & Rago — o tratamento clássico de sinais
  • man pages — man 2 sigaction, man 7 signal, man 2 kill
  • Computer Systems: A Programmer's Perspective, Bryant & O'Hallaron — Cap. 8, sobre sinais e controle de fluxo excepcional
  • CERT C — SIG série, sobre uso seguro de sinais — https://wiki.sei.cmu.edu/confluence/display/c

Exercícios

Exercício 1

Escreva um programa que capture o SIGINT (Ctrl+C) e, ao recebê-lo, imprima uma mensagem de despedida e saia graciosamente (usando o padrão da flag volatile sig_atomic_t). Teste apertando Ctrl+C enquanto ele roda.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t rodando = 1;

void tratar(int s) {
    (void)s;
    rodando = 0;
}

int main(void) {
    signal(SIGINT, tratar);
    printf("Rodando... Ctrl+C para sair.\n");
    while (rodando) {
        sleep(1);
    }
    printf("\nAté logo!\n");
    return 0;
}

Ao apertar Ctrl+C, o manipulador seta rodando para 0; o laço principal detecta isso e sai, imprimindo a despedida — um encerramento controlado em vez de uma morte abrupta.

Exercício 2

Modifique o programa para contar quantas vezes o Ctrl+C foi pressionado, e só sair de fato na terceira vez (nas duas primeiras, apenas avisa "aperte mais N vezes para sair"). Use a flag para controlar isso no laço principal.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t contador = 0;

void tratar(int s) {
    (void)s;
    contador++;
}

int main(void) {
    signal(SIGINT, tratar);
    printf("Aperte Ctrl+C três vezes para sair.\n");
    int ultimo_avisado = 0;
    while (contador < 3) {
        if (contador != ultimo_avisado && contador > 0) {
            printf("Aperte mais %d vez(es) para sair.\n", 3 - contador);
            ultimo_avisado = contador;
        }
        sleep(1);
    }
    printf("Saindo após 3 interrupções.\n");
    return 0;
}

O manipulador apenas incrementa contador (operação mínima e segura); o laço principal verifica a contagem, avisa quantas faltam, e só sai na terceira. Todo o "trabalho" (avisar, decidir sair) fica no fluxo principal, não no manipulador.

Exercício 3

Explique por que um manipulador de sinal deve fazer o mínimo possível. O que poderia dar errado se o manipulador chamasse malloc ou printf com estruturas complexas?

Ver resposta

✓ Resposta: Um manipulador de sinal deve fazer o mínimo possível porque ele pode interromper o programa a qualquer instante — inclusive no meio de uma operação delicada. Se o sinal chega enquanto o programa está no meio de um malloc (que manipula estruturas internas de gerenciamento de memória), e o manipulador também chama malloc, ele pode encontrar essas estruturas num estado inconsistente, intermediário, e corrompê-las — porque o malloc interrompido não terminou de atualizá-las. O resultado seria corrupção de memória, travamentos ou comportamento indefinido, e — pior — de forma intermitente e imprevisível, dependendo do momento exato em que o sinal chegou. O mesmo vale para printf (que manipula buffers internos de I/O) e para operações sobre estruturas de dados compartilhadas com o fluxo principal (uma lista encadeada sendo modificada poderia ser deixada num estado quebrado). Funções que são seguras de chamar num manipulador são chamadas "async-signal-safe", e são poucas — a maioria das funções da biblioteca, incluindo malloc e printf, não são. Por isso o padrão seguro é o manipulador apenas setar uma flag (volatile sig_atomic_t), uma operação atômica e segura, e deixar todo o trabalho real (que pode chamar malloc, printf, etc.) para o fluxo principal, que executa em contexto normal, verificando a flag. Fazer operações complexas no manipulador é convidar bugs sutis, difíceis de reproduzir, exatamente do tipo mais frustrante.

Exercício 4

Explique o que significa dizer que os sinais são "assíncronos". Como isso os torna conceitualmente diferentes das chamadas de função normais que fazemos no fluxo do programa?

Ver resposta

✓ Resposta: Dizer que os sinais são "assíncronos" significa que eles podem chegar ao programa a qualquer momento, de forma imprevisível, fora do controle do fluxo normal do código — não é o programa que decide quando um sinal ocorre, é um evento externo (o usuário apertando Ctrl+C, o sistema operacional, outro processo). Quando um sinal chega, ele interrompe o que o programa estava fazendo, desvia a execução para o manipulador, e depois retoma de onde parou. Isso os torna conceitualmente diferentes das chamadas de função normais de uma forma profunda: uma chamada de função normal é síncrona e determinística — ela acontece exatamente onde e quando você a escreve no código, numa ordem previsível que você controla (a instrução A executa, depois a B, depois a chamada C que você escreveu). Você sabe, olhando o código, quando cada função é chamada. Um sinal é o oposto: ele não aparece no fluxo do código onde será tratado; o manipulador pode ser disparado entre quaisquer duas instruções do programa, sem aviso. É como a diferença entre uma reunião agendada (chamada de função — você sabe quando acontece) e um telefonema que toca a qualquer momento (sinal — interrompe o que você estiver fazendo, quando menos espera). Essa assincronia é justamente o que exige o cuidado especial com manipuladores mínimos: como o sinal pode interromper qualquer ponto, o manipulador não pode assumir nada sobre o estado do programa no momento em que roda. É uma forma de concorrência — a razão pela qual as preocupações com atomicidade e volatile da Fase 8 reaparecem aqui.

Exercício 5

Explique o que é um "encerramento gracioso" (graceful shutdown) e por que ele é importante para programas de longa duração, como servidores. Que sinais um servidor tipicamente captura para implementá-lo, e que tarefas ele realiza antes de sair?

Ver resposta

✓ Resposta: Um encerramento gracioso (graceful shutdown) é o processo de um programa terminar de forma ordenada e controlada quando solicitado a parar, em vez de morrer abruptamente. Ao receber o pedido de término, o programa faz uma limpeza adequada antes de sair: termina de processar o trabalho em andamento, salva dados que estavam em memória, fecha conexões de rede e arquivos abertos, e libera a memória alocada. Ele é importante para programas de longa duração, como servidores, por várias razões: um servidor pode estar no meio de atender requisições quando recebe o pedido de parada — encerrar abruptamente deixaria essas requisições incompletas, possivelmente corrompendo dados ou deixando clientes sem resposta; pode ter dados em memória ainda não salvos que se perderiam; pode ter arquivos abertos cujos buffers não foram descarregados ao disco (lembrando que sem fclose os dados em buffer podem se perder, da aula Lendo e Gravando Arquivos de Texto); e pode ter recursos do sistema (conexões, memória, travas) que ficariam num estado inconsistente. Um encerramento abrupto arrisca corrupção de dados, perda de informação e recursos vazados; o gracioso preserva a integridade. Os sinais que um servidor tipicamente captura para implementá-lo são o SIGTERM (o pedido educado e padrão de término, usado por sistemas de gerenciamento de processos para pedir que um programa pare) e o SIGINT (o Ctrl+C, quando rodando interativamente). Ao capturar esses sinais, o servidor tipicamente seta uma flag que faz seu laço principal parar de aceitar novas conexões e, então, antes de sair, realiza tarefas como: terminar de atender as requisições já em andamento, salvar qualquer estado ou dado pendente em disco, fechar as conexões de rede ativas e os arquivos abertos (descarregando seus buffers), liberar a memória alocada, e talvez registrar no log que está encerrando. Só depois de toda essa limpeza é que o programa efetivamente termina — despedindo-se com dignidade e sem deixar rastros de destruição, em contraste com a morte súbita que um SIGKILL (que não pode ser capturado) ou a ausência de manipulador causariam.

Comentários

Mais em Linguagem C

Funções, Escopo e Passagem por Valor
Funções, Escopo e Passagem por Valor

Por que dobrar um número dentro de uma função deixa o original intacto? A…

Caçando Bugs de Memória com Valgrind e Sanitizers
Caçando Bugs de Memória com Valgrind e Sanitizers

A distância entre a causa e o sintoma é o que torna caro um bug de memória…

Automação de Build com Make
Automação de Build com Make

Indentar o comando com espaço em vez de TAB gera um erro enigmático — é o…