1 Punkte von GN⁺ 2024-08-29 | 1 Kommentare | Auf WhatsApp teilen
  • 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 Gos chan und verwendet _4chan:= make(chan chan chan chan int) als obersten Channel
  • Bei factor = 3 verzweigt jede Channel-Ebene Producer und Consumer; am Ende werden die int-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.WaitGroup und wegen Goroutine-Leaks; das Beispiel verlässt sich für die Beendigung statt auf Abschlusslogik auf time.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, ****int werden nacheinander weitergereicht
    • Führt die letzte Funktion ****i = 100 aus, gibt das Programm i is now 100 aus
  • Da Go chan kennt, 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 _4chan gesendete 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 int erreicht ist
  • Auf jeder Indirektions-Ebene werden entsprechend der Konstante factor Producer erzeugt
    • Im Beispiel: const factor = 3
    • sendChanChanChan startet einen 3-Channel-Producer als Goroutine
  • Auch auf der Consumer-Seite wird auf jeder Ebene ein eingehender Channel entgegengenommen und der Consumer der nächsten Stufe jeweils factor-mal gestartet
    • receiveChanChanChan empfängt auf _4chan einen _3chan und 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 send sendet _1chan an _2chan und startet danach factor viele int-Producer
  • Jeder int-Producer erzeugt wiederum factor viele Goroutines, die _1chan <- 1 ausführen
  • Der Consumer addiert die empfangenen Ganzzahlen zur globalen Variable sum
    • sum ist als atomic.Int32 deklariert
    • receive(c chan int) liest Werte aus dem Channel und führt sum.Add(int32(s)) aus

Ausführungsergebnis und Anzahl der Verzweigungen

  • Das gesamte Programm erzeugt _4chan, startet Sende- und Empfangsebene jeweils als Goroutine und wartet dann 500 * 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 factor verwendet, muss unter Umständen die Sleep-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-mal
    • starting 2chan producer: 9-mal
    • starting 3chan consumer: 9-mal
    • starting 2chan consumer: 27-mal
    • starting chan producer: 27-mal
    • starting 1chan consumer: 81-mal
    • starting int producer: 81-mal
    • sending int: 243-mal
    • received 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.WaitGroup ergä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

 
GN⁺ 2024-08-29
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 ist wirklich schlechte Praxis und entspricht eher der Art, wie ein übereifriger Junior Engineer Software schreibt.
      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 noch Schlimmeres. Das ist kein besonders übertriebenes Beispiel: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      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.
    • Am einen Ende des Spektrums von Codern stehen Wissenschaftler, am anderen Software Engineers. Retten kann uns nur Balance.
      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.
    • Overengineering ist eine häufige Ursache. Einfache Lösungen sind oft schwer auffindbar versteckt.
      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
    • Umgekehrt stimme ich zu, dass Software Engineers, wenn sie etwas nicht gut verstehen, Abstraktion zu weit treiben; aber Code, der von Leuten geschrieben wurde, die beruflich keine Software Engineers sind, schätze ich auch nicht besonders hoch ein.
      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 chan genau 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.

    • Ich erinnere mich daran, wie ich früh in meiner Karriere wegen eines Commits mit drei Sternchen angeschrien wurde. Es ging in die Richtung: Wer braucht denn einen Pointer auf einen Pointer auf einen Pointer?
      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 Value oder chan 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 chan wird 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örtliches chan 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 chan habe 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.
    • Vermutlich ist es ein ähnliches Pattern wie beim nebenläufigen Chat-Server aus gopl. Anfangs ist es etwas überraschend, aber dennoch ziemlich gut lesbar.
      [0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • Ich habe es ebenfalls verwendet. Es ist nützlich, um in Go Promise-ähnliche Semantik umzusetzen.
  • 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 den reply-Channel.
    Nützlich war das aber nur bis zu zwei Ebenen; einen Use Case, der chan chan chan erfordert, 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...

    • Zur Ergänzung für Interessierte: Limbo ist eine Art Vorgängersprache von Go und wurde im Betriebssystem Inferno verwendet, einem Nachfahren von Plan 9.
  • Das erinnert mich an Joe Armstrongs „My favorite Erlang Program“.
    https://joearms.github.io/published/2013-11-21-My-favorite-e...