Os Descaminhos dos Signal Handlers

Texto sobre interrupções, suas relações intrínsecas com async I/O e uma descrição sobre a complexidade de código async-signal-safe.
Linux
Assincronismo
Author

Gustavo Bodi

Published

May 5, 2025

Introdução

Se um dia já fizeste a pergunta de como o assincronismo poderia funcionar em um computador, ou até mesmo dentro das linguagens de programação, a resposta é uma somente: interrupções. Antes de passar por todo o raciocínio sobre como threads, processos, signal handlers e async I/O funcionam, a resposta já fica dada. No entanto, mesmo que temas, recomendo não fugir deste assunto, muito se fala sobre concorrência, como NodeJS funciona, as diferentes linguagens de programação e seus suportes para modelos de assincronismo e como utilizar superficialmente funções ‘async’.

Este breve artigo tem como finalidade terminal demonstrar o comportamento em suficiente baixo nível sobre as peripécias do assincronismo, fazendo uso medonho das APIs Linux para tal. Buscando assim, inspirar uma maior compreensão para aqueles que o lerem. Não terás aqui uma longa descrição sobre cada uma das APIs (para isso existe a documentação); supremamente verás alguns desafios passados na tentativa minha de uma implementação de uma pilha de comunicação de rede a partir de um protocolo próprio.

Interrupções (O primeiro passo)

Antes de comentar sobre minhas descobertas acerca dos descaminhos dos Signal Handlers, hei de apresentar brevemente o funcionamento das interrupções de hardware e, até certo ponto, de software. O objetivo aqui é dar uma pequena base para o entendimento dos grandes problemas gerados pelo código de natureza assíncrona. A maioria do conteúdo desta seção foi retirada do livro do Tanenbaum (Sistemas Operacionais Modernos) e as referências encontram-se no fim do corpo do texto.

Não hei de ser inexpressível, diante disso farei a descrição mais clara possível, interrupções nada mais são do que a maneira do computador ser notificado de atualizações de um de seus periféricos. Nem tudo na realidade funciona desta exata maneira (por vezes, vale-se mais a pena a utilização de técnicas como pooling), mas suponhamos que sim, já que a ideia é uma exemplificação concreta. Partamos então do cenário em que temos um teclado em nossas mesas, a cada vez que clicamos em uma tecla, a informação é enviada naturalmente ao computador. No entanto, perceba que normalmente o computador estará executando outro tipo de tarefa, como imprimir algum tipo de imagem na tela, ou realizando um cálculo/computação. Óbvio e ululante que não se deve esperar a perda do sinal do teclado caso o computador esteja a executar algo que não seja o código que aprovisiona o suporte ao mesmo. Imagine a completa insanidade do cenário onde o código do teclado deve estar sendo executado (de maneira lógica ativa) sempre na sua unidade central de computação. Para tanto, a solução desenvolvida pelos grandes criadores de hardware foi as interrupções.

Interrupções de Hardware e o S.O.

Dentro das interrupções de hardware, pode-se colocar como exemplo o seguinte: Ao pressionar uma tecla, o controlador do teclado envia um sinal pela linha de barramento ao qual está associado; a partir disso, a placa-mãe do computador recebe esse sinal e passa a tomar a decisão do que deve ser feito. Isso dito, existe todo um desenvolvimento a respeito da lógica das prioridades de cada uma das interrupções; por questões de simplificação e pelo fato maior de não serem de contribuição ao que se quer expor, serão omitidas.

Após a decisão sobre qual interrupção tratar, a controladora da placa-mãe insere em linhas de endereço específicas do processador um número que indica qual interrupção deve ser tratada, e por fim, manda um sinal para a CPU. Por conseguinte, o processador para o que está fazendo, salva o contexto, e verifica o vetor de interrupções: este vetor nada mais é que uma tabela que aponta para o endereço de memória a qual a rotina de tratamento está contida. E ao fim da execução desta função, o processador restaura o contexto e volta a executar a tarefa anterior.

Como deve ter sido observado nos últimos parágrafos, o funcionamento das interrupções parece bastante simples e uma solução correta para os problemas de notificação. E sim, isto é factualmente verdadeiro (perdoe o pleonasmo), no entanto a maneira pela qual foi desenvolvida fala principalmente sobre as interrupções de hardware, as quais somente desenvolvedores do próprio Sistema Operacional ou de microcontroladores têm acesso. Em comentar sobre microcontroladores, caso já tenhas passado por essa experiência, bem sabes que é possível escrever os endereços de memória no vetor de interrupção e manejar todas as partes correlatas ao belo prazer.

Quando trata-se de lidar com interrupções em aplicações userland em sistemas operacionais modernos, a história diverge ao que contou-se até então. Como é o sistema operacional que tratará as interrupções de fato, o que será recebido pela aplicação serão sinais que detêm suas próprias excentricidades. A próxima subseção é dedicada a explorar os funcionamentos desses sinais, sendo que a maioria do que será exposto são impressões acerca do definido no padrão POSIX.

Signals e Sigaction

Para o tratamento de sinais em sistemas operacionais POSIX compliant, têm-se uma série de funções para a definição de um sinal, a observação de alguma atualização e de como pode-se realizar operações a partir disso. Para exemplificar o funcionamento, segue o seguinte trecho de código utilizando a função signal:

#include <csignal>
#include <iostream>

void handler_sigterm(int) {
  std::cout << "Interrupção com signal está aqui" << std::endl;
}

int main(int argc, char ** argv) {
  std::cout << "Este processo é: " << getpid() << std::endl;

  signal(SIGINT, &handler_sigterm);

  while (true);
}

Acredito que para o leitor seja relativamente simples de compreender, a chamada signal, registra um callback para lidar com o sinal SIGINT (nada mais do que pressionar ctrl-c no terminal). Esse código nunca termina em seu fluxo normal, mas com ctrl-c ele imprimirá "Interrupção com signal está aqui". Para finalizar o processo, utilize:

kill -9 número_do_processo

Outra maneira, de certo mais elegante, é fazer uso do sigaction, um struct que permite uma configuração de maior granularidade sobre o sinal (ainda não espere grandes capacidades de configuração, mas certamente já é um avanço).

#include <csignal>
#include <iostream>

void handler_sigterm(int) {
  std::cout << "Interrupção com signal está aqui" << std::endl;
}

int main(int argc, char ** argv) {
  std::cout << "Este processo é: " << getpid() << std::endl;

  struct sigaction signal_conf {};

  signal_conf.sa_flags = SA_RESTART;
  signal_conf.sa_handler = &handler_sigterm;
  sigemptyset(&signal_conf.sa_mask);

  if (sigaction(SIGINT, &signal_conf, nullptr) != 0)
    std::perror("Problema ao configurar sigaction");

  while (true);
}

Cria-se um struct com o sigaction e se configura tanto o handler quanto as flags. Aqui, fiz uso do SA_RESTART, que retoma chamadas de sistema interrompidas pelo sinal após a sua execução.

Até então foi-se observado algo bastante simples, o sinal levantado pela tentativa de finalização do programa pelo terminal. Vale ressaltar que faremos uso na próxima seção do sinal para observar atualizações que chegam em um socket, estando aqui o real valor dessa API (senão pelo fato de epoll ser melhor).… Mas bom, tanto faz, tanto fez, a discussão sobre as dificuldades de utilização dos sinais só começou.

Peripécias dos Signal Handlers

Após essa introdução sobre as interrupções e o uso dos sinais, discutirei as grandes dificuldades em realizar algo de útil a partir dos mesmos. Assim, para fim de exemplificação, demonstrarei a minha tentativa de implementação de uma pilha de protocolo de rede baseada em sinais.

A minha pilha de rede

A pilha de rede consiste em três camadas principais, sendo elas: um comunicador (camada de alto nível agnóstica), a camada de protocolo e a camada da placa de rede/Ethernet. De maneira geral, as camadas funcionam da seguinte maneira: escolhes uma mensagem e um destinatário, envias para o comunicador, este que por sua vez envia para a camada de protocolo que monta o pacote para o nosso protocolo próprio, por fim envia à placa de rede que monta o Frame Ethernet e envia a mensagem em Broadcast via Raw Sockets. Cada uma dessas camadas está definida em um esquema observado/observador, dentro disso valia como ideia inicial o seguinte: a partir da notificação de um sinal, cada uma das camadas notificava as outras até que o comunicador (bloqueante) pudesse descer a pilha executando receive para obter o pacote.

sequenceDiagram
  participant C as Communicator
  participant P as Protocol
  participant N as Nic
  participant S as Signal
  participant O as Observer
  activate C
  C ->> P: send
  activate P
  P ->> N: send
  activate N
  deactivate N
  deactivate P
  deactivate C
  activate S
  S ->> N: handle_signal
  activate N
  N ->> O: update
  activate O
  deactivate N
  O ->> P: notify
  activate P
  P ->> O: update
  deactivate P
  O ->> C: notify
  activate C
  deactivate O
  C ->> P: receive
  activate P
  P ->> N: receive
  deactivate P
  deactivate C

Pilha de Rede

Dentro do design, pode-se ter múltiplos comunicadores (um em cada thread), sendo que a cada nova thread, registra-se no protocolo cada um dos observados. As notficações são filtradas a nível da placa de rede para avisar o protocolo somente de pacotes Ethernet do tipo definido, e o comunicador só é notificado pelo protocolo quando a porta de contato é aquela inscrita pelo comunicador.

Async-Signal-Safe (Os sinais contra-atacam)

De acordo com o padrão POSIX, não pode-se utilizar chamadas de sistema que não são async-signal-safe; no final isso implica que pode-se realizar poucas operações de fato úteis a partir do código do signal-handler. Hei de elencar algumas coisas a serem evitadas: chamadas virtuais (late binding) é possivelmente signal unsafe; adquirir um semáforo ou um lock é undefined behavior; além disso, alocação dinâmica e grande parte do código da standard library é signal unsafe, principalmente no que tange os contêineres. Resumo da ópera: dentro do fluxo de execução de um sinal, o que normalmente se faz é setar algum tipo de flag do tipo volatile sig_atomic_t, post em semáforo ou write em um pipe devido às suas limitações.

Mesmo depois de tantas palavras, faltou-me descrever de maneira certa o que me incomodou durante o processo de desenvolvimento. Pois então, fazê-lo-ei, observe a implementação dos observadores condicionais utilizados para notificação dentro da pilha:

template <typename T, typename Condition = void>
class ConditionallyDataObserved {
    friend class ConditionalDataObserver<T, Condition>;

public:
    using Observer = ConditionalDataObserver<T, Condition>;
    using Observed_Data = T;

public:
    void attach(Observer *o, Condition c);
    void detach(Observer *o, Condition c);
    bool notify(Condition c, Observed_Data *d);

private:
    std::mutex mutex {};
    std::size_t _size = { 0 };
    FixedOrderedObservers<Observer*, Condition, Traits<ConditionallyDataObserved<T, Condition>>::MAX_SIZE> _observers {};
};

template<typename T, typename Condition>
bool ConditionallyDataObserved<T, Condition>::notify(Condition c, Observed_Data *d) {
    std::lock_guard<std::mutex> lock(mutex);
    bool notified = false;
    for (std::size_t i = 0; i < _size; ++i) {
        if (_observers[i].condition == c) {
            _observers[i].observer->update(c, d);
            notified = true;
        }
    }

    return notified;
}

template<typename T, typename Condition>
void ConditionallyDataObserved<T, Condition>::detach(ConditionallyDataObserved::Observer *o, Condition c) {
    std::lock_guard<std::mutex> lock(mutex);
    --_size;
    _observers.remove(o, c);
}

template<typename T, typename Condition>
void ConditionallyDataObserved<T, Condition>::attach(ConditionallyDataObserved::Observer *o, Condition c) {
    std::lock_guard<std::mutex> lock(mutex);
    if (_observers.insert(o, c)) {
        ++_size;
    } else {
        throw std::runtime_error("Could not attach observer");
    }
}

Perfeito, caro leitor, tudo errado, exatamente como tardei a perceber. Não temerei refutar minha interpretação passada, deitam-se óbvios os erros deste código; para começar, uma vez que o que executa da função notify é dado por um signal, não pode-se adquirir um mutex, mas isso de certo que não deve ser grande problema já que somente ocorre o detach e o attach no começo da aplicação. A partir dessa ideia, removi o guard_lock ali presente. Não bastasse isso, reescrevi uma parte enorme do resto do código para evitar problemas com o late binding. O resultado? Nada concreto, meu programa continuava perdendo sinais eventuais e corrompendo, desta vez ainda pior: às vezes observadores não eram notificados.

O que acontece neste caso é que mesmo que exista um mutex para proteger de condições de corrida, isso não garante que quando o sinal executar (opera na thread do processo que está ativa no momento) o contido na cache da thread é de fato a versão da lista dos observadores que detêm todos os observados. A única solução correta para este caso é utilizar um método para forçar o sincronismo de memória antes de qualquer execução de signal handler. Dada a complexidade de continuar com essa abordagem, mudei a pilha para ter uma thread de notificação própria.

Conclusão - A minha não solução para o problema

Assim como os grandes guias detalham, utilize os signal handlers para gerar notificações como escrever em um pipe ou post em um semáforo. Esta é a maneira mais elegante e funcional para a resolução desse tipo de problema. No final, o caminho foi habilitar a reentrância no sinal e deixá-lo dar post em um semáforo, enquanto uma nova thread executa as notificações.