Voltar para Notícias para desenvolvedores

Signals in prod: dangers and pitfalls

27 de setembro de 2022PorChris Down

Nesta publicação de blog, o engenheiro de Kernel da Meta Chris Down fala sobre as armadilhas de usar sinais do Linux nos ambientes de produção do Linux e por que os desenvolvedores devem evitar fazer isso sempre que possível.

O que são os sinais do Linux?

Um sinal é um evento gerado pelos sistemas do Linux em resposta a uma condição. Os sinais podem ser enviados pelo kernel para um processo, de um processo para outro processo, ou de um processo para si mesmo. O recebimento de um sinal pode dar início a um processo.

Os sinais são uma parte central de ambientes operacionais do tipo Unix e existem desde sempre. Eles são a base de muitos dos principais componentes do sistema operacional (despejo de núcleo, gerenciamento de ciclo de vida de processo, entre outros) e, em geral, eles aguentaram bem o tranco nos últimos 50 anos em que os utilizamos. Por isso, quando alguém sugere que usá-los para comunicação entre processos (IPC) é potencialmente perigoso, essa ideia pode ser taxada como uma tentativa de inventar a roda. Mesmo assim, este artigo tem o objetivo de apresentar casos em que os sinais causaram problemas de produção e sugerir mitigações e alternativas.

Os sinais podem parecer atrativos devido à sua padronização, ampla disponibilidade e ao fato de não exigirem dependências adicionais além das fornecidas pelo sistema operacional. Contudo, pode ser difícil usá-los de modo seguro. Eles fazem uma série de suposições que devem ser validadas com cuidado para corresponder aos seus requisitos. Se esse não for o caso, é preciso ter cuidado para configurá-los corretamente. Na verdade, muitos aplicativos, mesmo os mais conhecidos, não fazem isso. Como resultado, esses aplicativos poderão ter incidentes de difícil depuração no futuro.

Vamos analisar um incidente recente que ocorreu no ambiente de produção da Meta e que reforça as armadilhas de usar sinais. Contaremos brevemente a história de alguns sinais e sobre como eles nos guiaram até onde estamos hoje. Depois, vamos comparar esse histórico com as necessidades e os problemas que vemos na produção hoje.

O incidente

Primeiro, vamos voltar um pouco no tempo. A equipe da LogDevice limpou a sua base de códigos, removendo todos os códigos e recursos não utilizados. Um dos recursos que estava obsoleto era um tipo de registro que documentava determinadas operações executadas pelo serviço. Esse recurso acabou ficando redundante, não tinha mais consumidores e, por isso, foi removido. É possível ver a mudança aqui no GitHub. Até aí tudo bem.

Os primeiros momentos após a mudança passaram sem grandes eventos, a produção continuou a mil entregando o tráfego como sempre. Algumas semanas depois, um relatório informava que os nós de serviço estavam sendo perdidos a uma taxa impressionante. Tinha algo a ver com a implementação do novo lançamento, mas não era claro o que exatamente estava errado. O que estava diferente agora e causando a queda?

A equipe em questão concluiu que o problema tinha relação com a mudança no código que mencionamos antes e descontinuou esses registros. Mas por quê? O que há de errado com o código? Se você ainda não sabe a resposta, vamos analisar a diferenciação e tentar entender o que está errado. Não é algo óbvio, é um erro que qualquer pessoa poderia cometer.

logrotate, entre no ringue

O logrotate é uma ferramenta mais ou menos padrão para rotação de registros ao usar o Linux. Ele existe há quase 30 anos, e o conceito é simples: faça rotação e aspiração dos registros para gerenciar o ciclo de vida.

O logrotate não envia sinais por conta própria, por isso, você não vai achar muita coisa (provavelmente nada) sobre eles na página principal nem na documentação do logrotate. Contudo, o logrotate pode receber comandos arbitrários a serem executados antes ou depois das rotações. Como exemplo básico da configuração-padrão do logrotate no CentOS, observe esta configuração:

/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    sharedscripts
    postrotate
        /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
    endscript
}

Ela parece um pouco frágil, mas vamos ignorar isso e supor que ela funciona conforme o esperado. A configuração diz que, depois que o logrotate fizer a rotação de qualquer um dos arquivos listados, ele deve enviar SIGHUP para o PID contido em /var/run/syslogd.pid, que deve ser o da instância syslogd em execução.

Tudo isso parece muito bom para uma API pública estável como o syslog, mas e se estivermos falando de algo interno em que a implementação de SIGHUP é um detalhe que pode mudar a qualquer momento?

Uma história de desligamentos

Um dos problemas é que, exceto no caso de sinais que não podem ser usados no espaço do usuário e, portanto, têm somente um significado, como SIGKILL e SIGSTOP, cabe aos desenvolvedores e usuários dos aplicativos programar e interpretar o significado semântico dos sinais. Em alguns casos, a distinção é bastante conhecida, como SIGTERM, cujo significado é entendido de maneira praticamente universal como "termine com elegância o mais rápido possível". Entretanto, no caso de SIGHUP, o significado é menos claro.

SIGHUP foi inventado para linhas em série e era originalmente usado para indicar que a linha do outro lado da conexão havia caído. Hoje em dia, ainda carregamos nossa linhagem conosco, então o SIGHUP ainda é enviado para o seu equivalente moderno: quando um terminal virtual ou pseudoterminal é fechado (daí o uso de ferramentas como nohup, que mascara isso).

Nos primórdios do Unix, havia uma necessidade de implementar o recarregamento do daemon. Isso geralmente consiste em configuração/reabertura de arquivo de registro sem reiniciar, e os sinais pareceram uma maneira livre de dependências para obter isso. É claro, não havia um sinal para isso, mas como os daemons não têm um terminal de controle, não deveria haver motivos para receber um SIGHUP. Por isso, pareceu conveniente ressignificar esse sinal sem um efeito colateral óbvio.

Mas há uma pequena dificuldade com esse plano. O estado-padrão para sinais não é "ignorado", mas específico a cada sinal. Logo, por exemplo, os programas não têm que configurar manualmente o SIGTERM para encerrar os aplicativos. Contanto que você não defina outro manipulador de sinal, o kernel encerrará o programa gratuitamente, sem necessidade de um código no espaço do usuário. Muito conveniente!

O que não é tão conveniente é que o SIGHUP também tem o comportamento-padrão de encerrar o programa de maneira imediata. Isso funciona muito bem para o caso original de desligamento de linha, quando os aplicativos provavelmente não eram mais necessários, mas não é tão bom para o novo significado.

Não haveria problema se removêssemos todos os locais que poderiam enviar SIGHUP para o programa. O problema é que, em uma base de código grande e madura, isso é muito difícil. SIGHUP não é uma chamada de IPC rigidamente controlada pela qual você pode fazer grep com facilidade na base de código. Os sinais podem vir de qualquer lugar, a qualquer momento, e há poucas verificações na sua operação (além da mais básica "você é este usuário ou tem CAP_KILL"). Em resumo, é difícil determinar de onde os sinais podem vir, mas com IPC mais explícito, é possível saber que esse sinal não significa nada e deve ser ignorado.

Do desligamento ao desmoronamento

A esta altura, você deve ter adivinhado o que houve. Um lançamento do LogDevice começou em uma tarde fatídica contendo a mudança de código supracitada. A princípio, nada deu errado. No entanto, à meia-noite do outro dia, tudo começou a desmoronar misteriosamente. O motivo é a seguinte estrofe na configuração do logrotate da máquina, que envia um SIGHUP não processado (e, por isso, fatal) para o daemon do dispositivo de registro:

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

Deixar passar uma estrofe curta de uma configuração do logrotate é muito fácil e comum quando se remove um recurso grande. Infelizmente, também é difícil ter certeza de que todos os vestígios da existência do recurso tenham sido removidos de uma vez só. Até em casos de validação mais fáceis do que este é comum deixar alguns resquícios por engano ao fazer limpezas de código. Mesmo assim, geralmente, não há consequências destrutivas, ou seja, os detritos remanescentes são apenas códigos mortos ou não operacionais.

Conceitualmente, o incidente em si e a respectiva resolução são simples: não envie SIGHUP e espalhe mais os horários das ações do LogDevice (ou seja, não execute isso à meia-noite em ponto). Contudo, não é só nas nuances do incidente que devemos nos concentrar aqui. Esse incidente, mais do que qualquer coisa, pode servir como um argumento para desencorajar o uso de sinais em produção para tudo que não sejam os casos mais básicos e essenciais.

Os perigos dos sinais

Para que servem os sinais

Primeiramente, o uso de sinais como um mecanismo que afeta as mudanças no estado do processo do sistema operacional é algo que tem fundamento. Isso inclui sinais como SIGKILL, para o qual é impossível instalar um manipulador de sinal e que faz exatamente o que você esperaria, e o comportamento-padrão do kernel para SIGABRT, SIGTERM, SIGINT, SIGSEGV, SIGQUIT e similares, que em geral são bem compreendidos pelos usuários e programadores.

O que todos esses sinais têm em comum é que, depois que você os recebe, todos progridem em direção a um estado final do terminal dentro do próprio kernel. Em outras palavras, não serão executadas mais instruções no espaço do usuário depois que você obter um SIGKILL ou SIGTERM sem manipulador de sinal do espaço do usuário.

Um estado final do terminal é importante porque geralmente significa que você está trabalhando para diminuir a complexidade da pilha e do código em execução no momento. Outros estados desejados com frequência resultam em complexidade mais alta e mais difícil de desvendar à medida que a simultaneidade e o fluxo do código ficam mais confusos.

Comportamento-padrão perigoso

Você pode ter notado que não mencionamos outros sinais que também são encerrados por padrão. Esta é uma lista de todos os sinais que encerram por padrão (excluindo sinais de despejo de núcleo como SIGABRT ou SIGSEGV, que são sensíveis):

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

À primeira vista, parece tudo muito razoável, mas há algumas peculiaridades:

  • SIGHUP: se for usado como pretendido originalmente, o padrão de encerramento será sensível. Com o significado de uso misto atual de "reabrir arquivos", isso é perigoso.
  • SIGPOLL e SIGPROF: fazem parte do grupo dos que "deveriam ser processados internamente por alguma função-padrão, e não pelo seu programa". Ainda que provavelmente inofensivo, o comportamento-padrão de encerramento ainda não parece ideal.
  • SIGUSR1 e SIGUSR2: são "sinais definidos pelo usuário" que você pode usar ostensivamente sempre que quiser. Mas como são terminais por padrão, se você implementar USR1 para alguma necessidade específica e depois decidir que não precisa disso, não será possível simplesmente remover o código com segurança. Você precisa refletir para explicitamente ignorar o sinal. Isso não será óbvio nem para programadores mais experientes.

Então, isso quer dizer que um terço dos sinais de terminal são no mínimo questionáveis e, em última instância, ativamente perigosos quando as necessidades de um programa mudam. Mas o pior é que mesmo os sinais supostamente "definidos pelo usuário" são um desastre esperando para acontecer quando alguém esquece de usar SIG_IGN de maneira explícita. Mesmo os inócuos SIGUSR1 ou SIGPOLL podem causar incidentes.

Não se trata apenas de familiaridade. Mesmo que você saiba muito bem como funcionam os sinais, ainda é extremamente difícil escrever códigos com sinais corretos na primeira vez porque os sinais são muito mais complexos do que parecem.

Fluxo de código, simultaneidade e o mito do SA_RESTART

Os programadores geralmente não passam o dia inteiro pensando sobre os funcionamentos internos dos sinais. Isso significa que, quando chega a hora de implementar a manipulação de sinais, quase sempre eles fazem as coisas de modo um pouco errado.

Não se trata de casos "triviais", como segurança na função de processar um sinal, cuja solução é apenas jogar um sig_atomic_t ou usar algum atomic signal fence no C++. Não, isso é fácil de pesquisar e memorizar como armadilha por qualquer pessoa depois da primeira dificuldade com os sinais. O mais difícil é entender o fluxo de código das porções nominais de um programa complexo quando ele recebe um sinal. Para fazer isso, é necessário um pensamento constante e explícito sobre sinais em todas as partes do ciclo de vida do aplicativo (ei, e o EINTR, será que SA_RESTART é suficiente aqui? Em que fluxo devemos entrar se isso for encerrado prematuramente? Agora tenho um programa simultâneo, quais são as implicações disso?), ou a definição de um sigprocmask ou pthread_setmask para alguma parte do ciclo de vida do seu aplicativo e torcer para que o fluxo de código nunca mude (o que com certeza não é uma boa aposta em uma atmosfera de desenvolvimento em ritmo rápido). signalfd ou executar sigwaitinfo em um fio dedicado pode ajudar um pouco, mas os dois têm casos de borda e dificuldades de usabilidade suficientes para serem pouco recomendados.

Queremos acreditar que a maioria dos programadores experientes a esta altura já sabem que mesmo um exemplo frívolo de escrita correta de código com segurança de fio de execução é muito difícil. E se você achou que escrever corretamente códigos com segurança de fio de execução era difícil, saiba que os sinais são ainda mais desafiadores. Os manipuladores de sinais devem depender somente de códigos estritamente livres de bloqueios com estruturas de dados atômicos, respectivamente, porque o fluxo principal de execução é suspenso, e não sabemos o que segura o bloqueio. Além disso, porque o fluxo principal de execução poderia estar executando operações não atômicas. Eles devem ser totalmente reentrantes, ou seja, devem ser capazes de aninhar-se dentro de si, já que os manipuladores de sinal podem se sobrepor se um sinal for enviado muitas vezes (ou mesmo com um só sinal com SA_NODEFER). Esta é uma das razões pelas quais você não pode usar funções como printf ou malloc em um manipulador de sinal, porque eles dependem de exclusões mútuas globais para a sincronização. Se você estiver segurando esse bloqueio quando o sinal for recebido e então chamar uma função exigindo esse bloqueio novamente, o seu aplicativo acabará em deadlock. Isso é muito, muito difícil de entender. É por isso que, ao processar sinais, muitas pessoas simplesmente escrevem algo assim:

static volatile sig_atomic_t received_sighup; 

static void sighup(int sig __attribute__((unused))) { received_sighup = 1; }

static int configure_signal_handlers(void) {
  return sigaction(
    SIGHUP,
    &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART},
    NULL);
}

int main(int argc, char *argv[]) {
  if (configure_signal_handlers()) {
       /* failed to set handlers */
  }

  /* usual program flow */

  if (received_sighup) {
    /* reload */
    received_sighup = 0;
  }

  /* usual program flow */
}

O problema é que, enquanto o signalfd ou outras tentativas de manipulação de sinal assíncrona podem parecer bastante simples e robustas, elas ignoram o fato de que o ponto de interrupção é tão importante quanto as ações executadas após o recebimento do sinal. Por exemplo, vamos supor que o código do espaço do usuário esteja fazendo E/S ou mudando os metadados de objetos que vêm do kernel (como inodes ou FDs). Neste caso, você provavelmente estará em uma pilha de espaço do kernel no momento da interrupção. Por exemplo, um fio de execução terá a seguinte aparência quando estiver tentando fechar um descritor de arquivo:

# cat /proc/2965230/stack
 [<0>] schedule+0x43/0xd0
 [<0>] io_schedule+0x12/0x40
 [<0>] wait_on_page_bit+0x139/0x230
 [<0>] filemap_write_and_wait+0x5a/0x90
 [<0>] filp_close+0x32/0x70
 [<0>] __x64_sys_close+0x1e/0x50
 [<0>] do_syscall_64+0x4e/0x140
 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

Aqui, __x64_sys_close é a variante x86_64 da chamada do sistema close, que fecha um descritor de arquivo. Nesse ponto da execução, estamos aguardando o armazenamento de fundo ser atualizado (é o que significa wait_on_page_bit). Como o trabalho de E/S geralmente é muito mais lento do que outras operações, o schedule aqui é uma maneira de sugerir voluntariamente ao agendador de CPU do kernel que estamos prestes a executar uma operação de alta latência (como E/S de disco ou rede) e que ele deve considerar encontrar outro processo para agendar em vez do atual por enquanto. Isso é bom porque nos permite sinalizar ao kernel que é uma boa ideia escolher um processo que de fato fará uso da CPU em vez de perder tempo com outro que não pode continuar até o fim da espera por uma resposta de algo que pode demorar um pouco.

Imagine que enviamos um sinal ao processo que estávamos executando. O sinal que enviamos tem um manipulador de espaço do usuário no fio receptor, portanto, voltaremos ao espaço do usuário. Um dos diversos possíveis resultados dessa corrida é o kernel tentar sair do schedule, desenrolar ainda mais a pilha e acabar retornando um erro de ESYSRESTART ou EINTR para o espaço do usuário a fim de indicar que houve uma interrupção. Faltou muito para fechá-lo? Qual é o estado do descritor de arquivo agora?

Agora que retornamos ao espaço do usuário, executaremos o manipulador de sinal. Quando o manipulador de sinal sair, propagaremos o erro para o wrapper close da libc do espaço do usuário que, em teoria, pode fazer algo sobre a situação encontrada. Dizemos "em teoria" porque é muito difícil saber o que fazer com relação a muitas dessas situações com sinais, e muitos serviços em produção não lidam muito bem com casos de borda. Isso pode ser aceitável em aplicativos em que a integridade dos dados não seja muito importante. Entretanto, em aplicativos de produção em que a consistência e a integridade dos dados são importantes, isso apresenta um problema significativo: o kernel não expõe um jeito granular de entender até onde ele foi, o que ele conseguiu fazer e o que não conseguiu, e as ações necessárias para resolver a situação. Pior ainda, se o close retornar EINTR, o estado do descritor de arquivo será não especificado:

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

Não é fácil entender como resolver isso de modo seguro no seu aplicativo. Em geral, lidar com EINTR é complicado mesmo com chamadas do sistema bem comportadas. Há uma série de problemas sutis que compõem grande parte do motivo pelo qual SA_RESTART não é suficiente. Nem todas as chamadas do sistema são reinicializáveis, e esperar que cada um dos desenvolvedores do aplicativo entenda e mitigue as nuances profundas de obter um sinal para cada chamada do sistema em cada site de chamada é pedir para que ocorram interrupções. De man 7 signal:

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

Da mesma forma, usar sigprocmask e esperar que o fluxo de código permaneça estático é arriscado, pois os desenvolvedores têm mais o que fazer do que dedicar tempo para pensar sobre os limites da manipulação de sinais ou sobre como produzir ou preservar códigos corretos de sinais. O mesmo vale para a manipulação de sinais em um fio dedicado com sigwaitinfo, que pode facilmente terminar com GDB e ferramentas similares incapazes de depurar o processo. Fluxos de código com erros sutis ou manipulação incorreta podem resultar em bugs, falhas, corrupções difíceis de depurar, deadlocks e muitos outros problemas que farão você correr para os braços da sua ferramenta de gerenciamento de incidentes favorita.

Alta complexidade em ambientes com fios múltiplos

Se você achou que todo esse papo sobre simultaneidade, reentrância e atomicidade era ruim o suficiente, jogar fios múltiplos nessa mistura complica ainda mais as coisas. Isso é ainda mais importante ao considerar o fato de que muitos aplicativos complexos executam fios separados implicitamente, por exemplo, como parte de jemalloc, GLib ou similares. Algumas dessas bibliotecas até instalam manipuladores de sinais por conta própria, o que abre mais uma caixa de Pandora.

Em geral, man 7 signal diz o seguinte sobre essa questão:

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

Em resumo, "para a maioria dos sinais, o kernel envia o sinal para qualquer fio que não tenha o sinal bloqueado com sigprocmask". SIGSEGV, SIGILL e similares parecem armadilhas e têm o sinal direcionado explicitamente para o fio afetado. Contudo, a despeito do que você pode pensar, a maioria dos sinais não pode ser enviada de modo explícito para um só fio em um grupo de fios, mesmo com tgkill ou pthread_kill.

Isso significa que você não pode mudar de maneira trivial as características gerais de manipulação de sinais assim que tiver um conjunto de fios. Se um serviço necessitar de bloqueio periódico de sinal com sigprocmask no fio principal, você precisará comunicar de alguma forma para os outros fios externos como eles deverão lidar com isso. Caso contrário, o sinal poderá ser engolido por outro fio e nunca mais ser visto. Você pode bloquear sinais em fios subordinados para evitar isso, mas se eles precisarem fazer a própria manipulação de sinais, mesmo para coisas simples como waitpid, isso vai acabar deixando as coisas complexas.

Assim como tudo que foi mencionado aqui, esses não são problemas tecnicamente insuperáveis. Entretanto, seria negligente ignorar o fato de que a complexidade da sincronização necessária para fazer isso funcionar corretamente é onerosa e que prepara o terreno para bugs, confusões e coisas piores.

Falta de definição e comunicação sobre sucesso ou falha

Os sinais são propagados de modo assíncrono no kernel. A chamada do sistema kill retorna assim que o sinal pendente é gravado para o task_struct do fio ou processo em questão. Por isso, não há garantia de entrega em tempo hábil, mesmo se o sinal não estiver bloqueado.

Mesmo que é entrega em tempo hábil do sinal, não há modo de comunicar ao emissor do sinal o status da sua solicitação de ação. Sendo assim, as ações importantes não devem ser entregues por sinais, pois eles só são implementados no modo "execute e esqueça", sem um mecanismo real para reportar o sucesso ou a falha da entrega e as ações subsequentes. Como vimos acima, mesmo sinais aparentemente inócuos podem ser perigosos quando não são configurados no espaço do usuário.

Qualquer pessoa usando o Linux há algum tempo já se deparou com uma situação de querer parar um processo e descobrir que ele não responde mesmo a sinais supostamente sempre fatais como SIGKILL. O problema é que o objetivo do kill(1), ao contrário do que parece, não é parar processos, mas sim colocar uma solicitação na fila do kernel (sem nenhuma indicação de quando ela será atendida) informando que alguém solicitou que uma ação fosse tomada.

O trabalho da chamada do sistema kill é marcar o sinal como pendente nos metadados da tarefa, o que ele faz com êxito, mesmo quando uma tarefa com SIGKILL não é interrompida. No caso específico de SIGKILL, o kernel garante que instruções do modo de usuário não serão mais executadas. No entanto, ainda pode ser necessário executar instruções no modo kernel para concluir ações que, caso contrário, resultariam em corrupção de dados ou de recursos da versão. Por esse motivo, obtemos sucesso mesmo se o estado for D (sono ininterrupto). A auto interrupção não falha a menos que você tenha informado um sinal inválido, não tenha permissão para enviar esse sinal ou se o PID para o qual você solicitou o envio do sinal não exista e, portanto, não seja útil para propagar estados não terminais de modo confiável para os aplicativos.

Considerações finais

  • Sinais são bons para estados terminais processados puramente no kernel sem manipulador no espaço do usuário. No caso de sinais que você gostaria que parassem o seu programa de forma imediata, deixe-os para o kernel. Isso também significa que o kernel poderá encerrar mais cedo o trabalho, liberando os recursos do seu programa mais rapidamente. Já as solicitações de IPC do espaço do usuário teriam que esperar a porção do espaço do usuário iniciar a execução de novo.
  • Um bom jeito de evitar problemas com a manipulação de sinais é não lidar com eles. Contudo, no caso de aplicativos que lidam com processamento de estados que devem fazer algo com relação a coisas como SIGTERM, o ideal é que você use uma API de alto nível como folly::AsyncSignalHandler, em que vários dos problemas já se tornaram mais intuitivos.

  • Evite comunicar solicitações de aplicativos com sinais. Use notificações autogerenciadas (como inotify) ou RPC de espaço do usuário com uma parte dedicada do ciclo de vida do aplicativo para lidar com isso, em vez de depender de interrupções do aplicativo.
  • Sempre que possível, limite o escopo dos sinais a uma subseção do seu programa ou a fios com sigprocmask, reduzindo a quantidade de código que precisa ser examinado regularmente para verificar se o sinal está correto. Tenha em mente que, se os fluxos de código ou as estratégias de fio mudarem, a máscara poderá não ter o efeito que você esperava.
  • Na inicialização do daemon, mascare os sinais do terminal que não são compreendidos uniformemente e poderão ser ressignificados em algum ponto do seu programa para evitar voltar ao comportamento-padrão do kernel. Minha sugestão é esta:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

O comportamento de sinais é extremamente complicado de entender, mesmo em programas bem escritos, e o seu uso incorre em um risco desnecessário para os aplicativos quando há outras alternativas disponíveis. Em geral, não use sinais para se comunicar com a porção do espaço do usuário do seu programa. Em vez disso, faça com que o programa processe os eventos de modo transparente por conta própria (por exemplo, com o inotify). Outra opção é usar comunicações com o espaço do usuário que possam reportar erros para o emissor e sejam enumeráveis e demonstráveis no momento da compilação, como Thrift, gRPC e similares.

Espero que este artigo tenha convencido você de que os sinais, ainda que pareçam ostensivamente simples, são, na realidade, o extremo oposto disso. A estética da simplicidade que promove o seu uso como API para software de espaço do usuário esconde uma série de decisões de design implícitas que não se encaixam na maioria dos casos de uso de produção da era moderna.

Que fique claro: há casos de uso válidos para os sinais. Os sinais são aceitáveis para comunicação básica com o kernel sobre um estado de processo desejado quando não há componente de espaço do usuário, por exemplo, quando um processo deve ser interrompido. Entretanto, é difícil escrever códigos de sinais corretamente na primeira vez quando se espera que os sinais ficarão presos no espaço do usuário.

Eles são atrativos devido a sua padronização, ampla disponibilidade e ausência de dependências, mas trazem consigo uma série de armadilhas que vão virar preocupações à medida que o projeto crescer. Espero que as mitigações e estratégias alternativas deste artigo ajudem você a atingir as suas metas de modo mais seguro, intuitivo e com menos complexidades sutis.

Para saber mais sobre o Meta Open Source, acesse o nosso site, inscreva-se no canal do YouTube ou siga o nosso perfil no Twitter, no Facebook e no LinkedIn.