post

EventEmitter: das Ereignismodell im Kern von Node.js

EventEmitter ist der gemeinsame Nenner unter Streams, HTTP-Server und Prozessen: Ich zeige on/once/off/emit, das Sonderverhalten von 'error' und wie man Listener-Lecks vermeidet.

In fast jeder meiner Node.js-Schulungen kommt irgendwann der Moment, in dem jemand auf einen HTTP-Server, einen Stream oder ein Kindprozess-Objekt zeigt und fragt: "Warum sieht dieses .on('...') überall gleich aus?" Genau das ist der interessante Punkt. Diese Ähnlichkeit ist kein Zufall und keine Konvention, die man freiwillig einhält – sie kommt daher, dass unter den meisten Objekten in Node dieselbe Maschinerie steckt: der EventEmitter. Wer diese eine Klasse wirklich verstanden hat, hat gleichzeitig verstanden, wie ein großer Teil der Standardbibliothek tickt.

In diesem Artikel nehme ich den EventEmitter auseinander, so wie ich es im Training tue: erst die Idee dahinter, dann die konkrete API, dann die zwei, drei Stellen, an denen es in der Praxis wehtut. Wenn du dir davor noch einmal ansehen willst, warum Node überhaupt ereignisgetrieben arbeitet, empfehle ich meinen Beitrag zum Node.js-Training rund um den Event Loop. Hier geht es um die Schicht direkt darüber.

Der gemeinsame Nenner unter allem

Node.js ist im Kern ereignisgetrieben. Ein HTTP-Server wartet nicht in einer Schleife auf die nächste Anfrage, sondern meldet an: "Wenn eine Anfrage kommt, ruf diese Funktion." Ein Lesestrom sagt nicht "gib mir die Daten", sondern "sag mir Bescheid, wenn ein Datenstück da ist". Dieses Muster – Interesse anmelden, später benachrichtigt werden – zieht sich durch die gesamte Plattform.

Damit dieses Muster nicht in jedem Modul neu erfunden wird, gibt es eine gemeinsame Basisklasse. net.Server, http.Server, die Stream-Klassen Readable und Writable, das globale process-Objekt, die Objekte aus child_process – sie alle sind Emitter oder erben von EventEmitter. Das erklärt, warum sich .on() überall gleich anfühlt: Es ist buchstäblich dieselbe Methode.

classDiagram
  class EventEmitter {
    +on(event, listener)
    +once(event, listener)
    +off(event, listener)
    +emit(event, ...args)
  }
  EventEmitter <|-- HttpServer
  EventEmitter <|-- Readable
  EventEmitter <|-- Writable
  EventEmitter <|-- Process
  EventEmitter <|-- MyEmitter
  class HttpServer["http.Server"]
  class Readable["stream.Readable"]
  class Writable["stream.Writable"]
  class Process["process"]
  class MyEmitter["eigene Klasse"]

Die Klasse selbst liegt im eingebauten Modul events. Man erreicht sie klassisch über require('events') oder – seit Node 16 möglich – über den Prefix require('node:events'). Beide Schreibweisen liefern dieselbe Klasse; der Prefix macht nur unmissverständlich klar, dass ein eingebautes Modul gemeint ist und kein Paket aus node_modules.

Die vier Methoden, um die sich alles dreht

Die API des Emitters ist erstaunlich klein. Vier Methoden reichen für den Alltag. Drei davon – on, once und off – geben den Emitter selbst zurück und sind damit verkettbar; emit bildet die Ausnahme und liefert stattdessen einen boolean, wie wir gleich sehen werden.

const { EventEmitter } = require('node:events');

const emitter = new EventEmitter();

function onTask(job) {
  console.log('received task', job.id);
}

emitter.on('task', onTask);        // register a listener
emitter.emit('task', { id: 1 });   // trigger it synchronously
emitter.off('task', onTask);       // remove it again

on(eventName, listener) hängt einen Listener ans Ende der Liste für dieses Event. emit(eventName, ...args) ruft alle registrierten Listener auf und reicht die zusätzlichen Argumente durch. off(eventName, listener) – Alias removeListener – nimmt einen Listener wieder heraus. Und once(eventName, listener) registriert einen Listener, der nach dem ersten Auslösen automatisch verschwindet.

Zwei Eigenschaften von emit sind für das Verständnis entscheidend, und ich betone sie im Training regelmäßig:

  • emit ruft die Listener synchron auf. Es gibt hier nichts Asynchrones, keinen Umweg über den Event Loop, keine Verzögerung. Wenn emit zurückkehrt, sind alle Listener durchgelaufen.
  • Die Listener laufen in der Reihenfolge ihrer Registrierung. Wer zuerst on() aufgerufen hat, dessen Funktion läuft zuerst.
sequenceDiagram
  participant C as Aufrufer
  participant E as EventEmitter
  participant L1 as Listener 1
  participant L2 as Listener 2
  C->>E: emit('task', data)
  E->>L1: listener(data)
  L1-->>E: fertig
  E->>L2: listener(data)
  L2-->>E: fertig
  E-->>C: return true

Der Rückgabewert von emit ist ein boolean: true, wenn es für dieses Event mindestens einen Listener gab, sonst false. Was die Listener selbst zurückgeben, interessiert den Emitter nicht – Rückgabewerte von Listenern werden schlicht ignoriert. Wer aus einem Listener heraus etwas nach draußen melden will, muss das über einen anderen Weg tun, etwa ein weiteres Event oder einen geteilten Zustand.

on, once und off im Zusammenspiel

Der Unterschied zwischen on und once klingt harmlos, hat aber Konsequenzen. Ein mit once registrierter Listener wird vor seinem eigenen Aufruf aus der Liste entfernt und dann ausgeführt. Er feuert also garantiert genau einmal, egal wie oft das Event danach noch ausgelöst wird.

const { EventEmitter } = require('node:events');
const queue = new EventEmitter();

let handled = 0;
queue.on('task', () => { handled += 1; });

let drained = 0;
queue.once('drain', () => { drained += 1; });

queue.emit('task');   // handled = 1
queue.emit('task');   // handled = 2
queue.emit('drain');  // drained = 1
queue.emit('drain');  // drained stays 1 - the listener is gone

Beim Entfernen lohnt ein genauer Blick. off entfernt höchstens eine Instanz eines Listeners. Wenn man dieselbe Funktion versehentlich zweimal mit on registriert hat, braucht man auch zwei off-Aufrufe, um sie vollständig loszuwerden. Und man kann nur entfernen, was man auch benennen kann – eine anonyme Arrow-Function, die man direkt an on übergibt, hat man nachher nicht mehr in der Hand. Deshalb registriere ich Listener, die ich später wieder abmelden will, immer als benannte Funktion oder als Variable.

Hier fasse ich die Semantik zusammen, die man sich merken sollte:

  • on – Listener bleibt registriert und läuft bei jedem emit erneut.
  • once – Listener läuft genau einmal und entfernt sich vorher selbst.
  • off – entfernt eine Instanz eines benannten Listeners; anonyme Funktionen bekommt man so nicht mehr weg.
  • emit – ruft alle Listener synchron in Registrierungsreihenfolge auf und liefert true/false zurück.

Neben diesen vieren gibt es noch nützliche Randmethoden: prependListener hängt einen Listener an den Anfang statt ans Ende, removeAllListeners räumt komplett auf, listeners(eventName) gibt eine Kopie der aktuellen Listener-Liste zurück und eventNames() verrät, für welche Events überhaupt jemand registriert ist. Man braucht sie selten, aber wenn man sie braucht, sind sie Gold wert – etwa beim Debuggen der Frage "wer hört hier eigentlich alles zu?".

Das error-Event ist ein Sonderfall

Wenn es eine Sache gibt, die ich in jedem Node-Training mit Nachdruck sage, dann diese: Das Event 'error' verhält sich anders als alle anderen. Bei einem gewöhnlichen Event ohne Listener passiert nichts – emit liefert false und gut ist. Beim 'error'-Event ist das nicht so.

Löst ein Emitter emit('error', ...) aus und es ist kein Listener für 'error' registriert, dann wird der Fehler geworfen. Und weil er innerhalb der Emitter-Maschinerie geworfen wird und niemand ihn auffängt, reißt er im Zweifel den ganzen Prozess ab. Aus einem "ein Stream hat sich beschwert" wird so ein kompletter Absturz des Node-Prozesses.

const { EventEmitter } = require('node:events');
const source = new EventEmitter();

// Without this listener the next line would crash the process:
source.on('error', (err) => {
  console.error('handled:', err.message);
});

source.emit('error', new Error('boom'));  // handled cleanly

Das ist übrigens kein Schikane-Verhalten, sondern eine bewusste Entscheidung des Node-Teams. Ein Fehler, den niemand behandelt, soll laut sein und nicht still verschluckt werden. Für uns folgt daraus eine einfache Regel: Für jeden Emitter, der Fehler melden kann – und das sind praktisch alle Streams, alle Netzwerkobjekte, alle Kindprozesse – registriere ich einen 'error'-Listener, bevor die eigentliche Arbeit losgeht. Nicht als Kür, sondern als Auffangnetz, ohne das die ganze Anwendung an einer einzigen unbehandelten Meldung zerbricht.

Es gibt noch zwei weitere Spezial-Events, die intern verwendet werden: 'newListener' feuert, bevor ein neuer Listener hinzugefügt wird, und 'removeListener' feuert, nachdem einer entfernt wurde. Beide sind eher für Werkzeuge und Diagnose gedacht als für den Alltag, aber es ist gut zu wissen, dass es sie gibt, wenn man einmal beobachten will, wer sich wann an- oder abmeldet.

Zu viele Listener: die Leck-Warnung

Kommen wir zum zweiten Klassiker aus der Praxis. Node führt für jeden Emitter Buch darüber, wie viele Listener pro Event registriert sind. Überschreitet diese Zahl einen Schwellenwert – standardmäßig 10 –, gibt Node eine Warnung auf stderr aus: die MaxListenersExceededWarning.

Diese Warnung ist keine Fehlermeldung und stoppt nichts. Sie ist eine Heuristik gegen eine sehr häufige Fehlerklasse: das Listener-Leck. Der typische Fall sieht so aus, dass irgendwo pro Anfrage, pro Schleifendurchlauf oder pro Wiederholung ein on() aufgerufen wird, das dazugehörige off() aber fehlt. Die Listener stapeln sich, der Speicher wächst, und irgendwann meldet sich Node.

const { EventEmitter } = require('node:events');
const bus = new EventEmitter();

// Optional: make the warning itself visible
process.on('warning', (warning) => {
  console.log(warning.name, '- count:', warning.count);
});

// Anti-pattern: a new, never-removed listener on every iteration
for (let i = 0; i < 12; i += 1) {
  bus.on('data', (chunk) => processChunk(chunk));
}
// From the 11th listener on, the MaxListenersExceededWarning appears.

Der entscheidende Punkt, den ich hier immer mache: Die Warnung ist das Symptom, nicht die Ursache. Die Ursache ist die nie entfernte Registrierung. Wer die Warnung mit emitter.setMaxListeners(Infinity) zum Schweigen bringt, hat nur das Warnlämpchen abgeklebt – das Leck läuft weiter, der Speicher wächst weiter. Richtig ist, die Listener wieder abzumelden, wenn man sie nicht mehr braucht, oder sie gleich als once zu registrieren, wenn sie ohnehin nur einmal feuern sollen.

function onData(chunk) {
  processChunk(chunk);
}

bus.on('data', onData);
// ... later, when done:
bus.off('data', onData);   // cleanly removed, no pile-up

Das heißt nicht, dass der Schwellenwert von 10 in Stein gemeißelt ist. Es gibt legitime Fälle, in denen ein Emitter bewusst mehr Listener hat. Dann justiert man das absichtlich: emitter.setMaxListeners(n) hebt das Limit für einen einzelnen Emitter an, events.defaultMaxListeners = n verschiebt den globalen Standardwert. Der Wert 0 beziehungsweise Infinity schaltet die Warnung ganz ab. Wichtig ist nur, dass diese Entscheidung eine bewusste ist und nicht ein Reflex, um eine unbequeme Meldung loszuwerden.

Eine eigene Klasse bauen

Der EventEmitter ist nicht nur etwas, von dem die Standardbibliothek erbt – man kann ihn genauso gut selbst als Basis nehmen. Immer dann, wenn eine eigene Komponente ihre Umgebung über Dinge informieren soll, ohne die Empfänger fest zu kennen, ist ein Emitter die naheliegende Wahl.

Das Muster ist class MyThing extends EventEmitter mit einem super()-Aufruf im Konstruktor. Hier eine kleine Aufgabenwarteschlange, die meldet, wenn eine Aufgabe hinzukommt, wenn sie abgearbeitet ist und wenn etwas schiefgeht:

const { EventEmitter } = require('node:events');

class TaskQueue extends EventEmitter {
  constructor() {
    super();
    this.pending = [];
  }

  add(job) {
    this.pending.push(job);
    this.emit('task', job);
  }

  run() {
    while (this.pending.length > 0) {
      const job = this.pending.shift();
      try {
        job.handler();
        this.emit('done', job);
      } catch (cause) {
        // Only emit 'error' when a listener exists -
        // otherwise it crashes the process:
        if (this.listenerCount('error') > 0) {
          this.emit('error', cause);
        } else {
          throw cause;
        }
      }
    }
    this.emit('drain');
  }
}

const queue = new TaskQueue();

queue.on('task', (job) => console.log('queued', job.id));
queue.on('done', (job) => console.log('done', job.id));
queue.once('drain', () => console.log('all work finished'));
queue.on('error', (err) => console.error('failed:', err.message));

queue.add({ id: 1, handler: () => {} });
queue.add({ id: 2, handler: () => { throw new Error('bad job'); } });
queue.run();

An diesem Beispiel sieht man mehrere Dinge zusammenwirken. Die Warteschlange kennt ihre Zuhörer nicht – sie ruft emit auf und wer will, hört zu. once('drain', ...) sorgt dafür, dass die Abschlussmeldung genau einmal kommt. Und beim Fehlerfall prüfe ich mit listenerCount('error'), ob überhaupt jemand zuhört, bevor ich emit('error', ...) aufrufe. So vermeide ich den Absturz durch ein unbehandeltes 'error'-Event und lasse den Fehler stattdessen als gewöhnliche Exception weiterlaufen, wenn niemand ihn abfängt.

Ein kleiner, aber wiederkehrender Stolperstein bei eigenen Klassen betrifft this. In einem klassischen Function-Listener zeigt this auf den Emitter, der das Event ausgelöst hat – praktisch, wenn man von innen auf den Emitter zugreifen will. In einer Arrow-Function gilt das nicht; dort behält this seine Bedeutung aus dem umgebenden Kontext. Beides ist legitim, man muss sich nur bewusst sein, welche Variante man wählt.

Synchron heißt synchron

Zum Schluss noch eine Klarstellung, die im Alltag Fehler verhindert. Weil Node so stark mit Asynchronität verbunden ist, nehmen viele intuitiv an, emit würde die Listener irgendwann später aufrufen. Das tut es nicht. Der Aufruf ist synchron, und die Listener blockieren den aufrufenden Code, bis sie durch sind.

Das ist meistens genau richtig, denn es macht den Ablauf vorhersehbar. Wer aber innerhalb eines Listeners bewusst etwas aus dem aktuellen Ablauf herauslösen will – damit lange Arbeit den emit-Aufrufer nicht aufhält –, muss das ausdrücklich tun, etwa mit setImmediate oder process.nextTick. Der Emitter selbst plant nichts um; er ruft nur der Reihe nach auf, was registriert ist.

Fazit

Der EventEmitter ist eine kleine Klasse mit großer Reichweite. Vier Methoden – on, once, off, emit – tragen den Alltag, und weil ein großer Teil der Standardbibliothek von genau dieser Klasse erbt, überträgt sich das Verständnis unmittelbar auf Streams, Server und Prozesse. Zwei Dinge sollte man dabei nie aus den Augen verlieren: Das 'error'-Event stürzt ohne Listener den Prozess ab, deshalb gehört überall dort, wo Fehler gemeldet werden können, ein 'error'-Listener hin. Und die MaxListenersExceededWarning ist ein ehrliches Warnsignal für ein Leck, kein lästiger Hinweis, den man wegkonfiguriert. Wer diese beiden Reflexe verinnerlicht hat, schreibt ereignisgetriebenen Node-Code, der auch dann noch stabil läuft, wenn es ungemütlich wird.

Weiterführende Quellen

Kommentare

Kommentar schreiben