Volver a las novedades para desarrolladores

Señales en producción: peligros y dificultades

27 de septiembre de 2022DeChris Down

En esta publicación en el blog, Chris Down, un ingeniero de kernel de Meta, aborda las dificultades que conlleva usar las señales de Linux en los entornos de producción de Linux y por qué los desarrolladores deberían evitar usarlas en la medida de lo posible.

¿Qué son las señales de Linux?

Una señal es un evento que los sistemas Linux generan en respuesta a alguna condición. Las señales puede enviarlas el kernel a un proceso, un proceso a otro proceso o un proceso a sí mismo. Cuando un proceso recibe una señal, puede tomar medidas.

Las señales son una parte fundamental de los entornos operativos de tipo Unix y existen más o menos desde el principio. Son las tuberías de muchos de los componentes fundamentales del sistema operativo (volcado de memoria, administración del ciclo de vida de los procesos, etc.) y, en general, han soportado bastante bien los cerca de 50 años que llevamos utilizándolas. Por lo tanto, cuando alguien sugiere que utilizarlas para la comunicación entre procesos (IPC) es potencialmente peligroso, podríamos pensar que se trata de las divagaciones de alguien desesperado por inventar la rueda. Sin embargo, con este artículo se pretende mostrar casos en los que las señales han sido la causa de problemas de producción y ofrecer varias alternativas y mitigaciones potenciales.

Las señales pueden parecer tentadoras por su estandarización, amplia disponibilidad y el hecho de que no requieren dependencias adicionales al margen de lo que proporciona el sistema operativo. No obstante, usarlas de forma segura puede ser difícil. Las señales hacen un gran número de suposiciones que debemos validar con cuidado para que coincidan con sus requisitos, y si no, debemos tener cuidado para configurarlas correctamente. En realidad, muchas aplicaciones, incluso algunas muy conocidas, no siguen este proceso y pueden enfrentarse a incidentes difíciles de depurar en el futuro a causa de ello.

Centrémonos en un incidente reciente que se produjo en el entorno de producción de Meta y que reafirma las dificultades que conlleva el uso de las señales. Trataremos brevemente la historia de algunas señales y explicaremos cómo nos han llevado al lugar en el que nos encontramos en la actualidad y, a continuación, compararemos esa información con nuestras necesidades y problemas actuales que percibimos en producción.

El incidente

En primer lugar, retrocedamos un poco. El equipo de LogDevice limpió su código base y eliminó las funciones y el código que no se utilizaban. Una de las funciones que se retiró fue un tipo de registro que documenta determinadas operaciones que realiza el servicio. Con el tiempo, esta función se volvió redundante y no tenía consumidores, por lo que se eliminó. Puedes consultar el cambio aquí en GitHub. Por ahora todo bien.

Tras el cambio, en un primer momento no sucedió nada destacable: la producción continuó su curso de forma estable y atendiendo el tráfico como de costumbre. Unas semanas más tarde, recibimos un informe en el que se indicaba que los nodos de servicio se estaban perdiendo a una velocidad alarmante. Tenía algo que ver con la implementación del nuevo lanzamiento, pero no teníamos claro cuál era exactamente el problema. ¿Qué aspectos habían cambiado y estaban provocando un problema?

El equipo en cuestión limitó el problema al cambio de código que mencionamos anteriormente, con el que se habían retirado estos registros. Pero ¿cuál era el motivo? ¿Qué problema había con ese código? Si todavía no sabes la respuesta, te invitamos a que mires la diferencia e intentes averiguar cuál es el problema, ya que no resulta inmediatamente obvio y es un error que cualquiera podría cometer.

logrotate aparece en escena

logrotate es más o menos la herramienta estándar de rotación de registros al usar Linux. Existe desde hace casi 30 años y el concepto es sencillo: administrar el ciclo de vida de los registros rotándolos y limpiándolos.

logrotate no envía ninguna señal por sí mismo, por lo que encontrarás muy poca información, o incluso ninguna, sobre el tema de las señales en la página principal o la documentación de logrotate. No obstante, logrotate puede seleccionar comandos arbitrarios para ejecutarlos antes o después de sus rotaciones. Como ejemplo básico de la configuración predeterminada de logrotate en CentOS, puedes consultar esta configuración:

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

Es un poco frágil, pero lo pasaremos por alto y supondremos que funciona según lo previsto. Esta configuración indica que, después de que logrotate rote alguno de los archivos señalados, debe enviar SIGHUP al PID incluido en /var/run/syslogd.pid, que debe ser el de la instancia de syslogd en ejecución.

Esto es perfecto para algo con una API pública estable como syslog, pero ¿qué sucedería con algo interno donde la implementación de SIGHUP es un detalle de implementación interna que podría cambiar en cualquier momento?

Una historia de detenciones

Uno de los problemas que surge en este punto es que, excepto en el caso de las señales que no se pueden capturar en el espacio del usuario y que por lo tanto solo tienen un significado (como SIGKILL y SIGSTOP), el significado semántico de las señales depende de cómo lo interpreten y programen los usuarios y los desarrolladores de aplicaciones. En algunos casos, la distinción es en gran parte académica, como SIGTERM, que se entiende casi universalmente como “terminar sin problemas lo antes posible”. Sin embargo, en el caso de SIGHUP, el significado es considerablemente menos claro.

SIGHUP se inventó para las líneas en serie y se utilizó inicialmente para indicar que el otro extremo de la conexión había abandonado la línea. En la actualidad, seguimos llevando nuestro linaje con nosotros, de modo que se sigue enviando SIGHUP para su equivalente moderno: donde está cerrado un terminal virtual o un seudoterminal (por eso se utilizan herramientas como nohup, que lo enmascaran).

En los comienzos de Unix, era necesario implementar la recarga del demonio. Esto suele consistir en al menos la reapertura de un archivo de registros o configuración sin reinicio, y las señales parecían una forma libre de dependencias de conseguirlo. Naturalmente no existía ninguna señal para algo así, pero como estos demonios no tienen terminal de control, no debería haber ningún motivo por el cual recibir SIGHUP, por lo que resultaba una señal adecuada de la que aprovecharse sin ningún efecto secundario evidente.

No obstante, hay una pequeña complicación con este plan. El estado predeterminado de las señales no se “ignora”, sino que es específico de las señales. De esta forma, por ejemplo, los programas no tienen que configurar SIGTERM manualmente para terminar su aplicación. Siempre y cuando no establezcan ningún otro controlador de señales, el kernel termina su programa de forma gratuita, sin necesidad de ningún código en el espacio del usuario. Resulta un método práctico.

En cambio, lo que no es tan práctico es que SIGHUP también presenta el comportamiento predeterminado de terminar el programa de inmediato. Esto funciona muy bien para el caso original de detención, en el que estas aplicaciones probablemente ya no sean necesarias, pero no va tan bien para este nuevo significado.

Evidentemente estaría bien si eliminásemos todos los lugares que SIGHUP podría enviar potencialmente al programa. El problema es que esto es difícil en cualquier código base extenso y maduro. SIGHUP no es como una llamada de IPC estrictamente controlada para la que puedas ejecutar el comando grep con facilidad en el código base. Las señales pueden llegar de cualquier lugar y en cualquier momento, y hay pocas comprobaciones sobre su funcionamiento (además de las más básicas “eres este usuario o tienes CAP_KILL”). La conclusión es que resulta difícil determinar de dónde pueden proceder las señales, pero con una comunicación entre procesos más explícita, sabríamos que esta señal no significa nada para nosotros y que se debería ignorar.

De la detención al riesgo

A estas alturas, supongo que habrás empezado a suponer lo que ha pasado. Un lanzamiento de LogDevice empezó una tarde fatídica con el cambio del código indicado anteriormente. Al principio, todo salió bien; pero el día siguiente, a medianoche, todo comenzó a fallar misteriosamente. El motivo es la siguiente estrofa de la configuración de logrotate de la máquina, que envía una señal SIGHUP ahora no controlada (y por lo tanto grave) al demonio de LogDevice:

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

Al eliminar una función extensa, es muy fácil y habitual pasar por alto una breve estrofa de una configuración de logrotate. Por desgracia, también es complicado asegurarse de se haya eliminado de una vez hasta el último vestigio de su existencia. Incluso en casos que son más fáciles de validar que este, es habitual dejar remanentes por error al limpiar el código. Sin embargo, normalmente esto no tiene consecuencias destructivas, es decir, el residuo restante es código no operativo o inactivo.

Teóricamente, el propio incidente y su resolución son simples: no enviar SIGHUP y extender las acciones de LogDevice más a lo largo del tiempo (es decir, no llevar a cabo la ejecución justo a medianoche). No obstante, no se trata solo de centrarnos en los matices de este incidente. Este incidente, ante todo, tiene que servir como una base para desincentivar el uso de las señales en producción en casos diferentes a los más básicos y esenciales.

Los peligros de las señales

¿Para qué son buenas las señales?

En primer lugar, el uso de las señales como un mecanismo para generar cambios en el estado de los procesos del sistema operativo está bien fundamentado. Se incluyen señales como SIGKILL, para las que es imposible instalar un controlador de señales y hacen exactamente lo previsto, y el comportamiento predeterminado del kernel de SIGABRT, SIGTERM, SIGINT, SIGSEGV y SIGQUIT y similares, que suelen entender bien los usuarios y los programadores.

Todas estas señales tienen en común que, una vez recibidas, avanzan hacia un estado final terminal dentro del propio kernel. Es decir, no se ejecutarán más instrucciones del espacio del usuario cuando recibas SIGKILL o SIGTERM sin ningún controlador de señales del espacio del usuario.

Un estado final terminal es importante porque normalmente implica que trabajas para reducir la complejidad de la pila y el código en ejecución en ese momento. Otros estados deseados a menudo provocan que la complejidad realmente sea mayor y más difícil de comprender, porque la simultaneidad y el flujo de código se vuelven más confusos.

Comportamiento predeterminado peligroso

Es posible que te hayas dado cuenta de que no mencionamos algunas señales diferentes que también terminan de manera predeterminada. A continuación, se incluye una lista de todas las señales estándar que terminan de manera predeterminada (se excluyen las señales de volcado de memoria como SIGABRT o SIGSEGV, ya que son todas razonables):

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

A primera vista, pueden parecer razonables, pero a continuación se señalan algunos casos atípicos:

  • SIGHUP: si esta señal solo se usara con la intención original, sería razonable que el comportamiento predeterminado fuese la terminación. Con el uso combinado actual que implica “reabrir los archivos”, se trata de un método peligroso.
  • SIGPOLL y SIGPROF: estas señales están en el grupo de “estas se deberían controlar internamente mediante alguna función estándar en lugar de mediante el programa”. Sin embargo, aunque probablemente no implique riesgos, sigue sin parecer idóneo que el comportamiento predeterminado sea la terminación.
  • SIGUSR1 y SIGUSR2: se trata de “señales definidas por el usuario” que aparentemente puedes utilizar como prefieras. No obstante, como son terminales de manera predeterminada, si implementas la señal USR1 por alguna necesidad concreta y más adelante no la necesitas, no puedes simplemente eliminar el código de forma segura. Tienes que considerar conscientemente ignorar la señal de forma explícita. Realmente eso no va a ser evidente ni para los programadores con experiencia.

Esto representa casi un tercio de las señales terminales, que en el mejor de los casos son cuestionables y en el peor, activamente peligrosas, ya que las necesidades de un programa cambian. Peor todavía, incluso las señales supuestamente “definidas por los usuarios” son un desastre que se espera que ocurra cuando alguien olvida usar de manera explícita SIG_IGN. Una señal inocua como SIGUSR1 o SIGPOLL también puede provocar incidentes.

No se trata de una mera cuestión de familiaridad. Independientemente de lo bien que conozcas el funcionamiento de las señales, sigue siendo muy complicado escribir el código correcto de las señales la primera vez ya que, a pesar de su aspecto, las señales son mucho más complejas de lo que parecen.

Flujo de código, simultaneidad y el mito de SA_RESTART

Por lo general, los programadores no se pasan todo el día pensando en el funcionamiento interno de las señales. Esto implica que, cuando tienen que implementar realmente un control de señales, suelen seguir sutilmente métodos equivocados.

No me refiero a los casos “triviales”, como la seguridad en la función de control de las señales, que en la mayoría de los casos se soluciona simplemente subiendo un tipo sig_atomic_t o con cuestiones de barreras de señales atómicas de C++. No. En general, eso es fácil de buscar y recordar como una dificultad para cualquiera después de su primera vez en el infierno de las señales. Algo que es mucho más complicado es comprender el flujo de código de las porciones nominales de un programa complejo cuando recibe una señal. Para ello, es necesario pensar de forma constante y explícita en las señales en todas las partes del ciclo de vida de la aplicación (¿y qué sucede con EINTR?, ¿aquí basta con SA_RESTART? ¿En qué flujo deberíamos meternos si esto se termina antes de tiempo? Ahora tengo un programa simultáneo, ¿qué implica eso?), o configurar sigprocmask o pthread_setmask para alguna parte del ciclo de vida de la aplicación y rezar para que el flujo de código nunca cambie (algo que realmente no es una buena conjetura en una atmósfera de desarrollo acelerado). signalfd o ejecutar sigwaitinfo en un subproceso dedicado puede ayudar un poco en este caso, pero estos dos métodos tienen suficientes casos extremos y problemas de usabilidad que hacen que sean difíciles de recomendar.

Nos gusta creer que los programadores con más experiencia a estas alturas ya saben que incluso un ejemplo ingenioso de escribir correctamente un fragmento de código seguro para subprocesos es muy complicado. Bien, pues si creías que escribir correctamente un fragmento de código seguro para subprocesos era complicado, las señales son considerablemente más complicadas. Los controladores de señales solo se deben basar en código estrictamente libre de bloqueos con estructuras de datos atómicos, respectivamente, porque el flujo principal de ejecución está suspendido y no sabemos qué bloqueos retiene, y porque el flujo principal de ejecución podría estar realizando operaciones no atómicas. Además, deben ser completamente reentrantes, es decir, deben ser capaces de anidarse dentro de sí mismos, ya que los controladores de señales se pueden solapar si una señal se envía varias veces (o incluso con una señal, con SA_NODEFER). Este es uno de los motivos por los cuales no puedes usar funciones como printf o malloc en un controlador de señales, porque se basan en exclusiones mutuas globales para la sincronización. Si estabas reteniendo ese bloqueo cuando se recibió la señal y, a continuación, llamaste a una función y se necesita de nuevo dicho bloqueo, la aplicación terminará con un interbloqueo. Esto es extremadamente difícil de comprender. Por ello, muchos usuarios simplemente escriben algo como lo siguiente como control de señales:

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

El problema es que, mientras este, signalfd, u otros intentos de controlar las señales asíncronas podrían parecer bastantes sencillos y sólidos, se ignora el hecho de que el punto de interrupción es tan importante como las acciones realizadas tras recibir la señal. Por ejemplo, supongamos que el código de tu espacio de usuario está realizando operaciones de E/S o cambiando los metadatos de los objetos que proceden del kernel (como inodes o FD). En este caso, es probable que realmente estés en una pila del espacio del kernel en el momento de la interrupción. Por ejemplo, a continuación se muestra el aspecto de un subproceso al intentar cerrar un descriptor de archivos:

# 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

Aquí, __x64_sys_close es la variante x86_64 de la llamada del sistema close, que cierra un descriptor de archivos. En este momento de la ejecución, estamos esperando a que se actualice el almacenamiento de respaldo (es este objeto wait_on_page_bit). Como normalmente el trabajo de E/S es mucho más lento que otras operaciones, en este caso, schedule es una forma de dar a entender voluntariamente al programador de CPU del kernel que vamos a realizar una operación de alta latencia (como E/S de red o disco) y que debería considerar la posibilidad de buscar otro proceso que programar en lugar del proceso actual por ahora. Esto es bueno, ya que nos permite señalar al kernel que es una buena idea continuar y elegir un proceso que realmente utilizará la CPU en lugar de perder tiempo en uno que no puede continuar hasta que termine de esperar una respuesta de una operación que podría tardar un tiempo.

Imagina que enviamos una señal al proceso que estábamos ejecutando. La señal que hemos enviado tiene un controlador del espacio del usuario en el subproceso receptor, de modo que continuaremos en el espacio del usuario. Una de las muchas formas que existen de finalizar esta carrera es que el kernel intente salir de schedule, desenrede todavía más la pila y, por último, devuelva un valor de errno de ESYSRESTART o EINTR al espacio del usuario para indicar que se produjo una interrupción. Pero ¿hasta dónde llegamos con el cierre? ¿Cuál es el estado del descriptor de archivos ahora?

Ahora que hemos vuelto al espacio del usuario, ejecutaremos el controlador de señales. Cuando se cierre el controlador de señales, propagaremos el error al contenedor close de libc del espacio del usuario y, a continuación, a la aplicación, que, en teoría, puede resolver de alguna forma la situación en cuestión. Decimos “en teoría” porque es muy difícil saber qué hacer en muchas de estas situaciones con las señales, y muchos servicios en producción no controlan muy bien los casos extremos aquí. Esa situación podría estar bien en algunas aplicaciones en las que la integridad de los datos no es importante. No obstante, en las aplicaciones de producción que se preocupan por la integridad y la coherencia de los datos, presenta un problema significativo: el kernel no expone ninguna forma detallada de entender a dónde llegó, qué consiguió y qué no, y qué deberíamos hacer para resolver la situación. Todavía peor, si close se devuelve con EINTR, el estado del descriptor de archivos ahora no se especifica:

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

Te deseamos buena suerte si intentas comprender cómo controlar esa situación de forma segura en tu aplicación. En general, es complicado controlar EINTR, incluso en el caso de las llamadas del sistema con un comportamiento correcto. Existen numerosos problemas sutiles que constituyen una gran parte del motivo por el cual no basta con SA_RESTART. No todas las llamadas del sistema se pueden reiniciar, y esperar que todos los desarrolladores de tu aplicación comprendan y mitiguen los matices complejos de recibir una señal para cada llamada del sistema en todos los sitios de llamadas es buscar interrupciones. Desde 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 [...]”

Asimismo, usar sigprocmask y esperar que el flujo de código permanezca estático es buscar problemas, ya que los desarrolladores normalmente no se pasan toda la vida pensando en los límites del control de señales o en cómo producir o conservar el código correcto de las señales. Lo mismo se aplica al control de las señales en un subproceso dedicado con sigwaitinfo, que puede provocar fácilmente que GDB y herramientas similares no sean capaces de depurar el proceso. El control de errores o los flujos de código ligeramente incorrectos pueden provocar errores, bloqueos, dificultades a la hora de depurar los daños, interbloqueos y muchos más problemas que te llevarán corriendo directamente a los brazos de tu herramienta de administración de incidentes preferida.

Complejidad elevada en entornos de varios subprocesos

Si creías que tenías suficiente con toda esta charla sobre simultaneidad, reentrada y atomicidad, el tema se vuelve todavía más complicado si metemos varios subprocesos en la combinación. Esto es especialmente importante al considerar el hecho de que muchas aplicaciones complejas ejecutan subprocesos independientes de forma implícita (por ejemplo, como parte de jemalloc, GLib o similares). Algunas de estas bibliotecas incluso instalan controladores de señales ellas mismas, lo que abre otro gran abanico de posibilidades.

En general, lo que man 7 signal tiene que decir al respecto es lo siguiente:

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

De forma más resumida, “en el caso de la mayoría de las señales, el kernel envía la señal a cualquier subproceso que no tenga dicha señal bloqueada con sigprocmask”. SIGSEGV, SIGILL y similares parecen trampas y tienen la señal dirigida de forma explícita al subproceso infractor. Sin embargo, a pesar de lo que uno pueda pensar, la mayoría de las señales no se pueden enviar explícitamente a un subproceso único de un grupo de subprocesos, ni siquiera con tgkill o pthread_kill.

Esto significa que no puedes cambiar trivialmente las características del control de señales generales tan pronto tengas un conjunto de subprocesos. Si un servicio necesita llevar a cabo un bloqueo periódico de señales con sigprocmask en el subproceso principal, tienes que comunicarte de alguna forma con otros subprocesos externamente para indicarles cómo deberían controlar esa situación. De lo contrario, otro proceso puede tragarse la señal y nunca la volverás a ver. Es evidente que puedes bloquear señales en subprocesos secundarios para evitar esto, pero si necesitan llevar a cabo su propio control de señales, incluso para objetos primitivos como waitpid, se terminarán complicando las cosas.

Al igual que lo que sucede con todo aquí, no se trata de problemas imposibles de solucionar desde el punto de vista técnico. No obstante, pecaríamos de descuidados si ignorásemos el hecho de que la complejidad de la sincronización que hace falta para que esto funcione correctamente resulta engorrosa y prepara el terreno para errores, confusiones y problemas peores.

Falta de definición y comunicación del éxito o el fracaso

Las señales se propagan de forma asíncrona en el kernel. La llamada del sistema kill regresa tan pronto como la señal pendiente se registra para el proceso o la estructura task_struct del subproceso en cuestión. Por lo tanto, no se garantiza una entrega oportuna, incluso si la señal no está bloqueada.

Aunque la señal se entregue de forma oportuna, no hay manera de volver a comunicar al emisor de dicha señal el estado de su solicitud de acción. Por lo tanto, cualquier acción significativa no se debería entregar mediante señales, ya que solo implementan métodos de activación y olvido sin ningún mecanismo real para informar del éxito o el fracaso de la entrega y las acciones posteriores. Como hemos visto anteriormente, incluso las señales aparentemente inocuas pueden ser peligrosas cuando no están configuradas en el espacio del usuario.

Cualquier persona que haya utilizado bastante Linux seguramente se habrá topado con un caso en el que quiso terminar un proceso, pero descubrió que el proceso no respondía incluso ante señales que supuestamente siempre son graves, como SIGKILL. El problema es que, de forma engañosa, el objetivo de kill(1) no es terminar procesos, sino poner en cola una solicitud para el kernel (sin ninguna indicación de cuándo se atenderá) respecto de que un usuario ha solicitado que se realice alguna acción.

El trabajo de la llamada del sistema kill es marcar la señal como pendiente en los metadatos de la tarea del kernel, lo que hace correctamente incluso cuando una tarea SIGKILL no se termina. En el caso concreto de SIGKILL, el kernel garantiza que no se ejecutarán más instrucciones en modo de usuario, pero es posible que todavía tengamos que ejecutar instrucciones en modo de kernel para completar acciones que, de lo contrario, podrían provocar daños en los datos o para liberar recursos. Por este motivo, la operación se realiza correctamente incluso si el estado es D (suspensión ininterrumpida). La terminación automática no fallará a menos que hayas proporcionado una señal no válida, no tengas permisos para enviar dicha señal o el PID al que hayas solicitado enviar una señal no exista y, por lo tanto, no es útil propagar de forma fiable estados no terminales a las aplicaciones.

En conclusión

  • Las señales son adecuadas para un estado terminal controlado estrictamente en el kernel sin ningún controlador del espacio del usuario. En el caso de las señales que realmente te gustaría que terminasen de inmediato tu programa, déjalas solas para que el kernel las controle. Esto también implica que el kernel puede ser capaz de salir de forma anticipada de su trabajo, lo que libera recursos del programa con mayor rapidez, mientras una solicitud de IPC del espacio del usuario tendría que esperar a que la porción del espacio del usuario se empezase a ejecutar de nuevo.
  • Una forma de evitar tener problemas relacionados con el control de las señales es no controlarlas. Sin embargo, en el caso de las aplicaciones que controlen el procesamiento de los estados que deban ocuparse de casos como SIGTERM, lo ideal es usar una API de alto nivel como folly::AsyncSignalHandler, en la que diversas imperfecciones ya se han hecho más intuitivas.

  • Evita comunicar las solicitudes de las aplicaciones con señales. Utiliza notificaciones administradas automáticamente (como inotify) o el mecanismo RPC del espacio del usuario con una parte dedicada del ciclo de vida de la aplicación para gestionar este aspecto, en lugar de depender de la interrupción de la aplicación.
  • Siempre que sea posible, limita el alcance de las señales a una subsección del programa o los subprocesos con sigprocmask, lo que reduce la cantidad de código que se debe analizar de forma habitual para controlar que las señales estén correctas. Ten en cuenta que si los flujos de código o las estrategias de subprocesos cambian, es posible que la máscara no tenga el efecto que esperabas.
  • Al iniciar el demonio, enmascara las señales terminales que no se entiendan uniformemente y que podrían readaptarse en algún momento en el programa para evitar volver a adoptar el comportamiento predeterminado del kernel. Te sugiero lo siguiente:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

Comprender el comportamiento de las señales es extremadamente complicado, incluso en programas creados correctamente, y su uso implica un riesgo innecesario en aplicaciones en las que hay disponibles otras alternativas. En general, no utilices las señales para comunicarte con la porción del espacio del usuario de tu programa. En su lugar, haz que el programa controle de forma transparente los eventos por su cuenta (por ejemplo, con inotify) o usa la comunicación del espacio del usuario que puede informar de errores al emisor y que se puede enumerar y demostrar en tiempo de compilación, como Thrift, gRPC o similares.

Espero que, con este artículo, te haya demostrado que las señales, a pesar de que aparentemente parezcan sencillas, son de todo menos eso. La estética de simplicidad que promueve su uso como una API para el software del espacio del usuario oculta una serie de decisiones implícitas de diseño que no se ajustan a la mayoría de los casos de uso de producción de la era moderna.

Seamos claros: hay casos de uso válidos para las señales. Las señales funcionan para la comunicación básica con el kernel con respecto al estado deseado de un proceso cuando no hay ningún componente de espacio del usuario (por ejemplo, comunicar que un proceso se debe terminar). Sin embargo, es difícil escribir el código correcto de las señales la primera vez cuando se espera que las señales queden retenidas en el espacio del usuario.

Las señales pueden parecer tentadoras por su estandarización, amplia disponibilidad y carencia de dependencias, pero presentan un número significativo de dificultades que lo único que harán será aumentar las preocupaciones a medida que se desarrolle tu proyecto. Espero que en este artículo te haya aportado algunas estrategias alternativas y mitigaciones que te permitirán alcanzar tus objetivos, pero de una forma más segura, menos sutilmente compleja y más intuitiva.

Para obtener más información sobre Meta Open Source, visita nuestro sitio de código abierto, suscríbete a nuestro canal de YouTube o síguenos en Twitter, Facebook y LinkedIn.