Kapitel 4. Generische Server
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Nachdem wir den Radio Frequency Allocator in generische und spezifische Module aufgeteilt und einige Eckfälle untersucht haben, die beim Umgang mit Gleichzeitigkeit auftreten können, wirst du feststellen, dass du diesen Prozess nicht jedes Mal wiederholen musst, wenn du ein Client-Server-Verhalten implementieren musst. In diesem Kapitel stellen wir dir das gen_server
OTP-Verhalten vor, ein Bibliotheksmodul, das die gesamte generische Client-Server-Funktionalität enthält und gleichzeitig eine große Anzahl von Eckfällen behandelt. Generische Server sind das am häufigsten verwendete Verhaltensmuster und bilden die Grundlage für andere Verhaltensweisen, die alle mit diesem Modul implementiert werden können (und in den Anfangstagen von OTP auch wurden).
Generische Server
Das Modul gen_server
implementiert das Client-Server-Verhalten, das wir im vorherigen Kapitel extrahiert haben. Es ist Teil der Standardbibliotheksanwendung und als Teil der Erlang/OTP-Distribution verfügbar. Es enthält den generischen Code, der über eine Reihe von Callback-Funktionen mit dem Callback-Modul verbunden ist. Das Callback-Modul, in unserem Beispiel , das den spezifischen Code für den Frequenzserver enthält, wird vom Programmierer implementiert. Das Callback-Modul muss eine Reihe von Funktionen exportieren, die den Namens- und Typisierungskonventionen folgen, damit ihre Eingaben und Rückgabewerte dem für das Verhalten erforderlichen Protokoll entsprechen.
Wie in Abbildung 4-1 zu sehen ist, werden die Funktionen des Verhaltens- und des Callback-Moduls innerhalb desselben Server-Prozesses ausgeführt. Mit anderen Worten: Ein Prozess läuft in einer Schleife im generischen Servermodul und ruft bei Bedarf die Callback-Funktionen im Callback-Modul auf.
Das Bibliotheksmodul gen_server
bietet Funktionen zum Starten und Stoppen des Servers. Du lieferst Callback-Code, um das System zu initialisieren, und im Falle einer normalen oder abnormalen Prozessbeendigung ist es möglich, eine Funktion aus deinem Callback-Modul aufzurufen, um den Zustand vor der Beendigung zu bereinigen. Vor allem brauchst du keine Nachrichten mehr an deinen Prozess zu senden. Generische Server kapseln die gesamte Nachrichtenübermittlung in zwei Funktionen - eine für das Senden von synchronen Nachrichten und eine für das Senden von asynchronen Nachrichten. Diese Funktionen behandeln alle Grenzfälle, die wir im vorherigen Kapitel besprochen haben, und viele andere, von denen wir wahrscheinlich nicht einmal wussten, dass sie ein Problem darstellen oder eine Race Condition verursachen könnten. Es gibt auch integrierte Funktionen für Software-Upgrades, mit denen du deinen Prozess anhalten und Daten von einer Version deines Systems zur nächsten migrieren kannst. Generische Server bieten auch Timeouts, sowohl auf der Client-Seite beim Senden von Anfragen als auch auf der Server-Seite, wenn innerhalb eines bestimmten Zeitintervalls keine Nachrichten empfangen werden.
Wir decken jetzt alle Callback-Funktionen ab, die bei der Verwendung von generischen Servern benötigt werden. Sie umfassen:
Die Funktion
init/1
callback initialisiert einen Serverprozess, der durch den Aufrufgen_server:start_link/4
erstellt wurde.Die Funktion
handle_call/3
callback bearbeitet synchrone Anfragen, die vongen_server:call/2
an den Server gesendet werden. Wenn die Anfrage bearbeitet wurde, gibtcall/2
einen Wert zurück, der vonhandle_call/3
berechnet wurde.Um asynchrone Anfragen kümmert sich in der Callback-Funktion
handle_cast/2
. Die Anfragen stammen aus dem Aufrufgen_server:cast/2
, der eine Nachricht an einen Serverprozess sendet und sofort zurückkehrt.Die Beendigung wird gehandhabt, wenn eine der Server-Callback-Funktionen eine Stoppmeldung zurückgibt, was dazu führt, dass die
terminate/2
Callback-Funktion aufgerufen wird.
Wir sehen uns diese Funktionen mit all ihren Argumenten, Rückgabewerten und zugehörigen Callbacks genauer an, sobald wir die Modulrichtlinien behandelt haben.
Verhaltensrichtlinien
Wenn wir ein OTP-Verhalten implementieren, müssen wir Verhaltensanweisungen in unsere Moduldeklarationen aufnehmen.
-
module
(
frequency
)
.
-
behavior
(
gen_server
)
.
-
export
(
[
start_link
/
1
,
init
/
1
,
.
.
.
]
)
.
start_link
(
.
.
.
)
-
>
.
.
.
Die Verhaltensdirektive wird vom Compiler verwendet, um Warnungen über Callback-Funktionen auszugeben, die nicht definiert, nicht exportiert oder mit der falschen Arität definiert sind. Auch das Dialyzer-Tool nutzt diese Deklarationen, um Typdiskrepanzen zu überprüfen. Eine noch wichtigere Verwendung der Verhaltensanweisung ist für die armen Seelen1 die deinen Code unterstützen, warten und debuggen müssen, lange nachdem du dich anderen spannenden und anregenden Projekten zugewandt hast. Sie werden diese Direktiven sehen und sofort wissen, dass du die generischen Server-Muster verwendet hast. Wenn sie die Initialisierung des Servers sehen wollen, gehen sie auf zur Funktion init/1
. Wenn sie sehen wollen, wie der Server sich selbst aufräumt, springen sie von zu terminate/3
. Das ist eine große Verbesserung gegenüber einer Situation, in der jedes Unternehmen, Projekt oder jeder Entwickler seine eigenen, möglicherweise fehlerhaften Client-Server-Implementierungen neu erfindet. Es wird keine Zeit damit verschwendet, dieses Framework zu verstehen, so dass sich derjenige, der den Code liest, auf die Besonderheiten konzentrieren kann.
In unserem Beispielcode werden Compilerwarnungen durch die Direktive -behavior(gen_server).
ausgelöst, weil wir die Funktion code_change/3
auslassen, einen Callback, den wir in Kapitel 12 behandeln, wenn es um Versions-Upgrades geht. Zusätzlich zu dieser Direktive verwenden wir manchmal eine zweite, optionale Direktive , -vsn(Version)
, um die Modulversionen während des Code-Upgrades (und Downgrades) zu verfolgen. In Kapitel 12 gehen wir ausführlicher auf Versionen ein.
Einen Server starten
Mit dem Wissen von und unseren Modulrichtlinien können wir einen Server starten. Generische Server und andere OTP-Verhaltensweisen werden nicht mit den Spawn-BIFs gestartet, sondern mit speziellen Funktionen, die im Hintergrund mehr tun, als nur einen Prozess zu starten:
gen_server
:
start_link
(
{
local
,
Name
}
,
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
ignore
|
{
error
,
Reason
}
Die Funktion start_link/4
erhält vier Argumente. Das erste weist das Modul gen_server
an, den Prozess lokal mit dem Alias Name
zu registrieren. Mod
ist der Name des Callback-Moduls, in dem der serverspezifische Code und die Callback-Funktionen zu finden sind. Args
ist ein Erlang-Term, der an die Callback-Funktion übergeben wird und den Serverzustand initialisiert. Opts
ist eine Liste von Prozess- und Debugging-Optionen, die wir in Kapitel 5 behandeln. Für den Moment halten wir es einfach und übergeben die leere Liste für Opts
. Wenn bereits ein Prozess mit dem Alias Name
registriert ist, wird {error,
{already_started, Pid}}
zurückgegeben. Behalte ein wachsames Auge darauf, welcher Prozess welche Funktionen ausführt. Du kannst sie in Abbildung 4-2 sehen, wo der Server, der an den Prozess Pid
gebunden ist, vom Supervisor gestartet wird. Der Supervisor ist durch einen doppelten Ring gekennzeichnet, da er Exits abfängt.
Wenn der Prozess gen_server
gestartet wurde, wird er mit dem Alias Name
registriert und ruft anschließend die Funktion init/1
in das Callback-Modul Mod
auf. Die Funktion init/1
nimmt Args
, den dritten Parameter des Aufrufs start_link
, als Argument, unabhängig davon, ob er benötigt wird. Wenn keine Argumente benötigt werden, kann die Funktion init/1
sie mit der Variable don't care ignorieren. Beachte, dass Args
jeder gültige Erlang-Term sein kann; du bist nicht an die Verwendung von Listen gebunden.
Hinweis
Wenn Args
eine (möglicherweise leere) Liste ist, wird die Liste als Liste an init/1
übergeben und führt nicht dazu, dass init
mit einer anderen Arität aufgerufen wird. Wenn du zum Beispiel [foo, bar]
übergibst, wird init([foo,bar])
aufgerufen, nicht init(foo, bar)
. Dies ist ein häufiger Fehler, den Entwickler beim Übergang von Erlang zu OTP machen, da sie die Eigenschaften von spawn
und spawn_link
mit denen der Funktionen start
und start_link
verwechseln.
Die Callback-Funktion init/1
ist für die Initialisierung des Serverstatus verantwortlich. In unserem Beispiel bedeutet das, dass sie die Variable mit den Listen der verfügbaren und zugewiesenen Frequenzen erstellt:
start
()
->
% frequency.erl
gen_server
:
start_link
({
local
,
frequency
},
frequency
,
[],
[]).
init
(_
Args
)
->
Frequencies
=
{
get_frequencies
(),
[]},
{
ok
,
Frequencies
}.
get_frequencies
()
->
[
10
,
11
,
12
,
13
,
14
,
15
].
Bei Erfolg gibt die Callback-Funktion init/1
{ok, LoopData}
zurück. Wenn der Start fehlschlägt, du aber nicht möchtest, dass andere Prozesse, die vom selben Supervisor gestartet wurden, davon betroffen sind, gib ignore
zurück. Wenn du andere Prozesse beeinflussen willst, gib {stop, Reason}
zurück. Wir behandeln ignore
in Kapitel 8und stop
in "Beendigung".
In unserem Beispiel übergibt start_link/4
die leere Liste []
an init/1
, die ihrerseits die Variable _Args
don't care verwendet, um sie zu ignorieren. Wir hätten auch jeden anderen Erlang-Term übergeben können, solange wir jedem, der den Code liest, klar machen, dass keine Argumente benötigt werden. Das Atom undefined
oder das leere Tupel {}
sind andere Favoriten.
Indem wir {timeout, Ms}
als Option in der Opts
Liste angeben, geben wir unserem generischen Server Ms
Millisekunden Zeit, um zu starten. Wenn es länger dauert, gibt start_link/4
das Tupel {error, timeout}
zurück und der Verhaltensprozess wird nicht gestartet. Es wird keine Ausnahme ausgelöst. Auf die Optionen gehen wir in Kapitel 5 genauer ein.
Das Starten eines generischen Serververhaltensprozesses ist ein synchroner Vorgang. Nur wenn die Callback-Funktion init/1
{ok,
LoopData}
an die Server-Schleife zurückgibt, gibt die Funktion gen_server:start_link/4
{ok,
Pid}
zurück. Es ist wichtig, die synchrone Natur von start_link
und ihre Bedeutung für eine wiederholbare Startsequenz zu verstehen. Die Möglichkeit, einen Fehler deterministisch zu reproduzieren, ist wichtig für die Fehlersuche bei Problemen, die beim Starten auftreten. Du könntest alle Prozesse asynchron starten und sie danach überprüfen, um sicherzustellen, dass sie alle korrekt gestartet wurden. Aber wenn sich die Implementierungen der Zeitplannungsprogramme und die Konfigurationswerte auf Multi-Core-Architekturen ändern, wenn unterschiedliche Hardware oder Betriebssysteme zum Einsatz kommen oder wenn sich sogar der Zustand der Netzwerkverbindung ändert, werden die Prozesse nicht unbedingt immer in der gleichen Reihenfolge initialisiert und gestartet. Wenn alles gut läuft, hast du kein Problem mit den Schwankungen, die ein asynchroner Startansatz mit sich bringt. Wenn jedoch Wettlaufbedingungen auftreten, ist es vor allem in Produktionsumgebungen nichts für schwache Nerven, herauszufinden, was wann schief gelaufen ist. Der synchrone Startansatz, der in start_link
implementiert ist, stellt durch seine Einfachheit sicher, dass jeder Prozess korrekt gestartet wurde, bevor er zum nächsten Prozess übergeht, und bietet Determinismus und reproduzierbare Startfehler auf einem einzelnen Knoten. Wenn Startfehler von externen Faktoren wie Netzwerken, externen Datenbanken oder dem Zustand der zugrunde liegenden Hardware oder des Betriebssystems beeinflusst werden, solltest du versuchen, sie einzudämmen. In den Fällen, in denen der Determinismus nicht hilft, beseitigt ein kontrollierter Startvorgang jeden Zweifel, wo das Problem liegen könnte.
Nachrichtenübermittlung
Nachdem unseren generischen Server gestartet und seine Schleifendaten initialisiert hat, schauen wir uns nun an, wie die Kommunikation funktioniert. Wie du vielleicht aus dem vorigen Kapitel verstanden hast, ist das Versenden von Nachrichten mit dem !
Operator aus der Mode gekommen. OTP verwendet funktionale Schnittstellen, die eine höhere Abstraktionsebene bieten. Das Modul gen_server
exportiert Funktionen, die es uns ermöglichen, sowohl synchrone als auch asynchrone Nachrichten zu senden, und verbirgt so die Komplexität der gleichzeitigen Programmierung und der Fehlerbehandlung vor dem Programmierer.
Synchrone Nachrichtenübermittlung
Erlang hat zwar die asynchrone Nachrichtenübermittlung als Teil der Sprache eingebaut, aber nichts hält uns davon ab, synchrone Aufrufe mit den vorhandenen Primitiven zu implementieren. Genau das tut die Funktion gen_server:call/2
. Sie sendet eine synchrone Message
an den Server und wartet auf eine Reply
, während der Server die Anfrage in einer Callback-Funktion bearbeitet. Die Reply
wird als Rückgabewert an den Aufruf übergeben. Die Nachricht und die Antwort folgen einem bestimmten Protokoll und enthalten ein eindeutiges Tag (oder eine Referenz), das der Nachricht und der Antwort entspricht. Schauen wir uns die Funktion gen_server:call/2
genauer an:
gen_server
:
call
(
Name
,
Message
)
-
>
Reply
Name
ist entweder die PID des Servers oder der registrierte Name des Serverprozesses. Die Message
ist ein Erlang-Term, der als Teil der Anfrage an den Server weitergeleitet wird. Anfragen werden als Erlang-Nachrichten empfangen, in der Mailbox gespeichert und der Reihe nach bearbeitet. Beim Empfang einer synchronen Anfrage wird die handle_call(Message, _From,
LoopData)
Callback-Funktion im Callback-Modul aufgerufen. Das erste Argument ist die Message
die an gen_server:call/2
übergeben wurde. Das zweite Argument, _From
, enthält eine eindeutige Anfragekennung und Informationen über den Kunden. Wir ignorieren es vorerst und binden es an eine Don't Care-Variable. Das dritte Argument ist die LoopData
, die ursprünglich von der Rückruffunktion init/1
zurückgegeben wurde. Du solltest den Aufruf in Abbildung 4-3 nachvollziehen können.
Die handle_call/3
Callback-Funktion enthält den gesamten Code, der zur Bearbeitung der Anfrage erforderlich ist. Es ist eine gute Praxis, für jede Anfrage eine eigene handle_call/3
Klausel zu haben und den Musterabgleich zu verwenden, um die richtige Anfrage auszuwählen, anstatt eine case
Anweisung zu verwenden, um die einzelnen Nachrichten herauszufiltern. In der Funktionsklausel führen wir den gesamten Code für die jeweilige Anfrage aus und geben anschließend ein Tupel im Format {reply, Reply, NewLoopData}
zurück. Ein Callback-Modul verwendet das Atom reply
, um dem gen_server
mitzuteilen, dass das zweite Element, Reply
, an den Client-Prozess zurückgeschickt werden muss und zum Rückgabewert der gen_server:call/2
-Anfrage wird. Das dritte Element, NewLoopData
, ist der neue Zustand des Callback-Moduls, den gen_server
in die nächste Iteration seiner tail-recursiven Empfangs- und Auswertungsschleife einbringt. Wenn LoopData
sich im Hauptteil der Funktion nicht ändert, geben wir einfach den ursprünglichen Wert im Antworttupel zurück. gen_server
speichert ihn lediglich, ohne ihn zu überprüfen oder seinen Inhalt zu verändern. Sobald er das Antworttupel an den Client zurückschickt, ist der Server bereit, die nächste Anfrage zu bearbeiten. Wenn sich keine Nachrichten in der Prozess-Mailbox befinden, wird der Server angehalten und wartet auf das Eintreffen einer neuen Anfrage.
In unserem Frequenz Server-Beispiel erfordert die Zuweisung einer Frequenz einen synchronen Aufruf, da die Antwort auf den Aufruf die zugewiesene Frequenz enthalten muss. Um die Anfrage zu bearbeiten, rufen wir die interne Funktion allocate/2
auf, die, wie du dich vielleicht erinnerst, {NewFrequencies, Reply}
zurückgibt. NewFrequencies
ist das Tupel, das die Listen der zugewiesenen und verfügbaren Frequenzen enthält, während Reply
das Tupel {ok, Frequency}
oder {error, no_frequency}
ist:
allocate
()
->
% frequency.erl
gen_server
:
call
(
frequency
,
{
allocate
,
self
()}).
handle_call
({
allocate
,
Pid
},
_
From
,
Frequencies
)
->
{
NewFrequencies
,
Reply
}
=
allocate
(
Frequencies
,
Pid
),
{
reply
,
Reply
,
NewFrequencies
}.
Nach Abschluss gibt die vom Client-Prozess aufgerufene Funktion allocate/0
{ok, Frequency}
oder {error,
no_frequency}
zurück. Die aktualisierten Schleifendaten mit den verfügbaren und zugewiesenen Frequenzen werden in der generischen Server-Empfangsauswertungsschleife gespeichert und warten auf die nächste Anfrage .
Asynchrone Nachrichtenübermittlung
Wenn der Client eine Nachricht an den Server senden muss, aber keine Antwort erwartet, kann er asynchrone Anfragen verwenden. Dies geschieht mit der Funktion gen_server:cast/2
der Bibliothek:
gen_server
:
cast
(
Name
,
Message
)
-
>
ok
Name
ist die pid oder der lokal registrierte Alias des Serverprozesses. Message
ist der Begriff, den der Client an den Server senden möchte. Sobald der Aufruf cast/2
seine Anfrage gesendet hat, gibt er das Atom ok
zurück. Auf der Serverseite wird die Anfrage in der Prozess-Mailbox gespeichert und der Reihe nach bearbeitet. Wenn sie empfangen wird, wird die Message
an weitergegeben. handle_cast/2
Callback-Funktion weitergeleitet, die vom Entwickler im Callback-Modul implementiert wird.
Die handle_cast/2
Callback-Funktion benötigt zwei Argumente. Das erste ist das Message
, das vom Client gesendet wurde, das zweite ist das LoopData
, das zuvor von den Callbacks init/1
, handle_call/3
oder handle_cast/2
zurückgegeben wurde. Dies ist in Abbildung 4-4 zu sehen.
Die Callback-Funktion handle_cast/2
muss ein Tupel im Format {noreply, NewLoopData}
zurückgeben. Das NewLoopData
wird als Argument an den nächsten Aufruf oder die nächste Cast-Anfrage übergeben.
In einigen Anwendungen geben Client-Funktionen einen fest kodierten Wert zurück, oft das Atom ok
, und verlassen sich auf Seiteneffekte, die im Callback-Modul ausgeführt werden. Solche Funktionen können als asynchrone Aufrufe implementiert werden. Ist dir in unserem Frequenzbeispiel aufgefallen, dass frequency:deallocate(Freq)
immer das Atom ok
zurückgibt? Es ist uns egal, ob sich die Bearbeitung der Anfrage verzögert, weil der Server mit anderen Aufrufen beschäftigt ist, daher ist dies ein perfekter Kandidat für ein Beispiel mit einem generischen Server-Cast:
deallocate
(
Frequency
)
->
% frequency.erl
gen_server
:
cast
(
frequency
,
{
deallocate
,
Frequency
}).
handle_cast
({
deallocate
,
Freq
},
Frequencies
)
->
NewFrequencies
=
deallocate
(
Frequencies
,
Freq
),
{
noreply
,
NewFrequencies
};
Die Client-Funktion deallocate/1
sendet eine asynchrone Anfrage an den generischen Server und gibt sofort das Atom ok
zurück. Diese Anfrage wird von der handle_cast/2
Funktion aufgegriffen, die im ersten Argument einen Mustervergleich mit der Nachricht {deallocate, Frequency}
durchführt und im zweiten Argument die Schleifendaten an Frequencies
bindet. Im Hauptteil der Funktion ruft sie die Hilfsfunktion deallocate/2
auf und verschiebt Frequency
von der Liste der zugewiesenen Frequenzen in die Liste der verfügbaren. Der Rückgabewert von deallocate/2
wird an die Variable NewFrequencies
gebunden und als neue Schleifendaten in das noreply
Kontrolltupel zurückgegeben.
Beachte, dass wir gesagt haben, dass nur in einigen Anwendungen Client-Funktionen Rückgabewerte von Server-Funktionen mit Seiteneffekten ignorieren. Das Anpingen eines Servers, um sich zu vergewissern, dass er noch aktiv ist, würde sich zum Beispiel darauf verlassen, dass gen_server:call/2
eine Ausnahme auslöst, wenn der Server beendet wurde oder wenn es bei der Bearbeitung der Anfrage und dem Senden der Antwort zu einer Verzögerung kommt, die möglicherweise auf eine hohe Last zurückzuführen ist. Ein weiteres Beispiel für den Einsatz von synchronen Aufrufen ist die Notwendigkeit, Anfragen zu drosseln und die Geschwindigkeit zu kontrollieren, mit der Nachrichten an den Server gesendet werden. Wir besprechen die Notwendigkeit, Nachrichten zu drosseln, in Kapitel 15.
Wie bei reinem Erlang sollten Aufrufe und Casts in einer funktionalen API abstrahiert werden, wenn sie von außerhalb des Moduls verwendet werden. So kannst du dein Protokoll flexibler ändern und private implementierungsbezogene Informationen vor dem Aufrufer der Funktion verbergen. Platziere die Client-Funktionen im selben Modul wie den Prozess, da es so einfacher ist, den Nachrichtenfluss zu verfolgen, ohne zwischen den Modulen zu springen.
Andere Nachrichten
OTP-Verhaltensweisen sind als Erlang-Prozesse implementiert. Obwohl die Kommunikation also idealerweise über die in den Funktionen gen_server:call/2
und gen_server:cast/2
definierten Protokolle erfolgen sollte, ist das nicht immer der Fall. Solange die pid oder der registrierte Name bekannt ist, steht dem Versenden einer Nachricht über das Name ! Message
Konstrukt nichts im Wege. In manchen Fällen sind Erlang-Nachrichten die einzige Möglichkeit, Informationen an den generischen Server zu übermitteln. Wenn der Server zum Beispiel mit anderen Prozessen oder Ports verbunden ist, aber die process_flag(trap_exit, true)
BIF aufgerufen hat, um Trap-Exits von diesen Prozessen oder Ports zu erhalten, kann er EXIT
Signal Nachrichten empfangen. Auch die Kommunikation zwischen Prozessen und Ports oder Sockets basiert auf der Weitergabe von Nachrichten. Und was ist, wenn wir einen Prozessmonitor verwenden, verteilte Knoten überwachen oder mit veraltetem, nicht-OTP-kompatiblem Code kommunizieren?
Diese Beispiele führen alle dazu, dass unser Server Erlang-Nachrichten empfängt, die nicht mit dem internen OTP-Nachrichtenprotokoll des Servers übereinstimmen. Ob konform oder nicht, wenn du Funktionen verwendest, die Nachrichten an deinen Server erzeugen können, muss dein Servercode in der Lage sein, diese zu verarbeiten. Generische Server bieten eine Callback-Funktion, die sich um all diese Nachrichten kümmert. Das ist der handle_info(_Msg,
LoopData)
Callback. Wenn sie aufgerufen wird, muss sie entweder das Tupel {noreply, NewLoopData}
oder, wenn sie angehalten wird, {stop, Reason, NewLoopData}
zurückgeben:
handle_info
(_
Msg
,
LoopData
)
->
% frequency.erl
{
noreply
,
LoopData
}.
Es ist gängige Praxis, diese Callback-Funktion einzubinden, auch wenn du keine Nachrichten erwartest. Wenn du das nicht tust und dem Server eine nicht-OTP-konforme Nachricht schickst (sie kommen dann an, wenn du sie am wenigsten erwartest!), führt das zu einem Laufzeitfehler und dem Abbruch des Servers, da die Funktion handle_info/2
im Callback-Modul aufgerufen wird, was zu einem undefinierten Funktionsfehler führt.
Wir haben unser Frequenzserver-Beispiel einfach gehalten. Wir ignorieren jede eingehende Nachricht und geben die unveränderte LoopData
im noreply
Tupel zurück. Wenn du sicher bist, dass du keine Nicht-OTP-Nachrichten empfangen solltest, kannst du solche Nachrichten als Fehler protokollieren. Wenn wir jedes Mal eine Fehlermeldung ausgeben wollten, wenn ein Prozess, mit dem der Server verbunden ist, abnormal beendet wird, würde der Code wie folgt aussehen (wir gehen davon aus, dass der betreffende Server Exits abfängt):
handle_info
({
'EXIT'
,
_
Pid
,
normal
},
LoopData
)
->
{
noreply
,
LoopData
};
handle_info
({
'EXIT'
,
Pid
,
Reason
},
LoopData
)
->
io
:
format
(
"Process:
~p
exited with reason:
~p~n
"
,[
Pid
,
Reason
]),
{
noreply
,
LoopData
};
handle_info
(_
Msg
,
LoopData
)
->
{
noreply
,
LoopData
}.
Warnung
Einer der Nachteile von OTP ist der Overhead, der sich aus der Schichtung der verschiedenen Verhaltensmodule und dem vom Kommunikationsprotokoll benötigten Daten-Overhead ergibt. Um ein paar Mikrosekunden einzusparen, haben Entwickler die gen_server:cast
Funktion umgangen und stattdessen das Pid ! Msg
Konstrukt verwendet oder, noch schlimmer, receive
Anweisungen in ihre Callback-Funktionen eingebettet, um diese Nachrichten zu empfangen. Tu das nicht! Dein Code wird dadurch schwer zu debuggen, zu unterstützen und zu warten sein, du verlierst viele der Vorteile, die OTP mit sich bringt, und die Autoren dieses Buches werden dich nicht mehr mögen. Wenn du Mikrosekunden einsparen musst, optimiere nur dann, wenn du durch tatsächliche Leistungsmessungen weißt, dass dein Programm nicht schnell genug ist.
Unbearbeitete Nachrichten
Erlang verwendet beim Abrufen von Nachrichten aus der Prozess-Mailbox selektive Empfänge. Die Möglichkeit, bestimmte Nachrichten zu extrahieren und andere unbehandelt zu lassen, birgt jedoch das Risiko von Speicherlecks. Was passiert, wenn ein Nachrichtentyp nie gelesen wird? Bei der Verwendung von Erlang ohne OTP würde die Nachrichtenwarteschlange immer länger werden und die Anzahl der Nachrichten, die durchlaufen werden müssen, bevor eine erfolgreich mit dem Muster übereinstimmt, steigen. Dieses Wachstum der Warteschlange macht sich in der Erlang-VM durch eine hohe CPU-Belastung bemerkbar, die durch das Durchlaufen der Mailbox entsteht, und dadurch, dass der VM irgendwann der Speicher ausgeht und sie möglicherweise neu gestartet werden muss: durch Herz, das wir in Kapitel 11 behandeln.
All das gilt, wenn wir reines Erlang verwenden, aber OTP-Verhaltensweisen verfolgen einen anderen Ansatz. Nachrichten werden in der gleichen Reihenfolge bearbeitet, in der sie empfangen werden. Starte deinen Frequenzserver und versuche, dir selbst eine Nachricht zu schicken, die du nicht bearbeitest:
1>frequency:start().
{ok,<0.33.0>} 2>gen_server:call(frequency, foobar).
=ERROR REPORT==== 29-Nov-2015::18:27:45 === ** Generic server frequency terminating ** Last message in was foobar ** When Server state == {data,[{"State", {{available,[10,11,12,13,14,15]}, {allocated,[]}}}]} ** Reason for termination == ** {function_clause,[{frequency,handle_call, [foobar, {<0.44.0>,#Ref<0.0.4.112>}, {[10,11,12,13,14,15],[]}], [{file,"frequency.erl"},{line,63}]}, {gen_server,try_handle_call,4, [{file,"gen_server.erl"},{line,629}]}, {gen_server,handle_msg,5, [{file,"gen_server.erl"},{line,661}]}, {proc_lib,init_p_do_apply,3, [{file,"proc_lib.erl"},{line,240}]}]}
Das ist wahrscheinlich nicht das, was du erwartet hast. Der Frequenzserver brach mit einem function_clause
Laufzeitfehler ab und gab einen Fehlerbericht aus.2 Wenn du eine Funktion aufrufst, muss eine der Klauseln immer übereinstimmen. Wenn das nicht der Fall ist, führt das zu einem Laufzeitfehler. Bei einem gen_server
-Aufruf oder -Cast wird die Nachricht immer von der Mailbox in der generischen Serverschleife abgerufen und die handle_call/3
- oder handle_cast/2
-Callback-Funktion wird aufgerufen. In unserem Beispiel stimmt handle_call(foobar, _From,
LoopData)
mit keiner der Klauseln überein und verursacht den Funktionsklauselfehler, den wir gerade gesehen haben. Dasselbe würde auch bei einem Cast passieren.
Wie können wir solche Fehler vermeiden? Eine Möglichkeit ist ein Catch-All, bei dem unbekannte Meldungen mit einer "Don't Care"-Variable abgeglichen und ignoriert werden. Das hängt von der jeweiligen Anwendung ab und kann die richtige Lösung sein oder auch nicht. Ein Catch-All könnte die Norm für den handle_info/2
Callback sein, wenn es um Ports, Sockets, Links, Monitore und die Überwachung verteilter Knoten geht, bei denen das Risiko besteht, dass vergessen wird, eine bestimmte Nachricht zu behandeln, die von der Anwendung nicht benötigt wird. Bei Aufrufen und Casts sollten jedoch alle Anfragen vom Callback-Modul für das Verhalten ausgehen, und unbekannte Nachrichten sollten bereits in den frühen Phasen des Tests abgefangen werden.
Im Zweifelsfall solltest du nicht defensiv vorgehen, sondern dafür sorgen, dass dein Server beim Empfang unbekannter Nachrichten abbricht. Behandle diese Abbrüche als Fehler und bearbeite die Nachrichten entweder oder korrigiere sie an der Quelle. Wenn du dich entscheidest, unbekannte Nachrichten zu ignorieren, vergiss nicht, sie zu protokollieren .
Kunden synchronisieren
Was passiert in einer Situation, in der zwei Clients jeweils eine synchrone Anfrage an einen Server senden, der Server aber auf beide Anfragen warten muss, bevor er auf die erste antwortet, anstatt sofort auf jede einzelne zu reagieren? Wir zeigen dies in Abbildung 4-5. Dies kann aus Gründen der Synchronisierung geschehen oder weil der Server die Daten beider Anfragen benötigt.
Die Lösung für dieses Problem ist einfach. Erinnerst du dich an das Feld From
in der handle_call(Message, From, State)
Callback-Funktion? Anstatt eine Antwort an die Verhaltensschleife zurückzugeben, geben wir {noreply, NewState}
zurück. Dann verwenden wir das Attribut From
und die Funktion:
gen_server
:
reply
(
From
,
Reply
)
um später die Antwort an den Kunden zurückzuschicken, wenn es uns passt. Wenn zwei Clients synchronisiert werden müssen, könnte dies im zweiten handle_call/3
Callback geschehen, wobei der From
Wert für den ersten Client zwischen den Aufrufen entweder als Teil der NewState
oder in einer Tabelle oder Datenbank gespeichert wird.
Du kannst reply/2
auch verwenden, wenn eine synchrone Anfrage eine zeitaufwändige Berechnung auslöst und die einzige Antwort, an der der Client interessiert ist, eine Bestätigung ist, dass die Anfrage eingegangen ist und gerade ausgeführt wird, ohne dass er warten muss, bis die gesamte Berechnung abgeschlossen ist. Um eine sofortige Bestätigung zu senden, kann der gen_server:reply/2
Aufruf im Callback selbst verwendet werden:
handle_call
({
add
,
Data
},
From
,
Sum
)
->
gen_server
:
reply
(
From
,
ok
),
timer
:
sleep
(
1000
),
NewSum
=
add
(
Data
,
Sum
),
io
:
format
(
"From:
~p
, Sum:
~p~n
"
,[
From
,
NewSum
]),
{
noreply
,
NewSum
}.
Führen wir diesen Code aus, wobei wir davon ausgehen, dass es sich um einen generischen Server handelt, der im Modul from
callback implementiert ist. Der Aufruf timer:sleep/1
hält den Prozess an, damit der Shell-Prozess die Antwort von gen_server:reply/2
verarbeiten kann, bevor den Aufruf io:format/2
ausführt:
1>gen_server:start({local, from}, from, 0, []).
{ok,<0.53.0>} 2>gen_server:call(from, {add, 10}).
ok From:{<0.55.0>,#Ref<0.0.3.248>}, Sum:10
Beachte den Wert und das Format des Arguments From
, das wir in der Shell ausgeben. Es ist ein Tupel, das die PID des Clients und eine eindeutige Referenz enthält. Dieser Verweis wird in einem Tag mit der Antwort verwendet, die an den Client zurückgeschickt wird, um sicherzustellen, dass es sich tatsächlich um die beabsichtigte Antwort handelt und nicht um eine protokollkonforme Nachricht, die von einem anderen Prozess gesendet wurde. Verwenden Sie From
immer als undurchsichtigen Datentyp; gehen Sie nicht davon aus, dass es sich um ein Tupel handelt, da sich seine Darstellung in zukünftigen Versionen ändern könnte.
Beendigung
Was ist, wenn wir einen generischen Server stoppen wollen? Bisher haben wir gesehen, dass die Callback-Funktionen init/1
, handle_call/3
und handle_cast/2
jeweils {ok, LoopData}
, {reply, Reply, LoopData}
und {noreply,
LoopData}
zurückgeben. Um den Server zu stoppen, müssen die Callbacks andere Tupel zurückgeben:
init/1
kann zurückkehren{stop, Reason}
handle_call/3
kann zurückkehren{stop, Reason, Reply, LoopData}
handle_cast/2
kann zurückkehren{stop, Reason, LoopData}
handle_info/2
kann zurückkehren{stop, Reason, LoopData}
Diese Rückgabewerte werden mit dem gleichen Verhalten beendet, als ob exit(Reason)
aufgerufen worden wäre. Bei Aufrufen und Casts wird vor dem Beenden die Callback-Funktion terminate(Reason, LoopData)
aufgerufen. Sie ermöglicht es dem Server, vor dem Herunterfahren aufzuräumen. Jeder von terminate/2
zurückgegebene Wert wird ignoriert. Im Fall von init
sollte stop
zurückgegeben werden, wenn bei der Initialisierung des Status etwas fehlschlägt. Infolgedessen wird terminate/2
nicht aufgerufen werden. Wenn wir im init/1
Callback {stop,
Reason}
zurückgeben, gibt die Funktion start_link
{error,
Reason}
zurück.
In unserem Frequenzserver-Beispiel sendet die Client-Funktion stop/0
eine asynchrone Nachricht an den Server. Nach dem Empfang gibt der handle_cast/2
Callback das Tupel mit dem stop
Kontrollatom zurück, was wiederum dazu führt, dass der terminate/2
Aufruf aufgerufen wird. Sieh dir den Code an:
stop
()
->
gen_server
:
cast
(
frequency
,
stop
).
% frequency.erl
handle_cast
(
stop
,
LoopData
)
->
{
stop
,
normal
,
LoopData
}.
terminate
(_
Reason
,
_
LoopData
)
->
ok
.
Um das Beispiel einfach zu halten, haben wir terminate
leer gelassen. In einer idealen Welt hätten wir wahrscheinlich alle Client-Prozesse, denen eine Frequenz zugewiesen wurde, beendet und so sichergestellt, dass nach einem Neustart alle Frequenzen verfügbar sind.
Schau dir die Nachricht an, die gen_server:cast/2
an den Frequenzserver sendet. Du wirst feststellen, dass es sich um das Atom stop
handelt, das im ersten Argument des Aufrufs handle_cast/2
vorkommt. Die Nachricht hat keine andere Bedeutung als die, die wir ihr in unserem Code geben. Wir hätten jedes beliebige Atom senden können, z. B. gen_server:cast(frequency,
donald_duck)
. Die Musterübereinstimmung donald_duck
in der handle_cast/2
hätte uns das gleiche Ergebnis geliefert. Die einzige stop
, die eine besondere Bedeutung hat, ist die, die im ersten Element des von handle_cast/2
zurückgegebenen Tupels vorkommt, da sie in der Empfangs-Auswertungsschleife des generischen Servers interpretiert wird.
Wenn du deinen Server im Rahmen deines normalen Arbeitsablaufs herunterfährst (z.B. weil der Socket, den er bedient, geschlossen wurde oder weil die Hardware, die er steuert und überwacht, heruntergefahren wird), ist es eine gute Praxis, deinen Reason
auf normal
zu setzen. Ein Grund, der nichtnormal
lautet, ist zwar durchaus akzeptabel, führt aber dazu, dass der SASL-Logger Fehlerberichte protokolliert. Diese Einträge können die Einträge von echten Abstürzen überschatten. (Der SASL-Logger ist eine weitere kostenlose Funktion, die du bei der Verwendung von OTP erhältst. Wir behandeln ihn in Kapitel 9.)
Obwohl Server normalerweise durch die Rückgabe des Tupels stop
beendet werden können, kann es vorkommen, dass sie aufgrund eines Laufzeitfehlers beendet werden. Wenn der generische Server in diesen Fällen Exits abfängt (indem er die process_flag(trap_exit,
true)
BIF aufruft), wird auch terminate/2
aufgerufen, wie in Abbildung 4-6 gezeigt. Wenn du keine Exits abfängst, wird der Prozess einfach beendet , ohne terminate/2
aufzurufen.
Wenn du möchtest, dass die Funktion terminate/2
nach einer abnormalen Beendigung ausgeführt wird, musst du das Flag trap_exit
setzen. Wenn es nicht gesetzt ist, könnte ein Supervisor oder ein verlinkter Prozess den Server zum Absturz bringen, ohne dass er sich bereinigen kann.
Überprüfe daher immer den Kontext auf Beendigung. Wenn ein Laufzeitfehler aufgetreten ist, solltest du den Serverstatus mit äußerster Vorsicht bereinigen, da sonst deine Daten beschädigt werden könnten und dein System nach dem Neustart des Servers für weitere Laufzeitfehler anfällig ist. Beim Neustart solltest du versuchen, den Serverstatus aus korrekten (und eindeutigen) Datenquellen wiederherzustellen und nicht aus einer Kopie, die du unmittelbar vor dem Absturz gespeichert hast, da diese durch denselben Fehler beschädigt worden sein könnte, der den Absturz verursacht hat.
Anrufzeitüberschreitungen
Wenn du mit einem gen_server
Aufruf synchrone Nachrichten an deinen Server sendest, solltest du innerhalb von Millisekunden eine Antwort erwarten. Aber was ist, wenn sich das Senden der Antwort verzögert? Dein Server könnte mit der Bearbeitung von Tausenden von Anfragen extrem ausgelastet sein, oder es könnte Engpässe bei externen Abhängigkeiten wie Datenbanken, Authentifizierungsservern, IP-Netzwerken oder anderen Ressourcen oder APIs geben, die ihre Zeit für die Antwort benötigen. OTP-Verhaltensweisen haben eine eingebaute Zeitüberschreitung von 5 Sekunden in ihren synchronen gen_server:call
APIs. Das sollte für die meisten Abfragen in einem Soft-Echtzeitsystem ausreichen, aber es gibt Grenzfälle, die anders behandelt werden müssen. Wenn du eine synchrone Anfrage mit OTP-Verhalten sendest und innerhalb von 5 Sekunden keine Antwort erhalten hast, löst der Client-Prozess eine Ausnahme aus. Probieren wir es in der Shell mit dem folgenden Callback-Modul aus:
-
module
(
timeout
).
-
behavior
(
gen_server
).
-
export
([
init
/
1
,
handle_call
/
3
]).
init
(_
Args
)
->
{
ok
,
undefined
}.
handle_call
({
sleep
,
Ms
},
_
From
,
LoopData
)
->
timer
:
sleep
(
Ms
),
{
reply
,
ok
,
LoopData
}.
In der Funktion gen_server:call/2
senden wir eine Nachricht im Format {sleep, Ms}
, wobei Ms
ein Wert ist, der in verwendet wird, der timer:sleep/1
Aufruf wird im handle_call/3
Callback ausgeführt. Wenn du einen Wert sendest, der größer als 5.000 Millisekunden ist, sollte die Funktion gen_server:call/2
eine Ausnahme auslösen, da ein solcher Wert das Standard-Timeout überschreitet. Probieren wir es in der Shell aus. Wir gehen davon aus, dass das Timeout-Modul bereits kompiliert ist, um die Compiler-Warnungen der Callback-Funktionen zu vermeiden, die wir weggelassen haben:
1>gen_server:start_link({local, timeout}, timeout, [], []).
{ok,<0.66.0>} 2>gen_server:call(timeout, {sleep, 1000}).
ok 3>catch gen_server:call(timeout, {sleep, 5001}).
{'EXIT',{timeout,{gen_server,call,[timeout,{sleep,5001}]}}} 4>flush().
Shell got {#Ref<0.0.0.300>,ok} 5>gen_server:call(timeout, {sleep, 5001}).
** exception exit: {timeout,{gen_server,call,[timeout,{sleep,5001}]}} in function gen_server:call/2 6>catch gen_server:call(timeout, {sleep, 1000}).
{'EXIT',{noproc,{gen_server,call,[timeout,{sleep,1000}]}}}
Wir starten den Server und senden mit Shell-Befehl 2 eine synchrone Nachricht, die den Server auffordert, 1.000 Millisekunden zu schlafen, bevor er mit dem Atom ok
antwortet. Da dies innerhalb des 5-Sekunden-Standard-Timeouts liegt, bekommen wir unsere Antwort zurück. In Shell-Befehl 3 erhöhen wir jedoch die Zeitüberschreitung auf 5.001 Millisekunden, wodurch die Funktion gen_server:call/2
eine Ausnahme auslöst. In unserem Beispiel fängt der Shell-Befehl 3 die Ausnahme ab, so dass die Client-Funktion alle Sonderfälle behandeln kann, die aufgrund der Zeitüberschreitung auftreten können.
Wenn du dich dafür entscheidest, Ausnahmen abzufangen, die als Folge einer Zeitüberschreitung auftreten, sei gewarnt: Wenn der Server aktiv, aber beschäftigt ist, sendet er eine Antwort zurück, nachdem die Zeitüberschreitungsausnahme ausgelöst wurde. Diese Antwort muss bearbeitet werden. Wenn der Client selbst ein OTP-Verhalten ist, führt die Ausnahme dazu, dass der Aufruf handle_info/2
aufgerufen wird. Wenn dieser Aufruf nicht implementiert wurde, stürzt der Client-Prozess ab.
Wenn der Aufruf von einem reinen Erlang-Client kommt, wird die Ausnahme in der Mailbox des Clients gespeichert und nie bearbeitet. Ungelesene Nachrichten in deiner Mailbox verbrauchen Speicher und verlangsamen den Prozess, wenn neue Nachrichten eingehen, da die ungelesenen Nachrichten durchlaufen werden müssen, bevor neue Nachrichten mit dem Muster verglichen werden können. Und nicht nur das: Wenn du eine Nachricht an einen Prozess mit einer großen Anzahl ungelesener Nachrichten sendest, wird der Absender langsamer, weil der Sendevorgang mehr Kürzungen erfordert. Das hat einen Dominoeffekt: Es kann zu weiteren Zeitüberschreitungen kommen und die Zahl der ungelesenen Nachrichten im Postfach des Kunden steigt weiter an.
Die Leistungseinbußen beim Senden von Nachrichten an einen Prozess mit einer langen Nachrichtenwarteschlange gelten nicht für Verhaltensweisen, die synchron auf den Prozess antworten, von dem die Anfrage stammt. Wenn der Client-Prozess eine lange Warteschlange hat, wird die receive
Klausel dank der Optimierungen des Compilers und der virtuellen Maschine mit der Antwort übereinstimmen, ohne dass die gesamte Warteschlange durchlaufen werden muss.
Wir sehen den Beweis für dieses Speicherleck in Shell-Befehl 4, wo ungelesene Nachrichten geleert werden. Hätten wir die Nachricht nicht geleert, wäre sie in der Mailbox der Shell geblieben. In diesem Buch weisen wir dich immer wieder darauf hin, dass du in deinem Code keine Eckfälle und unerwarteten Fehler behandeln sollst, da du sonst Gefahr läufst, mehr Bugs und Fehler einzuführen, als du tatsächlich löst. Dies ist ein typisches Beispiel, bei dem sich die Nebeneffekte dieser Timeouts wahrscheinlich nur unter extremer Belastung in einem Live-System bemerkbar machen.
Sieh dir jetzt den Shell-Befehl 5 und Abbildung 4-7 an. Wir haben einen Aufruf, der den Client-Prozess zum Absturz bringt, weil er außerhalb des Geltungsbereichs einer try-catch
-Anweisung ausgeführt wird. In den meisten Fällen, wenn dein Server aus irgendeinem (möglicherweise unbekannten) Grund nicht antwortet, ist es wahrscheinlich am besten, den Client-Prozess zu beenden und den Supervisor damit fertig werden zu lassen. In diesem Beispiel wird der Shell-Prozess beendet und sofort neu gestartet. Der Timeout-Server sendet nach 5.001 Millisekunden eine Antwort an die alte Client- (und Shell-) pid. Da dieser Prozess nicht mehr existiert, wird die Nachricht verworfen. Warum also schlägt der Shell-Befehl 6 mit der Begründung noproc
fehl? Sieh dir die Abfolge der Shell-Befehle an und versuche, es herauszufinden, bevor du weiterliest.
Als wir den Server gestartet haben, haben wir ihn mit der Shell verknüpft, sodass der Shell-Prozess sowohl als Client als auch als Parent fungiert. Der Timeout-Server wurde beendet, nachdem wir in Shell-Befehl 5 einen gen_server:call/2
-Aufruf außerhalb des Bereichs von try-catch
ausgeführt hatten. Da der Server keine Exits abfängt, wurde das Signal EXIT
an den Server weitergegeben, als die Shell beendet wurde, so dass dieser ebenfalls beendet wurde. Unter normalen Umständen wären der Client und der übergeordnete Prozess des Servers, der auf ihn verweist, nicht derselbe Prozess, so dass dies nicht passieren würde. Diese Probleme treten häufig auf, wenn du das Verhalten der Shell testest, also behalte sie im Hinterkopf, wenn du an deinen Übungen arbeitest.
Wie können wir also etwas anderes als den 5-Sekunden-Standardwert für die Zeitüberschreitung in Verhaltensweisen angeben? Ganz einfach: Wir setzen unser eigenes Timeout. In generischen Servern tun wir das mit dem folgenden Funktionsaufruf:
gen_server
:
call
(
Server
,
Message
,
TimeOut
)
-
>
Reply
wobei TimeOut
entweder der gewünschte Wert in Millisekunden oder das Atom infinity
ist.
Ein Client-Aufruf besteht oft aus einer Kette von synchronen Anfragen an mehrere, möglicherweise verteilte Verhaltensprozesse. Diese können ihrerseits Anfragen an externe Ressourcen senden. In den meisten Fällen ist die Wahl der Timeout-Werte schwierig, da diese Prozesse auf Dienste und APIs von Dritten zugreifen, die sich deiner Kontrolle entziehen. Systeme, die dafür bekannt sind, dass sie auf die meisten Anfragen in Millisekunden reagieren, können unter extremer Last Sekunden oder sogar Minuten brauchen. Der Durchsatz deines Systems, gemessen in Operationen pro Sekunde, mag immer noch derselbe sein, aber wenn eine höhere Last - möglicherweise um viele Größenordnungen höher - durch das System läuft, wird die Latenzzeit der einzelnen Anfragen höher sein.
Die einzige Möglichkeit, die Frage zu beantworten, welche TimeOut
du einstellen solltest, ist, mit deinen externen Anforderungen zu beginnen. Wenn ein Kunde eine 30-Sekunden-Zeitüberschreitung vorschreibt, fang damit an und arbeite dich durch die Kette der Anfragen. Was sind die garantierten Antwortzeiten deiner externen Abhängigkeiten? Wie reagieren Festplattenzugriff und E/A unter extremer Last? Wie sieht es mit der Netzwerklatenz aus? Verbringe viel Zeit mit Stresstests deines Systems auf der Zielhardware und passe deine Werte entsprechend an. Wenn du dir unsicher bist, beginne mit dem Standardwert von 5.000 Millisekunden. Verwende den Wert infinity
mit äußerster Vorsicht und vermeide ihn ganz, wenn es keine andere Alternative gibt.
Deadlocks
Stell dir zwei generische Server in einem schlecht konzipierten System vor. server1
ruft synchron server2
auf. server2
empfängt die Anfrage und führt über eine Reihe von Aufrufen in anderen Modulen schließlich (möglicherweise unwissentlich) einen synchronen Rückruf an server1
aus. Abbildung 4-8 zeigt, dass dieses Problem nicht durch komplexe Algorithmen zur Vermeidung von Deadlocks, sondern durch Timeouts gelöst wird.
Wenn server1
innerhalb von 5.000 Millisekunden keine Antwort erhalten hat, bricht es ab und veranlasst server2
, ebenfalls abzubrechen. Je nachdem, was zuerst eintrifft, wird der Abbruch entweder durch das Monitor-Signal oder durch eine eigene Zeitüberschreitung ausgelöst. Wenn weitere Prozesse in die Blockade verwickelt sind, wird der Abbruch auch auf sie übertragen. Der Supervisor empfängt die EXIT
Signale und startet die Server entsprechend neu. Der Abbruch wird in einer Protokolldatei gespeichert, in der er hoffentlich entdeckt und der Fehler, der zum Deadlock geführt hat, behoben wird.
In den 17 Jahren, in denen ich mit Erlang arbeite, bin ich nur einmal auf einen Deadlock gestoßen.3 Der Prozess A
rief synchron den Prozess B
auf, der wiederum einen Remote-Prozeduraufruf an einen anderen Knoten tätigte, der zu einem synchronen Aufruf des Prozesses C
führte. Der Prozess C
rief synchron den Prozess D
auf, der einen weiteren Remote-Prozeduraufruf zurück an den ersten Knoten tätigte. Dieser Aufruf führte zu einem synchronen Rückruf an den Prozess A
, der immer noch auf eine Antwort von B
wartete. Wir entdeckten diesen Deadlock, als wir die beiden Knoten zum ersten Mal integrierten, und brauchten 5 Minuten, um ihn zu lösen. Der Prozess A
hätte B
asynchron aufrufen müssen, und der Prozess B
hätte mit einem asynchronen Callback auf A
antworten müssen. Das Risiko von Deadlocks besteht zwar, aber wenn du das Problem richtig angehst, ist es minimal, denn die Hauptursache für Deadlocks ist die Kontrolle der Ausführung und der Fehler in kritischen Abschnitten - etwas, wofür der Shared-Nothing-Ansatz in Erlang viele Alternativen bietet.
Allgemeine Server-Timeouts
Stell dir einen generischen Server vor, dessen Aufgabe es ist, ein bestimmtes Hardware-Gerät zu überwachen und mit ihm zu kommunizieren. Wenn der Server innerhalb einer vordefinierten Zeitspanne keine Nachricht von dem Gerät erhalten hat, sollte er eine Ping-Anfrage senden, um sicherzustellen, dass das Gerät noch lebt. Diese Ping-Anfragen können durch interne Timeouts ausgelöst werden, die durch das Hinzufügen eines Timeout-Werts in den Kontrolltupeln, die als Ergebnis der Verhaltens-Callback-Funktionen zurückgesendet werden, erstellt werden:
init
/
1
->
{
ok
,
LoopData
,
Timeout
}
handle_call
/
3
->
{
reply
,
Reply
,
LoopData
,
Timeout
}
handle_cast
/
2
->
{
noreply
,
LoopData
,
Timeout
}
handle_info
/
2
->
{
noreply
,
LoopData
,
Timeout
}
Der Wert Timeout
ist entweder eine ganze Zahl in Millisekunden oder das Atom infinity
. Wenn der Server innerhalb von Timeout
Millisekunden keine Nachricht erhält, bekommt er eine timeout
Nachricht in seiner handle_info/2
Callback-Funktion. Die Rückgabe von infinity
ist dasselbe wie das Nichtfestlegen eines Timeout-Werts. Versuchen wir es mit einem einfachen Beispiel, bei dem wir alle 5.000 Millisekunden eine Zeitüberschreitung erzeugen, die die aktuelle Zeit abfragt und die Sekunden ausgibt. Wir können den Timer anhalten und neu starten, indem wir die synchronen Nachrichten start
und pause
senden:
-
module
(
ping
).
-
behavior
(
gen_server
).
-
export
([
init
/
1
,
handle_call
/
3
,
handle_info
/
2
]).
-
define
(
TIMEOUT
,
5000
).
init
(_
Args
)
->
{
ok
,
undefined
,
?
TIMEOUT
}.
handle_call
(
start
,
_
From
,
LoopData
)
->
{
reply
,
started
,
LoopData
,
?
TIMEOUT
};
handle_call
(
pause
,
_
From
,
LoopData
)
->
{
reply
,
paused
,
LoopData
}.
handle_info
(
timeout
,
LoopData
)
->
{_
Hour
,_
Min
,
Sec
}
=
time
(),
io
:
format
(
"
~2.w~n
"
,[
Sec
]),
{
noreply
,
LoopData
,
?
TIMEOUT
}.
Angenommen, das Ping-Modul ist kompiliert, dann starten wir es und erzeugen alle 5 Sekunden eine Zeitüberschreitung. Wir können die Zeitüberschreitung aussetzen, indem wir die Nachricht pause
senden, die, wenn sie in der zweiten Klausel der Funktion handle_call/3
behandelt wird, keine Zeitüberschreitung in ihrem Rückgabetupel enthält. Mit der Nachricht start
schalten wir sie wieder ein:
1>gen_server:start({local, ping}, ping, [], []).
{ok,<0.38.0>} 22 27 2>gen_server:call(ping, pause).
paused 3>gen_server:call(ping, start).
started 51 56 4>gen_server:call(ping, start).
started 4
Da wir eine relativ hohe Zeitüberschreitung einstellen, erzeugen wir nicht alle 5.000 Millisekunden eine Timeout-Nachricht. Wir senden nur dann eine Timeout-Nachricht, wenn das Verhalten noch keine Nachricht erhalten hat. Wenn eine Nachricht empfangen wird, wie es in unserem Beispiel mit dem Shell-Befehl 4 geschieht, wird der Timer zurückgesetzt.
Wenn du Timer brauchst, die nicht zurückgesetzt werden dürfen oder unabhängig von eingehenden Nachrichten in regelmäßigen Abständen laufen müssen, verwende Funktionen wie wie erlang:send_after/3
oder die Funktionen des Moduls timer
, einschließlich apply_after/3
, send_after/2
, apply_interval/4
, und send_interval/2
.
Verhaltensweisen im Winterschlaf
Wenn wir anstelle eines Timeout-Wertes oder des Atoms infinity
das Atom hibernate
zurückgeben, reduziert der Server seinen Speicherbedarf und geht in einen Wartezustand über. Du solltest hibernate
verwenden, wenn Server, die nur sporadisch speicherintensive Anfragen erhalten, dem System zu wenig Speicherplatz zur Verfügung stellen. hibernate
verwirft den Aufrufstapel und führt eine vollständige Speicherbereinigung durch, bei der alle Daten in einem kontinuierlichen Heap abgelegt werden. Der zugewiesene Speicher wird dann auf die Größe der Daten auf dem Heap verkleinert. Der Server bleibt in diesem Zustand, bis er eine neue Nachricht erhält.
Warnung
Der Ruhezustand von Prozessen ist mit Kosten verbunden, da er eine vollständige Speicherbereinigung vor dem Ruhezustand und eine weitere kurz nach dem Aufwachen des Prozesses erfordert. Verwende den Ruhezustand nur, wenn du in absehbarer Zeit keine Nachrichten erwartest und Speicherplatz sparen musst, aber nicht für Server, die häufig Nachrichten erhalten. Die Verwendung als Präventivmaßnahme ist gefährlich, vor allem, wenn dein Prozess ausgelastet ist, denn es könnte (und wird wahrscheinlich) mehr kosten, den Prozess in den Ruhezustand zu versetzen, als ihn einfach so zu lassen. Der einzige Weg, um sicher zu gehen, ist ein Benchmarking deines Systems unter Stress und der Nachweis eines Leistungszuwachses sowie einer erheblichen Verringerung des Speicherverbrauchs. Füge es nur dann nachträglich hinzu, wenn du weißt, was du tust. Im Zweifelsfall lass es lieber bleiben!
Going Global
Verhaltensprozesse können lokal oder global registriert werden. In unseren Beispielen wurden sie alle lokal mit einem Tupel des Formats {local,
ServerName}
registriert, wobei ServerName
ein Atom ist, das den Alias bezeichnet. Das ist gleichbedeutend mit der Registrierung des Prozesses unter Verwendung der register(ServerName, Pid)
BIF. Was aber, wenn wir in einem verteilten Cluster Standorttransparenz wünschen?
Global registrierte Prozesse hängen am globalen Nameserver, der sie in einem Cluster von (möglicherweise partitionierten) verteilten Knoten transparent zugänglich macht. Der Nameserver speichert lokale Replikate der Namen auf jedem Knoten und überwacht den Zustand der Knoten und Änderungen der Konnektivität, um sicherzustellen, dass es keinen zentralen Fehlerpunkt gibt. Du registrierst einen Server global, indem du das Tupel {global, Name}
als Argument für das Feld Servername verwendest. Das ist gleichbedeutend mit der Registrierung des Prozesses mit der Funktion global:register_name(Name, Pid)
. Verwende das gleiche Tupel in deinen synchronen und asynchronen Aufrufen:
gen_server
:
start_link
(
{
global
,
Name
}
,
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
ignore
|
{
error
,
Reason
}
gen_server
:
call
(
{
global
,
Name
}
,
Message
)
-
>
Reply
gen_server
:
cast
(
{
global
,
Name
}
,
Message
)
-
>
ok
Es gibt eine API, mit der du die globale Prozessregistrierung durch eine von dir selbst implementierte ersetzen kannst. global
Du kannst deine eigene erstellen, wenn die Funktionalität, die das Modul bietet, nicht ausreicht oder wenn du ein anderes Verhalten möchtest, das verschiedene Netzwerktopologien berücksichtigt. Du musst ein Callback-Modul bereitstellen - sagen wir Module
-, das die gleichen Funktionen und Rückgabewerte exportiert, die im Modul global
, nämlich register_name/2
, unregister_name/1
, whereis_name/1
und send/2
definiert sind. Für die Namensregistrierung wird dann das Tupel {via,
Module, Name}
verwendet, und wenn du deinen Prozess mit {via, global,
Name}
startest, ist es dasselbe, als wenn du ihn mit {global,
Name}
global registrierst. Bei global registrierten Prozessen muss Name
kein Atom sein; vielmehr ist jeder Erlang-Term gültig. Sobald du dein Callback-Modul hast, kannst du deinen Prozess starten und Nachrichten senden:
gen_server
:
start_link
(
{
via
,
Module
,
Name
}
,
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
gen_server
:
call
(
{
via
,
Module
,
Name
}
,
Message
)
-
>
Reply
gen_server
:
cast
(
{
via
,
Module
,
Name
}
,
Message
)
-
>
ok
Im weiteren Verlauf des Buches fassen wir {via, Module,
Name}
, {local, Name}
und {global, Name}
mit NameScope
zusammen. Die meisten Server werden lokal registriert, aber je nach Komplexität des Systems und der Clustering-Strategie werden auch global
und via
verwendet.
Wenn du mit Verhaltensweisen kommunizierst, kannst du ihre Pids anstelle ihrer registrierten Aliase verwenden. Die Registrierung von Verhaltensweisen ist nicht zwingend erforderlich. Wenn du sie nicht registrierst, können mehrere Instanzen desselben Verhaltens parallel ausgeführt werden. Wenn du die Verhaltensweisen startest, lass einfach das Namensfeld weg:
gen_server
:
start_link
(
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
ignore
|
{
error
,
Reason
}
Wenn du eine Anfrage an alle Server innerhalb eines Clusters von Knoten sendest, kannst du den Aufruf generic server multi_call/3
verwenden, wenn du Ergebnisse zurück benötigst, und abcast/3
, wenn du sie nicht benötigst:
gen_server
:
multi_call
(
Nodes
,
Name
,
Request
[
,
Timeout
]
)
-
>
{
[
{
Node
,
Reply
}
]
,
BadNodes
}
gen_server
:
abcast
(
Nodes
,
Name
,
Request
)
-
>
abcast
Auf den Servern der einzelnen Knoten werden die Anfragen in den Callbacks handle_call/3
und handle_cast/2
bearbeitet. Bei der asynchronen Übertragung mit abcast
wird nicht überprüft, ob die Knotenpunkte verbunden und noch am Leben sind. Anfragen an Knotenpunkte, die nicht erreicht werden können, werden einfach verworfen.
Verknüpfung von Verhaltensweisen
Wenn du Verhaltensweisen in der Shell startest, verknüpfst du den Shell-Prozess mit ihnen. Wenn der Shell-Prozess abnormal beendet wird, überträgt sich sein EXIT
Signal auf die von ihm gestarteten Verhaltensweisen und führt zu deren Beendigung. Generische Server können gestartet werden, ohne sie mit ihrem Parent zu verknüpfen, indem gen_server:start/3
oder gen_server:start/4
aufruft. Verwende diese Funktionen mit Bedacht und vorzugsweise nur für Entwicklungs- und Testzwecke, denn Verhaltensweisen sollten immer mit ihren Eltern verknüpft sein:
gen_server
:
start
(
NameScope
,
Mod
,
Args
,
Opts
)
gen_server
:
start
(
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
{
error
,
{
already_started
,
Pid
}
}
Erlang-Systeme funktionieren jahrelang, ohne dass die Computer, auf denen sie laufen, neu gestartet werden müssen. Sie können sogar während Software-Upgrades für Fehlerkorrekturen, Funktionserweiterungen und neue Funktionen sowie durch Verhaltensweisen, die abnormal enden und neu gestartet werden, weiterlaufen. Wenn du ein Subsystem herunterfährst, musst du zu 100 % sicher sein, dass alle Prozesse, die mit diesem Subsystem verbunden sind, beendet werden, und vermeiden, dass verwaiste Prozesse zurückbleiben. Die einzige Möglichkeit, dies mit Sicherheit zu tun, ist die Verwendung von Links. In Kapitel 8 gehen wir näher auf das Verhalten von Supervisoren ein.
Resümee
In diesem Kapitel haben wir die wichtigsten Konzepte und Funktionen des generischen Serververhaltens, dem Verhalten hinter allen Verhaltensweisen, vorgestellt. Du solltest jetzt die Vorteile des gen_server
Verhaltens verstehen, anstatt dein eigenes Verhalten zu entwickeln. Wir haben die meisten Funktionen und zugehörigen Rückrufe behandelt, die bei der Verwendung dieses Verhaltens benötigt werden. Du musst zwar nicht alles verstehen, was hinter den Kulissen abläuft, aber wir hoffen, dass du jetzt eine Vorstellung davon hast, dass es mehr gibt, als man auf den ersten Blick sieht. Die wichtigsten Funktionen, die wir behandelt haben, sind in Tabelle 4-1 aufgeführt.
gen_server Funktion oder Aktion | gen_server Rückruf-Funktion |
---|---|
gen_server:start/3 , gen_server:start/4 , gen_server:start_link/3 , gen_server:start_link/4 | Module:init/1 |
gen_server:call/2 , gen_server:call/3 , gen_server:multi_call/2 , gen_server:multi_call/3 | Module:handle_call/3 |
gen_server:cast/2 , gen_server:abcast/2 , gen_server:abcast/3 | Module:handle_cast/2 |
Pid ! Msg Monitore, Exit-Nachrichten, Nachrichten von Ports und Sockets, Knotenmonitore und andere Nicht-OTP-Nachrichten | Module:handle_info/2 |
Ausgelöst durch die Rückgabe von {stop, ...} oder bei abnormaler Beendigung während des Trappings von Exits | Module:terminate/2 |
Beim Kompilieren von Verhaltensmodulen wirst du eine Warnung über den fehlenden code_change/3
Callback gesehen haben. Wir behandeln dies in Kapitel 11, wenn wir uns mit der Handhabung von Releases und Software-Upgrades beschäftigen. Im nächsten Kapitel werden wir uns am Beispiel des allgemeinen Serververhaltens mit fortgeschrittenen Themen und verhaltensspezifischen Funktionen befassen, die mit OTP einhergehen.
An dieser Stelle solltest du unbedingt die Handbuchseiten für das Modul gen_server
durchlesen. Wenn du dich mutig fühlst, solltest du den Code in der Quelldatei gen_server.erlund den Quellcode für das Hilfemodul gen
lesen. Wenn du dieses und das vorige Kapitel gelesen und die Eckfälle verstanden hast, wirst du feststellen, dass der Code nicht so kryptisch ist, wie er auf den ersten Blick erscheinen mag.
Was kommt als Nächstes?
Das nächste Kapitel enthält Kleinigkeiten, die es dir ermöglichen, tiefer in die Verhaltensweisen einzudringen. Wir beginnen mit der Untersuchung der eingebauten Tracing- und Logging-Funktionen, die wir durch die Verwendung dieser Funktionen erhalten. Außerdem stellen wir dir die Opts
Flags in den Startfunktionen vor. Mit den Flags kannst du die Leistung und die Speichernutzung feinabstimmen und dein Verhalten mit aktivierten Trace-Flags starten. Lies also weiter, denn im nächsten Kapitel warten interessante Dinge auf dich.
1 Auch auf die Gefahr hin, dass du dich wiederholst: Sei nett zu ihnen, denn du könntest es eines Tages sein.
2 Wenn du dieses Beispiel in der Shell ausführst, erhältst du auch eine Fehlermeldung von der Shell selbst, die aufgrund des Exit-Signals, das sich über die Verbindung verbreitet, beendet wird.
3 Ich bin der Autor, der im letzten Buch den landesweiten Datenausfall in einem Mobilfunknetz verursacht hat.
Get Design für Skalierbarkeit mit Erlang/OTP now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.