2 Punkte von GN⁺ 2023-07-18 | 1 Kommentare | Auf WhatsApp teilen
  • In Go gibt es Nebenläufigkeitsmuster, die sich allein mit Goroutinen und Channels nur umständlich ausdrücken lassen; Koroutinen sind ein Vorschlag, der den Kontrollfluss ohne Parallelität explizit übergibt und so bei der Strukturierung von Programmen hilft
  • Koroutinen geben sich die Ausführung mit resume und yield gegenseitig, und da immer nur eine gleichzeitig läuft, werden gemeinsame Daten-Races vermieden, während Umschaltpunkte zu Synchronisationspunkten werden
  • Python-Generatoren und CLU-Iteratoren ähneln Koroutinen, aber die Position von yield ist eingeschränkt; deshalb gehen beim direkten Übertragen einer geschachtelten Baumtraversierung im Lua-Stil einige Werte verloren
  • coro.New in Go lässt sich auch mit Channels und Goroutinen ausdrücken, und coro.Pull wandelt einen Push-Iterator in einen Pull-Iterator um, der bei jedem Aufruf genau einen Wert liefert
  • Eine Channel-basierte Implementierung brauchte etwa 190 ns pro Umschaltung; ein direkter Wechsel in der Runtime senkte das auf etwa 20 ns pro Umschaltung und etwa 40 ns pro Wert bei coro.Pull, mit dem Ziel, Engpässe in realen Anwendungen zu vermeiden

Das Ausführungsmodell von Koroutinen

  • Koroutinen sehen wie Funktionsaufrufe aus, laufen aber auf unterschiedlichen Stacks und nicht gleichzeitig
    • Selbst wenn F G startet, wird G nicht sofort ausgeführt; F muss G ausdrücklich mit resume fortsetzen
    • G kann während der Ausführung jederzeit mit yield die Kontrolle an F zurückgeben
    • Wenn G zurückkehrt, wird es bereinigt, und F erhält das Signal, dass es G nicht mehr mit resume fortsetzen darf
  • In diesem Modell läuft immer nur eine Koroutine gleichzeitig, während der Aufrufer auf einem anderen Stack wartet
  • Da Ausführungswechsel nur an bestimmten Punkten im Programm stattfinden, laufen mehrere Abläufe koordiniert abwechselnd

Koroutinen am Beispiel von Lua

  • Das Lua-5-Beispiel vergleicht, ob zwei unterschiedlich aufgebaute Binärbäume dieselbe Wertfolge haben
    • t1 und t2 enthalten 1, 2, 3, 4, 5
    • t3 enthält 1, 2, 3, 4, 6
  • visit(t) traversiert den Baum in Inorder und gibt jeden Wert mit coroutine.yield(t.value) aus
  • Die Vergleichsfunktion erzeugt zwei visit-Koroutinen und liest abwechselnd den nächsten Wert per coroutine.resume
    • Wenn sich der Endzustand der beiden Koroutinen oder ihre Werte unterscheiden, ist das Ergebnis false
    • Wenn beide zu Ende sind, ist das Ergebnis true
  • Idiomatischerer Lua-Code verwendet coroutine.wrap, um das Koroutinenobjekt zu verbergen und eine Funktion next zu erhalten
    • Wenn die Koroutine endet, gibt die Funktion next nil zurück
    • Der vollständige Code steht im Gist

Grenzen von Python-Generatoren und CLU-Iteratoren

  • Python-Generatoren sehen auf den ersten Blick wie Lua-Koroutinen aus, folgen aber nicht demselben Modell
  • Überträgt man das Lua-Beispiel direkt nach Python, führt visit(t['left']) die Traversierung nicht wirklich aus, sondern erzeugt nur ein Generatorobjekt, das dann verworfen wird
    • Wenn yield im Funktionsrumpf vorkommt, definiert def visit keine normale Funktion mehr, sondern einen Generator
    • Das einfache Übersetzungsbeispiel gibt aus dem Baum nur 4 aus und verliert 1, 2, 3 und 5
  • Korrektes Python muss verschachtelte Generatoren explizit iterieren und die Werte erneut per yield weitergeben
    • yield from in Python 3.3 vereinfacht dieses Muster erheblich
  • Ein Python-Generatorobjekt enthält nur den Zustand eines einzelnen visit-Aufrufs
    • Werte lokaler Variablen und die aktuell ausgeführte Zeile werden im Generatorobjekt gespeichert
    • Bei der Fortsetzung wird dieser Zustand wieder auf den Aufruf-Stack gelegt und bei yield zurück in das Generatorobjekt ausgelagert
    • yield ist nur im obersten Aufruf-Frame möglich
  • CLU bezeichnete diese Abstraktion als Iterator und unterschied statisch zwischen iter und proc
    • Dank Typinformationen konnte der Compiler Fälle erkennen, in denen ein Iterator fälschlich wie eine normale Funktion aufgerufen wurde
    • Das Paper von Barbara Liskov u. a. aus dem Jahr 1977, “Abstraction Mechanisms in CLU”, erklärt, dass Iteratoren eine eingeschränkte Form von Koroutinen sind und nur mit dem Programm-Stack implementiert werden

Unterschiede zwischen Koroutinen, Threads und Generatoren

  • Alle drei Konzepte bieten eine Form von Nebenläufigkeit, unterscheiden sich aber in Leistungsfähigkeit und Kosten
  • Koroutinen

    • Bieten Nebenläufigkeit ohne Parallelität
    • Wenn eine Koroutine läuft, laufen weder die Koroutine, die sie fortgesetzt hat, noch die, an die sie die Kontrolle abgegeben hat
    • Da Umschaltpunkte explizit sind, entstehen bei gemeinsam genutzten Daten keine Races
    • Wechsel wie coroutine.resume oder Aufrufe von next werden zu Synchronisationspunkten und erzeugen eine Happens-before-Beziehung
    • Da ohne Betriebssystem explizit geplant wird, können Umschaltungen grob unter 10 ns liegen
  • Threads

    • Sie sind mächtiger als Koroutinen; die zusätzliche Stärke ist Parallelität
    • Der Preis dafür sind Scheduling-Overhead, teurere Kontextwechsel und die Notwendigkeit irgendeiner Form von Präemption
    • Ein typischer Thread-Wechsel liegt im Bereich einiger Mikrosekunden
  • Go-Goroutinen

    • In dieser Einteilung stehen sie billigen Threads näher
    • Die Go-Runtime übernimmt einen Teil des Schedulings, sodass Wechsel eher im Bereich einiger hundert ns liegen
    • Wie Threads bieten sie Parallelität und Präemption
    • Auch Javas neue Lightweight Threads sind im Grunde mit Goroutinen vergleichbar
  • Generatoren

Anwendungsfälle, in denen Go Koroutinen braucht

  • Gos bestehende Nebenläufigkeitsbibliotheken stellen das Koroutinenmuster nicht direkt bereit
  • Goroutinen sind oft ähnlich genug, können wegen Parallelität und Präemption aber andere Ergebnisse liefern als Koroutinen
  • Rob Pikes Vortrag von 2011, “Lexical Scanning in Go”, behandelt den frühen Entwurf von Lexer und Parser im Paket text/template
    • Lexer und Parser liefen in getrennten Goroutinen und waren über Channels verbunden
    • Das war eine unvollständige Nachbildung eines Koroutinenpaars
    • Der Lexer sah bereits das nächste Token voraus, während der Parser das aktuelle verarbeitete
    • Ein Generator reichte für einen Lexer nicht aus, der in mehreren Funktionen Werte per yield liefern muss
    • Die Parallelität der Goroutinen erzeugte Races, und am Ende wechselte man zu einem Entwurf, bei dem der Lexer-Zustand in einem Objekt gespeichert wird
    • Mit echten Koroutinen hätte man die Races vermeiden können und wäre effizienter gewesen als mit Goroutinen
  • Ein möglicher künftiger Anwendungsfall ist die Traversal generischer Collections
  • Schon heute lassen sich in Go mit Funktionswerten Push-Iteratoren implementieren
    • Beispiel: func (t *Tree[V]) All(yield func(v V))
    • Derzeit kann man das so aufrufen: t.All(func(v V) { fmt.Println(v) })
    • Künftig könnte auch eine Form wie for v := range t.All möglich sein
  • Traversierungen, die nicht in eine einzelne for-Schleife passen, bleiben ein Problem
    • Etwa wenn man wie beim Vergleich von Binärbäumen zwei Traversierungen ineinander verschränken muss
    • Koroutinen können einen Push-Iterator wie (*Tree).All in einen Pull-Iterator umwandeln, der bei jedem Aufruf genau einen Wert zurückgibt

coro.New in reinem Go ausgedrückt

  • Wenn man Go Koroutinen hinzufügen würde, sollte das ohne Sprachänderungen möglich sein und sich als normales Go-Code verstehen und implementieren lassen
  • Ein einfaches coro.New lässt sich mit Channels und Goroutines ausdrücken
    • cin übergibt Eingabewerte
    • cout gibt Ausgabewerte zurück
    • resume sendet einen Wert an cin und wartet auf das Ergebnis aus cout
    • Die neue Goroutine blockiert anfangs bei <-cin, daher gibt es keine Gelegenheit zur parallelen Ausführung
  • Fügt man yield hinzu, kann f während der Ausführung Werte ausgeben, und der Aufrufer kann beim nächsten resume wieder einen Wert einspeisen
    • yield(out) sendet einen Wert an cout und wartet an cin auf die nächste Eingabe
    • Auch das ist nur ein Send-Receive-Paar, also ohne Parallelität
  • Dieses Kommunikationsmuster beschränkt eine Goroutine darauf, wie eine Koroutine zu arbeiten
    • Tatsächlich ist es eine Goroutine, aber resume und yield übernehmen die Rolle der Umschaltoperationen

Beispiel eines String-Parsers

  • Das Problem aus „Storing Data in Control Flow” besteht darin, func parseQuoted(read func() byte) bool in einem separaten Kontrollfluss auszuführen und die Bytes einzeln über eine Write-Methode bereitzustellen
  • Mit coro.New lässt sich das auf höherem Niveau schreiben als mit der provisorischen Channel-basierten Implementierung aus dem vorherigen Beitrag
    • Init definiert die Funktion coparse
    • read führt erst yield(NeedMoreInput) aus und gibt dann das vom Aufrufer gesendete Byte zurück
    • Das boolesche Ergebnis von parseQuoted(read) wird in BadInput oder Success umgewandelt
    • p.resume(0) lässt die Ausführung bis zum ersten read von parseQuoted laufen
    • Write(c byte) wird zu einem dünnen Wrapper, der p.resume(c) aufruft
  • Der vollständige Code steht im Go Playground

Beispiel eines Primzahlsiebs

  • Doug McIlroys gleichzeitiges Primzahlsieb ist eine Pipeline mit einer Koroutine für jede Primzahl p
    • Jeder Filter empfängt Zahlen vom linken Nachbarn und gibt sie nach rechts weiter, wenn sie nicht durch p teilbar sind
    • Der Zähler am linken Ende liefert 2, 3, 4, ...
    • Die Ausgabe-Koroutine am rechten Ende liest Primzahlen, gibt sie aus und erzeugt neue Filter-Koroutinen
  • counter ist eine Funktion, die eine Schleife mit yield-Aufrufen in coro.New kapselt
    • more bool signalisiert, ob weiter erzeugt werden soll
    • yield(i) gibt einen Wert aus und erhält zurück, ob fortgesetzt werden soll
  • filter(p, next) holt Werte über next(true) aus der linken Koroutine und führt yield(n) nur aus, wenn n%p != 0 gilt
  • main hält die aktuelle Pipeline-Ausgabe in next
    • Es liest die Primzahl p
    • Es gibt p aus
    • Es fügt rechts an die Pipeline einen neuen Filter hinzu, der Vielfache von p entfernt
  • Die Aufrufbeziehungen zwischen den Koroutinen können sich zur Laufzeit ändern
    • Das erste yield des Zählers geht an main, spätere yield-Aufrufe gehen aber an den 2-Filter
    • Die erste Ausgabe jedes p-Filters geht als nächste Primzahl an main, spätere Ausgaben an den nächsten Filter
  • Der vollständige Code steht im Go Playground

Beziehung zwischen Goroutine und Koroutine

  • Der hier gebaute Kontrollfluss ist streng genommen eine Goroutine
    • Sie kann alles tun, was normale Goroutines auch können, etwa auf Mutexe, Channels oder System Calls warten
  • coro.New erzeugt eine Goroutine, in der innerhalb von yield und resume Koroutinen-Umschaltoperationen verwendet werden können
  • Die go-Anweisung erzeugt einen neuen gleichzeitigen und parallelen Kontrollfluss, coro.New dagegen einen neuen gleichzeitigen, aber nicht parallelen Kontrollfluss
    • Führt man 10 go-Anweisungen aus, können einschließlich main 11 Goroutines gleichzeitig laufen
    • Ruft man coro.New 10-mal auf, gibt es zwar 11 Kontrollflüsse, aber die Parallelität des Programms bleibt unverändert, und es läuft immer nur einer gleichzeitig
  • Welche Goroutine die Rolle einer „nicht parallelen“ Koroutine übernimmt, kann sich während der Ausführung ändern
    • Das ist ähnlich dazu, dass sich zur Laufzeit ändern kann, welche Goroutine gerade auf einem Channel sendet oder empfängt

Robusteres resume

  • Das ursprüngliche coro.New führt zu einem Deadlock, wenn resume nach Ende der Funktion aufgerufen wird
  • Um das zu beheben, gibt resume zusammen mit dem Ergebnis ein bool zurück
    • true bedeutet, dass das Ergebnis von yield stammt
    • Wenn die Funktion zurückkehrt, gibt resume den Rückgabewert und false zurück
    • Wird resume nach Ende der Koroutine aufgerufen, gibt es den zero value und false zurück
  • Die Variable running verfolgt, ob f gerade läuft
    • Da resume und die Koroutine abwechselnd laufen, ist die gemeinsame Nutzung von running kein Race
  • Das Beispiel gibt "hello" true, "world" true, "done" false, "" false aus

Iterator-Umwandlung mit coro.Pull

  • coro.Pull wandelt einen Push-Iterator in einen Pull-Iterator um
  • Der Eingabe-Push-Iterator hat die folgende Form
    • push func(yield func(V) bool)
    • Der boolesche Rückgabewert von yield zeigt an, ob fortgesetzt werden soll
  • Der angestrebte Pull-Iterator hat die folgende Form
    • pull func() (V, bool)
    • Wie bei einem Channel-Receive oder einem Map-Lookup werden Wert und Ende der Iteration zurückgegeben
  • Für einen vorzeitigen Abbruch gibt Pull nicht nur pull, sondern auch stop zurück
  • Für die Implementierung genügt ein kleiner Wrapper, der den Push-Iterator mit coro.New ausführt
    • pull ruft resume(true) auf
    • stop ruft resume(false) auf
  • Die All-Methode des Baums wird so geändert, dass sie das bool-Ergebnis von yield verwendet
    • Traversierung links, yield des aktuellen Werts und Traversierung rechts werden mit && verknüpft, damit sich ein vorzeitiger Abbruch fortpflanzt
  • Die Vergleichsfunktion für Bäume erzeugt zwei coro.Pull-Instanzen und vergleicht Werte nacheinander
    • defer stop1() und defer stop2() stoppen die Koroutinen bei vorzeitigem Ende
    • Bei unterschiedlichen Werten oder unterschiedlichem Endestatus ergibt sich false
    • Wenn beide enden, ergibt sich true
  • Der vollständige Code steht im Go Playground

Weitergabe von Panics und Abbruch

  • Ein in einer Koroutine auftretender Panic kann an den Aufrufer zurückgegeben werden, der diese Koroutine zuletzt mit resume fortgesetzt hat
    • Bei normalen Goroutinen ist schwer zu erkennen, welcher Goroutine man den Panic melden sollte und ob diese bereit ist, ihn zu empfangen
    • Bei Koroutinen wartet der Aufrufer blockiert in resume, daher ist das Ziel für die Weitergabe des Panics eindeutig
  • Die Implementierung überträgt über cout eine Nachricht, die entweder einen Wert oder einen Panic enthält
    • Das defer der neuen Koroutine fängt den Panic ab
    • Das wartende resume löst dann mit demselben Panic-Wert erneut einen Panic aus
  • Im Beispiel yieldet die Koroutine zunächst "hello" und panict danach mit "world"
    • Der Panic wird an die main-Goroutine weitergegeben, und im Stack sieht es so aus, als sei er beim Aufruf von resume entstanden
    • Der vollständige Code steht im Go Playground
  • Es wird eine cancel-Funktion ergänzt, um die Koroutine zu benachrichtigen, wenn der Aufrufer vorzeitig beendet wird
    • cancel ist resume ähnlich, sorgt aber dafür, dass yield nicht einen Wert zurückgibt, sondern einen Panic auslöst
    • Für den Abbruch-Panic wird ein eindeutiger Error-Wrapper verwendet, der ErrCanceled erfüllt
    • Ein durch cancel ausgelöster Panic wird nicht erneut weitergegeben; wenn die Koroutine während des Abbruchs jedoch einen anderen Panic auslöst, wird dieser weitergegeben
    • Wenn resume noch nicht aufgerufen wurde, verhindert cancel, dass f überhaupt ausgeführt wird
  • Für das Stoppen eines Iterators ist ein explizites bool klarer als ein Panic, daher behält Pull die bool-basierte Unterbrechung bei

Erneuter Blick auf das Primzahl-Sieb: Aufräumen und Fehlerweitergabe

  • In der neuen API geben counter und filter gemeinsam eine resume-Funktion und eine cancel-Funktion zurück
  • primes(n int) erstellt den Counter und registriert defer cancel()
    • Es liest jede Primzahl und gibt sie aus
    • Jedes Mal, wenn ein neuer Filter hinzugefügt wird, wird auch dessen cancel mit defer registriert
  • Wenn die Funktion n Primzahlen erhalten hat und zurückkehrt, räumen die verzögerten cancel-Aufrufe die erzeugten Koroutinen auf
  • Wenn eine Koroutine einen Panic auslöst, wird er an die wartende Koroutine weitergegeben
    • Wenn es eine Koroutine ist, die primes direkt mit next fortgesetzt hat, kommt der Panic zu primes zurück
    • Wenn es eine Koroutine ist, die ein Filter mit next fortgesetzt hat, steigt der Panic entlang der Filterkette bis zu p := next(true) in primes auf
    • Danach räumen die verzögerten cancel-Aufrufe in primes die übrigen Koroutinen auf
  • Der vollständige Code steht im Go Playground

Endgültige API-Form

  • New erzeugt eine neue pausierte Koroutine und bereitet die Ausführung der Funktion f vor
    • Die neue Koroutine ist eine Goroutine, läuft aber nicht von selbst
    • Sie läuft nur, während eine andere Goroutine resume oder cancel aufruft und wartet
  • resume(in) hält die aufrufende Goroutine an und wechselt in die neue Koroutine
    • Der erste Aufruf startet f(in, yield)
    • resume blockiert, bis f yield(out) aufruft oder out zurückgibt
    • Wenn yield aufgerufen wird, gibt resume out, true zurück
    • Wenn f zurückkehrt, gibt resume out, false zurück
    • Das nächste resume(in) sorgt dafür, dass das blockierte yield in zurückgibt
  • cancel stoppt die Ausführung von f und beendet die Koroutine
    • Wenn resume noch nie aufgerufen wurde, wird f nicht ausgeführt
    • Andernfalls löst das blockierte yield einen Panic mit einem Error aus, der ErrCanceled erfüllt
  • Wenn f einen Panic auslöst, den es nicht per recover behandelt, wechselt dieser Panic in die bei resume oder cancel wartende Goroutine und löst dort mit demselben Wert erneut einen Panic aus
    • cancel löst jedoch keinen erneuten Panic für den von ihm selbst verursachten Abbruch-Panic aus
  • Wenn f zurückkehrt oder panict, existiert die Koroutine nicht mehr
    • Spätere Aufrufe von resume geben den Zero Value und false zurück
    • Spätere Aufrufe von cancel kehren einfach zurück
  • resume, cancel und yield können an andere Goroutinen übergeben und dort verwendet werden
    • Dadurch kann sich dynamisch ändern, welche Goroutine die „Koroutine“ ist
  • New erzeugt eine neue Goroutine, hält aber die Invariante aufrecht, dass immer genau eine Goroutine in resume, cancel, yield oder im anfänglichen Wartezustand blockiert ist
    • Diese Invariante bleibt bestehen, bis f zurückkehrt
    • Dadurch erzeugt coro.New zwar neue Nebenläufigkeit, aber keine zusätzliche Parallelität
  • Die endgültige Signatur lautet wie folgt
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())

Effizienz

  • Es sollte möglich sein, Koroutinen in reinem Go zu definieren, für die praktische Nutzung ist jedoch eine optimierte Runtime-Implementierung nötig
  • Auf einem MacBook Pro von 2019 benötigt coro.New auf Kanalbasis pro Umschaltung für den Hin- und Rücktransport eines Werts etwa 190ns
    • Bei coro.Pull sind es etwa 380ns pro Wert
  • coro.Pull ist nicht die Standardnutzung von Iteratoren
    • Der Standardweg ist, den Iterator direkt aufzurufen; dabei entsteht kein Koroutinen-Overhead
    • coro.Pull wird benötigt, wenn Werte schrittweise verarbeitet werden müssen statt in einer einzelnen for-Schleife
  • Der erste Optimierungsversuch besteht darin, dass der Compiler Send-Receive-Paare markiert und der Runtime Hinweise hinterlässt, damit sie diese zu einer einzigen Operation zusammenfassen kann
    • Die Kanal-Runtime kann den Scheduler umgehen und direkt in die andere Koroutine springen
    • Das benötigt etwa 118ns pro Umschaltung und etwa 236ns pro gezogenem Wert
    • Das ist 38% schneller als die ursprüngliche Kanal-Implementierung
  • Die zweite Implementierung vermeidet Kanäle vollständig und ergänzt Koroutinenwechsel direkt in der Runtime
    • Der Koroutinenwechsel reduziert sich auf drei atomare Compare-and-Swap-Operationen
    • Eine für die Koroutinen-Datenstruktur, eine für den Scheduler-Status der blockierenden Koroutine und eine für den Scheduler-Status der fortgesetzten Koroutine
    • Das benötigt etwa 20ns pro Umschaltung und etwa 40ns pro gezogenem Wert
    • Das ist etwa 10-mal schneller als die ursprüngliche Kanal-Implementierung
  • Kosten von 40ns pro Wert gelten als klein genug, um in Code, der coro.Pull benötigt, kein Flaschenhals zu sein

1 Kommentare

 
GN⁺ 2023-07-18
Hacker-News-Kommentare
  • Viele scheinen hier den Kernpunkt zu übersehen. Es stimmt zwar, dass eine Coroutine-Bibliothek eine schlechtere und umständlichere Art ist, Nebenläufigkeit zu behandeln als das Schlüsselwort go, aber der eigentliche Anwendungsfall, der diese Komplexität hereinbringt, sind Funktions-Iteratoren: also range auf Funktionen vom Typ func() (T, bool) anwenden zu können. Das wird in der Go-Community schon lange diskutiert und dürfte für die meisten Go-Programmierer intuitiv verständlich sein.
    Dieser Artikel behandelt dann die nächste Frage: Wenn Funktions-Iteratoren zur Sprache hinzugefügt werden, wie schreibt man Iteratoren, die sich in for-Schleifen verwenden lassen? Er beginnt damit, dass Push-Iteratoren oft leicht zu schreiben sind, erweitert das dann zu einem Push-Pull-Adapter, und dieser Adapter wird auf Coroutines aufgebaut.
    Wenn das alles kommt, wäre der Einsatz von Coroutines für andere Zwecke als Iteration wohl eine schlechte Praxis, ähnlich wie Channels/Goroutines dort zu verwenden, wo ein Mutex ausreicht

    • Erwähnenswert ist auch, dass Coroutines in bestimmten Anwendungsfällen deutlich effizienter sind als vollwertige Goroutines. Beim Wechsel zu einer Coroutine braucht man weder Context Switch noch Rescheduling.
      Wenn zwei Aufgaben logisch synchron zusammenarbeiten, zum Beispiel bei Iteratoren, ist es viel effizienter, alles auf derselben CPU laufen zu lassen. Der Kernel muss nichts neu planen oder CPU-Kerne schlafen legen und wieder aufwecken, und die Daten bleiben passend im CPU-Cache, was Cache-Latenz und Trefferquote verbessert.
      Bei Goroutines kann das zufällig ebenfalls so passieren, aber es ist nicht garantiert, und man zahlt zumindest die Kosten dafür, durch den Goroutine-Scheduler der Go-Runtime zu gehen. Das ist zwar schnell, aber nicht so schnell wie das Ausführen eines anderen Codekontexts innerhalb derselben Goroutine.
      Bei Coroutines weiß man, dass Aufgabe A direkt zu Aufgabe B wechselt, daher ist das Scheduling-Verhalten besser vorhersagbar. Am Ende des Artikels zeigt Russ, dass eine optimierte Runtime-Coroutine-Implementierung 10-mal schneller ist als eine mit Goroutines nachgebildete Variante.
      Bei Google gibt es intern einen Kernel-Patch, der solches kooperatives Multithreading implementiert; intern nennt man das Fibers. Das existiert für bessere Latenz und vorhersehbares Scheduling. Paul Turner hat die Motivation dafür vor etwa zehn Jahren auf der LPC in einem Vortrag erklärt: https://www.youtube.com/watch?v=KXuZi9aeGTw
    • Ich verstehe nicht, was an for { next := getNext(); ... } problematisch sein soll. Mich würde interessieren, worin der Vorteil von for next := range getNext { ... } besteht
    • Coroutines in Go könnten Go als Host-Sprache für eine saubere Definition von Diskrete-Elemente-Simulationen nutzbar machen. Im Moment wirkt das Yielding an Akteure unbeholfen
    • Ich verstehe nicht, warum man nicht einfach range oder switch auf einem Channel verwendet und eine Goroutine laufen lässt, die Werte in den Channel schiebt. Ich bin immer noch nicht überzeugt, warum Coroutines nötig sind
    • Am Ende wirkt das trotzdem wie eine Kitchen-Sink-Erweiterung
      Diesmal sieht es nicht nach sorgfältigem Nachdenken und einer guten 80/20-Lösung aus, sondern eher nach: „Wenn wir das richtig machen wollen, brauchen wir wohl Coroutines, also fügen wir sie einfach hinzu.“
      Bei der Einführung von Generics wurde wirklich lange und gründlich darüber nachgedacht, und man hat einen hervorragend ausbalancierten, innovativen Kompromiss gefunden.
      Auch hier hätte ich eher einen Ansatz erwartet wie „Wir erweitern Goroutines um eine zusätzliche Funktion, die in bestimmten Situationen Kontrolle ermöglicht“. Das hätte besser gewirkt als: „Für dieses Problem gehen wir wie Rust all-in und bauen es einfach komplett ein“
  • Ich nutze Go seit vielen Jahren professionell, aber ich möchte nicht, dass es wie Python mit Twisted / Tornado / anderen Frameworks wird.
    Das Schlüsselwort go verhindert das ziemlich schmerzhafte Problem der Function Coloring recht gut.
    In Hochleistungskontexten möchte man manchmal Dinge wie Data Sharding pro CPU-Core machen, aber dieser Vorschlag lindert genau diesen Schmerzpunkt nicht

    • Coroutines und Goroutines besetzen unterschiedliche Nischen. Goroutines besetzen bereits den Bereich, den Dinge wie Twisted ausfüllen. Hier geht es nicht darum, so etwas wie async/await nach Go zu holen.
      Coroutines würden eher einen anderen Bereich füllen, näher an den Generatoren von Python. Dort, wo man Pipelines aus kombinierbaren Bausteinen zusammensetzen muss, können sie Speicherverbrauch und Codekomplexität oft stark reduzieren. Das Design wirkt vollständig synchron
    • Im Artikel gibt es nirgends einen Vorschlag, der dem Function-Coloring-Problem entsprechen würde
    • Wenn man eine Goroutine schreibt und Context verwenden muss, dann ist das wortwörtlich Function Coloring
    • Das „Problem“ anderer Async-/Threading-Frameworks könnte darin liegen, dass sie Threading auf Iteratoren/Coroutines aufbauen. In diesem Fall sind die Dinge aber orthogonal, daher ist es vermutlich nicht so schlimm, wie man denken könnte.
      Natürlich wird irgendein kluger Entwickler irgendwo auf der Welt damit etwas Dummes bauen, und es wird fast sicher sehr populär werden. Meine Vermutung ist eine einheitliche Abstraktion für Goroutines und Coroutines
    • Ich würde mich über Hinweise zu besseren Channel-Management-Mustern oder Frameworks freuen.
      Meistens wirkt Goroutine-bezogener Code wegen der Channels unübersichtlich, und ich weiß nicht, wie man ihn „sauberer“ machen kann. Falls es Tipps zu Mustern gibt, die sich als wartbar bewährt haben, wäre ich wirklich dankbar
  • Multitasking-Systeme haben uns Prozesse gegeben
    Aber sie waren zu schwergewichtig
    Deshalb kamen Threads auf, also Prozesse, die Adressraum, Dateitabelle und einige andere Dinge gemeinsam nutzen. Der Scheduler kann leichter zwischen Threads als zwischen Prozessen umschalten, und für die gemeinsame Datennutzung zwischen Threads ist keine Serialisierung nötig
    Aber auch das war zu schwergewichtig
    Deshalb kamen User-Space-Threads auf. Das sind logische Ausführungsthreads, die vollständig von der Runtime im User Space betrieben werden. Die Runtime baut Scheduling-Hooks in alle Ein-/Ausgabefunktionen der Standardbibliothek ein oder präemptiert logische Threads über System-APIs wie Unix-Signale. Systemseitige Kontextwechsel sind nicht nötig, und sie können sehr klein sein
    Aber auch das war zu schwergewichtig
    Deshalb kamen Coroutines auf. Sie erlauben es Programmierern, logische „Threads“ zu definieren, die kooperativ miteinander interagieren. Dabei wird nicht vorausgesetzt, dass ein Scheduler existiert. Der Programmierer schreibt entweder selbst eine Event Loop oder ruft die Event Loop einer Bibliothek aus einem „echten“ logischen Thread auf
    Ich frage mich, was als Nächstes kommt. Aus der Sicht von [Communicating Sequential Processes][1] könnten kooperative Coroutines die unterste Stufe sein, auf die man noch hinabsteigen kann
    [1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf

    • Auch Coroutines retten den Programmierer nicht vor dem Schrecken, dem Computer jedes mögliche Detail erklären zu müssen. Insbesondere muss man erklären, wie das Programm nicht ewig laufen soll, wenn die Eingabe auch nur leicht von dem abweicht, was der Programmierer erwartet hat
    • Es überrascht mich ein wenig, dass es kaum Sprachen gibt, die Code so weit wie möglich automatisch parallelisieren, aber nur in dem Bereich, in dem die gemessene Performance davon profitiert
      Dafür bräuchte man Semantik, die Parallelität unterstützt. Zum Beispiel müsste Iteration standardmäßig ungeordnet sein, und es würde auch von einer globalen Programmflussanalyse abhängen, aber ein prinzipielles Hindernis sehe ich nicht
      Hat Microsoft nicht vor einigen Jahren an so einer Sprache geforscht? Es gibt auch ParaSail, entwickelt von jemandem aus der Ada-Community. Ich frage mich, was aus solchen Projekten geworden ist und ob sie wirklich niemand benutzt
    • Ich glaube nicht, dass es noch weiter nach unten geht. Stattdessen könnte es nach oben in Richtung verteiltes Rechnen weitergehen. Wenn ich mich richtig erinnere, funktionierten in einer frühen Alpha-Version von Go Kanäle sogar zwischen Maschinen
    • Der nächste Schritt könnte etwas sein wie: „Verarbeite diese Sammlung von Work Items auf die gewünschte Weise.“ Wenn man die Augen ein wenig zusammenkneift, lässt sich jede nebenläufige Ausführung als Sequenz von Aufgaben betrachten, und die Aufgaben selbst können ebenfalls Sammlungen sein. Das gilt auch dann, wenn es nur ein oder zwei Aufgaben gibt
  • Ich dachte immer, der Kern von Green Threads sei, gute kooperative Planung zu bekommen, ohne Schlüsselwörter wie yield in Python verwenden zu müssen
    Die Designentscheidung von Go, an Aufrufstellen und an bestimmten Positionen Wiederaufnahmepunkte einzufügen, erschien mir als ein sehr guter Kompromiss
    Es legt immer mehr Kontrolle offen, die näher an der Hardware ist. Ab einem gewissen Punkt weiß ich nicht mehr, ob man nicht einfach Zig neu erfindet. Kommt als Nächstes ein optionaler Garbage Collector?

    • Hier sind yield und resume keine Schlüsselwörter, sondern normale Variablen. Es sind nur gewöhnliche Closure-Referenzen, die zu Lehrzwecken so benannt wurden
      Ungewöhnlich ist hier der Teil, in dem ein Cancel-Callback erzeugt und verwendet wird. Ich habe nicht viel Go-Erfahrung, daher weiß ich nicht, ob das eine Performance-Optimierung ist, um den Zustand eines verworfenen Iterators schneller einzusammeln, oder ob es nötig ist, weil Go eine auf einem Kanal wartende Goroutine nicht per Garbage Collection freigibt, wenn diese Goroutine die einzige Referenz auf den Kanal hält
      In Lua wäre so etwas nicht nötig. Coroutines/Threads werden wie andere Objekte per Garbage Collection verwaltet, daher werden sie eingesammelt, sobald alle Referenzen verschwunden sind, selbst wenn die letzte Operation nicht die Rückkehr aus der Entry-Funktion, sondern ein yield war
    • Der Garbage Collector von Go ist optional. Wenn man die Umgebungsvariable GOGC=off setzt, kann man die Garbage Collection abschalten
      Mehr Details zu GOGC: https://dave.cheney.net/tag/gogc
  • Ich würde Coroutines lieber als Sprachunterstützung als über eine Bibliothek sehen
    Ich stelle mir etwas in der Art von x := co func(){ var z int; for { z++; yield z } } oder eine entsprechende Form vor
    Dass es auch in reinem Go möglich ist, ist cool, und ich verstehe den Reiz, es lieber als Standardbibliothekspaket mit optimierter Runtime bereitzustellen, statt die Sprachspezifikation komplizierter zu machen. Wenn es am Ende in reinem Go möglich ist, können andere Implementierungen schließlich auch schneller gebootstrappt werden
    Als jemand, der Go täglich bei $work benutzt, würde ich beides begrüßen, bevorzuge aber eine eingebaute Lösung in der Sprache. Die Nebenläufigkeits-Primitiven von Go waren schon immer eine Stärke, also sollte man in diese Richtung weitergehen

    • Coroutines brauchen Sprachunterstützung. Was passiert, wenn in einer Coroutine der Stack überläuft?
      Bei einer Bibliothekslösung müsste man entweder die Coroutine beenden oder das Programm abstürzen lassen. Wenn der Stack transparent wachsen soll, kann das nur der erzeugte Code leisten, weil er den Stackverbrauch überwachen und ihn bei Bedarf erweitern muss. Soweit ich weiß, gibt es so etwas bei Goroutines
      Vielleicht könnte eine Bibliothekslösung auch eine Guard Page am Ende des Stacks anlegen. Wenn sie erreicht wird, könnte ein Error-Handler versuchen, den Stack zu erweitern. Aber wenn Zeiger auf Stack-Variablen gespeichert wurden, würde das wahrscheinlich nicht funktionieren
    • Ich würde es bevorzugen, Funktionen einen yield-Parameter hinzuzufügen, damit es besser mit dem aktuellen Typsystem zusammenarbeitet
      Wenn eine Funktion Werte yielden können soll, müsste sie einen sogenannten yield-Parameter haben, und man dürfte nur innerhalb von Funktionen mit derselben Yield-Signatur oder ohne Signatur yielden
      Das Beispiel könnte sich in etwas wie x := func(:z int) { for { z++; :- z } } ändern. : fügt eine Yield-Signatur hinzu, und :- yieldet einen Wert
      Eine Funktion, die nur einen Wert X yieldet, bräuchte einfach : X oder : name X. Um bei der Wiederaufnahme einen Wert vom Typ Y zu erhalten, würde sich die Signatur in :[Y] X oder :[Y] name X ändern
      Die Annahme sollte locker sein. An Stellen, die eine Funktion erwarten, die mit Y fortgesetzt wird und X yieldet, sollte auch eine Funktion akzeptiert werden, die nur X yieldet und keine Wiederaufnahme verwendet
      Wenn spezielle Funktionen des co-Pakets die Features von resume und New bereitstellen, ließe sich der Go-Stil beibehalten. Die range-Syntax könnte erweitert werden, um mit -: Wiederaufnahmewerte zu übergeben; wenn keine Werte übergeben werden, sollte mit dem entsprechenden Zero Value wiederaufgenommen werden
    • Das ist wirklich schrecklich. Überhaupt nicht intuitiv und auch nicht im Einklang mit einer der Stärken von Go. Um solchen Code zu schreiben oder zu lesen, müsste man die Coroutine-Semantik von Go erst separat lernen
  • Gefällt mir nicht besonders. Wenn man sich die Beispiele ansieht, scheint es die Sprache deutlich schwerer lesbar und nachvollziehbar zu machen. Natürlich könnte das auch an meinem Kopf und meinen Vorurteilen liegen
    Außerdem scheint es nichts zu ermöglichen, was mit den aktuellen blockierenden Channels oder mit State nicht ohnehin schon machbar wäre

    • Genau das. Die Leute, die das verteidigen, wirken, als könnten sie nur bis direkt vor ihre Nase sehen
    • Ich weiß nicht, welche Sprachänderung gemeint ist. Das hier ist nur ein Vorschlag, das zu standardisieren und effizienter zu machen, was Leute ohnehin schon mit „State“ tun
      Ich habe schon Iteratoren ähnlich denen aus diesem Artikel verwendet, um Zuweisungen auf zentralen Codepfaden zu vermeiden. Damit würde solcher Code sich viel weniger unbeholfen anfühlen. Besonders zusammen mit der bald kommenden range-Iterator-Sprachänderung
    • Es ist auch nicht gerade einfacher, dass es wie in der aktuellen Standardbibliothek Dutzende zueinander inkompatible Iterator-Implementierungen gibt
      Channels sind ohne besonderen Grund langsam, wenn sie nicht gerade blockierende Operationen kapseln
  • Die Kommentare zu lesen ist unerquicklich
    Viele halten Coroutines und Green Threads fast für dasselbe, dabei haben beide Vor- und Nachteile
    Es ist traurig, dass es in der Go-Community akzeptabel sein kann, dass Iteratoren fehlen. Es wirkt, als würde man jede Funktion absichtlich ablehnen, die die Sprache auch nur ein wenig komplexer machen könnte, unter dem Vorwand der Einfachheit. Wenigstens hat man die Haltung zu Generics inzwischen aufgegeben
    Das bestätigt für mich erneut, dass Go wohl einfach nicht meine Sprache ist

    • Ich nutze Go täglich, aber ehrlich gesagt haben Generics meinen Code kaum verändert
      Ich verwende sie inzwischen ein wenig, weil die syntaktische Bequemlichkeit am Aufrufort etwas besser ist. Am Definitionsort sind sie erwartungsgemäß immer noch hässlich, auch wenn die Go-Syntax dafür im Vergleich zu anderen Sprachen, die ich gesehen habe, fast zu den besten gehört
      Am Ende wurde aus meiner Sicht ziemlich viel Einfachheit geopfert, nur um das Gejammer über „fehlende Generics“ zum Schweigen zu bringen. Kein guter Tausch
    • Man sollte HN nicht mit der „Go-Community“ verwechseln. Der Autor dieses Artikels ist der Leiter des Go-Teams
      Wird das im Artikel vorgeschlagene coro-Paket genau so hinzugefügt? Vielleicht, aber vermutlich nicht exakt in dieser Form. Wird etwas Ähnliches hinzugefügt? Wenn ich darauf wetten müsste, würde ich ja sagen
      Wie lange wird es dauern? Mindestens ein Jahr, also ungefähr bis zum Release von Go 1.23 im August 2024. Es kann auch etwas länger dauern. Viel schneller dürfte es kaum gehen
    • Die Haltung zu Generics wurde nicht aufgegeben
      Generics wurden von der Community akzeptiert, weil sie vollständig abwärtskompatibel zu bestehendem Code sind und man sie gefahrlos ignorieren kann, wenn man sie nicht braucht
      Erwartungsgemäß tut der Großteil des Go-Codes das weiterhin. Abgesehen von verschiedenen Arten von „Collections“ ist ein praktischer Einsatz von Generics nicht besonders leicht zu finden. Die meisten Codebasen sehen ohnehin nie mehr als einen Typ, und mehr als zwei Typen sind schon eher selten
      Eher hat die Einführung von Generics der größeren Community gezeigt, wie viel weniger nötig viele der gefeierten Features tatsächlich in einer guten Sprache sind, obwohl Leute sie als „unverzichtbar“ fordern
    • Generics haben nicht einmal 20 % meiner Codebasis verändert, und in diesen 20 % waren auch Bibliotheken enthalten. Ich weiß nicht, ob das eher eine Go- oder eine C-Gewohnheit ist, aber Generics wirken auf mich wie einfach noch eine weitere Bibliothek, in der irgendein Programmierer irgendein Problem schon gelöst hat
    • Ich hätte gedacht, die Go-Community würde sich über eine einheitliche Iterator-Schnittstelle freuen
  • Dass man sich jetzt endlich für Programmiersprachen wie CLU interessiert, ist wohl etwas Gutes
    Andererseits bin ich nach meinen Erfahrungen mit .NET- und C++-Coroutines sowie Active Objects in Symbian C++ und Active Oberon nicht sicher, ob man das wirklich zu Go hinzufügen sollte
    Selbst das .NET-Team hat dieses Jahr auf der BUILD eingeräumt, dass es, wenn man die Zeit zurückdrehen könnte, besser gewesen wäre, die Runtime das im Go-Stil handhaben zu lassen. Denn viele Entwickler haben immer noch Schwierigkeiten, async/await zu verstehen

  • Ich bin nicht sicher, ob das wirklich nötig ist. Für die meisten Fälle in Go reichen Goroutines aus, und für yield/resume-Semantik genügen zwei blockierende Channels
    Es wirkt wie zusätzliche Komplexität um der Komplexität willen, und ich bin nicht einmal sicher, ob dadurch tatsächlich neue Fähigkeiten hinzukommen, die Go bisher nicht hatte

    • Goroutines und Channels verursachen enormen Overhead. Sie als Iteratoren zu verwenden ergibt praktisch keinen Sinn
    • Darauf geht der Artikel ein
  • Zum Vergleich: In einem aktuellen Vortrag wurde eine Demo gezeigt, in der 1 Million Elixir-(BEAM-VM)-Threads gestartet wurden und an alle die Nachricht "Hello!" gesendet wurde; danach wartete jeder Thread eine zufällige Zeit zwischen 0 und 2 Sekunden und schickte dann "Process received message !" zurück
    Parallel dazu lief Erlang observer, um CPU- und Speicherverbrauch sowie die Geschwindigkeit der Erholung nach der Garbage Collection zu beobachten
    Der größte Engpass dabei ist die Fähigkeit des Terminals, überhaupt mitzuhalten, aber observer scheint die tatsächliche Lage ziemlich genau widerzuspiegeln
    https://www.youtube.com/watch?v=yxyYKnashR0
    Verwendeter Code: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
    Solche Funktionen sind seit den 1980er Jahren in Erlang und damit auch in Elixir eingebaut. Man hat sicher schon viel über das Actor-Modell oder Erlangs „legendäre“ Implementierung gehört, aber ich weiß nicht, wie viele es tatsächlich einmal live zusammen mit Monitoring-Werkzeugen gesehen haben
    Es wäre schön, wenn Go solche Unterstützung auf Sprachebene bieten würde, aber die Thread-Implementierung der BEAM VM ist sowohl bei der Erzeugung als auch beim Laufzeitverbrauch extrem ressourceneffizient, kombiniert mit der durch ausschließlich unveränderliche Werte erleichterten Nebenläufigkeit, sodass das wohl kaum einzuholen ist