- DoltHub hat ein scherzhaftes Beispiel gebaut, das die in Go-Concurrency häufig verwendeten Channels absichtlich übertrieben verschachtelt und dabei Channels über Channels sendet
- In tatsächlich übernommenem Code gab es
chan chan struct{}; das wurde für ein Fan-out-Muster verwendet, das neue Channels an Worker-Goroutines weiterreicht, ließ sich aber schwer nachvollziehen und verwalten und wurde deshalb neu geschrieben - Das Beispiel erweitert den Witz vom C-artigen
int****-„4-star programmer“ auf Goschanund verwendet_4chan:= make(chan chan chan chan int)als obersten Channel - Bei
factor = 3verzweigt jede Channel-Ebene Producer und Consumer; am Ende werden dieint-Werte aufsummiert und es wird 243, also 3 hoch 5, ausgegeben - Für die Praxis ist das ungeeignet wegen der Schwierigkeit bei Implementierung und Debugging, der Behandlung des Schließens von Channels, der Notwendigkeit von
sync.WaitGroupund wegen Goroutine-Leaks; das Beispiel verlässt sich für die Beendigung statt auf Abschlusslogik auftime.Sleep()
Ein Fall von verschachtelten Channels bei Dolt
- DoltHub entwickelt Dolt, die weltweit erste versionsverwaltete SQL-Datenbank, in Go
- Wie in typischen Go-Codebasen werden channels und goroutines zur Implementierung nebenläufiger Ausführung verwendet
- Da Concurrent Programming an sich schon schwierig ist, geht man mit Channels und Goroutines normalerweise einfach und intuitiv um
- In Code, der einmal aus einem anderen Open-Source-Projekt übernommen wurde, gab es jedoch einen Channel, der Channels sendet
var c chan chan struct{}
- Diese Struktur diente dazu, Channels zwischen Goroutines weiterzugeben und so ein Fan-out-Muster für Worker-Goroutines umzusetzen
- Der Zwischen-Channel fungiert als Vermittler, der neu erzeugte Channels an die Worker weiterreicht, die die eigentliche Arbeit erledigen
- Es funktionierte, war aber schwer zu verstehen und zu handhaben, insbesondere wenn man auch Goroutine-Leaks berücksichtigt
- Der betreffende Code wurde neu geschrieben, und
chan chan struct{}verschwand
Die Go-Version des „4-star programmer“-Witzes
- Als C und seine Abkömmlinge weit verbreitet waren, gab es einen Witz über Anfänger, die Pointer nur schwer verstehen: den „4-star programmer“
- Das typische Beispiel ist Code mit mehrstufiger Pointer-Indirektion wie
int**** - Da Go stark von C abstammt, lässt sich dieselbe Art von Code auch dort mit Pointern schreiben
*int,**int,***int,****intwerden nacheinander weitergereicht- Führt die letzte Funktion
****i = 100aus, gibt das Programmi is now 100aus
- Da Go
chankennt, was es in C nicht gibt, lässt sich derselbe Witz auf Channel-Indirektion ausweiten
Mit einem 4-stufigen Channel 5. Potenzen berechnen
- Der oberste Channel wird so deklariert
_4chan := make(chan chan chan chan int)
- Da Go-Identifikatoren nicht mit einer Zahl beginnen dürfen, verwendet das Beispiel den Namen
_4chan - Der über
_4changesendete Wert ist ein 3-stufiger Channel_3chan := make(chan chan chan int)
- Auf dieselbe Weise geht es die Ebenen hinunter, bis am Ende ein Werte-Channel vom Typ
chan interreicht ist - Auf jeder Indirektions-Ebene werden entsprechend der Konstante
factorProducer erzeugt- Im Beispiel:
const factor = 3 sendChanChanChanstartet einen 3-Channel-Producer als Goroutine
- Im Beispiel:
- Auch auf der Consumer-Seite wird auf jeder Ebene ein eingehender Channel entgegengenommen und der Consumer der nächsten Stufe jeweils
factor-mal gestartetreceiveChanChanChanempfängt auf_4chaneinen_3chanund startet den 3-Channel-Consumer
Werteübertragung und Summierung in der letzten Ebene
- Auf der untersten Ebene werden nicht mehr Channels, sondern tatsächliche
int-Werte übertragen - Die Funktion
sendsendet_1chanan_2chanund startet danachfactorvieleint-Producer - Jeder
int-Producer erzeugt wiederumfactorviele Goroutines, die_1chan <- 1ausführen - Der Consumer addiert die empfangenen Ganzzahlen zur globalen Variable
sumsumist alsatomic.Int32deklariertreceive(c chan int)liest Werte aus dem Channel und führtsum.Add(int32(s))aus
Ausführungsergebnis und Anzahl der Verzweigungen
- Das gesamte Programm erzeugt
_4chan, startet Sende- und Empfangsebene jeweils als Goroutine und wartet dann500 * time.Millisecond - Die Beispielausgabe lautet
3 ^ 5: 243
- Das Programm ist ein verallgemeinertes Beispiel dafür, die 5. Potenz einer Zahl auf maximal verteilte Weise zu berechnen
- Ein lauffähiges Beispiel gibt es im Go Playground, eine Version mit Syntax-Highlighting im GitHub Gist
- Wer einen größeren
factorverwendet, muss unter Umständen dieSleep-Dauer erhöhen, damit die Ausführung abgeschlossen werden kann - Wenn Logging aktiviert ist, lässt sich die Zahl der Verzweigungen in jeder Ebene der Channel-Produktion und des -Konsums sehen
starting 3chan producer: 3-malstarting 2chan producer: 9-malstarting 3chan consumer: 9-malstarting 2chan consumer: 27-malstarting chan producer: 27-malstarting 1chan consumer: 81-malstarting int producer: 81-malsending int: 243-malreceived int: 243-mal
Warum man das in produktivem Code vermeiden sollte
- Dieser Ansatz ist für echten Code schwer zu implementieren und zu debuggen
- Wenn man Channels über Channels sendet, ist schwer zu entscheiden, wann welcher Channel geschlossen werden muss
- In einem realen Anwendungsfall müssten die Channels geschlossen werden, doch dafür müsste man nachverfolgen, wann sämtliche Channel-Sendungen abgeschlossen sind
- Um die Beendigung sauber zu implementieren, hätte man überall
sync.WaitGroupergänzen müssen; dadurch wäre das scherzhafte Beispiel schwer lesbar geworden - Das endgültige Beispiel wurde daher vereinfacht: Statt Abschlusslogik verwendet es
time.Sleep()und hinterlässt viele Goroutine-Leaks
1 Kommentare
Meinungen auf Hacker News
Aus der Sicht eines Wissenschaftlers, der eng mit echten professionellen Software Engineers zusammenarbeitet, sieht vieles von dem, was sie tun, genau so aus, und es ist kaum zu verstehen, warum sie es so machen.
Ich habe gesehen, wie eine einzige Codezeile, bevor sie tatsächlich aufgerufen wird, der Reihe nach vier Interface-Funktionen durchläuft, die wiederum über verschiedene Dateien in verschiedenen Ordnern verstreut sind.
Dadurch wird es ermüdend, herauszulesen, was der Code eigentlich tut, und nach ein paar Ebenen fragt man sich, ob man überhaupt noch an der richtigen Stelle schaut und ob man irgendwann zu dem Punkt gelangt, an dem tatsächlich gerechnet wird.
Das Gefühl, dass es übertrieben und verwirrend wirkt, ist nicht falsch; wenn man zum ersten Mal „interessanten“ Code schreibt, kann er technisch komplex und sogar elegant wirken, wird aber in Software, die wirklich wachsen muss, zu einem technischen Albtraum.
Ich habe einmal fast zwei Jahre lang Missbrauch von Channels in Go-Code aufgeräumt. Channels braucht man tatsächlich nur selten, aber am Anfang lassen sie sich leicht für allerlei Zwecke einsetzen, und genau das wird zum Problem.
Ein Maßstab dafür, ob man Channels richtig einsetzt, ist: Kann man auf „Geht das nicht mit einem direkten Funktionsaufruf?“ und „Geht das nicht mit einer Wait Group oder einem Mutex?“ mit Nein antworten und auf „Ist der Gewinn an Nebenläufigkeit/Parallelität groß genug, um die Debugging-Komplexität von concurrent Code zu rechtfertigen?“ mit Ja?
Es gibt Fälle, in denen dort, wo Verständnis und Kompetenz vorhanden sein sollten, erschreckend wenig davon da ist.
Im Studium habe ich einmal etwa 30 Minuten Pair Programming mit einem Doktor der Informatik gemacht, und das war ziemlich erhellend.
Er verstand Software überhaupt nicht und ging so weit, zu bemängeln, dass nicht geprüft werde, ob die Größe einer Datenstruktur aus der Standardbibliothek negativ sei.
Allerdings gibt es manchmal auch Gründe für solche Strukturen. Manchmal sind sie tatsächlich vernünftig, und manchmal sind sie eine Art, mit einer wahnsinnigen Codebase umzugehen, die frühere Leute hinterlassen haben.
Ich habe schon Code gelesen, der zu Forschungsarbeiten gehörte; theoretische Mathematik entzieht sich meist ohnehin dem Verständnis, und wenn man dann in den Code geht, um die Logik zu sehen, ist es oft noch viel schlimmer und völlig unlesbar.
Am Ende sind wir an unsere eigene Arbeitsweise gewöhnt, und andere Ansätze fühlen sich fremd an.
Trotzdem werden zusätzliche Indirektionsschichten normalerweise durch die Gesamtarchitektur gerechtfertigt, und wenn sie sinnvoll sind, kann ihr Wert bei rein lokaler Betrachtung schwer zu erkennen sein.
„Die leichte Hand und die Stille früher Programmiersprachen erfreuen einen immer wieder. Es gibt nicht viel Text, aber es geschieht viel. Alte Programme lesen sich nicht wie ein Streit mit dem Compiler, sondern wie ein stilles Gespräch zwischen einem beredten Forscher und einem gut trainierten maschinellen Kollegen. Wer hätte gedacht, dass Raffinesse einen solchen Lärm erkaufen würde?“ — Dick Gabriel
Man sieht beide Extreme: Codebases mit zu vielen Abstraktionen, die dadurch übermäßig zerstreut sind, und Codebases ganz ohne Abstraktion, die eher wie Zweckskripte wirken. Beide sind schwer zu bearbeiten.
Viele Python-, JS- und PHP-Skripte wurden mit der Haltung „egal, gib mir einfach das Ergebnis, das ich will“ geschrieben, und Menschen, die täglich im Code arbeiten, brauchen Abstraktionen, die Zusammenarbeit und Robustheit unterstützen.
Das Meme am Anfang war als sich erholender C-Programmierer tatsächlich lustig.
Es macht Spaß, Beispiele zu sehen, in denen eine Sprache so verbogen wird; C bietet dafür reichlich Gelegenheit, und es ist interessant, das auch in Go zu sehen.
Es heißt zwar „ein alter Programmierwitz aus der Zeit, als C und seine Derivate dominierten“, aber wir leben immer noch in dieser Zeit.
Es ist ironisch, dass das in diesem Thread als Paradebeispiel für zu viel Abstraktion kritisiert wird.
Der Grund, warum man in C selten Variablen mit drei Sternchen sieht, ist nicht, dass Pointer-Ketten selten wären, sondern dass man selten mehr als zwei Ebenen gleichzeitig manipulieren muss.
In Sprachen wie Python, Java und JavaScript, in denen das meiste im Grunde pointerähnlich ist, gibt es durchaus Pointer-Ketten, die weit länger als 4 sind.
Die tiefen Teile sind meist in Strukturen verborgen, deren Inneres einen nicht kümmern muss – sie sind also abstrahiert.
Channels erfüllen in concurrent Code grob die Rolle, die Pointer in sequenziellem Code haben; wenn es hier also einen fatalen Mangel gibt, könnte es statt zu viel Abstraktion eher zu wenig Abstraktion sein.
Allerdings kenne ich die Codebase nicht; vielleicht war
chan changenau die passende Abstraktion für das, was sie schreiben wollten. In Sprachen mit starker Nebenläufigkeit wie Erlang ist es sehr üblich, einem PID einen anderen PID zu schicken, um den Prozess zu identifizieren, der die Antwort erhalten soll.Aber darum ging es nicht; es war die Adresse eines String-Arrays. Es war eben C, und auch rund 20 Jahre später halte ich das noch für die intuitivste Lösung.
Zur Verteidigung meines Kollegen: Vermutlich gab es keinen Kommentar. Das war meine Verantwortung.
Das erinnert mich an den zeitlosen Klassiker des Buena Vista Social Club: https://www.youtube.com/watch?v=o5cELP06Mik
chan chan Valueoderchan struct{resp chan Value}sind Patterns, die ich in sehr speziellen Situationen tatsächlich verwendet habe.Man könnte auch einen Message Bus verwenden, aber dann müsste man sich eben auch wieder um den Message Bus kümmern.
chan chanwird ziemlich häufig verwendet. Dabei sendet man eine Nachricht an einen internen Server oder Actor und legt den Channel, über den die Antwort kommen soll, gleich mit in die Nachricht.In der Praxis ist es eher eine Form wie
chan struct { ... irgendetwas mit einem Channel darin ... }als ein wörtlicheschan chan, das man per grep finden könnte, aber das Prinzip ist dasselbe.Es ist ein sehr nützliches Pattern und meiner Ansicht nach eine der Grundlagen von Go.
chan chan chanhabe ich allerdings, soweit ich weiß, noch nie verwendet.All das in einen Message Bus umzubauen, wäre übertrieben. Auch die Performance-Eigenschaften sind sehr unterschiedlich, und Go-Channels sind eher Konstrukte innerhalb eines Betriebssystemprozesses; für prozessinterne Kommunikation lohnt sich ein „Upgrade“ auf einen Message Bus daher nicht.
[0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
Channels von Channels sind ein normales Pattern, erscheinen aber meist eher als Channel von Struct-Werten, wobei der Struct-Typ ein Channel-Feld enthält.
Man kann damit Anfragen losschicken, die abgeschlossen werden sollen, und anschließend unabhängig von ihrer Reihenfolge auf diese Werte warten, um Head-of-Line-Blocking zu vermeiden.
Zum Beispiel sendet man eine Anfrage als Channel, etwa
type request struct { params, reply chan response }, und ein Worker verarbeitet die Arbeit und legt das Ergebnis anschließend in denreply-Channel.Nützlich war das aber nur bis zu zwei Ebenen; einen Use Case, der
chan chan chanerfordert, habe ich noch nicht gesehen.Es gibt einen Blogpost als Gegenbeispiel, der Dynamic Dispatch mit einem Channel implementiert, der Channels sendet. Nicht in Go, sondern in Limbo, aber das Konzept ist dasselbe. Vielleicht belegt die Komplexität sogar eher den Punkt: https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...
Das erinnert mich an Joe Armstrongs „My favorite Erlang Program“.
https://joearms.github.io/published/2013-11-21-My-favorite-e...