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. ContextDie 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.

cngo 0401
Abbildung 4-1. Eine erfolgreiche Anfrage eines Benutzers an einen Dienst, an eine Datenbank

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.

cngo 0402
Abbildung 4-2. Unterprozesse, die nichts von einer abgebrochenen Benutzeranfrage wissen, werden trotzdem fortgesetzt

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).

cngo 0403
Abbildung 4-3. Durch die gemeinsame Nutzung von Kontext können Abbruchsignale zwischen Prozessen koordiniert werden

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 Ebene Context für eingehende Anfragen verwendet.

func TODO() Context

Es gibt auch ein leeres Context, aber es soll als Platzhalter verwendet werden, wenn unklar ist, welches Context verwendet werden soll oder wenn ein übergeordnetes Context 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 der Done Kanal geschlossen wird.

func WithTimeout(Context, time.Duration) (Context, CancelFunc)

Akzeptiert eine Dauer, nach der die Context abgebrochen und der Done 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 die Context 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 Contexts, die von ihm abgeleitet sind, ebenfalls gelöscht. Contexts, 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 von parent zurück, in der key mit dem Wert val 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 SlowFunctionhinzufü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.Contextzu 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).

cngo 0405
Abbildung 4-5. Vertikales Sharding einer Map nach Schlüssel-Hash

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.