Torna alle notizie per sviluppatori

Segnali in produzione: pericoli e insidie

In questo post sul blog, Chris Down, un Kernel Engineer di Meta, illustra le insidie relative all'utilizzo dei segnali Linux negli ambienti di produzione Linux e il motivo per cui gli sviluppatori dovrebbero evitare di utilizzare i segnali quando possibile.

Cosa sono i segnali Linux?

Un segnale è un evento generato dai sistemi Linux in risposta ad alcune condizioni. I segnali possono essere inviati dal kernel a un processo, da un processo a un altro oppure da un processo a sé stesso. Un processo può eseguire un'azione al momento della ricezione di un segnale.

I segnali rappresentano una parte fondamentale degli ambienti operativi simil-Unix ed esistono praticamente da sempre. Sono lo scheletro di molti dei componenti principali del sistema operativo (ad esempio, core dumping, gestione del ciclo di vita dei processi ecc.) e, in generale, hanno resistito abbastanza bene in circa cinquanta anni di utilizzo. In quanto tali, quando qualcuno suggerisce che utilizzarli per la comunicazione interprocesso (IPC) è potenzialmente pericoloso, si potrebbe pensare che queste siano le divagazioni di una persona che desidera disperatamente riprogettare elementi già funzionanti. Tuttavia, questo articolo ha lo scopo di dimostrare i casi in cui i segnali sono stati la causa di problemi di produzione e offre alcune potenziali mitigazioni e alternative.

I segnali possono sembrare interessanti in virtù della loro standardizzazione, dell'ampia disponibilità e del fatto che non richiedono alcuna dipendenza aggiuntiva al di fuori di quelle fornite dal sistema operativo. Tuttavia, può risultare difficile utilizzarli in modo sicuro. I segnali fanno un elevato numero di supposizioni che bisogna fare attenzione a convalidare per soddisfare i loro requisiti e, in caso contrario, bisogna stare attenti a configurarli correttamente. In realtà, molte applicazioni, anche quelle molto conosciute, non eseguono tali operazioni e di conseguenza potrebbero generare in futuro incidenti per cui sarà difficile effettuare il debug.

Analizziamo un incidente recente che si è verificato nell'ambiente di produzione Meta, che evidenzia nuovamente le insidie relative all'utilizzo dei segnali. Esamineremo brevemente la cronologia di alcuni segnali e del modo in cui ci hanno guidato verso la situazione odierna, e poi confronteremo tali informazioni con le nostre attuali esigenze e problemi osservati nell'ambito della produzione.

Descrizione dell'incidente

Innanzitutto, facciamo qualche passo indietro. Il team LogDevice ha ripulito il codebase, rimuovendo le funzioni e il codice inutilizzati. Una delle funzioni che è stata dichiarata obsoleta consisteva in un tipo di registro che documentava determinate operazioni eseguite dal servizio. Questa funzione è diventata ridondante, non aveva clienti e in quanto tale è stata rimossa. La modifica è visibile qui su GitHub. Fino a qui, nessun problema.

Non essendosi presentato alcun problema particolare dopo aver apportato la modifica, la produzione ha continuato a lavorare in modo regolare e ad assicurare il traffico come al solito. Qualche settimana dopo, è stato ricevuto un rapporto secondo cui i nodi di servizio venivano persi a un ritmo sbalorditivo. Si trattava di una questione relativa al lancio della nuova uscita, ma non era chiaro quale fosse esattamente il problema. Che cosa è cambiato e ha determinato tale situazione problematica?

Il team in questione ha ristretto il problema alla modifica del codice citata in precedenza, che considerava obsoleti questi registri. Ma qual è il motivo? Qual è il problema del codice? Se non conosci già la risposta, ti invitiamo a osservare la modifica e a cercare di capire il problema perché non è immediatamente evidente e si tratta di un errore che potrebbe essere commesso da chiunque.

logrotate e la salita sul ring

logrotate è lo strumento standard per la rotazione dei registri durante l'utilizzo di Linux. È in circolazione da quasi trent'anni e il suo concetto di base è semplice: gestire il ciclo di vita dei registri ruotandoli ed eliminandoli.

logrotate non invia segnali, infatti troverai informazioni ridotte (se non del tutto assenti) in relazione ai segnali nella pagina principale di logrotate o nella relativa documentazione. Tuttavia, logrotate può determinare l'esecuzione di comandi arbitrari prima o dopo le rotazioni. Come esempio semplice di configurazione predefinita di logrotate in CentOS, osserva questa configurazione:

/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
}

L'esempio appare poco solido, però presupponiamo che funzioni come previsto. Sulla base di questa configurazione, dopo la rotazione dei file elencati da parte di logrotate, SIGHUP deve essere inviato al pid contenuto in /var/run/syslogd.pid, che deve corrispondere a quello dell'istanza syslogd in esecuzione.

Questo processo funziona perfettamente con un'API pubblica e stabile come syslog, ma se si trattasse di un elemento interno in cui l'implementazione di SIGHUP è un dettaglio di implementazione interna che potrebbe essere modificato in qualsiasi momento?

Una storia di intoppi

Uno dei problemi è che, ad eccezione dei segnali che non possono essere acquisiti nello spazio utente e quindi dispongono di un unico significato, come SIGKILL e SIGSTOP, l'interpretazione del significato semantico dei segnali e la conseguente programmazione spetta agli sviluppatori di applicazioni e agli utenti. In alcuni casi, la distinzione è prevalentemente accademica, come SIGTERM, che è sostanzialmente inteso a livello universale come "chiudere in modo discreto il prima possibile". Tuttavia, nel caso di SIGHUP, il significato è molto meno chiaro.

SIGHUP è stato inventato per le linee seriali e in origine è stato utilizzato per indicare l'interruzione della linea da parte dell'altra estremità della connessione. Oggi questa eredità ci accompagna e SIGHUP viene inviato ancora nella forma equivalente moderna, cioè nel caso in cui viene chiuso un terminale virtuale o pseudoterminale (da cui strumenti come nohup che lo mascherano).

Agli albori di Unix, era necessaria l'implementazione del ricaricamento dei demoni. Di solito, questa operazione è composta almeno dalla riapertura del file di registro/di configurazione senza il riavvio e i segnali sembravano una modalità priva di dipendenze per ottenere tale risultato. Non esisteva alcun segnale per un'operazione del genere, ma poiché questi demoni non dispongono un terminale di controllo, non dovrebbe sussistere alcun motivo per la ricezione di SIGHUP, quindi sembrava un segnale utile da reindirizzare senza effetti collaterali evidenti.

Tuttavia, in questo piano è presente un piccolo intoppo. Lo stato predefinito per i segnali non è "ignorato", ma specifico per il segnale. Quindi, ad esempio, i programmi non devono configurare manualmente SIGTERM per chiudere la propria applicazione. Finché non impostano un altro gestore dei segnali, il kernel chiude il loro programma gratuitamente, senza bisogno di codice nello spazio utente. È una soluzione pratica!

Tuttavia, ciò che non è pratico è che anche per SIGHUP il comportamento predefinito è quello di chiudere immediatamente il programma. Funziona perfettamente per il caso di intoppo originale, in cui queste applicazioni probabilmente non sono più necessarie, ma non così bene per questo nuovo significato.

Tale situazione funzionerebbe bene se rimuovessimo tutte le posizioni che potrebbero inviare SIGHUP al programma. Il problema è che in qualsiasi codebase di grandi dimensioni e maturo, è difficile realizzare tale situazione. SIGHUP non è come una chiamata IPC strettamente controllata per la quale puoi facilmente eseguire il grep del codebase. I segnali possono provenire da qualsiasi luogo, in qualsiasi momento ed esistono pochi controlli sul loro funzionamento (a parte il più elementare "o sei questo utente o ricevi CAP_KILL"). La conclusione è che è difficile determinare il luogo da cui potrebbero provenire i segnali, ma con una IPC più esplicita sapremmo che questo segnale non ha alcun significato per noi e che dovrebbe essere ignorato.

Dall'intoppo al pericolo

A questo punto, suppongo che tu abbia iniziato a ipotizzare cosa è successo. Un fatidico pomeriggio è stata rilasciata la versione di LogDevice che conteneva la suddetta modifica del codice. Inizialmente tutto funzionava correttamente, ma a mezzanotte del giorno successivo, il problema iniziò misteriosamente a palesarsi. Il motivo è rappresentato dalla seguente stanza nella configurazione di logrotate della macchina, che invia un SIGHUP ora non gestito (e quindi fatale) al demone di LogDevice:

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

Dimenticare solo una breve stanza di una configurazione di logrotate è incredibilmente facile e comune durante la rimozione di una funzione di grandi dimensioni. Purtroppo, è anche difficile contemporaneamente essere certi della rimozione di ogni ultima traccia della sua esistenza. Anche nei casi in cui la convalida è più semplice rispetto a questo caso, è comune lasciare erroneamente delle parti rimanenti durante la pulizia del codice. Tuttavia, di solito, tale azione è priva di conseguenze dannose, ovvero i detriti rimanenti sono solo codici morti o non operativi.

Idealmente, l'incidente stesso e la relativa risoluzione sono semplici: non inviare SIGHUP e diffondere le azioni LogDevice in un arco di tempo maggiore (ovvero, non procedere con l'esecuzione a mezzanotte in punto). Tuttavia, non sono solo le sfumature di questo incidente su cui dovremmo concentrarci. Questo incidente, più di ogni altro aspetto, deve servire da piattaforma per dissuadere l'utilizzo dei segnali nell'ambito della produzione per azioni diverse dai casi più elementari ed essenziali.

Pericoli dei segnali

Aspetti positivi dei segnali

In primo luogo, l'utilizzo dei segnali come meccanismo per influenzare le modifiche a livello di stato del processo del sistema operativo è ben fondato. In tale ambito, sono inclusi segnali come SIGKILL, per cui è impossibile installare un gestore di segnali e compie esattamente le azioni che ti aspetteresti e il comportamento predefinito del kernel di SIGABRT, SIGTERM, SIGINT, SIGSEGV e SIGQUIT e simili, che sono generalmente ben compresi da utenti e programmatori.

L'elemento comune di tutti questi segnali è che dopo la loro ricezione, avanzano tutti verso uno stato finale del terminale all'interno del kernel stesso. Vale a dire che non verranno più eseguite istruzioni per lo spazio utente dopo aver ottenuto un segnale SIGKILL o SIGTERM senza un gestore di segnali.

Uno stato finale del terminale è importante perché di solito significa che lavori per la riduzione della complessità dello stack e del codice attualmente in esecuzione. Altri stati desiderati spesso fanno sì che la complessità diventi effettivamente più elevata e più difficile da decodificare man mano che la simultaneità e il flusso di codice diventano più confusi.

Comportamento predefinito pericoloso

Potresti notare che non abbiamo menzionato altri segnali che eseguono anch'essi la chiusura per impostazione predefinita. Ecco una lista di tutti i segnali standard che eseguono la chiusura per impostazione predefinita (esclusi i segnali core dump come SIGABRT o SIGSEGV poiché sono tutti logici):

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

A prima vista, possono sembrare sensati, ma ecco alcune anomalie:

  • SIGHUP: se fosse stato utilizzato solo come era stato previsto in origine, sarebbe ragionevole eseguire la chiusura come impostazione predefinita. Invece, con l'attuale utilizzo misto che significa "riaprire i file", è diventato pericoloso.
  • SIGPOLL e SIGPROF: questi segnali sono presenti nel calderone di "questi segnali dovrebbero essere gestiti internamente da alcune funzioni standard piuttosto che dal tuo programma". Tuttavia, sebbene probabilmente sia non pericoloso, il comportamento predefinito di chiusura sembra ancora non ideale.
  • SIGUSR1 e SIGUSR2: questi sono "segnali definiti dall'utente" che puoi apparentemente utilizzare come preferisci. Ma poiché sono terminali per impostazione predefinita, se implementi USR1 per qualche esigenza specifica e in seguito non ne hai bisogno, non puoi semplicemente rimuovere il codice in modo sicuro. Devi pensare consapevolmente di ignorare in modo esplicito il segnale. Tuttavia, tale operazione non sarà ovvia neppure per un programmatore esperto.

Quindi, quasi un terzo dei segnali dei terminali è nella migliore delle ipotesi discutibile e, nella peggiore, attivamente pericoloso in caso di modifica delle esigenze di un programma. E c'è di peggio: anche per i segnali teoricamente "definiti dall'utente" potrebbero determinare un disastro se si dimentica di eseguire il SIG_IGN in modo esplicito. Gli incidenti possono essere determinati anche dagli innocui segnali SIGUSR1 o SIGPOLL.

Non si tratta semplicemente di una questione di familiarità. Non importa quanto tu conosca in modo approfondito la modalità di funzionamento dei segnali, è comunque estremamente difficile scrivere il codice corretto per i segnali la prima volta perché, nonostante il loro aspetto, i segnali sono molto più complessi di quanto possano apparire.

Flusso del codice, simultaneità e la leggenda di SA_RESTART

In genere, i programmatori non dedicano tutta la giornata a pensare al funzionamento interno dei segnali. Ciò significa che, quando si passa alla fase di implementazione effettiva della gestione dei segnali, spesso eseguono sottilmente l'azione errata.

Non sto parlando dei casi "banali", come la sicurezza in una funzione di gestione dei segnali, che viene per lo più risolta solo lanciando un sig_atomic_t o utilizzando l'atomic signal fence di C++. Probabilmente questa sarà considerata come l'insidia più ricercabile e indimenticabile da parte di chiunque abbia attraversato per la prima volta la bolgia infernale dei segnali. La cosa molto più difficile è ragionare sul flusso di codice delle porzioni nominali di un programma complesso durante la ricezione di un segnale. Per eseguire tale azione è necessario pensare in modo costante ed esplicito ai segnali in ogni parte del ciclo di vita dell'applicazione (ad esempio, nell'ambito di EINTR, è sufficiente SA_RESTART in questo contesto? In quale flusso dovremmo entrare se questo segnale esegue la chiusura in anticipo? Ora ho un programma simultaneo, quali sono le implicazioni di tale situazione?), oppure configurare un sigprocmask o pthread_setmask per una parte del ciclo di vita dell'applicazione e sperare che il flusso del codice non cambi mai (e quest'ultima non è una buona supposizione in un'atmosfera di sviluppo dinamico). signalfd o sigwaitinfo in un thread dedicato possono essere utili in questa situazione, ma entrambi hanno casi limite e problemi di usabilità sufficienti da renderli difficili da consigliare.

Vogliamo credere che i programmatori più esperti sappiano ormai che è molto difficile scrivere correttamente anche un esempio faceto di codice thread-safe. Tuttavia, se pensavi che scrivere correttamente il codice thread-safe fosse difficile, i segnali sono significativamente più difficili. I gestori di segnali devono fare affidamento solo su codice rigorosamente privo di blocco con strutture di dati atomic, rispettivamente, perché il flusso principale di esecuzione viene sospeso e non sappiamo quali blocchi contiene e perché il flusso principale di esecuzione potrebbe essere in fase di esecuzione di operazioni non di tipo atomic. Devono anche essere completamente rientranti, ovvero devono essere in grado di nidificare al loro interno poiché i gestori di segnali possono sovrapporsi se un segnale viene inviato più volte (o anche con un singolo segnale, con SA_NODEFER). Questo è uno dei motivi per cui non puoi usare funzioni come printf o malloc in un gestore di segnali, perché fanno affidamento su mutex globali per la sincronizzazione. Se stavi tenendo quel blocco quando il segnale è stato ricevuto e poi hai chiamato una funzione che richiede di nuovo quel determinato blocco, per la tua applicazione si verificherebbe un deadlock. Si tratta di un ragionamento estremamente difficile. Ecco perché molte persone come gestione dei segnali scrivono semplicemente questo:

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 */
}

Il problema è che, mentre questo, signalfd, o altri tentativi di gestione dei segnali asincroni possono sembrare abbastanza semplici e solidi, ignorano il fatto che il punto di interruzione è importante quanto le azioni eseguite dopo aver ricevuto il segnale. Ad esempio, supponiamo che il codice dello spazio utente stia eseguendo I/O o modificando i metadati di oggetti che provengono dal kernel (come inode o FD). In questo caso, probabilmente ti trovi in ​​uno stack di spazio del kernel al momento dell'interruzione. Ad esempio, ecco come potrebbe apparire un thread quando tenta di chiudere un descrittore di file:

# 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

Qui __x64_sys_close è la variante x86_64 della chiamata di sistema close, che chiude un descrittore di file. A questo punto della sua esecuzione, stiamo aspettando che la memoria di backup venga aggiornata (questo è wait_on_page_bit). Poiché il lavoro di I/O è solitamente di diversi ordini di grandezza più lento rispetto ad altre operazioni, schedule qui è un modo per suggerire volontariamente allo scheduler della CPU del kernel che stiamo per eseguire un'operazione ad alta latenza (come I/O di disco o di rete) e che per ora dovrebbe prendere in considerazione la possibilità di trovare un altro processo da pianificare invece del processo corrente. Questo è positivo, in quanto ci consente di segnalare al kernel che è una buona idea andare avanti e scegliere un processo che utilizzerà effettivamente la CPU invece di perdere tempo con uno che non può continuare finché non ha finito di aspettare una risposta per la cui ricezione potrebbe essere necessario del tempo.

Immagina se inviassimo un segnale al processo che stavamo eseguendo. Il segnale che abbiamo inviato ha un gestore dello spazio utente nel thread di ricezione, quindi riprenderemo nello spazio utente. Uno dei tanti modi in cui questa corsa può finire è che il kernel cercherà di uscire da schedule, continuerà con l'unwinding dello stack e alla fine restituirà un errore di ESYSRESTART o EINTR nello spazio utente per indicare che siamo stati interrotti. Ma fino a che punto siamo arrivati ​​con la chiusura? Qual è lo stato attuale del descrittore di file?

Ora che siamo tornati allo spazio utente, eseguiremo il gestore di segnali. Quando il gestore di segnali si chiuderà, propagheremo l'errore al wrapper close della libc dello spazio utente, e quindi all'applicazione, che, in teoria, può fare qualcosa per la situazione incontrata. Diciamo "in teoria" perché è davvero difficile sapere cosa fare in molte di queste situazioni relative ai segnali e molti servizi in produzione non gestiscono molto bene i casi limite. Potrebbe andare bene in alcune applicazioni in cui l'integrità dei dati non è così importante. Tuttavia, nelle applicazioni di produzione per le quali la coerenza e l'integrità dei dati sono importanti, questo presenta un problema significativo: il kernel non espone alcun modo granulare per capire fino a che punto è arrivato, cosa ha ottenuto e cosa no e cosa dovremmo effettivamente fare riguardo alla situazione. Ancora peggio, se close viene restituito con EINTR, lo stato del descrittore di file ora non è specificato:

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

Ti auguriamo buona fortuna mentre provi a ragionare su come gestire la situazione in modo sicuro e protetto nella tua applicazione. In generale, la gestione di EINTR è complicata anche in syscall che si "comportano bene". Ci sono molti problemi velati che costituiscono gran parte del motivo per cui SA_RESTART non è sufficiente. Non tutte le chiamate di sistema si possono riavviare e aspettarsi che ogni singolo sviluppatore dell'applicazione conosca e mitighi le sfumature profonde legate all'ottenimento di un segnale per ogni singola syscall in ogni singolo sito di chiamata significa dare per scontato delle interruzioni. Da 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 [...]”

Allo stesso modo, usare una sigprocmask e aspettarsi che il flusso del codice rimanga statico è fonte di problemi poiché gli sviluppatori in genere non passano la vita a pensare ai limiti della gestione dei segnali o a come produrre o preservare codice corretto per i segnali. Lo stesso vale per la gestione dei segnali in un thread dedicato con sigwaitinfo, che può facilmente finire con GDB e strumenti simili non in grado di eseguire il debug del processo. Flussi di codice o gestione degli errori lievemente errati possono causare bug, arresti anomali, danneggiamenti per i quali risulta difficile eseguire il debug, deadlock e molti altri problemi che ti faranno correre direttamente tra le calorose braccia del tuo strumento di gestione degli incidenti preferito.

Elevata complessità in ambienti multithread

Se pensavi che tutto questo parlare di simultaneità, rientro e atomicità fosse già sufficientemente problematico, inserire il multithreading nel mix rende le cose ancora più complicate. Ciò è particolarmente importante se si considera il fatto che molte applicazioni complesse eseguono thread separati in modo implicito, ad esempio come parte di jemalloc, GLib o simili. Alcune di queste librerie installano anche direttamente gestori di segnali, aprendo un altro vaso di Pandora.

Nel complesso, ecco cosa ha da dire man 7 signal sulla questione:

“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.”

Più sinteticamente, "per la maggior parte dei segnali, il kernel invia il segnale a qualsiasi thread che non ha quel segnale bloccato con sigprocmask". SIGSEGV, SIGILL e simili assomigliano a trap e fanno in modo che il segnale sia esplicitamente diretto al thread incriminato. Tuttavia, nonostante ciò che si potrebbe pensare, la maggior parte dei segnali non può essere inviata esplicitamente a un singolo thread in un gruppo di thread, anche con tgkill o pthread_kill.

Questo significa che non è possibile modificare banalmente le caratteristiche generali di gestione del segnale non appena si dispone di una serie di thread. Se un servizio deve eseguire un blocco periodico del segnale con sigprocmask nel thread principale, devi comunicare in qualche modo ad altri thread esternamente come dovrebbero gestirlo. In caso contrario, il segnale potrebbe essere inghiottito da un altro thread e sparire per sempre. Ovviamente puoi bloccare i segnali nei thread secondari per evitare tale situazione, ma se anche questi devono gestire i segnali, anche per cose primitive come waitpid, la situazione si complicherebbe.

Proprio come per tutto il resto, questi non sono problemi tecnicamente insormontabili. Tuttavia, si sarebbe negligenti nell'ignorare il fatto che la complessità della sincronizzazione richiesta per fare in modo che tutto funzioni correttamente è gravosa e pone le basi per bug e confusione, se non peggio.

Mancanza di definizione e comunicazione di successo o fallimento

I segnali vengono propagati in modo asincrono nel kernel. La syscall kill viene restituita non appena il segnale in sospeso viene registrato per il processo o la task_struct del thread in questione. Pertanto, non c'è garanzia di consegna tempestiva, anche se il segnale non è bloccato.

Anche se avviene una consegna tempestiva del segnale, non c'è modo di comunicare all'emittente del segnale quale sia lo stato della sua richiesta di azione. Per questo motivo, qualsiasi azione significativa non dovrebbe essere consegnata dai segnali, poiché questi implementano solo il "fire-and-forget" senza alcun meccanismo reale per segnalare il successo o il fallimento della consegna e le azioni successive. Come abbiamo visto sopra, anche i segnali apparentemente innocui possono essere pericolosi quando non sono configurati nello spazio utente.

Chiunque utilizzi Linux da un tempo sufficientemente lungo si sarà indubbiamente imbattuto in un caso in cui vorrebbe terminare un processo, ma scopre che il processo non risponde anche a segnali presumibilmente sempre fatali come SIGKILL. Il problema è che, in modo fuorviante, lo scopo di kill(1) non è quello di terminare i processi, ma solo di mettere in coda una richiesta al kernel (senza alcuna indicazione su quando verrà gestita) indicante che qualcuno ha richiesto di intraprendere una determinata azione.

Il compito della syscall kill è contrassegnare il segnale come in sospeso nei metadati dell'attività del kernel, cosa che fa con successo anche quando un'attività SIGKILL non si interrompe. Nel caso di SIGKILL in particolare, il kernel garantisce che non verranno più eseguite istruzioni in modalità utente, ma potremmo comunque dover eseguire istruzioni in modalità kernel per completare azioni che altrimenti potrebbero causare il danneggiamento dei dati o il rilascio di risorse. Per questo motivo, l'operazione ha successo anche se lo stato è D (sospensione ininterrotta). Kill non fallisce a meno che non sia stato fornito un segnale non valido, non manchi l'autorizzazione per inviare il necessario segnale o il pid a cui è stato richiesto di inviare un segnale non esista, pertanto non è utile per propagare in modo affidabile stati non terminali alle applicazioni.

Conclusione

  • I segnali possono essere utilizzati per lo stato del terminale gestito esclusivamente all'interno del kernel senza alcun gestore dello spazio utente. Per i segnali che vorresti terminassero immediatamente il tuo programma, lascia che vengano gestiti dal kernel. Ciò significa anche che il kernel potrebbe essere in grado di uscire prima dal suo lavoro, liberando le risorse del programma più rapidamente, mentre una richiesta IPC di spazio utente dovrebbe attendere che la parte di spazio utente ricominci l'esecuzione.
  • Un modo per evitare di avere problemi nella gestione dei segnali è di non gestirli affatto. Per le applicazioni che gestiscono l'elaborazione dello stato e devono eseguire azioni per casi come SIGTERM, invece, è idealmente consigliabile utilizzare un'API di alto livello come folly::AsyncSignalHandler, in cui un certo numero di problematiche è già stato reso più intuitivo.

  • Evita di comunicare le richieste dell'applicazione con i segnali. Per la gestione, utilizza le notifiche autogestite (come inotify) o l'RCP dello spazio utente con una parte dedicata del ciclo di vita dell'applicazione invece di fare affidamento sull'interruzione dell'applicazione.
  • Ove possibile, limita la portata dei segnali a una sottosezione del programma o dei thread con sigprocmask, riducendo la quantità di codice che deve essere controllato regolarmente per accertarne la correttezza per i segnali. Tieni presente che se i flussi di codice o le strategie di threading cambiano, la maschera potrebbe non avere l'effetto desiderato.
  • All'avvio del demone, è consigliabile mascherare i segnali del terminale che non vengono interpretati in modo uniforme e potrebbero essere riutilizzati a un certo punto del programma, per evitare di tornare al comportamento predefinito del kernel. Io suggerisco questo:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

È estremamente complicato ragionare sul comportamento dei segnali anche in programmi ben creati e l'utilizzo dei segnali rappresenta un rischio inutile nelle applicazioni in cui sono disponibili alternative. In generale, non utilizzare segnali per comunicare con la parte dello spazio utente del programma. Invece, fai in modo che il programma gestisca gli eventi in modo trasparente (ad esempio, con inotify) o utilizza la comunicazione dello spazio utente che può segnalare errori all'emittente ed è enumerabile e dimostrabile in fase di compilazione, come Thrift, gRPC o simili.

Spero che questo articolo ti abbia mostrato che i segnali, anche se apparentemente possono sembrare semplici, in realtà non lo sono affatto. L'estetica della semplicità che promuove il loro utilizzo come API per il software dello spazio utente cela una serie di decisioni di progettazione implicite che non si adatta alla maggior parte dei casi d'uso di produzione nell'era moderna.

Sia chiaro: ci sono casi d'uso validi per i segnali. I segnali vanno bene per la comunicazione di base con il kernel su uno stato di processo desiderato quando non c'è alcun componente dello spazio utente, ad esempio, per indicare che un processo dovrebbe essere terminato. Tuttavia, è difficile scrivere codice corretto per i segnali al primo tentativo quando si prevede che i segnali vengano intrappolati nello spazio utente.

I segnali possono sembrare interessanti a causa della loro standardizzazione, dell'ampia disponibilità e della mancanza di dipendenze, ma presentano un numero significativo di insidie ​​che aumenteranno solo la preoccupazione man mano che il tuo progetto cresce. Spero che questo articolo ti abbia indicato alcune mitigazioni e strategie alternative che ti consentiranno di raggiungere comunque i tuoi obiettivi, ma in un modo più sicuro, meno complesso e più intuitivo.

Per maggiori informazioni su Meta Open Source, visita il nostro sito open source, iscriviti al nostro canale YouTube o seguici su Twitter, Facebook e LinkedIn.