Kapitel 4. Cloud Native Patterns
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Fortschritte sind nur möglich, wenn wir uns antrainieren, über Programme nachzudenken, ohne sie als Teile von ausführbarem Code zu betrachten.1
Edsger W. Dijkstra, August 1979
1991, als er noch bei Sun Microsystems arbeitete, formulierte L Peter Deutsch2 die Fallacies of Distributed Computing (Irrtümer des verteilten Rechnens), in denen er einige der falschen Annahmen auflistet, die Programmierer/innen, die neu (und nicht mehr so neu) in verteilten Anwendungen sind, oft machen:
-
Das Netzwerk ist zuverlässig: Switches schlagen fehl, Router werden falsch konfiguriert
-
Die Latenz ist gleich Null: Es dauert, bis Daten über ein Netzwerk übertragen werden
-
Die Bandbreite ist unendlich: Ein Netzwerk kann nur so viele Daten auf einmal verarbeiten
-
Das Netzwerk ist sicher: Gib keine Geheimnisse im Klartext weiter; verschlüssele alles
-
Die Topologie ändert sich nicht: Server und Dienste kommen und gehen
-
Es gibt nur einen Administrator: Mehrere Administratoren führen zu heterogenen Lösungen
-
Die Transportkosten sind gleich null: Das Verschieben von Daten kostet Zeit und Geld
-
Das Netzwerk ist homogen: Jedes Netzwerk ist (manchmal sehr) unterschiedlich
Wenn ich so dreist sein darf, würde ich gerne noch eine neunte hinzufügen:
-
Dienste sind zuverlässig: Dienste, auf die du angewiesen bist, können jederzeit fehlschlagen
In diesem Kapitel stelle ich eine Auswahl an idiomatischen Mustern - getestete, bewährte Entwicklungsparadigmen - vor, die eine oder mehrere der in Deutsch's Fallacies beschriebenen Bedingungen adressieren, und zeige, wie man sie in Go implementiert. Keines der Muster, die in diesem Buch besprochen werden, ist neu - einige gibt es schon so lange, wie es verteilte Anwendungen gibt - aber die meisten wurden bisher noch nicht in einem Werk veröffentlicht. Viele von ihnen sind einzigartig für Go oder haben neuartige Implementierungen in Go im Vergleich zu anderen Sprachen.
Leider werden in diesem Buch keine Patterns auf Infrastrukturebene wie die Bulkhead- oder Gatekeeper-Patterns behandelt. Das liegt vor allem daran, dass unser Schwerpunkt auf der Entwicklung der Anwendungsschicht in Go liegt und diese Muster zwar unverzichtbar sind, aber auf einer ganz anderen Abstraktionsebene funktionieren. Wenn du mehr darüber erfahren möchtest, empfehle ich dir die Bücher Cloud Native Infrastructure von Justin Garrison und Kris Nova (O'Reilly) und Designing Distributed Systems von Brendan Burns (O'Reilly).
Das Context-Paket
Die meisten Codebeispiele in diesem Kapitel verwenden das Paket context
, das in Go 1.7 eingeführt wurde, um eine idiomatische Methode zur Übertragung von Deadlines, Abbruchsignalen und anforderungsspezifischen Werten zwischen Prozessen bereitzustellen. Es enthält eine einzige Schnittstelle, context.Context
, deren Methoden im Folgenden aufgelistet sind:
type
Context
interface
{
// Done returns a channel that's closed when this Context is cancelled.
Done
()
<-
chan
struct
{}
// Err indicates why this context was cancelled after the Done channel is
// closed. If Done is not yet closed, Err returns nil.
Err
()
error
// Deadline returns the time when this Context should be cancelled; it
// returns ok==false if no deadline is set.
Deadline
()
(
deadline
time
.
Time
,
ok
bool
)
// Value returns the value associated with this context for key, or nil
// if no value is associated with key. Use with care.
Value
(
key
interface
{})
interface
{}
}
Drei dieser Methoden können verwendet werden, um etwas über den Abbruchstatus oder das Verhalten eines Context
Wertes zu erfahren. Die vierte, Value
, kann verwendet werden, um einen Wert abzurufen, der mit einem beliebigen Schlüssel verbunden ist. Context
Die Methode Value
ist in der Go-Welt umstritten und wird im Abschnitt "Definieren von Request-Scoped Values" näher erläutert .
Was der Kontext für dich tun kann
Ein context.Context
Wert wird verwendet, indem er direkt an eine Dienstanforderung übergeben wird, die ihn wiederum an eine oder mehrere Unteranfragen weitergeben kann. Das Nützliche daran ist, dass beim Abbruch der Context
alle Funktionen, die sie halten (oder eine abgeleitete Context
; mehr dazu in den Abbildungen4-1,4-2 und4-3), das Signal erhalten, so dass sie ihren Abbruch koordinieren und den Aufwand reduzieren können.
Nehmen wir zum Beispiel eine Anfrage eines Benutzers an einen Dienst, der wiederum eine Anfrage an eine Datenbank stellt. In einem idealen Szenario können die Anfragen des Nutzers, der Anwendung und der Datenbank wie in Abbildung 4-1 dargestellt werden.
Was aber, wenn der Nutzer seine Anfrage abbricht, bevor sie vollständig abgeschlossen ist? In den meisten Fällen werden die Prozesse ungeachtet des Gesamtzusammenhangs der Anfrage trotzdem weiterlaufen(Abbildung 4-2) und Ressourcen verbrauchen, um ein Ergebnis zu liefern, das nie genutzt wird.
Durch die gemeinsame Nutzung von Context
für jede nachfolgende Anfrage können jedoch alle lang laufenden Prozesse gleichzeitig ein "Fertig"-Signal erhalten, sodass das Abbruchsignal zwischen den einzelnen Prozessen koordiniert werden kann(Abbildung 4-3).
Wichtig ist, dass die Werte von Context
auch thread-sicher sind, d.h. sie können sicher von mehreren gleichzeitig ausgeführten Goroutinen verwendet werden, ohne dass ein unerwartetes Verhalten zu befürchten ist.
Kontext schaffen
Eine brandneue context.Context
kannst du über eine von zwei Funktionen erhalten:
func Background() Context
-
Gibt ein leeres
Context
zurück, das nie abgebrochen wird, keine Werte hat und keine Frist hat. Sie wird in der Regel von der Hauptfunktion, der Initialisierung und den Tests sowie als oberste EbeneContext
für eingehende Anfragen verwendet. func TODO() Context
-
Es gibt auch ein leeres
Context
, aber es soll als Platzhalter verwendet werden, wenn unklar ist, welchesContext
verwendet werden soll oder wenn ein übergeordnetesContext
noch nicht verfügbar ist.
Festlegen von Kontextfristen und Timeouts
Das Paket context
enthält außerdem eine Reihe von Methoden zur Erstellung abgeleiteter Context
Werte, mit denen du das Abbruchverhalten steuern kannst, entweder durch die Anwendung einer Zeitüberschreitung oder durch einen Funktionshaken, der explizit einen Abbruch auslösen kann.
func WithDeadline(Context, time.Time) (Context, CancelFunc)
-
Akzeptiert eine bestimmte Zeit, zu der die
Context
abgebrochen und derDone
Kanal geschlossen wird. func WithTimeout(Context, time.Duration) (Context, CancelFunc)
-
Akzeptiert eine Dauer, nach der die
Context
abgebrochen und derDone
Kanal geschlossen wird. func WithCancel(Context) (Context, CancelFunc)
-
Im Gegensatz zu den vorherigen Funktionen akzeptiert
WithCancel
nichts und gibt nur eine Funktion zurück, die aufgerufen werden kann, um dieContext
explizit abzubrechen.
Alle drei Funktionen geben eine abgeleitete Context
zurück, die alle gewünschten Dekorationen enthält, sowie eine context.CancelFunc
, eine Null-Parameter-Funktion, die aufgerufen werden kann, um die Context
und alle abgeleiteten Werte explizit zu löschen.
Tipp
Wenn ein Context
gelöscht wird, werden alle Context
s, die von ihm abgeleitet sind, ebenfalls gelöscht. Context
s, von denen es abgeleitet wurde, werden nicht gelöscht.
Definieren von Request-Scoped Values
Schließlich enthält das Paket context
eine Funktion, mit der ein beliebiges Schlüssel-Wert-Paar definiert werden kann , auf das über die zurückgegebene Context
- und alle davon abgeleiteten Context
- über die Methode Value
zugegriffen werden kann.
func WithValue(parent Context, key, val interface{}) Context
-
WithValue
gibt eine Ableitung vonparent
zurück, in derkey
mit dem Wertval
verbunden ist.
Einen Kontext verwenden
Wenn eine Dienstanforderung ausgelöst wird, entweder durch eine eingehende Anforderung oder durch die Funktion main
, verwendet der Hauptprozess die Funktion Background
, um einen neuen Wert Context
zu erstellen und ihn möglicherweise mit einer oder mehreren der Funktionen context.With*
zu verzieren, bevor er ihn an alle Unteranforderungen weitergibt. Diese Subrequests müssen dann nur noch den Done
Kanal auf Abbruchsignale überwachen.
Sieh dir zum Beispiel die folgende Stream
Funktion an:
func
Stream
(
ctx
context
.
Context
,
out
chan
<-
Value
)
error
{
// Create a derived Context with a 10s timeout; dctx
// will be cancelled upon timeout, but ctx will not.
// cancel is a function that will explicitly cancel dctx.
dctx
,
cancel
:=
context
.
WithTimeout
(
ctx
,
time
.
Second
*
10
)
// Release resources if SlowOperation completes before timeout
defer
cancel
()
res
,
err
:=
SlowOperation
(
dctx
)
if
err
!=
nil
{
// True if dctx times out
return
err
}
for
{
select
{
case
out
<-
res
:
// Read from res; send to out
case
<-
ctx
.
Done
():
// Triggered if ctx is cancelled
return
ctx
.
Err
()
}
}
}
Stream
erhält eine ctx Context
als Eingabeparameter, die er an WithTimeout
sendet, um dctx
zu erstellen, eine abgeleitete Context
mit einer 10-Sekunden-Zeitüberschreitung. Aufgrund dieser Dekoration könnte der Aufruf von SlowOperation(dctx)
nach zehn Sekunden eine Zeitüberschreitung verursachen und einen Fehler zurückgeben. Funktionen, die das Original ctx
verwenden, haben diese Zeitüberschreitung jedoch nicht und werden auch nicht unterbrochen.
Weiter unten wird der ursprüngliche ctx
Wert in einer for
Schleife um eine select
Anweisung verwendet, um Werte aus dem res
Kanal abzurufen, der von der SlowOperation
Funktion bereitgestellt wird. Beachte die case <-ctx.Done()
Anweisung, die ausgeführt wird, wenn der ctx.Done
Kanal geschlossen wird, um einen entsprechenden Fehlerwert zurückzugeben.
Aufbau dieses Kapitels
Die allgemeine Darstellung der einzelnen Muster in diesem Kapitel basiert lose auf der Darstellung im berühmten Buch "Gang of Four" Design Patterns,3 aber einfacher und weniger formal. Jedes Muster beginnt mit einer kurzen Beschreibung seines Zwecks und der Gründe für seine Verwendung, gefolgt von den folgenden Abschnitten:
- Anwendbarkeit
-
Kontext und Beschreibungen, wo dieses Muster angewendet werden kann.
- TeilnehmerInnen
-
Eine Auflistung der Komponenten des Musters und ihrer Aufgaben.
- Umsetzung
-
Eine Diskussion über die Lösung und ihre Umsetzung.
- Beispielcode
-
Eine Demonstration, wie der Code in Go implementiert werden kann.
Stabilitätsmuster
Die hier vorgestellten Stabilitätsmuster beziehen sich auf eine oder mehrere der Annahmen, die in den Fallacies of Distributed Computing genannt werden. Sie sind in der Regel für verteilte Anwendungen gedacht, um ihre eigene Stabilität und die Stabilität des größeren Systems, zu dem sie gehören, zu verbessern.
Stromkreisunterbrecher
Circuit Breaker schaltet bei einem wahrscheinlichen Fehler automatisch die Servicefunktionen ab und verhindert so größere oder kaskadenartige Ausfälle, indem er wiederkehrende Fehler beseitigt und angemessene Fehlerreaktionen liefert.
Anwendbarkeit
Wenn man die Irrtümer des verteilten Computings auf einen Punkt bringen wollte, dann wäre es, dass Fehler und Ausfälle eine unbestreitbare Tatsache im Leben verteilter, cloudbasierter Systeme sind. Dienste werden falsch konfiguriert, Datenbanken stürzen ab, Netzwerke trennen sich. Wir können das nicht verhindern, wir können es nur akzeptieren und dafür geradestehen.
Wenn du das nicht tust, kann das ziemlich unangenehme Folgen haben. Wir haben sie alle schon gesehen, und sie sind nicht schön. Einige Dienste versuchen vergeblich, ihre Arbeit zu erledigen und liefern dem Kunden nur Unsinn zurück; andere schlagen katastrophal fehl und geraten vielleicht sogar in eine Todesspirale aus Absturz und Neustart. Das spielt keine Rolle, denn am Ende verschwenden sie alle Ressourcen, verschleiern die Ursache des ursprünglichen Fehlers und machen kaskadenartige Ausfälle noch wahrscheinlicher.
Andererseits kann ein Dienst, der mit der Annahme entwickelt wurde, dass seine Abhängigkeiten jederzeit fehlschlagen können, angemessen reagieren, wenn sie es tun. Der Circuit Breaker ermöglicht es einem Dienst, solche Ausfälle zu erkennen und "den Kreislauf zu öffnen", indem er die Ausführung von Anfragen vorübergehend einstellt und den Kunden stattdessen eine Fehlermeldung entsprechend dem Kommunikationsvertrag des Dienstes übermittelt.
Stell dir zum Beispiel einen Dienst vor, der (idealerweise) eine Anfrage von einem Kunden erhält, eine Datenbankabfrage durchführt und eine Antwort zurückgibt. Was ist, wenn die Datenbank fehlschlägt? Der Dienst könnte weiterhin vergeblich versuchen, die Datenbank abzufragen, die Protokolle mit Fehlermeldungen überfluten und schließlich eine Zeitüberschreitung oder nutzlose Fehler zurückgeben. Ein solcher Dienst kann einen Circuit Breaker verwenden, um den Stromkreis zu unterbrechen, wenn die Datenbank fehlschlägt. So wird verhindert, dass der Dienst weitere fehlgeschlagene Datenbankabfragen durchführt (zumindest für eine Weile), und der Dienst kann dem Kunden sofort eine aussagekräftige Antwort geben.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Kreislauf
-
Die Funktion, die mit dem Dienst interagiert.
- Unterbrecher
-
Ein Abschluss mit der gleichen Funktionssignatur wie Circuit.
Umsetzung
Im Grunde genommen ist der Circuit Breaker nur ein spezielles Adapter-Muster, das mit Breaker
ummantelt wird Circuit
, um eine zusätzliche Logik zur Fehlerbehandlung hinzuzufügen.
Wie der elektrische Schalter, von dem dieses Muster seinen Namen ableitet, hat Breaker
zwei mögliche Zustände: geschlossen und offen. Im geschlossenen Zustand funktioniert alles normal. Alle Anfragen, die Breaker
vom Kunden erhält, werden unverändert an Circuit
weitergeleitet, und alle Antworten von Circuit
werden an den Kunden zurückgeschickt. Im offenen Zustand leitet Breaker
keine Anfragen an Circuit
weiter. Stattdessen schlägt es "schnell fehl", indem es mit einer informativen Fehlermeldung antwortet.
Breaker
verfolgt intern die Fehler, die von Circuit
zurückgegeben werden; wenn die Anzahl der aufeinanderfolgenden Fehler, die von Circuit
zurückgegeben werden, einen festgelegten Schwellenwert überschreitet, löst Breaker
aus und sein Zustand wechselt zu offen.
Die meisten Implementierungen von Circuit Breaker enthalten eine Logik, um den Stromkreis nach einer gewissen Zeit automatisch zu schließen. Bedenke aber, dass es zu Problemen führen kann, wenn ein bereits schlecht funktionierender Dienst mit vielen Wiederholungsversuchen belastet wird. Deshalb ist es Standard, eine Art Backoff einzubauen, also eine Logik, die die Anzahl der Wiederholungsversuche mit der Zeit reduziert. Das Thema "Backoff" ist ziemlich komplex, aber wir werden es in "Play It Again" ausführlich behandeln : Wiederholte Anfragen" behandelt.
In einem Dienst mit mehreren Knotenpunkten kann diese Implementierung um einen Mechanismus zur gemeinsamen Speicherung erweitert werden, z. B. einen Memcached- oder Redis-Netzwerkcache, um den Zustand des Kreises zu verfolgen.
Beispielcode
Wir beginnen damit, einen Circuit
Typ zu erstellen, der die Signatur der Funktion angibt, die mit deiner Datenbank oder einem anderen vorgelagerten Dienst interagiert. In der Praxis kann dies jede Form annehmen, die für deine Funktion geeignet ist. Sie sollte jedoch ein error
in ihrer Rückgabeliste enthalten:
type
Circuit
func
(
context
.
Context
)
(
string
,
error
)
In diesem Beispiel ist Circuit
eine Funktion, die einen Context
Wert annimmt, der in "Das Context-Paket" ausführlich beschrieben wurde . Deine Implementierung kann davon abweichen.
Die Funktion Breaker
akzeptiert eine beliebige Funktion, die der Typdefinition Circuit
entspricht, sowie eine ganze Zahl ohne Vorzeichen, die die Anzahl der zulässigen aufeinanderfolgenden Fehler angibt, bevor der Stromkreis automatisch geöffnet wird. Im Gegenzug liefert sie eine andere Funktion, die ebenfalls der Circuit
Typdefinition entspricht:
func
Breaker
(
circuit
Circuit
,
failureThreshold
uint
)
Circuit
{
var
consecutiveFailures
int
=
0
var
lastAttempt
=
time
.
Now
()
var
m
sync
.
RWMutex
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
m
.
RLock
()
// Establish a "read lock"
d
:=
consecutiveFailures
-
int
(
failureThreshold
)
if
d
>=
0
{
shouldRetryAt
:=
lastAttempt
.
Add
(
time
.
Second
*
2
<<
d
)
if
!
time
.
Now
().
After
(
shouldRetryAt
)
{
m
.
RUnlock
()
return
""
,
errors
.
New
(
"service unreachable"
)
}
}
m
.
RUnlock
()
// Release read lock
response
,
err
:=
circuit
(
ctx
)
// Issue request proper
m
.
Lock
()
// Lock around shared resources
defer
m
.
Unlock
()
lastAttempt
=
time
.
Now
()
// Record time of attempt
if
err
!=
nil
{
// Circuit returned an error,
consecutiveFailures
++
// so we count the failure
return
response
,
err
// and return
}
consecutiveFailures
=
0
// Reset failures counter
return
response
,
nil
}
}
Die Funktion Breaker
konstruiert eine weitere Funktion, ebenfalls vom Typ Circuit
, die circuit
umhüllt, um die gewünschte Funktionalität bereitzustellen. Du kennst das vielleicht aus "Anonyme Funktionen und Closures" als Closure: eine verschachtelte Funktion mit Zugriff auf die Variablen der übergeordneten Funktion. Wie du sehen wirst, funktionieren alle in diesem Kapitel implementierten "Stabilitätsfunktionen" auf diese Weise.
Die Schließung funktioniert, indem die Anzahl der aufeinanderfolgenden Fehler gezählt wird, die voncircuit
. Wenn dieser Wert die Fehlerschwelle erreicht, wird der Fehler "service unreachable" zurückgegeben, ohne circuit
aufzurufen. Jeder erfolgreiche Aufruf von circuit
führt dazu, dass consecutiveFailures
auf 0 zurückgesetzt wird, und der Zyklus beginnt von neuem.
Der Closure enthält sogar einen automatischen Reset-Mechanismus, der es ermöglicht, circuit
nach einigen Sekunden erneut aufzurufen. Dabei wird ein exponentieller Backoff-Algorithmus verwendet, bei dem sich die Dauer der Verzögerungen zwischen den Wiederholungsversuchen mit jedem Versuch ungefähr verdoppelt. Obwohl dies einfach und weit verbreitet ist, ist es nicht der ideale Backoff-Algorithmus. Warum das so ist, erfährst du im Abschnitt "Backoff-Algorithmen".
Entprellung
Die Entprellung begrenzt die Häufigkeit eines Funktionsaufrufs, so dass nur der erste oder letzte in einer Gruppe von Aufrufen tatsächlich ausgeführt wird.
Anwendbarkeit
Debounce ist das zweite unserer Muster, das sich mit einem elektrischen Schaltkreis beschäftigt. Genauer gesagt ist es nach einem Phänomen benannt, bei dem die Kontakte eines Schalters beim Öffnen oder Schließen "hüpfen", wodurch der Stromkreis ein wenig schwankt, bevor er sich wieder beruhigt. Normalerweise ist das keine große Sache, aber in Logikschaltungen, in denen eine Reihe von Ein- und Ausschaltimpulsen als Datenstrom interpretiert werden kann, kann dieses "Kontaktprellen" ein echtes Problem darstellen. Die Beseitigung des Kontaktprellens, damit beim Öffnen oder Schließen eines Kontakts nur ein Signal übertragen wird, nennt man "Entprellen".
In der Welt der Dienstleistungen kommt es manchmal vor, dass wir eine Reihe von potenziell langsamen oder kostspieligen Operationen ausführen, für die eine einzige ausreichen würde. Mit dem Debounce-Muster wird eine Reihe von ähnlichen Aufrufen, die zeitlich eng beieinander liegen, auf einen einzigen Aufruf beschränkt, in der Regel den ersten oder letzten in einem Stapel.
Diese Technik wird in der JavaScript-Welt schon seit Jahren verwendet, um die Anzahl der Operationen zu begrenzen, die den Browser verlangsamen könnten, indem nur der erste in einer Reihe von Benutzerereignissen ausgeführt wird, oder um einen Aufruf zu verzögern, bis ein Benutzer bereit ist. Du hast wahrscheinlich schon einmal eine Anwendung dieser Technik in der Praxis gesehen. Wir alle kennen die Erfahrung, eine Suchleiste zu benutzen, deren Autovervollständigungs-Pop-up erst erscheint, nachdem du mit dem Tippen aufgehört hast, oder einen Button mit einem Spam-Klick zu belegen, um die Klicks nach dem ersten ignoriert zu sehen.
Diejenigen von uns, die sich auf Backend-Dienste spezialisiert haben, können viel von unseren Frontend-Kollegen lernen, die seit Jahren daran arbeiten, die Zuverlässigkeit, Latenz und Bandbreitenprobleme verteilter Systeme zu berücksichtigen. Dieser Ansatz könnte zum Beispiel verwendet werden, um eine sich langsam aktualisierende Remote-Ressource abzurufen, ohne den Client und den Server mit unnötigen Anfragen zu belasten.
Dieses Muster ähnelt dem "Throttle", da es begrenzt, wie oft eine Funktion aufgerufen werden kann. Während "Debounce" jedoch die Häufigkeit von Aufrufen einschränkt, beschränkt "Throttle" die Anzahl der Aufrufe nach einem bestimmten Zeitraum. Mehr über den Unterschied zwischen den Mustern Debounce und Throttle erfährst du unter "Was ist der Unterschied zwischen Throttle und Debounce?
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Kreislauf
-
Die Funktion zu regulieren.
- Entprellung
-
Ein Abschluss mit der gleichen Funktionssignatur wie Circuit.
Umsetzung
Die Debounce-Implementierung ist der Implementierung von Circuit Breaker sehr ähnlich, da sie Circuit mit der Logik zur Ratenbegrenzung umgibt. Diese Logik ist eigentlich ganz einfach: Bei jedem Aufruf der äußeren Funktion - unabhängig von ihrem Ergebnis - wird ein Zeitintervall festgelegt. Jeder nachfolgende Aufruf, der vor Ablauf dieses Zeitintervalls erfolgt, wird ignoriert; jeder Aufruf, der danach erfolgt, wird an die innere Funktion weitergeleitet. Diese Implementierung, bei der die innere Funktion einmal aufgerufen wird und nachfolgende Aufrufe ignoriert werden, wird function-first genannt und ist nützlich, weil sie es ermöglicht, die erste Antwort der inneren Funktion zwischenzuspeichern und zurückzugeben.
Bei der Implementierung der letzten Funktion wird nach einer Reihe von Aufrufen eine Pause abgewartet, bevor die innere Funktion aufgerufen wird. Diese Variante ist in der JavaScript-Welt üblich, wenn ein Programmierer eine bestimmte Menge an Eingaben abwarten will, bevor er eine Funktion aufruft, z. B. wenn eine Suchleiste auf eine Pause bei der Eingabe wartet, bevor sie automatisch vervollständigt wird. Function-Last ist in Backend-Diensten eher weniger verbreitet, weil es keine sofortige Antwort liefert, aber es kann nützlich sein, wenn deine Funktion nicht sofort Ergebnisse benötigt.
Beispielcode
Genau wie in der Circuit Breaker-Implementierung beginnen wir mit der Definition eines Funktionstyps mit der Signatur der Funktion, die wir begrenzen wollen. Wie bei Circuit Breaker nennen wir ihn Circuit
; er ist identisch mit dem, der in diesem Beispiel deklariert wurde. Auch hier kann Circuit
jede Form annehmen, die für deine Funktion geeignet ist, aber sie sollte ein error
in ihren Rückgaben enthalten:
type
Circuit
func
(
context
.
Context
)
(
string
,
error
)
Die Ähnlichkeit mit der Circuit Breaker-Implementierung ist durchaus beabsichtigt: Ihre Kompatibilität macht sie "verkettbar", wie im Folgenden gezeigt wird:
func
myFunction
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
/* ... */
}
wrapped
:=
Breaker
(
Debounce
(
myFunction
))
response
,
err
:=
wrapped
(
ctx
)
Die Implementierung der ersten Funktion von Debounce-DebounceFirst
- ist im Vergleich zur letzten Funktion sehr einfach, da sie nur den letzten Aufruf verfolgen und ein zwischengespeichertes Ergebnis zurückgeben muss, wenn sie nach weniger als d
Dauer erneut aufgerufen wird:
func
DebounceFirst
(
circuit
Circuit
,
d
time
.
Duration
)
Circuit
{
var
threshold
time
.
Time
var
result
string
var
err
error
var
m
sync
.
Mutex
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
m
.
Lock
()
defer
func
()
{
threshold
=
time
.
Now
().
Add
(
d
)
m
.
Unlock
()
}()
if
time
.
Now
().
Before
(
threshold
)
{
return
result
,
err
}
result
,
err
=
circuit
(
ctx
)
return
result
,
err
}
}
Diese Implementierung von DebounceFirst
sorgt für die Sicherheit von Threads, indem sie die gesamte Funktion in einen Mutex einwickelt. Dadurch müssen sich überschneidende Aufrufe am Anfang eines Clusters zwar warten, bis das Ergebnis zwischengespeichert ist, aber es wird auch garantiert, dass circuit
genau einmal aufgerufen wird, und zwar ganz am Anfang eines Clusters. Ein defer
sorgt dafür, dass der Wert von threshold
, der den Zeitpunkt des Endes eines Clusters angibt (wenn es keine weiteren Aufrufe gibt), bei jedem Aufruf zurückgesetzt wird.
Unsere Implementierung der letzten Funktion ist etwas umständlicher, weil sie time.Ticker
verwendet, um festzustellen, ob seit dem letzten Funktionsaufruf genügend Zeit vergangen ist, und um circuit
aufzurufen, wenn dies der Fall ist. Alternativ könnten wir bei jedem Aufruf eine neue time.Ticker
erstellen, aber das kann ziemlich teuer werden, wenn die Funktion häufig aufgerufen wird:
type
Circuit
func
(
context
.
Context
)
(
string
,
error
)
func
DebounceLast
(
circuit
Circuit
,
d
time
.
Duration
)
Circuit
{
var
threshold
time
.
Time
=
time
.
Now
()
var
ticker
*
time
.
Ticker
var
result
string
var
err
error
var
once
sync
.
Once
var
m
sync
.
Mutex
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
m
.
Lock
()
defer
m
.
Unlock
()
threshold
=
time
.
Now
().
Add
(
d
)
once
.
Do
(
func
()
{
ticker
=
time
.
NewTicker
(
time
.
Millisecond
*
100
)
go
func
()
{
defer
func
()
{
m
.
Lock
()
ticker
.
Stop
()
once
=
sync
.
Once
{}
m
.
Unlock
()
}()
for
{
select
{
case
<-
ticker
.
C
:
m
.
Lock
()
if
time
.
Now
().
After
(
threshold
)
{
result
,
err
=
circuit
(
ctx
)
m
.
Unlock
()
return
}
m
.
Unlock
()
case
<-
ctx
.
Done
():
m
.
Lock
()
result
,
err
=
""
,
ctx
.
Err
()
m
.
Unlock
()
return
}
}
}()
})
return
result
,
err
}
}
Wie DebounceFirst
verwendet auch DebounceLast
einen Wert namens threshold
, um das Ende einer Gruppe von Aufrufen anzuzeigen (vorausgesetzt, es gibt keine weiteren Aufrufe). Damit endet die Ähnlichkeit jedoch weitgehend.
Du wirst feststellen, dass fast die gesamte Funktion innerhalb der Do
Methode eines sync.Once
Wertes ausgeführt wird, was sicherstellt, dass (wie der Name schon sagt) die enthaltene Funktion genau einmal ausgeführt wird. Innerhalb dieses Blocks wird ein time.Ticker
verwendet, um zu prüfen, obthreshold
übergeben wurde und ruft circuit
auf, wenn dies der Fall ist. Zum Schluss wird die time.Ticker
angehalten, die sync.Once
zurückgesetzt und der Zyklus zur Wiederholung vorbereitet.
Wiederholen
Die Wiederholung berücksichtigt einen möglichen vorübergehenden Fehler in einem verteilten System, indem sie einen fehlgeschlagenen Vorgang transparent wiederholt.
Anwendbarkeit
Vorübergehende Fehler sind bei der Arbeit mit komplexen verteilten Systemen eine Tatsache. Sie können durch eine Vielzahl von (hoffentlich) vorübergehenden Zuständen verursacht werden, vor allem, wenn der nachgelagerte Dienst oder die Netzwerkressource über Schutzstrategien verfügt, wie z. B. Drosselung, die Anfragen bei hoher Auslastung vorübergehend zurückweist, oder adaptive Strategien wie Autoskalierung, die bei Bedarf Kapazitäten hinzufügen können.
Diese Fehler lösen sich in der Regel nach einiger Zeit von selbst auf, sodass die Wiederholung der Anfrage nach einer angemessenen Verzögerung wahrscheinlich (aber nicht garantiert) erfolgreich ist. Werden vorübergehende Fehler nicht berücksichtigt, kann dies zu einem unnötig brüchigen System führen. Andererseits kann die Implementierung einer automatischen Wiederholungsstrategie die Stabilität des Dienstes erheblich verbessern, wovon sowohl der Dienst als auch seine Kunden profitieren können.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Effektor
-
Die Funktion, die mit dem Dienst interagiert.
- Wiederholen
-
Eine Funktion, die einen Effektor akzeptiert und eine Closure mit der gleichen Funktionssignatur wie der Effektor zurückgibt.
Umsetzung
Dieses Muster funktioniert ähnlich wie Circuit Breaker oder Debounce: Es gibt einen Typ, Effector, der eine Funktionssignatur definiert. Diese Signatur kann jede beliebige Form annehmen, die für deine Implementierung geeignet ist, aber wenn die Funktion, die den potenziell fehlgeschlagenen Vorgang ausführt, implementiert wird, muss sie der vom Effektor definierten Signatur entsprechen.
Die Funktion Retry akzeptiert die benutzerdefinierte Effektorfunktion und gibt eine Effektorfunktion zurück, die die benutzerdefinierte Funktion umhüllt, um die Wiederholungslogik bereitzustellen. Neben der benutzerdefinierten Funktion akzeptiert Retry auch eine ganze Zahl, die die maximale Anzahl der Wiederholungsversuche angibt, und eine time.Duration
, die beschreibt, wie lange zwischen den einzelnen Wiederholungsversuchen gewartet werden soll. Wenn der Parameter retries
den Wert 0 hat, ist die Wiederholungslogik praktisch ein No-op.
Hinweis
Auch wenn sie hier nicht aufgeführt ist, beinhaltet die Wiederholungslogik normalerweise eine Art Backoff-Algorithmus.
Beispielcode
Die Signatur für das Funktionsargument der Funktion Retry
lautet Effector
. Sie sieht genauso aus wie die Funktionstypen für die vorherigen Muster:
type
Effector
func
(
context
.
Context
)
(
string
,
error
)
Die Funktion Retry
selbst ist relativ einfach, zumindest im Vergleich zu den Funktionen, die wir bisher gesehen haben:
func
Retry
(
effector
Effector
,
retries
int
,
delay
time
.
Duration
)
Effector
{
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
for
r
:=
0
;
;
r
++
{
response
,
err
:=
effector
(
ctx
)
if
err
==
nil
||
r
>=
retries
{
return
response
,
err
}
log
.
Printf
(
"Attempt %d failed; retrying in %v"
,
r
+
1
,
delay
)
select
{
case
<-
time
.
After
(
delay
):
case
<-
ctx
.
Done
():
return
""
,
ctx
.
Err
()
}
}
}
}
Du hast vielleicht schon bemerkt, warum die Funktion Retry
so schlank ist: Obwohl sie eine Funktion zurückgibt, hat diese Funktion keinen externen Zustand. Das bedeutet, dass wir keine ausgeklügelten Mechanismen zur Unterstützung der Gleichzeitigkeit brauchen.
Um Retry
zu verwenden, können wir die Funktion implementieren, die die potenziell fehlschlagende Operation ausführt und deren Signatur mit dem Typ Effector
übereinstimmt; diese Rolle wird vonEmulateTransientError
in dem folgenden Beispiel:
var
count
int
func
EmulateTransientError
(
ctx
context
.
Context
)
(
string
,
error
)
{
count
++
if
count
<=
3
{
return
"intentional fail"
,
errors
.
New
(
"error"
)
}
else
{
return
"success"
,
nil
}
}
func
main
()
{
r
:=
Retry
(
EmulateTransientError
,
5
,
2
*
time
.
Second
)
res
,
err
:=
r
(
context
.
Background
())
fmt
.
Println
(
res
,
err
)
}
In der Funktion main
wird die Funktion EmulateTransientError
an Retry
übergeben und die Funktionsvariable r
bereitgestellt. Wenn r
aufgerufen wird, wird EmulateTransientError
aufgerufen und nach einer Verzögerung erneut aufgerufen, wenn es einen Fehler zurückgibt, entsprechend der zuvor gezeigten Wiederholungslogik. Nach dem vierten Versuch gibt EmulateTransientError
schließlich einen Fehler an nil
zurück und beendet sich.
Drosselklappe
Throttle begrenzt die Häufigkeit eines Funktionsaufrufs auf eine bestimmte maximale Anzahl von Aufrufen pro Zeiteinheit.
Anwendbarkeit
Das Throttle-Muster ist nach einer Vorrichtung benannt, mit der der Durchfluss einer Flüssigkeit gesteuert wird, z. B. die Menge an Kraftstoff, die in einen Automotor fließt. Wie der namensgebende Mechanismus beschränkt auch Throttle die Anzahl der Funktionsaufrufe innerhalb eines bestimmten Zeitraums. Ein Beispiel:
-
Einem Nutzer dürfen nur 10 Dienstanfragen pro Sekunde gestattet werden.
-
Ein Client kann sich darauf beschränken, eine bestimmte Funktion einmal alle 500 Millisekunden aufzurufen.
-
Einem Konto dürfen innerhalb von 24 Stunden nur drei fehlgeschlagene Anmeldeversuche erlaubt werden.
Der wohl häufigste Grund für die Anwendung einer Drosselung ist das Auffangen von starken Aktivitätsspitzen, die das System mit einer möglicherweise unangemessenen Anzahl von Anfragen sättigen könnten, deren Beantwortung teuer wäre oder zu einer Verschlechterung des Dienstes und schließlich zum Ausfall führen würde. Es ist zwar möglich, die Kapazität eines Systems zu erhöhen, um die Nachfrage der Nutzer/innen zu befriedigen, aber das braucht Zeit, und das System kann möglicherweise nicht schnell genug reagieren.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Effektor
-
Die Funktion zu regulieren.
- Drosselklappe
-
Eine Funktion, die einen Effektor akzeptiert und eine Closure mit der gleichen Funktionssignatur wie der Effektor zurückgibt.
Umsetzung
Das Throttle-Muster ähnelt vielen anderen Mustern, die in diesem Kapitel beschrieben werden: Es wird als Funktion implementiert, die eine Effektor-Funktion akzeptiert und eine Throttle
Closure mit der gleichen Signatur zurückgibt, die die ratenbegrenzende Logik bereitstellt.
Der gebräuchlichste Algorithmus zur Implementierung von ratenbegrenzendem Verhalten ist der Token-Bucket, der die Analogie eines Eimers nutzt, der eine bestimmte maximale Anzahl von Token aufnehmen kann. Wenn eine Funktion aufgerufen wird, wird ein Token aus dem Eimer entnommen, der dann mit einer bestimmten Rate wieder aufgefüllt wird.
Die Art und Weise, wie ein Throttle
Anfragen behandelt, wenn nicht genügend Token im Bucket sind, um sie zu bezahlen, kann je nach den Bedürfnissen des Entwicklers variieren. Einige gängige Strategien sind:
- Einen Fehler zurückgeben
-
Dies ist die einfachste Strategie und wird häufig verwendet, wenn du nur versuchst, eine unangemessene oder potenziell missbräuchliche Anzahl von Client-Anfragen zu begrenzen. Ein RESTful-Dienst, der diese Strategie anwendet, könnte mit einem Status
429 (Too Many Requests)
antworten. - Gib die Antwort des letzten erfolgreichen Funktionsaufrufs wieder
-
Diese Strategie kann nützlich sein, wenn ein Dienst oder ein teurer Funktionsaufruf wahrscheinlich ein identisches Ergebnisliefert, wenn er zu früh aufgerufen wird. Sie wird häufig in derJavaScript-Welt verwendet.
- Die Anfrage in die Warteschlange stellen, wenn genügend Token verfügbar sind
-
Dieser Ansatz kann nützlich sein, wenn du am Ende alle Anfragen bearbeiten willst, aber er ist auch komplexer und erfordert Sorgfalt, um sicherzustellen, dass der Speicher nicht erschöpft wird.
Beispielcode
Das folgende Beispiel implementiert einen sehr einfachen "Token Bucket"-Algorithmus, der die "Error"-Strategie verwendet:
type
Effector
func
(
context
.
Context
)
(
string
,
error
)
func
Throttle
(
e
Effector
,
max
uint
,
refill
uint
,
d
time
.
Duration
)
Effector
{
var
tokens
=
max
var
once
sync
.
Once
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
if
ctx
.
Err
()
!=
nil
{
return
""
,
ctx
.
Err
()
}
once
.
Do
(
func
()
{
ticker
:=
time
.
NewTicker
(
d
)
go
func
()
{
defer
ticker
.
Stop
()
for
{
select
{
case
<-
ctx
.
Done
():
return
case
<-
ticker
.
C
:
t
:=
tokens
+
refill
if
t
>
max
{
t
=
max
}
tokens
=
t
}
}
}()
})
if
tokens
<=
0
{
return
""
,
fmt
.
Errorf
(
"too many calls"
)
}
tokens
--
return
e
(
ctx
)
}
}
Diese Throttle
Implementierung ähnelt unseren anderen Beispielen, da sie eine Effektorfunktion e
mit einer Closure umhüllt, die die Logik zur Ratenbegrenzung enthält. Dem Bucket werden anfangs max
Token zugewiesen; jedes Mal, wenn die Closure ausgelöst wird, wird geprüft, ob noch Token vorhanden sind. Wenn Token verfügbar sind, wird die Anzahl der Token um eins verringert und die Effektorfunktion ausgelöst. Wenn nicht, wird ein Fehler zurückgegeben. Die Token werden mit einer Rate von refill
Token pro Dauer d
hinzugefügt.
Timeout
Mit der Zeitüberschreitung kann ein Prozess aufhören, auf eine Antwort zu warten, wenn klar ist, dass eine Antwort nicht kommen wird.
Anwendbarkeit
Der erste Trugschluss des Distributed Computing ist, dass "das Netzwerk zuverlässig ist", und das nicht ohne Grund. Switches schlagen fehl, Router und Firewalls werden falsch konfiguriert, Pakete werden blockiert. Selbst wenn dein Netzwerk perfekt funktioniert, ist nicht jeder Dienst durchdacht genug, um eine sinnvolle und rechtzeitige Reaktion zu garantieren - oder überhaupt eine Reaktion, wenn es zu einer Störung kommt.
Timeout ist eine gängige Lösung für dieses Dilemma, die so einfach ist, dass sie sich kaum als Muster qualifiziert: Wenn eine Dienstanforderung oder ein Funktionsaufruf länger als erwartet läuft, hört der Aufrufer einfach auf zu warten.
Verwechsle aber nicht "einfach" oder "üblich" mit "nutzlos". Im Gegenteil: Die Allgegenwart der Timeout-Strategie ist ein Beweis für ihre Nützlichkeit. Der sinnvolle Einsatzvon Timeouts kann eine gewisse Fehlerisolierung bewirken, die kaskadenartige Ausfälle verhindertund die Wahrscheinlichkeitverringert, dass ein Problem in einer nachgelagerten Ressource zu deinemProblem wird.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Kunde
-
Der Kunde, der die SlowFunction ausführen möchte.
- SlowFunction
-
Die langlaufende Funktion, die die vom Kunden gewünschte Funktionalität implementiert.
- Timeout
-
Eine Wrapper-Funktion um SlowFunction, die die Timeout-Logik implementiert.
Umsetzung
Es gibt mehrere Möglichkeiten, eine Zeitüberschreitung in Go zu implementieren, aber der idiomatische Weg ist, die Funktionen des Pakets context
zu verwenden. Siehe "Das Context-Paket" für weitere Informationen.
In einer idealen Welt akzeptiert jede möglicherweise langlaufende Funktion einen context.Context
Parameter direkt. Wenn das der Fall ist, ist deine Arbeit ziemlich einfach: Du musst ihr nur einen Context
Wert übergeben, der mit der Funktion context.WithTimeout
verziert ist:
ctx
:=
context
.
Background
()
ctxt
,
cancel
:=
context
.
WithTimeout
(
ctx
,
10
*
time
.
Second
)
defer
cancel
()
result
,
err
:=
SomeFunction
(
ctxt
)
Das ist jedoch nicht immer der Fall, und bei Bibliotheken von Drittanbietern hast du nicht immer die Möglichkeit, einen Context
Wert zu akzeptieren. In diesen Fällen ist es vielleicht am besten, den Funktionsaufruf sozu verpacken, dass er deineContext
respektiert.
Stell dir zum Beispiel vor, du hast eine potenziell lang laufende Funktion, die nicht nur keinen Context
Wert akzeptiert, sondern auch aus einem Paket stammt, das du nicht kontrollierst. Wenn der Client SlowFunction direkt aufruft, müsste er warten, bis die Funktion beendet ist, falls sie überhaupt jemals beendet wird. Was nun?
Anstatt SlowFunction direkt aufzurufen, kannst du sie in einer Goroutine aufrufen. Auf diese Weise kannst du die Ergebnisse auffangen, wenn sie in einer akzeptablen Zeitspanne zurückgegeben werden. Das ermöglicht dir aber auch, weiterzumachen, wenn sie es nicht tut.
Dazu können wir ein paar Tools nutzen, die wir schon kennen: context.Context
für Timeouts, Kanäle für die Übermittlung von Ergebnissen und select
, um denjenigen abzufangen, der zuerst handelt.
Beispielcode
Das folgende Beispiel stellt die Existenz der fiktiven Funktion Slow
vor, deren Ausführung in einer angemessenen Zeitspanne abgeschlossen sein kann oder auch nicht, und deren Signatur der folgenden Typdefinition entspricht:
type
SlowFunction
func
(
string
)
(
string
,
error
)
Anstatt Slow
direkt aufzurufen, stellen wir stattdessen eine Timeout
Funktion zur Verfügung, die ein bereitgestelltes SlowFunction
in eine Closure verpackt und eine WithContext
Funktion zurückgibt, die eine context.Context
zur Parameterliste von SlowFunction
hinzufügt:
type
WithContext
func
(
context
.
Context
,
string
)
(
string
,
error
)
func
Timeout
(
f
SlowFunction
)
WithContext
{
return
func
(
ctx
context
.
Context
,
arg
string
)
(
string
,
error
)
{
chres
:=
make
(
chan
string
)
cherr
:=
make
(
chan
error
)
go
func
()
{
res
,
err
:=
f
(
arg
)
chres
<-
res
cherr
<-
err
}()
select
{
case
res
:=
<-
chres
:
return
res
,
<-
cherr
case
<-
ctx
.
Done
():
return
""
,
ctx
.
Err
()
}
}
}
Innerhalb der Funktion, die Timeout
konstruiert, wird Slow
in einer Goroutine ausgeführt, deren Rückgabewerte in eigens dafür konstruierte Kanäle gesendet werden, falls und wenn sie jemals fertig wird.
Die folgende Goroutine-Anweisung ist ein select
Block auf zwei Kanälen: dem ersten Antwortkanal der Slow
Funktion und dem Done
Kanal des Context
Wertes. Wenn ersterer zuerst abgeschlossen wird, gibt die Closure die Rückgabewerte der Slow
Funktion zurück; andernfalls gibt sie den von der Context
bereitgestellten Fehler zurück.
Die Verwendung der Funktion Timeout
ist nicht viel komplizierter als die direkte Verwendung von Slow
, nur dass wir statt eines Funktionsaufrufs zwei haben: den Aufruf von Timeout
, um die Schließung abzurufen, und den Aufruf der Schließung selbst:
func
main
()
{
ctx
:=
context
.
Background
()
ctxt
,
cancel
:=
context
.
WithTimeout
(
ctx
,
1
*
time
.
Second
)
defer
cancel
()
timeout
:=
Timeout
(
Slow
)
res
,
err
:=
timeout
(
ctxt
,
"some input"
)
fmt
.
Println
(
res
,
err
)
}
Obwohl es normalerweise bevorzugt wird, Service-Timeouts mitcontext.Context
zu implementieren, können Channel-Timeouts auch über den Channel der Funktion time.After
implementiert werden. Unter "Implementierung von Channel-Timeouts" findest du ein Beispiel für diese Vorgehensweise.
Gleichzeitigkeit von Mustern
Ein Cloud Native Service muss oft mehrere Prozesse effizient jonglieren und hohe (und stark schwankende) Lasten bewältigen, idealerweise ohne die Mühe und Kosten einer Skalierung auf sich nehmen zu müssen. Daher muss er hochgradig konkurrierend sein und mehrere gleichzeitige Anfragen von mehreren Kunden bewältigen können. Obwohl Go für seine Gleichzeitigkeitsunterstützung bekannt ist, kann es zu Engpässen kommen. Einige der Muster, die entwickelt wurden, um sie zu vermeiden, werden hier vorgestellt.
Fan-In
Beim Fan-in werden mehrere Eingangskanäle auf einen Ausgangskanal gemultiplext.
Anwendbarkeit
Bei Diensten mit mehreren Workern, die alle eine Ausgabe erzeugen, kann es sinnvoll sein, die Ausgaben aller Worker in einem einzigen Stream zu verarbeiten. Für diese Szenarien verwenden wir das Fan-in-Pattern, das von mehreren Eingangskanälen lesen kann, indem es sie auf einen einzigen Zielkanal multiplexiert.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Quellen
-
Ein Satz von einem oder mehreren Eingangskanälen mit dem gleichen Typ. Wird von Funnel akzeptiert.
- Ziel
-
Ein Ausgangskanal des gleichen Typs wie Sources. Wird von Funnel erstellt und bereitgestellt.
- Trichter
-
Nimmt Quellen an und gibt sofort das Ziel zurück. Alle Eingaben von beliebigen Quellen werden von Destination ausgegeben.
Umsetzung
Funnel ist als Funktion implementiert, die null bis N Eingangskanäle(Sources) empfängt. Für jeden Eingangskanal in Sources startet die Funnel-Funktion eine eigene Goroutine, um Werte aus dem zugewiesenen Kanal zu lesen und sie an einen einzigen Ausgangskanal weiterzuleiten, der von allen Goroutines gemeinsam genutzt wird(Destination).
Beispielcode
Die Funktion Funnel
ist eine variadische Funktion, die sources
: Null bis N Kanäle eines Typs (int
im folgenden Beispiel) empfängt:
func
Funnel
(
sources
...<-
chan
int
)
<-
chan
int
{
dest
:=
make
(
chan
int
)
// The shared output channel
var
wg
sync
.
WaitGroup
// Used to automatically close dest
// when all sources are closed
wg
.
Add
(
len
(
sources
))
// Set size of the WaitGroup
for
_
,
ch
:=
range
sources
{
// Start a goroutine for each source
go
func
(
c
<-
chan
int
)
{
defer
wg
.
Done
()
// Notify WaitGroup when c closes
for
n
:=
range
c
{
dest
<-
n
}
}(
ch
)
}
go
func
()
{
// Start a goroutine to close dest
wg
.
Wait
()
// after all sources close
close
(
dest
)
}()
return
dest
}
Für jeden Kanal in der Liste von sources
startet Funnel
eine eigene Goroutine, die Werte aus dem ihr zugewiesenen Kanal liest und an dest
weiterleitet, einen Einzelausgangskanal, den sich alle Goroutinen teilen.
Beachte die Verwendung von sync.WaitGroup
, um sicherzustellen, dass der Zielkanal ordnungsgemäß geschlossen wird. Zu Beginn wird eine WaitGroup
erstellt und auf die Gesamtzahl der Quellkanäle gesetzt. Wenn ein Kanal geschlossen wird, wird die zugehörige Goroutine beendet und wg.Done
aufgerufen. Wenn alle Kanäle geschlossen sind, erreicht der Zähler der WaitGroup den Wert Null, die von wg.Wait
auferlegte Sperre wird aufgehoben und der Kanal dest
wird geschlossen.
Die Verwendung von Funnel
ist relativ einfach: Gib N Quellkanäle (oder eine Scheibe von N Kanälen) an Funnel
weiter. Der zurückgegebene Zielkanal kann auf die übliche Weise gelesen werden und wird geschlossen, wenn alle Quellkanäle geschlossen sind:
func
main
()
{
sources
:=
make
([]
<-
chan
int
,
0
)
// Create an empty channel slice
for
i
:=
0
;
i
<
3
;
i
++
{
ch
:=
make
(
chan
int
)
sources
=
append
(
sources
,
ch
)
// Create a channel; add to sources
go
func
()
{
// Run a toy goroutine for each
defer
close
(
ch
)
// Close ch when the routine ends
for
i
:=
1
;
i
<=
5
;
i
++
{
ch
<-
i
time
.
Sleep
(
time
.
Second
)
}
}()
}
dest
:=
Funnel
(
sources
...
)
for
d
:=
range
dest
{
fmt
.
Println
(
d
)
}
}
In diesem Beispiel wird ein Slice aus drei int
Kanälen erstellt, in die die Werte von 1 bis 5 gesendet werden, bevor sie geschlossen werden. In einer separaten Goroutine werden die Ausgaben des einzelnen dest
Kanals gedruckt. Wenn du dies ausführst, werden die entsprechenden 15 Zeilen gedruckt, bevor dest
geschlossen wird und die Funktion endet.
Fan-Out
Mit Fan-out werden Nachrichten von einem Eingangskanal gleichmäßig auf mehrereAusgangskanäle verteilt.
Anwendbarkeit
Fan-out empfängt Nachrichten von einem Eingangskanal und verteilt sie gleichmäßig auf die Ausgangskanäle. Dies ist ein nützliches Muster zur Parallelisierung der CPU- und E/A-Auslastung.
Stell dir zum Beispiel vor, du hast eine Eingabequelle, wie Reader
auf einem Eingabestrom oder einen Listener auf einem Message Broker, der die Eingaben für eine ressourcenintensive Arbeitseinheit liefert. Anstatt die Eingabe- und Berechnungsprozesse zu koppeln, was den Aufwand auf einen einzigen seriellen Prozess beschränken würde, könntest du die Arbeitslast lieber parallelisieren, indem du sie auf eine Reihe gleichzeitigerWorker-Prozesse verteilst.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Quelle
-
Ein Eingangskanal. Wird von Split akzeptiert.
- Reiseziele
-
Ein Ausgangskanal vom gleichen Typ wie die Quelle. Wird von Split erstellt und bereitgestellt.
- Split
-
Eine Funktion, die die Quelle akzeptiert und sofort die Ziele zurückgibt. Jede Eingabe der Quelle wird an ein Ziel ausgegeben.
Umsetzung
Fan-out mag vom Konzept her relativ einfach sein, aber der Teufel steckt im Detail.
Normalerweise wird das Fan-out als Split-Funktion implementiert, die einen einzelnen Quellkanal und eine ganze Zahl für die gewünschte Anzahl von Zielkanälen akzeptiert. Die Split-Funktion erstellt die Zielkanäle und führt einenHintergrundprozess aus, der die Werte aus dem Quellkanal abruft und an einen derZielkanäle weiterleitet.
Die Implementierung der Weiterleitungslogik kann auf zwei Arten erfolgen:
-
Mit einer einzigen Goroutine, die Werte aus der Quelle liest und sie nach dem Round-Robin-Prinzip an die Ziele weiterleitet. Das hat den Vorteil, dass nur eine Master-Goroutine benötigt wird, aber wenn der nächste Kanal noch nicht zum Lesen bereit ist, verlangsamt das den gesamten Prozess.
-
Mit separaten Goroutinen für jedes Ziel, die darum konkurrieren, den nächsten Wert aus der Quelle zu lesen und an das jeweilige Ziel weiterzuleiten. Das erfordert etwas mehr Ressourcen, aber es ist weniger wahrscheinlich, dass ein einzelner langsamer Worker die Arbeit behindert.
Das nächste Beispiel verwendet den letzteren Ansatz.
Beispielcode
In diesem Beispiel akzeptiert die Funktion Split
einen einzelnen Nur-Empfangskanal, source
, und eine Ganzzahl, die die Anzahl der Kanäle beschreibt, in die die Eingabe aufgeteilt werden soll, n
. Sie gibt eine Scheibe von n
send-only-Kanälen mit demselben Typ wie source
zurück.
Intern erstellt Split
die Zielkanäle. Für jeden erstellten Kanal wird eine Goroutine ausgeführt, die in einer for
Schleife Werte von source
abruft und sie an den zugewiesenen Ausgabekanal weiterleitet. Jede Goroutine konkurriert also um die Werte von source
; wenn mehrere versuchen, Werte zu lesen, wird der "Gewinner" zufällig ermittelt. Wenn source
geschlossen wird, werden alle Goroutinen beendet und alle Zielkanäle geschlossen:
func
Split
(
source
<-
chan
int
,
n
int
)
[]
<-
chan
int
{
dests
:=
make
([]
<-
chan
int
,
0
)
// Create the dests slice
for
i
:=
0
;
i
<
n
;
i
++
{
// Create n destination channels
ch
:=
make
(
chan
int
)
dests
=
append
(
dests
,
ch
)
go
func
()
{
// Each channel gets a dedicated
defer
close
(
ch
)
// goroutine that competes for reads
for
val
:=
range
source
{
ch
<-
val
}
}()
}
return
dests
}
Bei einem Kanal eines bestimmten Typs gibt die Funktion Split
eine Reihe von Zielkanälen zurück. Normalerweise wird jeder dieser Kanäle an eine eigene Goroutine übergeben, wie im folgenden Beispiel gezeigt wird:
func
main
()
{
source
:=
make
(
chan
int
)
// The input channel
dests
:=
Split
(
source
,
5
)
// Retrieve 5 output channels
go
func
()
{
// Send the number 1..10 to source
for
i
:=
1
;
i
<=
10
;
i
++
{
// and close it when we're done
source
<-
i
}
close
(
source
)
}()
var
wg
sync
.
WaitGroup
// Use WaitGroup to wait until
wg
.
Add
(
len
(
dests
))
// the output channels all close
for
i
,
ch
:=
range
dests
{
go
func
(
i
int
,
d
<-
chan
int
)
{
defer
wg
.
Done
()
for
val
:=
range
d
{
fmt
.
Printf
(
"#%d got %d\n"
,
i
,
val
)
}
}(
i
,
ch
)
}
wg
.
Wait
()
}
In diesem Beispiel wird ein Eingangskanal, source
, erstellt, der an Split
weitergegeben wird, um die Ausgangskanäle zu empfangen. Gleichzeitig werden die Werte 1 bis 10 in einer Goroutine an source
übergeben, während in fünf weiteren Kanälen Werte von dests
empfangen werden. Wenn die Eingaben abgeschlossen sind, wird der Kanal source
geschlossen, was die Schließung der Ausgangskanäle auslöst und die Leseschleifen beendet, was dazu führt, dass wg.Done
von jeder der Lese-Goroutinen aufgerufen wird, was die Sperre von wg.Wait
aufhebt und das Ende der Funktion ermöglicht.
Zukunft
Future bietet einen Platzhalter für einen Wert, der noch nicht bekannt ist.
Anwendbarkeit
Futures (auch bekannt als Promises oder Delays4) sind ein Synchronisationskonstrukt, das einen Platzhalter für einen Wert bietet, der noch von einem asynchronenProzess erzeugt wird.
Dieses Muster wird in Go nicht so häufig verwendet wie in einigen anderen Sprachen, weil Kanäle oft auf ähnliche Weise genutzt werden können. Zum Beispiel kann die langlaufende blockierende Funktion BlockingInverse
(nicht gezeigt) in einer Goroutine ausgeführt werden, die das Ergebnis (wenn es eintrifft) über einen Kanal zurückgibt. Die Funktion ConcurrentInverse
tut genau das: Sie gibt einen Kanal zurück, der gelesen werden kann, wenn ein Ergebnis vorliegt:
func
ConcurrentInverse
(
m
Matrix
)
<-
chan
Matrix
{
out
:=
make
(
chan
Matrix
)
go
func
()
{
out
<-
BlockingInverse
(
m
)
close
(
out
)
}()
return
out
}
Mit ConcurrentInverse
kann man dann eine Funktion erstellen, die das inverse Produkt zweier Matrizen berechnet:
func
InverseProduct
(
a
,
b
Matrix
)
Matrix
{
inva
:=
ConcurrentInverse
(
a
)
invb
:=
ConcurrentInverse
(
b
)
return
Product
(
<-
inva
,
<-
invb
)
}
Das scheint nicht so schlimm zu sein, aber es bringt einige Probleme mit sich, die es für eine öffentliche API unerwünscht machen. Erstens muss der Aufrufer darauf achten, dass der AufrufConcurrentInverse
mit dem richtigen Timing aufrufen. Um zu sehen, was ich meine, sieh dir das Folgende genau an:
return
Product
(
<-
ConcurrentInverse
(
a
),
<-
ConcurrentInverse
(
b
))
Siehst du das Problem? Da die Berechnung erst nach dem Aufruf von ConcurrentInverse
gestartet wird, würde dieses Konstrukt effektiv seriell ausgeführt werden und die doppelte Laufzeit benötigen.
Außerdem weisen Funktionen mit mehr als einem Rückgabewert jedem Mitglied der Rückgabeliste einen eigenen Kanal zu. Das kann unangenehm werden, wenn die Rückgabeliste wächst oder wenn die Werte von mehr als einer Goroutine gelesen werden müssen.
Das Future-Pattern enthält diese Komplexität, indem es sie in einer API kapselt, die dem Verbraucher eine einfache Schnittstelle bietet, deren Methode normal aufgerufen werden kann und alle aufrufenden Routinen blockiert, bis alle Ergebnisse aufgelöst sind. Die Schnittstelle, die der Wert erfüllt, muss nicht einmal speziell für diesen Zweck konstruiert werden; es kann jede Schnittstelle verwendet werden, die für den Verbraucher geeignet ist.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- Zukunft
-
Die Schnittstelle, die der Verbraucher erhält, um das endgültige Ergebnis abzurufen.
- SlowFunction
-
Eine Wrapper-Funktion um eine Funktion, die asynchron ausgeführt werden soll; bietet Future.
- InnerFuture
-
Entspricht der Schnittstelle Future; enthält eine angehängte Methode, die die Logik für den Ergebniszugriff enthält.
Umsetzung
Die API für den Verbraucher ist ziemlich einfach: Der Programmierer ruft SlowFunction auf, die einen Wert zurückgibt, der die Future-Schnittstelle erfüllt. Future kann eine benutzerdefinierte Schnittstelle sein, wie im folgenden Beispiel, oder eher eine io.Reader
, die an eigene Funktionen übergeben werden kann.
Wenn SlowFunction aufgerufen wird, führt es die gewünschte Kernfunktion als Goroutine aus. Dabei definiert sie Kanäle, um die Ausgabe der Kernfunktion zu erfassen, die sie in InnerFuture verpackt.
InnerFuture hat eine oder mehrere Methoden, die der Future-Schnittstelle entsprechen und die die von der Kernfunktion zurückgegebenen Werte aus den Kanälen abrufen, zwischenspeichern und zurückgeben. Wenn die Werte im Kanal nicht verfügbar sind, wird die Anfrage blockiert. Wenn sie bereits abgerufen wurden, werden die gecachten Werte zurückgegeben.
Beispielcode
In diesem Beispiel verwenden wir eine Future
Schnittstelle, die die InnerFuture
erfüllt:
type
Future
interface
{
Result
()
(
string
,
error
)
}
Die Struktur InnerFuture
wird intern verwendet, um die gleichzeitige Funktionalität bereitzustellen. In diesem Beispiel erfüllt es die Schnittstelle Future
, aber es könnte genauso gut auch die Schnittstelle io.Reader
erfüllen, indem es z. B. eine Methode Read
anhängt:
type
InnerFuture
struct
{
once
sync
.
Once
wg
sync
.
WaitGroup
res
string
err
error
resCh
<-
chan
string
errCh
<-
chan
error
}
func
(
f
*
InnerFuture
)
Result
()
(
string
,
error
)
{
f
.
once
.
Do
(
func
()
{
f
.
wg
.
Add
(
1
)
defer
f
.
wg
.
Done
()
f
.
res
=
<-
f
.
resCh
f
.
err
=
<-
f
.
errCh
})
f
.
wg
.
Wait
()
return
f
.
res
,
f
.
err
}
In dieser Implementierung enthält die Struktur selbst einen Kanal und eine Variable für jeden Wert, der von der Methode Result
zurückgegeben wird. Wenn Result
zum ersten Mal aufgerufen wird, versucht es, die Ergebnisse aus den Kanälen zu lesen und sie an die InnerFuture
Struktur zurückzusenden, damit nachfolgende Aufrufe von Result
sofort die zwischengespeicherten Werte zurückgeben können.
Beachte die Verwendung von sync.Once
und sync.WaitGroup
. Ersteres tut das, was auf der Verpackung steht: Es stellt sicher, dass die Funktion, die an es übergeben wird, genau einmal aufgerufen wird. WaitGroup
wird verwendet, um diesen Funktionsaufruf thread-sicher zu machen: Alle Aufrufe nach dem ersten werden bei wg.Wait
blockiert, bis die Kanallesungen abgeschlossen sind.
SlowFunction
ist ein Wrapper um die Kernfunktion, die du gleichzeitig ausführen möchtest. Sie hat die Aufgabe, die Ergebniskanäle zu erstellen, die Kernfunktion in einer Goroutine auszuführen und die Future
Implementierung (InnerFuture
, in diesem Beispiel) zu erstellen und zurückzugeben:
func
SlowFunction
(
ctx
context
.
Context
)
Future
{
resCh
:=
make
(
chan
string
)
errCh
:=
make
(
chan
error
)
go
func
()
{
select
{
case
<-
time
.
After
(
time
.
Second
*
2
):
resCh
<-
"I slept for 2 seconds"
errCh
<-
nil
case
<-
ctx
.
Done
():
resCh
<-
""
errCh
<-
ctx
.
Err
()
}
}()
return
&
InnerFuture
{
resCh
:
resCh
,
errCh
:
errCh
}
}
Um dieses Muster zu nutzen, musst du nur SlowFunction
aufrufen und den zurückgegebenen Future
wie jeden anderen Wert verwenden:
func
main
()
{
ctx
:=
context
.
Background
()
future
:=
SlowFunction
(
ctx
)
res
,
err
:=
future
.
Result
()
if
err
!=
nil
{
fmt
.
Println
(
"error:"
,
err
)
return
}
fmt
.
Println
(
res
)
}
Dieser Ansatz bietet eine recht gute Benutzererfahrung. Der Programmierer kann eine Future
erstellen und nach Belieben darauf zugreifen, und er kann sogar Timeouts oder Fristen mit einer Context
anwenden.
Sharding
Beim Sharding wird eine große Datenstruktur in mehrere Partitionen aufgeteilt, um die Auswirkungen von Lese-/Schreibsperren zu begrenzen.
Anwendbarkeit
Der Begriff Sharding wird normalerweise im Zusammenhang mit verteilten Zuständen verwendet, um Daten zu beschreiben, die zwischen Serverinstanzen aufgeteilt sind. Diese Art des horizontalen Shardings wirdhäufig von Datenbanken und anderen Datenspeichern verwendet, um die Last zu verteilen undRedundanz zu schaffen.
Ein etwas anderes Problem kann manchmal hochgradig konkurrierende Dienste betreffen, die eine gemeinsame Datenstruktur mit einem Sperrmechanismus haben, um sie vor widersprüchlichen Schreibvorgängen zu schützen. In diesem Szenario können die Sperren, die dazu dienen, die Treue der Daten zu gewährleisten, auch zu einem Engpass führen, wenn die Prozesse mehr Zeit damit verbringen, auf Sperren zu warten, als ihre Aufgaben zu erledigen. Dieses unglückliche Phänomen wird als Sperrkonflikt bezeichnet.
Dies kann zwar in einigen Fällen durch die Skalierung der Anzahl der Instanzen gelöst werden, erhöht aber auch die Komplexität und die Latenzzeit, da verteilte Sperren eingerichtet werden müssen und Schreibvorgänge die Konsistenz herstellen müssen. Eine alternative Strategie zur Reduzierung von Sperrkonflikten bei gemeinsam genutzten Datenstrukturen innerhalb einer Instanz eines Dienstes ist das vertikale Sharding, bei dem eine große Datenstruktur in zwei oder mehr Strukturen aufgeteilt wird, die jeweils einen Teil des Ganzen darstellen. Bei dieser Strategie muss jeweils nur ein Teil der Gesamtstruktur gesperrt werden, wodurch die Sperrkonflikte insgesamt verringert werden.
TeilnehmerInnen
Dieses Muster umfasst die folgenden Teilnehmer:
- ShardedMap
-
Eine Abstraktion um einen oder mehrere Shards, die den Lese- und Schreibzugriff ermöglicht, als ob die Shards eine einzige Map wären.
- Scherbe
-
Eine individuell abschließbare Sammlung, die eine einzelne Datenpartition darstellt.
Umsetzung
Obwohl Go idiomatisch die gemeinsame Nutzung von Speicher über Kanäle gegenüber der Verwendung von Sperren zum Schutz gemeinsam genutzter Ressourcen bevorzugt,5 ist dies nicht immer möglich. Maps sind besonders unsicher für die gleichzeitige Nutzung, so dass die Verwendung von Sperren als Synchronisierungsmechanismus ein notwendiges Übel ist. Glücklicherweise bietet Go sync.RWMutex
für genau diesen Zweck.
RWMutex
bietet Methoden, um sowohl Lese- als auch Schreibsperren einzurichten, wie im Folgenden gezeigt wird. Mit dieser Methode können beliebig viele Prozesse gleichzeitig Lesesperren einrichten, solange es keine offenen Schreibsperren gibt; ein Prozess kann nur dann eine Schreibsperre einrichten, wenn es keine bestehenden Lese- oder Schreibsperren gibt. Der Versuch, weitere Sperren einzurichten, wird so lange blockiert, bis alle vor ihm liegenden Sperren freigegeben werden:
var
items
=
struct
{
// Struct with a map and a
sync
.
RWMutex
// composed sync.RWMutex
m
map
[
string
]
int
}{
m
:
make
(
map
[
string
]
int
)}
func
ThreadSafeRead
(
key
string
)
int
{
items
.
RLock
()
// Establish read lock
value
:=
items
.
m
[
key
]
items
.
RUnlock
()
// Release read lock
return
value
}
func
ThreadSafeWrite
(
key
string
,
value
int
)
{
items
.
Lock
()
// Establish write lock
items
.
m
[
key
]
=
value
items
.
Unlock
()
// Release write lock
}
Diese Strategie funktioniert in der Regel sehr gut. Da Sperren jedoch jeweils nur einem Prozess Zugriff gewähren, kann die durchschnittliche Wartezeit für die Freigabe von Sperren in einer lese- und schreibintensiven Anwendung mit der Anzahl der gleichzeitig auf die Ressource zugreifenden Prozesse drastisch ansteigen. Die daraus resultierende Sperrkonkurrenz kann zu Engpässen bei wichtigen Funktionen führen.
Vertikales Sharding reduziert den Sperrkonflikt, indem die zugrunde liegende Datenstruktur - in der Regel eine Map - in mehrere einzeln sperrbare Maps aufgeteilt wird. Eine Abstraktionsschicht ermöglicht den Zugriff auf die zugrunde liegenden Shards, als wären sie eine einzige Struktur (siehe Abbildung 4-5).
Intern wird dies durch eine Abstraktionsschicht erreicht, die im Wesentlichen eine Karte von Karten darstellt. Jedes Mal, wenn ein Wert in der Map-Abstraktion gelesen oder geschrieben wird, wird ein Hash-Wert für den Schlüssel berechnet, der dann mit der Anzahl der Shards multipliziert wird, um einen Shard-Index zu erzeugen. Auf diese Weise kann die Map-Abstraktion das notwendige Sperren nur auf den Shard mit diesem Index beschränken.
Beispielcode
Im folgenden Beispiel verwenden wir die Standardpakete sync
und crypto/sha1
, um eine einfache Sharded Map zu implementieren: ShardedMap
.
Intern ist ShardedMap
nur eine Scheibe mit Zeigern auf eine Anzahl von Shard
Werten, aber wir definieren sie als einen Typ, damit wir ihr Methoden zuordnen können. Jeder Shard
enthälteinen map[string]interface{}
, der die Daten des Shards enthält, und einen zusammengesetztensync.RWMutex
damit er einzeln gesperrt werden kann:
type
Shard
struct
{
sync
.
RWMutex
// Compose from sync.RWMutex
m
map
[
string
]
interface
{}
// m contains the shard's data
}
type
ShardedMap
[]
*
Shard
// ShardedMap is a *Shards slice
Go hat kein Konzept für Konstruktoren, also stellen wir eine NewShardedMap
Funktion zur Verfügung, um eine neue ShardedMap
zu erhalten:
func
NewShardedMap
(
nshards
int
)
ShardedMap
{
shards
:=
make
([]
*
Shard
,
nshards
)
// Initialize a *Shards slice
for
i
:=
0
;
i
<
nshards
;
i
++
{
shard
:=
make
(
map
[
string
]
interface
{})
shards
[
i
]
=
&
Shard
{
m
:
shard
}
}
return
shards
// A ShardedMap IS a *Shards slice!
}
ShardedMap
hat zwei nicht exportierte Methoden, getShardIndex
und getShard
, die dazu dienen, den Shard-Index eines Schlüssels zu berechnen bzw. den korrekten Shard eines Schlüssels zu ermitteln. Diese Methoden könnten leicht zu einer einzigen zusammengefasst werden, aber wenn man sie so aufteilt, lassen sie sich leichter testen:
func
(
m
ShardedMap
)
getShardIndex
(
key
string
)
int
{
checksum
:=
sha1
.
Sum
([]
byte
(
key
))
// Use Sum from "crypto/sha1"
hash
:=
int
(
checksum
[
17
])
// Pick an arbitrary byte as the hash
return
hash
%
len
(
m
)
// Mod by len(m) to get index
}
func
(
m
ShardedMap
)
getShard
(
key
string
)
*
Shard
{
index
:=
m
.
getShardIndex
(
key
)
return
m
[
index
]
}
Das vorherige Beispiel hat eine offensichtliche Schwäche: Da es einen byte
-großen Wert als Hash-Wert verwendet, kann es nur bis zu 255 Shards verarbeiten. Wenn du aus irgendeinem Grund mehr als das willst, kannst du ein wenig binäre Arithmetik darüber streuen: hash := int(sum[13]) << 8 | int(sum[17])
.
Schließlich fügen wir Methoden zu ShardedMap
hinzu, damit ein Benutzer Werte lesen und schreiben kann. Diese Beispiele zeigen natürlich nicht alle Funktionen, die eine Karte braucht. Der Quellcode für dieses Beispiel befindet sich jedoch im GitHub-Repository zu diesem Buch, du kannst sie also gerne als Übung implementieren. Eine Delete
und eine Contains
Methode wären schön:
func
(
m
ShardedMap
)
Get
(
key
string
)
interface
{}
{
shard
:=
m
.
getShard
(
key
)
shard
.
RLock
()
defer
shard
.
RUnlock
()
return
shard
.
m
[
key
]
}
func
(
m
ShardedMap
)
Set
(
key
string
,
value
interface
{})
{
shard
:=
m
.
getShard
(
key
)
shard
.
Lock
()
defer
shard
.
Unlock
()
shard
.
m
[
key
]
=
value
}
Wenn du Sperren für alle Tabellen einrichten musst, ist es im Allgemeinen am besten, dies gleichzeitig zu tun. Im Folgenden implementieren wir eine Keys
Funktion mit Goroutinen und unserem alten Freund sync.WaitGroup
:
func
(
m
ShardedMap
)
Keys
()
[]
string
{
keys
:=
make
([]
string
,
0
)
// Create an empty keys slice
mutex
:=
sync
.
Mutex
{}
// Mutex for write safety to keys
wg
:=
sync
.
WaitGroup
{}
// Create a wait group and add a
wg
.
Add
(
len
(
m
))
// wait value for each slice
for
_
,
shard
:=
range
m
{
// Run a goroutine for each slice
go
func
(
s
*
Shard
)
{
s
.
RLock
()
// Establish a read lock on s
for
key
:=
range
s
.
m
{
// Get the slice's keys
mutex
.
Lock
()
keys
=
append
(
keys
,
key
)
mutex
.
Unlock
()
}
s
.
RUnlock
()
// Release the read lock
wg
.
Done
()
// Tell the WaitGroup it's done
}(
shard
)
}
wg
.
Wait
()
// Block until all reads are done
return
keys
// Return combined keys slice
}
Die Verwendung von ShardedMap
ist leider nicht ganz so einfach wie die Verwendung einer Standardkarte, aber obwohl sie anders ist, ist sie nicht komplizierter:
func
main
()
{
shardedMap
:=
NewShardedMap
(
5
)
shardedMap
.
Set
(
"alpha"
,
1
)
shardedMap
.
Set
(
"beta"
,
2
)
shardedMap
.
Set
(
"gamma"
,
3
)
fmt
.
Println
(
shardedMap
.
Get
(
"alpha"
))
fmt
.
Println
(
shardedMap
.
Get
(
"beta"
))
fmt
.
Println
(
shardedMap
.
Get
(
"gamma"
))
keys
:=
shardedMap
.
Keys
()
for
_
,
k
:=
range
keys
{
fmt
.
Println
(
k
)
}
}
Der vielleicht größte Nachteil von ShardedMap
(neben seiner Komplexität natürlich) ist der Verlust der Typsicherheit, der mit der Verwendung von interface{}
einhergeht, und die daraus resultierende Notwendigkeit von Typ-Assertions. Mit der bevorstehenden Veröffentlichung der Generika für Go wird dieses Problem hoffentlich bald der Vergangenheit angehören (oder ist es vielleicht schon, je nachdem, wann du dies liest)!
Zusammenfassung
In diesem Kapitel wurden einige sehr interessante und nützliche Wörter behandelt. Wahrscheinlich gibt es noch viel mehr,6 aber das sind die, die mir am wichtigsten erschienen, weil sie entweder direkt anwendbar sind oder weil sie eine interessante Eigenschaft der Sprache Go aufzeigen. Oft auch beides.
In Kapitel 5 gehen wir auf die nächste Ebene und setzen einige der in Kapitel 3 und 4 besprochenen Dinge in die Praxis um, indem wir einen einfachen Key-Value-Store von Grund auf aufbauen!
1 Gesprochen im August 1979. Bestätigt von Vicki Almstrum, Tony Hoare, Niklaus Wirth, Wim Feijen und Rajeev Joshi. In Pursuit of Simplicity: Ein Symposium zu Ehren von Professor Edsger Wybe Dijkstra, 12-13 Mai 2000.
2 L (ja, sein offizieller Name ist L) ist ein brillanter und faszinierender Mensch. Schau mal bei ihm vorbei.
3 Erich Gamma et al. Design Patterns: Elements of Reusable Object-Oriented Software, 1st edition. Addison-Wesley Professional, 1994).
4 Obwohl diese Begriffe oft synonym verwendet werden, können sie je nach Kontext auch unterschiedliche Bedeutungen haben. Ich weiß. Bitte schreibe mir deswegen keine bösen Briefe.
5 Siehe den Artikel "Share Memory By Communicating" auf The Go Blog.
6 Habe ich deinen Favoriten ausgelassen? Lass es mich wissen und ich werde versuchen, ihn in der nächsten Ausgabe aufzunehmen!
Get Cloud Native Go 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.