Zurück zu den Neuigkeiten für Entwickler

Signale in der Produktion: Gefahren und Hürden

27. September 2022VonChris Down

In diesem Blog-Post erläutert Chris Down, Kernel Engineer bei Meta, die Gefahren im Zusammenhang mit Linux-Signalen in Linux-Produktionsumgebungen und erklärt, warum Entwickler*innen Signale nach Möglichkeit vermeiden sollten.

Was sind Linux-Signale?

Ein Signal ist ein Event, das Linux-Systeme als Antwort auf eine Bedingung generieren. Signale können vom Kernel an einen Prozess, von einem Prozess an einen anderen Prozess oder von einem Prozess an sich selbst gesendet werden. Wenn ein Prozess ein Signal empfängt, kann er eine Aktion ausführen.

Signale sind ein wesentlicher Bestandteil von Unix-Betriebsumgebungen. Sie werden schon seit einer halben Ewigkeit genutzt. Auf ihnen beruhen viele der Kernkomponenten eines Betriebssystems – Core-Dumping, Prozesslebenszyklus-Management usw. Im Allgemeinen haben sie sich in den etwa 50 Jahren, die wir sie jetzt schon nutzen, als nützlich erwiesen. Wenn jetzt also jemand sagt, dass ihre Verwendung für die Interprozesskommunikation (Interprocess Communication, kurz IPC) potenziell Gefahren bergen kann, hört sich das an, als würde man verzweifelt versuchen, das Rad neu zu erfinden. Dieser Artikel soll aber Fälle aufzeigen, bei denen Signale zu Produktionsproblemen geführt haben, und ein paar potenzielle Lösungen und Alternativen vorschlagen.

Signale scheinen aufgrund ihrer Standardisierung, ihrer breiten Verfügbarkeit und der Tatsache, dass sie keine zusätzlichen Abhängigkeiten außerhalb des Betriebssystems erfordern, attraktiv zu sein. Ihre sichere Verwendung kann sich aber als schwierig erweisen. Signale treffen unglaublich viele Annahmen, die unbedingt geprüft werden müssen, um ihre Anforderungen zu erfüllen. Wenn nicht, müssen sie sorgfältig konfiguriert werden. In der Realität ist das in vielen Anwendungen, selbst weithin bekannten, nicht der Fall. So können in Zukunft Vorfälle entstehen, die sich nur schwer debuggen lassen.

Sehen wir uns einen Vorfall an, der vor Kurzem in der Meta-Produktionsumgebung aufgetreten ist und die Gefahren von Signalen veranschaulicht. Zuerst gehen wir kurz auf die Geschichte einiger Signale und die Entwicklung bis heute ein. Dann stellen wir dem unsere aktuellen Anforderungen und Probleme in der Produktion gegenüber.

Der Vorfall

Fangen wir ganz am Anfang an. Das LogDevice-Team hat seine Codebase bereinigt, um nicht genutzten Code und Features zu entfernen. Eines der veralteten Features war ein Protokolltyp, in dem bestimmte vom Service ausgeführte Vorgänge dokumentiert wurden. Dieses Feature war am Ende redundant und wurde nicht genutzt. Also wurde es entfernt. Die Änderung ist hier auf GitHub zu sehen. So weit, so gut.

In der ersten Zeit nach der Änderung gab es keine Probleme. Der Produktionsbetrieb wurde normal fortgesetzt und hat Traffic wie immer verarbeitet. Nach ein paar Wochen wurde gemeldet, dass eine enorme Anzahl an Service-Nodes verloren ging. Das Problem hatte irgendetwas mit dem Rollout des neuen Release zu tun, aber niemand wusste genau, was eigentlich die Ursache war. Wie kam es zu diesen Schwierigkeiten?

Das betroffene Team grenzte das Problem auf die oben genannte Codeänderung ein, also das Verwerfen dieser Protokolle. Aber warum? Was ist denn nicht in Ordnung mit diesem Code? Wenn du es noch nicht weißt, solltest du dir die Diff ansehen und versuchen, es selber herauszufinden. Der Fehler ist nämlich nicht sofort ersichtlich und könnte jedem passieren.

Hier kommt logrotate ins Spiel

logrotate ist mehr oder weniger das Standardtool für die Protokollrotation in Linux. Das Tool gibt es jetzt schon seit fast 30 Jahren und nutzt ein ganz einfaches Konzept: Verwaltung des Lebenszyklus von Protokollen durch Rotation und Leeren.

logrotate sendet selbst keine Signale. Deshalb werden diese auf der logrotate-Hauptseite oder seiner Dokumentation gar nicht oder kaum erwähnt. Für logrotate können aber Befehle festgelegt werden, die vor oder nach der Rotation ausgeführt werden. Hier siehst du ein ganz einfaches Beispiel für die logrotate-Standardkonfiguration in 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
}

Wir übersehen jetzt mal alle Mängel und gehen davon aus, das funktioniert wie gedacht. Gemäß dieser Konfiguration soll logrotate nach der Rotation einer der aufgelisteten Dateien SIGHUP an die PID in /var/run/syslogd.pid senden. Das sollte die PID der laufenden syslogd-Instanz sein.

Das ist ja schön und gut mit einer stabilen öffentlichen API wie syslog. Wie ist das aber, wenn die Implementierung von SIGHUP eine interne Komponente ist, die sich jederzeit ändern kann?

Eine problematische Vorgeschichte

Eines der Probleme hier ist, dass die semantische Bedeutung von Signalen, außer bei Signalen, die nicht im Nutzungsbereich abgefangen werden können und daher nur eine Bedeutung haben, wie SIGKILL und SIGSTOP, von den App-Entwickler*innen und Nutzer*innen interpretiert und programmiert wird. In einigen Fällen macht das keinen Unterschied, wie bei SIGTERM, das eigentlich immer als „so bald wie möglich ordnungsgemäß beenden“ verstanden wird. Im Fall von SIGHUP ist die Bedeutung aber weitaus weniger eindeutig.

SIGHUP wurde für serielle Verbindungen entwickelt und gab ursprünglich an, dass das andere Ende die Verbindung unterbrochen hat. Heutzutage wird SIGHUP noch für sein modernes Äquivalent gesendet: wenn ein Pseudo- oder virtuelles Terminal geschlossen wird. (Mit Tools wie nohup kann das Signal dabei unterdrückt werden).

In den Anfangszeiten von Unix mussten wiederholte Daemon-Ladevorgänge implementiert werden. Dabei wurden in der Regel zumindest eine Konfigurations-/Protokolldatei ohne Neustart neu geöffnet, und Signale schienen eine Möglichkeit, das ohne Abhängigkeiten zu erreichen. Es gab natürlich kein Signal dafür. Da diese Daemons aber kein Steuerterminal haben, sollten sie SIGHUP aus keinem anderen Grund empfangen. Daher bot sich dieses Signal dafür an, es ohne offensichtliche Nebenwirkungen für diesen Zweck einzusetzen.

Dieser Plan hat aber einen kleinen Nachteil. Der Standardstatus für Signale ist nicht „ignoriert“, sondern signalspezifisch. Programme müssen SIGTERM also beispielsweise nicht manuell konfigurieren, um ihre Anwendung zu beenden. Solange sie keinen anderen Signal-Handler festlegen, beendet der Kernel das Programm einfach, ohne dass Code im Nutzungsbereich erforderlich ist. Wie praktisch!

Nicht so praktisch ist aber, dass SIGHUP als Standardverhalten ebenfalls das Programm sofort beendet. Das eignet sich hervorragend für den ursprünglichen Beendigungsfall, wenn diese Anwendungen wahrscheinlich nicht mehr benötigt werden, aber nicht so sehr für diese neue Bedeutung.

Das wäre natürlich kein Problem, wenn wir alle Stellen entfernen, die potenziell SIGHUP an das Programm senden könnten. In großen, ausgereiften Codebases ist das aber schwierig. SIGHUP ist kein eng kontrollierter IPC-Aufruf, nach dem wir ganz einfach mit „Grep“ in der Codebase suchen können. Signale können jederzeit von überall gesendet werden, mit nur wenigen Prüfungen ihrer Nutzung (außer ganz simplen Checks wie „bist du diese*r Nutzer*in oder hast du CAP_KILL“). Fazit ist, dass wir nur schwer bestimmen können, von wo Signale gesendet werden könnten. Mit expliziterer IPC wissen wir aber, dass dieses Signal für uns nicht von Bedeutung ist und ignoriert werden sollte.

Von Problem zu Gefahr

Jetzt könnt ihr euch wahrscheinlich schon vorstellen, was passiert ist. Ein LogDevice-Release wurde mit der oben erwähnten, verhängnisvollen Codeänderung eingeführt. Erst ging nichts schief, aber um Mitternacht am nächsten Tag fingen die Probleme an. Grund war der folgende Abschnitt in der logrotate-Konfiguration des Geräts, der einen jetzt nicht verarbeiteten (und daher als schwerwiegender Fehler geltenden) SIGHUP an den logdevice-Daemon sendet:

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

Wenn man ein großes Feature entfernt, hat man sehr schnell einen kurzen Abschnitt einer logrotate-Konfiguration übersehen. Leider kann man auch kaum feststellen, ob jeder Bestandteil davon gleichzeitig entfernt wurde. Selbst bei Fällen, in denen die Validierung einfacher ist als hier, bleiben oft Rückstände nach der Codebereinigung übrig. Meistens hat das keine schwerwiegenden Folgen. Der Rest ist einfach unwirksamer Code oder no-op-Code.

Im Grunde sind der Vorfall selbst und seine Lösung einfach: Sende kein SIGHUP, und verteile LogDevice-Aktionen zeitlich mehr (das heißt, führe das nicht um Punkt Mitternacht aus). Wir sollten uns aber nicht nur auf die Feinheiten dieses einen Vorfalls konzentrieren. Dieser Vorfall veranschaulicht in erster Linie, dass Signale in der Produktion ausschließlich für die grundlegendsten Fälle eingesetzt werden sollten.

Die Gefahren von Signalen

Zweck von Signalen

Vorweg: Die Verwendung von Signalen als Mechanismus, um Änderungen im Prozessstatus des Betriebssystems zu bewirken, hat durchaus ihre Begründung. Dazu gehören Signale wie SIGKILL, für die kein Signal-Handler installiert werden kann und die eine eindeutige Handlung ausführen, und das Kernel-Standardverhalten von SIGABRT, SIGTERM, SIGINT, SIGSEGV, SIGQUIT und Ähnlichen, die im Allgemeinen für Nutzer*innen und Programmierer*innen leicht verständlich sind.

All diese Signale haben eins gemeinsam: Ihr Empfang führt zu einem beendenden Endstatus im Kernel selbst. Nach Erhalt von SIGKILL oder SIGTERM ohne Signal-Handler im Nutzungsbereich werden also keine Nutzungsbereichsanweisungen mehr ausgeführt.

Ein beendender Endstatus ist wichtig, weil du damit in der Regel die Komplexität des Stacks und den derzeit ausgeführten Code reduzierst. Andere erwünschte Statusangaben führen oft zu höherer Komplexität und komplexerer Logik, während Nebenläufigkeit und Codefluss unübersichtlicher werden.

Gefährliches Standardverhalten

Du hast vielleicht gemerkt, dass wir einige andere Signale, die Prozesse auch standardmäßig beenden, nicht erwähnt haben. Die folgenden Standardsignale beenden Prozesse standardmäßig (außer Code-Dump-Signale wie SIGABRT oder SIGSEGV, da diese sinnvoll sind):

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

Auf den ersten Blick scheinen diese Signale angemessen, aber es gibt einige Ausreißer:

  • SIGHUP: Wenn dieses Signal ausschließlich wie ursprünglich beabsichtigt verwendet wird, wäre die standardmäßige Beendigung sinnvoll. Mit der aktuellen gemischten Nutzung zum erneuten Öffnen von Dateien wird es gefährlich.
  • SIGPOLL und SIGPROF: Diese Signale sollten intern von einer Standardfunktion und nicht vom Programm verarbeitet werden. Sie sind zwar wahrscheinlich harmlos, aber das Standardverhalten zum Beenden ist dennoch nicht ideal.
  • SIGUSR1 und SIGUSR2: Dabei handelt es sich um „benutzerdefinierte Signale“, die du augenscheinlich nach Belieben verwenden kannst. Da sie aber standardmäßig beendend sind, kannst du, wenn du USR1 aus einem bestimmten Grund implementierst und später nicht mehr brauchst, den Code nicht einfach sicher entfernen. Du musst daran denken, das Signal explizit zu ignorieren. Selbst erfahrene Programmierer*innen können das übersehen.

Fast ein Drittel der beendenden Signale ist also im besten Fall fragwürdig und im schlimmsten Fall gefährlich, wenn Programme geändert werden müssen. Noch schlimmer: Selbst die angeblich „benutzerdefinierten“ Signale können zu einer Katastrophe führen, wenn jemand vergisst, sie explizit mit SIG_IGN zu ignorieren. Selbst eine eigentlich harmlose Nutzung von SIGUSR1 oder SIGPOLL kann Vorfälle verursachen.

Dabei geht es nicht nur um deine Kenntnisse. Ganz egal, wie gut du dich mit Signalen auskennst – es ist dennoch extrem schwierig, Code mit richtigen Signalen beim ersten Mal zu schreiben. Entgegen ihrer Erscheinung sind Signale nämlich viel komplexer als es scheint.

Codefluss, Nebenläufigkeit und der Mythos von SA_RESTART

Programmierer*innen denken in der Regel nicht den ganzen Tag über die Funktionsweise von Signalen nach. Deshalb unterlaufen ihnen bei der tatsächlichen Implementierung von Signalverarbeitung oft geringfügige Fehler.

Damit meine ich noch nicht einmal die „trivialen“ Fälle, wie die Sicherheit in einer Signalverarbeitungsfunktion, die meist einfach durch Bumping von sig_atomic_t oder mit atomic_signal_fence von C++ gelöst werden können. Nein. Diese Fälle sind in der Regel einfach auffindbar und als Hürden bekannt, wenn man sich erst einmal durch Signale gekämpft hat. Viel schwieriger ist die Logik des Codeflusses der nominalen Teile eines komplexen Programms bei Empfang eines Signals. Dazu muss man andauernd und explizit bei jedem Punkt des Anwendungslebenszyklus über Signale nachdenken. (Was ist mit EINTR, ist SA_RESTART hier ausreichend? Welcher Ablauf soll gestartet werden, wenn dieser Prozess vorzeitig beendet wird? Welche Auswirkungen hat mein nebenläufiges Programm?) Oder man muss sigprocmask oder pthread_setmask für einen Teil des Anwendungslebenszyklus einrichten und hoffen, dass der Codefluss sich nie ändert (was bei der schnelllebigen Entwicklung sehr unwahrscheinlich ist). Die Ausführung von signalfd oder sigwaitinfo in einem dedizierten Thread kann hier etwas Abhilfe schaffen, aber beide Signale sind aufgrund ihrer Ausnahmefälle und Nutzbarkeitsprobleme nicht unbedingt zu empfehlen.

Sicherlich wissen erfahrene Programmierer*innen mittlerweile, dass es sehr schwierig ist, Code mit sicheren Threads korrekt zu schreiben. Ihr dachtet vielleicht, Code mit sicheren Threads sei schwierig, aber Signale sind noch viel schwieriger. Signal-Handler dürfen ausschließlich auf Code ohne Sperren mit atomaren Datenstrukturen basieren, da sonst der Hauptausführungsfluss unterbrochen wird und wir nicht wissen, welche Sperren enthalten sind, und da der Hauptausführungsfluss sonst nicht-atomare Vorgänge ausführen könnte. Sie müssen außerdem in sich selbst verschachtelt werden können, da Signal-Handler sich überschneiden können, wenn ein Signal mehrmals gesendet wird (oder sogar mit einem Signal mit SA_NODEFER). Das ist einer der Gründe, warum du keine Funktionen wie printf oder malloc in einem Signal-Handler verwenden kannst, da sie auf globalen Mutexen für die Synchronisierung basieren. Wenn du beim Signalempfang eine Sperre hältst und dann eine Funktion aufrufst, die diese Sperre erneut benötigt, führt das zu einem Anwendungs-Deadlock. Das kann man nur sehr schwer mit Logik umgehen. Deshalb schreiben viele Entwickler*innen einfach ähnlichen Code wie den Folgenden als Signalverarbeitung:

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

Das Problem ist, dass dieser Versuch, signalfd oder andere Ansätze für die asynchrone Signalverarbeitung zwar recht einfach und robust erscheinen, aber ignorieren, dass der Unterbrechungspunkt genauso wichtig wie die Handlungen sind, die nach Signalempfang ausgeführt werden. Angenommen, der Nutzungsbereichscode führt I/O aus oder ändert die Metadaten von Objekten aus dem Kernel (wie inodes oder FDs). In diesem Fall befindest du dich zum Zeitpunkt der Unterbrechung wahrscheinlich in einem Kernel-Bereichs-Stack. So könnte beispielsweise ein Thread aussehen, wenn er versucht, einen Dateideskriptor zu schließen:

# 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

Hierbei ist __x64_sys_close die x86_64-Variante des Systemaufrufs close, der einen Dateideskriptor schließt. Zu diesem Punkt der Ausführung warten wir auf die Aktualisierung des sekundären Speichers (wait_on_page_bit). Da I/O-Vorgänge in der Regel viel langsamer als andere Vorgänge sind, kannst du hier mit schedule für den CPU-Scheduler des Kernels angeben, dass jetzt ein Vorgang mit hoher Latenz (wie Datenträger- oder Netzwerk-I/O) ausgeführt wird und fürs Erste ein anderer Prozess anstelle des aktuellen Prozesses geplant werden sollte. Das ist nützlich, da wir damit dem Kernel signalisieren können, dass er einen Prozess auswählen sollte, der die CPU auch tatsächlich nutzt, anstatt auf einen Prozess zu warten, der erst abgeschlossen werden kann, wenn eine Antwort von einem anderen, möglicherweise langsamen, Prozess eingegangen ist.

Angenommen, wir senden ein Signal an den Prozess, den wir ausgeführt haben. Das gesendete Signal hat einen Nutzungsbereichs-Handler im Empfänger-Thread. Also wird der Vorgang im Nutzungsbereich fortgesetzt. Dieses Rennen kann auf viele Arten enden. Der Kernel könnte beispielsweise versuchen, schedule zu verlassen und den Stack weiter abzuarbeiten, und gibt schließlich die Fehlernummer ESYSRESTART oder EINTR an den Nutzungsbereich aus, um eine Unterbrechung anzugeben. Wie weit war der Vorgang zum Schließen aber fortgeschritten? Welchen Status hat der Dateideskriptor jetzt?

Nach der Rückkehr zum Nutzungsbereich führen wir den Signal-Handler aus. Nach Beendigung des Signal-Handlers propagieren wir den Fehler zum close-Wrapper von libc im Nutzungsbereich und dann zur Anwendung, die theoretisch eine Handlung zur vorliegenden Situation ausführen kann. Ich sage hier „theoretisch“, weil man kaum bestimmen kann, wie mit vielen dieser Signalsituationen umgegangen werden sollte. Viele Services in der Produktion weisen hierbei Mängel auf. In Anwendungen, bei denen die Datenintegrität nicht so wichtig ist, mag das ja in Ordnung sein. Bei Produktionsanwendungen, bei denen die Datenkonsistenz und -integrität durchaus eine große Rolle spielen, stellt das ein erhebliches Problem dar: Der Kernel eröffnet keine granulare Möglichkeit, zu bestimmen, wie weit der Prozess fortgeschritten war, was erreicht wurde und was nicht und wie die Situation eigentlich verarbeitet werden sollte. Noch schlimmer: Wenn closeEINTR zurückgibt, ist der Status des Dateideskriptors jetzt unbestimmt:

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

Wie willst du da erreichen, dass die Anwendung sicher damit umgeht? Im Allgemeinen ist die Verarbeitung von EINTR selbst bei einwandfreien Systemaufrufen kompliziert. Es gibt viele subtile Probleme, die allesamt dazu führen, dass SA_RESTART nicht ausreicht. Nicht alle Systemaufrufe können neu gestartet werden. Wenn du außerdem erwartest, dass jede*r einzelne Entwickler*in einer Anwendung die vielseitigen Nuancen beim Abrufen eines Signals für jeden Systemaufruf an jedem Aufrufpunkt versteht und eindämmen kann, musst du eigentlich mit Ausfällen rechnen. Von 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 [...]”

Gleichermaßen fordert man Fehler nahezu heraus, wenn man sigprocmask verwendet und erwartet, dass der Codefluss statisch bleibt. Immerhin denken Entwickler*innen nicht andauernd an die Einschränkungen der Signalverarbeitung oder daran, wie sie Code mit korrekten Signalen erzeugen oder aufrecht erhalten können. Dasselbe gilt für die Verarbeitung von Signalen in einem dedizierten Thread mit sigwaitinfo. Dabei kann es schnell passieren, dass GDB und ähnliche Tools den Prozess nicht debuggen können. Geringfügige Fehler in Codeflüssen oder beim Umgang mit Fehlern können zu Bugs, Abstürzen, schwer zu debuggenden Beschädigungen, Deadlocks und vielen weiteren Problemen führen, für die dein bevorzugtes Vorfallsverwaltungstool herangezogen werden muss.

Hohe Komplexität in Umgebungen mit Multithreading

Wenn du jetzt gedacht hast, Nebenläufigkeit, Neustartbarkeit und Atomarität seien schon kompliziert genug, wird das Ganze noch viel schlimmer, wenn Multithreading ins Spiel kommt. Das ist besonders wichtig, weil viele komplexe Anwendungen implizit separate Threads ausführen, beispielsweise im Rahmen von jemalloc, GLib o. Ä. Einige dieser Bibliotheken installieren sogar selbst Signal-Handler, um das Ganze noch komplizierter zu machen.

man 7 signal sagt dazu Folgendes:

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

Kurz: „Für die meisten Signale sendet der Kernel das Signal an irgendeinen Thread, bei dem dieses Signal nicht mit sigprocmask blockiert ist.“ SIGSEGV, SIGILL und ähnliche Signale ähneln Traps und richten Signale explizit auf den betreffenden Thread aus. Die meisten Signale können aber nicht explizit an einen einzelnen Thread in einer Thread-Gruppe gesendet werden, selbst mit tgkill oder pthread_kill.

Das bedeutet, dass man allgemeine Signalverarbeitungsmerkmale nicht einfach ändern kann, wenn eine Gruppe von Threads vorliegt. Wenn ein Service periodische Signalblockierung mit sigprocmask im Haupt-Thread benötigt, musst du anderen Threads irgendwie extern mitteilen, wie sie damit umgehen sollen. Andernfalls kann das Signal von einem anderen Thread angenommen werden und verschwindet somit. Du kannst natürlich Signale in untergeordneten Threads blockieren, um das zu vermeiden. Wenn diese aber ihre eigene Signalverarbeitung erfordern, selbst für einfache Fälle wie waitpid, wird das Ganze komplex.

All diese Probleme sind an sich nicht unüberwindbar. Man darf aber nicht ignorieren, dass die dafür erforderliche komplexe Synchronisierung aufwendig ist und zu Fehlern, Verwirrung oder Schlimmerem führen kann.

Mangel an Definition und Kommunikation von Erfolgs- oder Fehlerstatus

Signale werden asynchron im Kernel propagiert. Der Systemaufruf kill wird zurückgegeben, sobald das ausstehende Signal für den Prozess oder die task_struct des Threads aufgezeichnet wird. Daher ist die zeitnahe Zustellung nicht garantiert, selbst wenn das Signal nicht blockiert wird.

Selbst wenn das Signal zeitnah übermittelt wird, kann dem*der Signalaussteller*in nicht mitgeteilt werden, wie der Status der Anfrage lautet. Daher darf keine bedeutende Handlung von Signalen ausgeführt werden. Bei ihnen besteht nämlich keine Möglichkeit, den Erfolgs- oder Fehlerstatus von Zustellung und nachfolgenden Handlungen zu melden. Wie bereits erwähnt, können selbst scheinbar harmlose Signale gefährlich werden, wenn sie nicht im Nutzungsbereich konfiguriert sind.

Wir alle, die Linux schon lange verwenden, haben es schon einmal erlebt, dass ein Prozess abgebrochen werden sollte, dieser aber nicht reagiert.Nicht einmal auf Signale, die eigentlich immer zu einem Abbruch führen sollten, wie SIGKILL. Das Problem ist, dass es nicht Zweck von kill(1) ist, Prozesse abzubrechen, sondern nur, eine Anfrage an den Kernel in die Warteschlange zu stellen (ohne Angabe zum Zeitpunkt der Verarbeitung).

Der Systemaufruf kill dient dazu, das Signal in den Aufgabenmetadaten des Kernels als ausstehend zu markieren. Das wird auch erfolgreich ausgeführt, selbst wenn eine SIGKILL-Aufgabe nicht beendet wird. Im Falle von SIGKILL garantiert der Kernel, dass keine weiteren Nutzungsmodusanweisungen ausgeführt werden. Wir müssen aber möglicherweise noch Anweisungen im Kernel-Modus ausführen, um Handlungen abzuschließen, die ansonsten zu Datenbeschädigung führen, oder um Ressourcen freizugeben. Aus diesem Grund sind wir auch dann erfolgreich, wenn der Status D lautet (ununterbrechbarer Schlaf). „Kill“ selbst schlägt nur fehl, wenn du ein ungültiges Signal angegeben hast, du nicht zum Senden dieses Signals berechtigt bist oder die angeforderte Ziel-PID für das Signal nicht vorhanden ist und nicht-beendende Statuswerte daher nicht zuverlässig an Anwendungen propagiert werden können.

Fazit

  • Signale eignen sich für Endstatuswerte, die ausschließlich im Kernel ohne Nutzungsbereichs-Handler verarbeitet werden. Wenn Signale das Programm sofort beenden sollen, müssen sie allein vom Kernel verarbeitet werden. Das bedeutet auch, dass der Kernel seine Arbeit frühzeitig beenden kann, sodass Programmressourcen schneller freigegeben werden. Eine IPC-Anfrage im Nutzungsbereich müsste dagegen warten, bis der Nutzungsbereichsteil wieder ausgeführt wird.
  • Wenn du Probleme bei der Signalverarbeitung vermeiden möchtest, könntest du einfach gar keine Signale verarbeiten. Verwende aber bei Anwendungen mit Statusverarbeitung, die Fälle wie SIGTERM verarbeiten müssen, am besten eine API auf hoher Ebene wie folly::AsyncSignalHandler. Damit werden einige Problempunkte intuitiver.

  • Nutze keine kommunizierenden Anwendungsanfragen mit Signalen. Verwende selbst verwaltete Benachrichtigungen (wie inotify) oder Nutzungsbereichs-RPC mit einem dedizierten Teil des Anwendungslebenszyklus, anstatt die Anwendung zu unterbrechen.
  • Begrenze nach Möglichkeit den Umfang von Signalen auf einen Teilbereich von Programmen oder Threads mit sigprocmask. So muss weniger Code regelmäßig auf korrekte Signale überprüft werden. Denke daran, dass die Maske bei Änderungen an Codeflüssen oder Threading-Strategien möglicherweise nicht wie beabsichtigt funktioniert.
  • Maskiere beim Daemon-Start beendende Signale, die keine eindeutige Bedeutung haben und in deinem Programm für einen anderen Zweck eingesetzt werden können, um den Fallback auf das Kernel-Standardverhalten zu vermeiden. Ich empfehle Folgendes:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

Logik für Signalverhalten ist selbst in gut geschriebenen Programmen extrem kompliziert. Du gehst mit der Verwendung von Signalen unnötige Risiken mit Anwendungen ein, für die auch Alternativen in Frage kommen. Verwende im Allgemeinen keine Signale für die Kommunikation mit dem Nutzungsbereichsteil deines Programms. Stattdessen sollte das Programm Events transparent selbst verarbeiten (zum Beispiel mit inotify) oder Nutzungsbereichs-Kommunikation verwendet werden, die Fehler an den*die Aussteller*in melden kann und zur Kompilierungszeit enumerierbar und demonstrierbar ist, wie Thrift, gRPC o. Ä.

Ich hoffe, dieser Artikel hat veranschaulicht, dass Signale alles andere als einfach sind, obwohl sie simpel erscheinen. Wenn man sie aufgrund ihrer augenscheinlichen Einfachheit als API für Nutzerbereichs-Software verwendet, muss man eine Reihe impliziter Designentscheidungen treffen, die mit den meisten modernen Produktions-Anwendungsfällen nicht vereinbar sind.

Versteht mich aber nicht falsch: Es gibt Anwendungsfälle, bei denen sich Signale anbieten. Signale eignen sich für die einfache Kommunikation mit dem Kernel zu einem gewünschten Prozessstatus, wenn keine Nutzungsbereichskomponente beteiligt ist, z. B. wenn ein Prozess beendet werden soll. Es ist aber schwer, direkt beim ersten Mal Code mit korrekten Signalen zu schreiben, wenn Signale erwartungsgemäß im Nutzungsbereich festgehalten werden sollten.

Signale erscheinen aufgrund ihrer Standardisierung, breiten Verfügbarkeit und ihrem Mangel an Abhängigkeiten attraktiv, bringen aber zahlreiche Gefahren mit sich, die bei wachsenden Projekten nur noch zunehmen. Hoffentlich hast du in diesem Artikel einige Lösungsstrategien und Alternativen gefunden, mit denen du deine Ziele auf sichere, weniger komplexe und intuitivere Weise erreichen kannst.

Um mehr über Meta Open Source zu erfahren, besuche unsere Open-Source-Website, abonniere unseren YouTube-Kanal oder folge uns auf Twitter, Facebook und LinkedIn.