Volver a las novedades para desarrolladores

Signals in prod: dangers and pitfalls

27 de septiembre de 2022DeChris Down

En esta publicación de blog, Chris Down, ingeniero del kernel de Meta, cuenta las dificultades que implica el uso de las señales de Linux en los entornos de producción de Linux y por qué los desarrolladores deben, siempre que sea posible, evitar usar señales.

¿Qué son las señales de Linux?

Una señal es un evento que generan los sistemas de Linux como respuesta a la misma condición. El recorrido de las señales puede ser desde un kernel hacia un proceso, desde un proceso hacia otro proceso o dentro de un mismo proceso. Cuando se recibe una señal, un proceso puede realizar una acción.

Las señales son una parte esencial de los entornos que funcionan con sistemas operativos tipo UNIX y existen más o menos desde tiempos inmemoriales. Son la base de muchos de los componentes principales del sistema operativo (volcado de memoria, administración del ciclo de vida de los procesos, etc.), y, en términos generales, resistieron bastante bien en los cincuenta y algo de años que llevamos usándolas. Por ese motivo, cuando alguna persona sugiere que usarlas para la comunicación entre procesos (IPC) puede suponer un riesgo, es posible que uno piense que son los desvaríos de alguien desesperado por inventar la rueda. Sin embargo, el objetivo de este artículo es mostrar casos en los que las señales fueron la causa que generó problemas y ofrecer algunas posibles soluciones y alternativas.

Las señales pueden resultar atractivas por su calidad de estándar, por su amplia disponibilidad y porque no requieren de ninguna dependencia adicional fuera de los que proporciona el sistema operativo. No obstante, pueden ofrecer dificultad a la hora de usarlas de manera segura. Las señales implican un gran número de suposiciones con cuya validación uno debe tener cuidado para que se cumplan los requisitos. Caso contrario, será necesario que se las configure correctamente. En realidad, muchas apps, incluso algunas que son sumamente conocidas, no lo tienen en cuenta, lo que puede llevar a que se produzcan en el futuro incidentes que resultan difíciles de depurar.

Echemos un vistazo a un incidente reciente que se produjo en el entorno de producción de Meta. Este hecho pone de manifiesto las dificultades a la hora de usar señales. Repasaremos brevemente la historia de algunas señales y cómo nos llevaron a donde nos encontramos hoy, y, luego, las compararemos con las necesidades y los problemas a los que nos enfrentamos hoy en día en la producción.

El incidente

Primero, retrocedamos un poco en el tiempo. El equipo de LogDevice limpió el código base y eliminó el código y las funciones que no se usaban. Una de las funciones que quedó obsoleta fue un tipo de registro que documenta determinadas operaciones que realiza el servicio. Esta función devino redundante, ningún consumidor la usaba y, por consiguiente, se eliminó. Puedes ver el cambio aquí en GitHub. Hasta ahora, todo bien.

El tiempo transcurrió desde que se realizó el cambio sin que hubiera mucho para hablar al respecto, y la producción continuó su curso firme y atendiendo el tráfico, como de costumbre. Unas semanas después, se recibió un informe de indicaba que se perdían los nodos de servicio a un ritmo acelerado. Estaba relacionado con la implementación del nuevo lanzamiento, pero nadie sabía qué pasaba exactamente. ¿Qué era lo diferente ahora que provocaba que todo fallara?

El equipo en cuestión encontró el problema en el cambio de código al que se hizo alusión anteriormente, que había dejado obsoletos esos registros. Pero, ¿por qué? ¿Cuál es el problema de ese código? Si no conoces la respuesta, te invitamos a que mires la diferencia e intentes averiguar cuál es el problema, porque no es algo que resulte inmediatamente obvio, pero es un error que todos podemos cometer.

logrotate, entra al cuadrilátero

logrotate es, en mayor o menor medida, la herramienta estándar de rotación de registros cuando se usa Linux. Pasaron unos treinta años, y el concepto es simple: administrar el ciclo de vida de los registros rotándolos y vaciándolos.

logrotate no envía señales por sí solo, por lo que no encontrarás mucho, o poco, sobre el tema señales en la página principal de logrotate o en su documentación. Sin embargo, logrotate puede tomar comandos arbitrarios y ejecutarlos antes de las rotaciones o después de estas. Puedes consultar la siguiente configuración para ver un ejemplo básico de la configuración predeterminada de logrotate en CentOS:

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

Poco sofisticado, pero lo pasaremos por alto y asumiremos que funciona como se supone que funcione. Esta configuración indica que, después de que logrotate rota los archivos listados, debe enviar SIGHUP al PID ubicado en /var/run/syslogd.pid, que debe ser el de la instancia syslogd en ejecución.

Lo anterior es útil 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 cuelgues

Uno de los problemas en este punto es que, excepto por las señales que no pueden registrarse en el espacio del usuario y, por consiguiente, que solo tienen un único significado, como SIGKILL y SIGSTOP, el significado semántico de las señales depende de cómo lo interpreten y programen los desarrolladores de apps y de los usuarios. En algunos casos, la distinción es puramente académica, como SIGTERM, que se entiende de manera bastante generalizada como "finalizar con estilo tan rápido como sea posible". Sin embargo, en el caso de SIGHUP, el significado es muchísimo menos claro.

SIGHUP se inventó para líneas en serie y se usó en sus inicios para indicar que el otro extremo de la conexión abandonó la línea. Hoy en día, continuamos llevando nuestro linaje con nosotros, por supuesto. Por este motivo, se sigue enviando SIGHUP para su equivalente moderno: donde está cerrada una pseudoterminal o una terminal virtual (por lo tanto, herramientas como nohup, que la enmascaran).

En los comienzos de UNIX, existía una necesidad de implementar la recarga del daemon. Suele consistir en al menos una configuración o un archivo de registro que se vuelve a abrir sin que se reinicie; las señales parecen ser una forma libre de dependencia de lograrlo. Por supuesto, no existía una señal para algo semejante, pero, como estos daemons no cuentan con una terminal de control, no habría motivo para recibir SIGHUP. Por esta razón, resultaba ser una señal conveniente a la que sumarse sin que se produjera ningún efecto secundario evidente.

Sin embargo, hay un pequeño problema con este plan. El estado predeterminado de las señales no se "ignora", sino que es específico de la señal. Por ejemplo, los programas no deben configurar SIGTERM de manera manual para terminar la app. Siempre que no se configure ningún otro controlador de señales, el kernel termina el programa de manera gratuita, sin la necesidad de contar con ningún código en el espacio del usuario. ¡Útil!

Lo que no resulta tan conveniente, sin embargo, es que SIGHUP también presenta el comportamiento predeterminado de finalizar el programa de manera inmediata. Esto funciona muy bien en el caso original de cuelgue, donde estas apps probablemente ya no son necesarias, pero no es tan bueno para este nuevo significado.

Por supuesto, que estaría bien si eliminamos todos los lugares que pudiera enviar SIGHUP al programa. El problema es que esto es difícil en las bases de datos grandes y antiguas. SIGHUP no funciona como una llamada al IPC sumamente controlada para la que puedes utilizar simplemente el comando "grep". Las señales pueden provenir de cualquier lado, en cualquier momento, y solo cuentan con pocas verificaciones en la operación (además de la más básica "eres este usuario o tienes CAP_KILL"). Lo principal es que resulta difícil determinar de dónde pueden provenir las señales, pero, con un IPC más explícito, sabríamos que esta señal no significa nada para nosotros y, por consiguiente, debe ignorarse.

Del cuelgue al peligro

Supongo que ya comenzaste a sospechar lo que sucedió. Una fatídica tarde, se comenzó con el lanzamiento de LogDevice, que contenía el cambio de código al que se hizo referencia anteriormente. Al principio, todo salió bien; pero a la medianoche del día siguiente, todo comenzó a fallar misteriosamente. La razón es la siguiente porción de texto en la configuración de logrotate de la máquina, que envía ahora la señal SIGHUP no controlada (y, por consiguiente, fatal) al daemon logdevice:

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

Es muy fácil y habitual olvidarse una pequeña porción de texto de la configuración de logrotate cuando se elimina una función extensa. Por desgracia, también es difícil asegurarse de que se haya eliminado de una vez hasta el último vestigio de su existencia. Incluso en los casos en los que resulta más fácil realizar la validación que en ese caso, suelen dejarse por error remanentes cuando se limpia el código. No obstante, no es habitual que tenga consecuencias devastadoras, es decir, el residuo remanente simplemente es código muerto o no operativo.

En teoría, el propio incidente y su solución son simples: no enviar SIGHUP y extender las acciones de LogDevice más en el tiempo (es decir, no lo ejecutes justo a medianoche). Sin embargo, no se trata solo de concentrarnos en los matices de este incidente. Nos debe servir, ante todo, como punto de partida para desalentar el uso de señales a la hora de producir los casos que no sean lo más básicos y esenciales.

El peligro de las señales

Para qué son buenas las señales

En primer lugar, usar señales como mecanismo para tener impacto en los cambios en los estados del proceso del sistema operativo resulta bien justificado. Se incluyen señales como SIGKILL, para las que es imposible poder instalar un controlador de señales y que hacen lo que esperarías que hagan, y el comportamiento predeterminado del kernel de SIGABRT, SIGTERM, SIGINT, SIGSEGV, SIGQUIT y similares, que suelen entender bien los usuarios y programadores.

Lo que tienen en común todas estas señales es que, una vez que las recibes, avanzan a un estado terminal dentro del propio kernel. Es decir, no se ejecutará ninguna instrucción del espacio del usuario más cuando recibas SIGKILL o SIGTERM sin un controlador de señal de espacio del usuario.

Es importante contar con un estado terminal, porque esto suele significar que trabajas con el objetivo de reducir la complejidad de la pila y del código que se están ejecutando en ese momento. Otros estados deseados suelen provocar que resulte más difícil y complejo comprenderlo, ya que el proceso de código y de la concurrencia se vuelve más confuso.

Comportamiento predeterminado peligroso

Es posible que hayas notado que no mencionamos algunas otras señales que también terminan de manera predeterminada. Aquí, encontrarás una lista de todas las señales estándar que terminan de manera predeterminada (con excepción de 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 hay algunas excepciones:

  • SIGHUP: si se usara como se pensó originalmente, resultaría sensato finalizarla de manera predeterminada. Con el uso mezclado que se le da actualmente, que significa "reabrir archivos", resulta peligroso.
  • SIGPOLL y SIGPROF: estas señales tienen que ver con que "alguna función debería manejarlas de manera interna en vez de hacerlo tu programa". Sin embargo, aunque probablemente sea inofensivo, el comportamiento predeterminado de finalizar sigue pareciendo poco ideal.
  • SIGUSR1 y SIGUSR2: estas señales son "señales definidas por el usuario", que, supuestamente, puedes usar como te guste. Pero, dado que estas señales son terminales de manera predeterminada, si implementas USR1 para satisfacer alguna necesidad específica y, luego, no la necesitas, no podrás simplemente eliminar el código. Debes considerar con deliberación ignorar explícitamente la señal, lo que no les resulta obvio ni a los programadores experimentados.

Esto representa casi un tercio de las señales terminales, que, a lo sumo, son cuestionables, y, en el peor de los casos, activamente peligrosas, ya que las necesidades de un programa cambian. Lo que es aún peor: las señales que se suponen que son "definidas por el usuario" son, incluso, un desastre a punto de suceder si alguien se olvida de usar explícitamente SIG_IGN. También las señales inocuas SIGUSR1 o SIGPOLL pueden generar incidentes.

No se trata de una mera cuestión de familiaridad. No importa cuán bien sepas cómo funcionan las señales. Sigue siendo sumamente difícil escribir código con las señales correctas la primera vez que se hace, porque, a pesar de lo que parece, las señales son más complejas de lo que aparentan ser.

Proceso de código, concurrencia y el mito de SA_RESTART

Los programadores no suelen pasar todo el día pensando en el funcionamiento interno de las señales. Esto implica que, cuando se trata de implementar de verdad el control de las señales, suele hacerse mal de manera sutil.

Ni siquiera me refiero a los casos "triviales", como la seguridad en la función de control de una señal, que, por lo general, se suele resolver con sig_atomic_t o usando la operación de barrera atómica para la señal en C++. No. Suele ser fácil para todos buscarlo y recordarlo como un problema después de que uno vivió el infierno que suponen las señales. Lo que resulta mucho más difícil es comprender el proceso de código de las porciones nominales de un programa complejo cuando recibe una señal. Para hacerlo, es necesario pensar de manera constante y explícita en las señales en todas las partes del ciclo de vida de la app (¿qué pasa con EINTR? ¿Basta acá con SA_RESTART? ¿En qué proceso deberíamos concentrarnos si finaliza de forma prematura? Ahora, cuento con un programa concurrente (¿qué implicaciones tiene esto?), o bien configuro sigprocmask o pthread_setmask para alguna parte del ciclo de vida de la app y ruego que el proceso de código no cambie nunca (lo que seguramente no sucederá en un entorno de desarrollos vertiginosos). La señal signalfd o ejecutar sigwaitinfo en un hilo específico puede ayudar aquí de alguna manera, pero ambas opciones presentan suficientes casos límite y problemas relativos al uso que hacen que sea difícil recomendarlas.

Nos gusta creer que los programadores más experimentados ahora saben que resulta muy difícil encontrar un ejemplo ocurrente de un código correctamente escrito que sea seguro para un hilo. Si pensaste que un código correctamente escrito que sea seguro para un hilo es algo difícil, las señales son aún peor. Los controladores de señal deben confiar únicamente en el código libre de bloqueos con estructuras de datos atómicas, respectivamente, porque el proceso principal de ejecución está suspendido, y no sabemos qué bloqueos mantiene, y, además, porque el proceso principal de ejecución podría estar ejecutando operaciones no atómicas. También deben ser totalmente reentrantes, es decir, deben ser capaces de anidarse dentro de sí mismos, ya que los controladores de señales pueden superponerse si una señal se envía varias veces (o incluso con una señal, con SA_NODEFER). Esta es una de las razones por las que no puedes usar funciones como printf o malloc en un controlador de señales, puesto que dependen de mutex globales para realizar la sincronización. Si mantienes ese bloqueo cuando se recibe la señal y, luego, llamas a una función que solicita nuevamente ese bloqueo, tu app terminará bloqueada. Resulta realmente muy, muy difícil de comprender. Por este motivo, muchas personas escriben algo como lo que muestra a continuación para controlar la señal:

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, aunque los intentos de signalfd u otra señal por controlar la señal asincrónica pueden parecer bastante simples y sólidos, se ignora el hecho de que, a esta altura de la interrupción, esta cuestión es tan importante como las acciones que se realizan después de recibir la señal. Por ejemplo: supongamos que tu código de espacio de usuario realiza E/S o cambia los metadatos de los objetos que provienen del kernel (como inodos o FD). En este caso, es probable que te encuentres en una pila de espacio cuando se produzca la interrupción. Aquí, te mostramos como, por ejemplo, se vería un hilo cuando intenta cerrar un descriptor de archivo:

# 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 al sistema close, que cierra el descriptor del archivo. En este momento de la ejecución, esperamos que se actualice el almacenamiento de seguridad (es este wait_on_page_bit). Dado que el trabajo de E/S suele ser varias veces más lento que otras operaciones, schedule aquí es una forma de avisarle de manera voluntaria a la CPU del kernel que estamos a punto de realizar una operación de alta latencia (como disco o red E/S) y que se debe considerar encontrar otro proceso que se pueda programar en vez del proceso que está en ejecución en esto momentos. Esto resulta ser bueno, porque nos permite señalar al kernel que es una buena idea avanzar y elegir un proceso que realmente utilice la CPU, en vez de perder el tiempo en un proceso que no puede continuar hasta finalice y que espere una respuesta de algo que puede demorar bastante tiempo.

Imagina que enviamos una señal al proceso que está en ejecución. La señal que enviamos cuenta con un controlador de espacio de usuario en el hilo receptor, por lo que continuaremos en el espacio de usuario. Una de las varias formas que existen de finalizar esta carrera es que el kernel intente salir de schedule, continúe desenrollando la pila y, eventualmente, devuelva un error de ESYSRESTART o EINTR al espacio de usuario para indicar que se produjo una interrupción. ¿Pero cuánto nos abocamos a cerrarlo? ¿Cuál es el estado actual del descriptor de archivo?

Ahora, que regresamos al espacio de usuario, ejecutaremos el controlador de señal. Cuando esté disponible el controlador de señal, propagaremos el error al contenedor close de libc del espacio de usuario, y, luego, a la app, lo que, en teoría, puede resolver de cierta manera la situación en cuestión. Decimos "en teoría", porque es difícil saber qué hacer en muchas de estas situaciones con las señales. Muchos servicios en producción no manejan muy bien aquí los casos límite. Puede que esta situación no importe un problema en algunas apps en las que la integridad de los datos no es importante. Sin embargo, en la producción, las apps en las que interesa la integridad y la consistencia de los datos, esto representa un problema significativo: el kernel no expone ninguna forma granular de saber cuán lejos se llegó, qué es lo que se logró y qué no, y qué debemos hacer para solucionar la situación. Lo que es aún peor: si close devuelve EINTR, el estado del descriptor del archivo ahora no está especificado:

"Si una señal interrumpe 'close()' [...] el estado del [descriptor del archivo] no está especificado".

Te deseamos que tengas suerte tratando de entender cómo manejarlo de manera segura en tu app. En términos generales, es complicado administrar EINTR, incluso en las llamadas al sistema con buen comportamiento. Hay un montón de problemas sutiles que conforman una gran parte de la razón por la que SA_RESTART no resulta suficiente. No todas las llamadas al sistema se pueden volver a iniciar, y esperar que todos los desarrolladores de tu app comprendan, y logren solucionar, las grandes matices de obtener una señal para cada llamada al sistema en todos los sitios de la llamada es pedir que las cosas no funcionen. Desde man 7 signal:

"Las siguientes interfaces nunca se reinician después de que un controlador de señales las interrumpió, sin importar cuál sea el uso de 'SA_RESTART'; siempre fallan con el error 'EINTR [...]'"

Del mismo modo, usar sigprocmask y esperar que el proceso de código permanezca estático es comprarse un problema, porque los desarrolladores no suelen pasar sus vidas pensando en los límites que presenta el manejo de señales o cómo se produce o preserva un código con las señales correctas. Lo mismo aplica cuando se manejan señales en un hilo específico con sigwaitinfo, lo que puede provocar fácilmente que GDB y las herramientas similares no puedan depurar el proceso. Los procesos de código levemente erróneos o el manejo de errores pueden provocar errores, fallas, corrupciones difíciles de depurar, bloqueos y muchos otros errores que te llevarán corriendo directamente a los brazos de tu herramienta de administración de incidentes preferida.

Alta complejidad en entornos de hilos múltiples

Si pensabas que ya tenías suficiente con todo lo que hablamos de concurrencia, reentradas y atomicidad, solo resta agregar múltiples hilos a la mezcla para que todo se complique aún más. Esto resulta especialmente importante cuando se considera la posibilidad de que varias apps complejas ejecuten hilos separados de manera implícita, por ejemplo, como parte de jemalloc, GLib o similares. Algunas de estas bibliotecas instalan por sí mismas, incluso, controladores de señal, lo que abre otro gran abanico de posibilidades.

Por lo general, man 7 signal nos informa lo siguiente respecto de esta cuestión:

"Es posible generar una señal (y, por consiguiente, que esté pendiente) para todo un proceso (por ejemplo, cuando se envía usando 'kill(2)') o para un hilo determinado [...] Si más de un hilo tiene la señal bloqueada, el kernel selecciona de manera arbitraria un hilo al que entregará la señal".

De manera más sucinta, "en relación con la mayoría de las señales, el kernel envía la señal a cualquier hilo que no tenga la señal bloqueada con sigprocmask". SIGSEGV, SIGILL y similares se parecen a traps, y la señal está explícitamente dirigida al hilo puesto en entredicho. Sin embargo, a pesar de lo que uno podría pensar, la mayoría de las señales no pueden enviarse de manera explícita a un solo hilo en un grupo de hilos, incluso tampoco con tgkill ni pthread_kill.

Esto significa que no puedes cambiar simplemente las características generales del manejo de señales cuando cuentas con un conjunto de hilos. Si un servicio necesita bloquear señales de forma periódica con sigprocmask en el hilo principal, debes comunicarte externamente de alguna manera con los otros hilos para establecer la forma en que es necesario que lo manejen. De lo contrario, es posible que otro hilo se trague la señal y que desaparezca. Obviamente, puedes bloquear las señales en los hilos secundarios para evitar que suceda esto, pero si es necesario controlar las propias señales, incluso para elementos primitivos, como waitpid, se terminará 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, uno pecaría de negligente si ignora que resulta engorrosa la complejidad de la sincronización que se debe lograr para que esto funcione correctamente, y que es la base para que se produzcan errores, confusión y cosas aún peores.

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

En el kernel, se propagan las señales de manera asincrónica. Se devuelve la llamada al sistema kill enseguida después de que se registra la señal pendiente del proceso o de la estructura task_struct del hilo en cuestión. Por ese motivo, no se puede garantizar que se entregue a tiempo; tampoco si la señal no está bloqueada.

Aunque la señal se entregue a tiempo, no es posible volver a comunicarse con el emisor de la señal para saber cuál es el estado de su solicitud de acción. Por lo tanto, las señales no deben realizar ninguna acción importante, dado que solo implementan "dispara y olvida" sin contar con ningún mecanismo que reporte el éxito o el fracaso de la entrega y de las acciones subsiguientes. Como ya vimos con anterioridad, incluso las señales que parecen inocuas pueden resultar peligrosas cuando no se configuran en el espacio de usuario.

Cualquier persona que haya usado bastante Linux se encontró sin lugar a dudas con un caso en el que quiso matar algún proceso, pero notó que dicho proceso no responde, ni siquiera a las señales que, supuestamente, son fatales como SIGKILL. El problema es que, de manera engañosa, la finalidad de " kill(1)" no es matar el proceso, sino poner en cola una solicitud al kernel (sin que se indique cuándo se atenderá) respecto de que alguien solicitó que se realice cierta acción.

La función de la llamada al sistema kill es marcar la señal como "pendiente" en los metadatos de la tarea del kernel, lo que hace correctamente, aunque una tarea SIGKILL no deje de ejecutarse. En el caso particular de SIGKILL, el kernel garantiza que no se ejecuten más instrucciones en modo de usuario. No obstante, es posible que se deban ejecutar instrucciones en modo kernel para completar las acciones que, de otra manera, provocarían que se corrompan los datos o que se liberen recursos. Por este motivo, seguiremos teniendo éxito, incluso si el estado es D (suspensión ininterrumpida). La propia señal "Kill" no falla, excepto que proporciones una señal no válida, que no cuentes con permiso para enviar esa señal o que el PID al que solicitaste enviar una señal no exista. Por este motivo, no resulta útil propagar de manera confiable estados no terminales en las apps.

En conclusión

  • Las señales están bien si el estado terminal se maneja en el kernel sin un controlador de espacio de usuario. En relación con las señales que quisieras que maten directamente a tu programa, déjalas solas para que el kernel se encargue de ellas. Esto también implica que el kernel podría abandonar su tarea con antelación, lo que permitiría liberar los recursos de tu programa de manera más rápida. Por el contrario, una solicitud de IPC de espacio de usuario debería esperar que la porción de espacio de usuario se vuelva a ejecutar.
  • Una forma de evitar tener problemas a la hora de manejar las señales es no manejarlas en absoluto. Sin embargo, en relación con las apps que manejan procesos de estado que deben ocuparse de casos como SIGTERM, lo ideal es usar una API de alto nivel, como folly::AsyncSignalHandler, en la que ya se hizo más intuitivo un número de verrugas.

  • Evita comunicar solicitudes de app con señales. Utiliza notificaciones autogestionadas (como "inotify") o RPC de espacio de usuario con una parte determinada del ciclo de vida de la app para manejarla en vez de depender de la interrupción de la app.
  • De ser posible, limita el alcance de las señales a una subsección de tu programa o hilo con sigprocmask, lo que reduce la cantidad de código que es necesario analizar de manera regular para controlar que las señales estén correctas. Ten presente que, si cambian las estrategias de hilos o los procesos de código, es posible que la máscara no tenga el efecto que pretendías que tenga.
  • Cuando se inicia el daemon, oculta las señales terminales que no se entienden de manera uniforme y cuya finalidad podría replantearse en algún punto de tu programa con el fin de evitar que se produzca nuevamente el comportamiento predeterminado del kernel. Sugiero hacer lo siguiente:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

Es sumamente complicado comprender el comportamiento de las señales, incluso en los programas bien escritos, y su uso representa un riesgo innecesario en las apps en las que hay disponibles otras alternativas. En términos generales, no uses señales para comunicarte con la porción del espacio de usuario de tu programa. En su lugar, permite que el programa maneje los eventos por cuenta propia y de manera transparente (por ejemplo, con "inotify"), o bien usa la comunicación del espacio de usuario que puede reportar errores al emisor y que se puede enumerar y comprobar en el tiempo de compilación, como Thrift, gRPC o similares.

Espero que este artículo te haya demostrado que, aunque puedan parecer ostensiblemente simples, las señales, en realidad, bastante distan de serlo. La apariencia de simplicidad que pregona su uso como API para un software de espacio personal contradice una serie de decisiones de diseño implícitas que no son compatibles con la mayoría de los casos de uso de producción de la era moderna.

Seamos claros: sí existen casos de uso válidos para las señales. Las señales funcionan para la comunicación básica con el kernel respecto de un estado de proceso deseado cuando no existe ningún componente de espacio de usuario, por ejemplo, que se debe matar un proceso. Sin embargo, resulta difícil escribir un código con las señales correctas la primera vez que se hace, cuando se espera que las señales estén atrapadas en el espacio de usuario.

Es posible que las señales parezcan atractivas por su nivel de estandarización, por estar ampliamente disponibles y por la falta de dependencias, pero aparejadas con una gran cantidad de dificultades que solo generan más preocupación a medida que crece tu proyecto. Esperamos que este artículo te haya aportado algunas soluciones y alternativas que aun te permitan lograr tus metas, pero de una manera más segura, menos compleja y más intuitiva.

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