- 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
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: alsorangeauf Funktionen vom Typfunc() (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
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
for { next := getNext(); ... }problematisch sein soll. Mich würde interessieren, worin der Vorteil vonfor next := range getNext { ... }bestehtrangeoderswitchauf 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 sindDiesmal 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
goverhindert 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
async/awaitnach 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
Contextverwenden muss, dann ist das wortwörtlich Function ColoringNatü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
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
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 dachte immer, der Kern von Green Threads sei, gute kooperative Planung zu bekommen, ohne Schlüsselwörter wie
yieldin Python verwenden zu müssenDie Designentscheidung von Go,
an Aufrufstellen und an bestimmten Positionen Wiederaufnahmepunkte einzufügen, erschien mir als ein sehr guter KompromissEs 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?
yieldundresumekeine Schlüsselwörter, sondern normale Variablen. Es sind nur gewöhnliche Closure-Referenzen, die zu Lehrzwecken so benannt wurdenUngewö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
yieldwarGOGC=offsetzt, kann man die Garbage Collection abschaltenMehr 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 vorDass 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
$workbenutzt, 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 weitergehenBei 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
yield-Parameter hinzuzufügen, damit es besser mit dem aktuellen Typsystem zusammenarbeitetWenn 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 yieldenDas Beispiel könnte sich in etwas wie
x := func(:z int) { for { z++; :- z } }ändern.:fügt eine Yield-Signatur hinzu, und:-yieldet einen WertEine Funktion, die nur einen Wert
Xyieldet, bräuchte einfach: Xoder: name X. Um bei der Wiederaufnahme einen Wert vom TypYzu erhalten, würde sich die Signatur in:[Y] Xoder:[Y] name XändernDie Annahme sollte locker sein. An Stellen, die eine Funktion erwarten, die mit
Yfortgesetzt wird undXyieldet, sollte auch eine Funktion akzeptiert werden, die nurXyieldet und keine Wiederaufnahme verwendetWenn spezielle Funktionen des
co-Pakets die Features vonresumeundNewbereitstellen, ließe sich der Go-Stil beibehalten. Dierange-Syntax könnte erweitert werden, um mit-:Wiederaufnahmewerte zu übergeben; wenn keine Werte übergeben werden, sollte mit dem entsprechenden Zero Value wiederaufgenommen werdenGefä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
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änderungChannels 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 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
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 sagenWie 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
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
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 ChannelsEs 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
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ückParallel 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