bidirektionale Kommunikation mit coproc, ohne für jede Anfrage neu zu starten
Wer in einer Schleife tausendfach ein externes Kommando neu startet, verschenkt Laufzeit durch wiederholten Prozessstart. Ein Coprozess hält genau ein solches Kommando dauerhaft im Hintergrund am Leben und erlaubt der Shell, über zwei Filedeskriptoren in beide Richtungen mit ihm zu sprechen, statt für jede einzelne Anfrage einen neuen Kindprozess zu erzeugen.
Inhaltsverzeichnis
- 1. Was ein Coprozess ist und wann er sich lohnt
- 2. Die coproc-Syntax: Deklaration und Filedeskriptoren
- 3. Bidirektionale Kommunikation über Arrays
- 4. Praxisbeispiel: eine SQLite-Sitzung als Coprozess
- 5. Coprozesse vs. Named Pipes vs. Process Substitution
- 6. Fehlerbehandlung und Timeouts bei Coprozessen
- 7. Lifecycle: Coprozesse sauber beenden
- 8. Grenzen von Coprozessen in Bash
- 9. Coprozess-Ansätze im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Coprozess ist und wann er sich lohnt
Ein Coprozess ist ein asynchron gestarteter Hintergrundprozess, mit dem die aufrufende Shell über ein Paar von Filedeskriptoren bidirektional kommunizieren kann, statt nur seine Ausgabe einzusammeln. Der entscheidende Unterschied zu einem gewöhnlichen Hintergrundjob mit & ist, dass Bash bei einem Coprozess automatisch je einen Deskriptor zum Schreiben in die Standardeingabe und einen zum Lesen aus der Standardausgabe des Prozesses bereitstellt. Damit lässt sich ein einmal gestartetes externes Programm über die gesamte Laufzeit des Skripts hinweg wiederverwenden.
Der klassische Anwendungsfall für einen Coprozess ist eine Schleife, die hunderte oder tausende kleine Anfragen an dasselbe externe Werkzeug schickt, etwa eine Datenbank-CLI, einen Taschenrechner wie bc oder einen REPL. Ohne Coprozess müsste jede einzelne Anfrage einen neuen Prozess starten, was bei kleinen Werkzeugen zwar schnell wirkt, sich aber bei hoher Anzahl an Iterationen messbar summiert. Ein Coprozess startet das Werkzeug einmal und hält die Verbindung offen, was den Overhead des wiederholten fork und exec eliminiert.
Nicht jedes Problem rechtfertigt einen Coprozess. Für einfache, einmalige Aufrufe ist die Komplexität unnötig, und für sehr hohe Parallelität ist ein einzelner Coprozess ohnehin ein serieller Flaschenhals, weil nur eine Anfrage gleichzeitig beantwortet werden kann. Die folgenden Abschnitte zeigen, wie ein Coprozess syntaktisch deklariert wird, wie die bidirektionale Kommunikation funktioniert und wo die praktischen Grenzen liegen.
2. Die coproc-Syntax: Deklaration und Filedeskriptoren
Das Builtin coproc wurde mit Bash 4.0 eingeführt und startet einen benannten oder unbenannten Coprozess. Die einfachste Form ist coproc gefolgt vom Kommando, wodurch Bash automatisch ein Array namens COPROC anlegt: COPROC[0] ist der Filedeskriptor zum Lesen der Ausgabe des Prozesses, COPROC[1] der Filedeskriptor zum Schreiben in seine Eingabe. Diese beiden Zahlen sind Indizes in die Dateideskriptor-Tabelle der Shell und können direkt mit Umleitungsoperatoren wie <& und >& verwendet werden.
Wird der Coprozess mit einem eigenen Namen deklariert, etwa coproc DB { sqlite3 app.db; }, dann heißen die Arrays DB statt COPROC, und mehrere Coprozesse können parallel existieren, ohne sich gegenseitig zu überschreiben. Diese benannte Form ist in der Praxis fast immer vorzuziehen, weil das unbenannte COPROC-Array bei einem zweiten coproc-Aufruf überschrieben wird und der Zugriff auf den ersten Coprozess sonst verloren geht.
#!/usr/bin/env bash
set -euo pipefail
# Named coprocess: keeps its own file descriptor array
coproc CALC { bc -l; }
echo "Read FD: ${CALC[0]}"
echo "Write FD: ${CALC[1]}"
# Write an expression into the coprocess's stdin
echo "22/7" >&"${CALC[1]}"
# Read exactly one line back from the coprocess's stdout
read -r pi_approx <&"${CALC[0]}"
echo "22/7 = $pi_approx"
# Send another expression on the same still-running process
echo "sqrt(2)" >&"${CALC[1]}"
read -r sqrt2 <&"${CALC[0]}"
echo "sqrt(2) = $sqrt2"
# Terminate the coprocess explicitly when done
exec {CALC[1]}>&-
wait "$CALC_PID"
Bemerkenswert ist die automatisch angelegte Variable mit dem Suffix _PID, im Beispiel CALC_PID. Sie enthält die Prozess-ID des Coprozess-Kindprozesses und ist notwendig, um ihn später gezielt mit kill zu beenden oder mit wait auf seinen Exit-Code zu warten. Ohne diese Variable müsste die PID manuell aus ps herausgesucht werden, was fehleranfällig und unnötig kompliziert wäre.
3. Bidirektionale Kommunikation über Arrays
Der eigentliche Mehrwert eines Coprozess liegt in der bidirektionalen Kommunikation, die eine einfache Pipe nicht bietet. Eine Pipe wie befehl_a | befehl_b verbindet zwei Prozesse nur in eine Richtung, während ein Coprozess der Shell selbst erlaubt, abwechselnd zu schreiben und zu lesen, in einem klassischen Request-Response-Muster. Genau das macht einen Coprozess zur richtigen Wahl für interaktive Werkzeuge, die auf jede Eingabe mit genau einer Ausgabezeile antworten.
Wichtig für die Robustheit dieses Musters ist, dass genau eine Zeile pro Anfrage zurückkommt, sonst blockiert read entweder dauerhaft, weil keine weitere Zeile ankommt, oder liest versehentlich die Antwort einer späteren Anfrage. Werkzeuge, die mehrzeilige oder unvorhersehbare Ausgaben produzieren, eignen sich daher schlecht als Coprozess, es sei denn, man kann ein eindeutiges Trennzeichen am Ende jeder Antwort erzwingen, etwa über ein Prompt-Muster oder eine Sentinel-Zeile.
#!/usr/bin/env bash
set -euo pipefail
coproc JQFILTER { jq --unbuffered -c '.value * 2'; }
# Send several independent JSON lines to the same running coprocess
for n in 3 7 21; do
echo "{\"value\": $n}" >&"${JQFILTER[1]}"
read -r result <&"${JQFILTER[0]}"
echo "doubled: $result"
done
exec {JQFILTER[1]}>&-
wait "$JQFILTER_PID"
4. Praxisbeispiel: eine SQLite-Sitzung als Coprozess
Ein realistisches Beispiel aus dem Administrationsalltag: Ein Skript muss für tausende Zeilen einer CSV-Datei jeweils einen einzelnen Lookup in einer SQLite-Datenbank durchführen. Ohne Coprozess würde jede Zeile einen neuen sqlite3-Prozess starten, samt Datenbankverbindung, Öffnen der Datei und Beenden des Prozesses, tausendfach wiederholt. Mit einem Coprozess wird sqlite3 genau einmal gestartet und bleibt für alle Lookups geöffnet.
Der Trick bei sqlite3 als Coprozess ist, den Ausgabemodus so zu konfigurieren, dass jede Antwort in genau einer Zeile endet, und nach jeder Abfrage eine eindeutige Sentinel-Zeile auszugeben, damit das Skript weiß, wo eine Antwort endet. Dieses Muster lässt sich auf jedes REPL-artige Werkzeug übertragen, das im Batch-Modus zeilenweise Eingaben verarbeitet.
#!/usr/bin/env bash
set -euo pipefail
coproc DB { sqlite3 -batch -noheader app.db; }
query_db() {
local sql="$1"
# Emit a unique sentinel after each query result to mark its end
echo "${sql}" >&"${DB[1]}"
echo "SELECT '###END###';" >&"${DB[1]}"
local line result=""
while IFS= read -r line <&"${DB[0]}"; do
[[ "$line" == "###END###" ]] && break
result+="${line}"$'\n'
done
printf '%s' "$result"
}
while IFS=, read -r customer_id order_total; do
balance=$(query_db "SELECT balance FROM accounts WHERE id = ${customer_id};")
echo "Customer ${customer_id}: balance ${balance}, order ${order_total}"
done < orders.csv
exec {DB[1]}>&-
wait "$DB_PID"
Dieses Beispiel zeigt den vollen Nutzen eines Coprozess: Eine einzige Datenbankverbindung bedient tausende Lookups, während die Shell weiterhin gewohnte Kontrollstrukturen wie while-Schleifen und CSV-Verarbeitung nutzt. In Benchmarks mit mehreren tausend Zeilen liegt der Geschwindigkeitsgewinn gegenüber wiederholtem Prozessstart typischerweise bei einem Faktor zehn bis zwanzig, abhängig von den Startkosten des jeweiligen Werkzeugs.
5. Coprozesse vs. Named Pipes vs. Process Substitution
Named Pipes, angelegt mit mkfifo, lösen ein ähnliches Problem wie ein Coprozess, benötigen aber zwei getrennte Dateideskriptoren im Dateisystem und mehr manuelles Öffnen und Schließen. Ein Coprozess kapselt das Anlegen der Verbindung vollständig in einem Bash-Builtin, ohne dass temporäre Dateien im Dateisystem sichtbar werden, was in Multi-User-Umgebungen auch ein kleiner Sicherheitsvorteil ist.
Process Substitution mit <(befehl) und >(befehl) ist dagegen unidirektional: Sie eignet sich hervorragend, um Ausgaben zweier Befehle zu vergleichen oder eine Ausgabe in mehrere Senken zu duplizieren, aber nicht für ein Request-Response-Muster in einer Schleife. Ein Coprozess ist die einzige reine Bash-Bordmittel-Lösung, die echte bidirektionale, interaktive Kommunikation mit einem laufenden Prozess erlaubt, ohne auf externe Werkzeuge wie socat zurückzugreifen.
6. Fehlerbehandlung und Timeouts bei Coprozessen
Ein Coprozess, der unerwartet abstürzt, hinterlässt einen ungültigen Filedeskriptor, und der nächste read-Aufruf blockiert nicht etwa mit einem Fehler, sondern häufig unendlich, wenn keine Timeout-Absicherung existiert. read -t Sekunden gibt read einen Timeout mit und liefert einen Exit-Code ungleich null zurück, falls in dieser Zeit keine Zeile ankommt, sodass das Skript den Ausfall des Coprozess erkennen und reagieren kann, statt für immer zu hängen.
Zusätzlich sollte vor jedem Schreiben in einen Coprozess geprüft werden, ob der Prozess noch existiert, etwa mit kill -0 "$DB_PID" 2>/dev/null. Schlägt diese Prüfung fehl, ist der Coprozess bereits beendet, und ein Schreibversuch würde SIGPIPE auslösen. Wer diese beiden Absicherungen, Timeout beim Lesen und Existenzprüfung vor dem Schreiben, konsequent kombiniert, vermeidet die häufigsten Hänger in produktiven Skripten mit Coprozessen.
#!/usr/bin/env bash
set -euo pipefail
coproc WORKER { ./slow-lookup-tool.sh; }
safe_query() {
local input="$1"
# Guard: is the coprocess still alive before writing?
if ! kill -0 "$WORKER_PID" 2>/dev/null; then
echo "[ERROR] Coprocess is no longer running" >&2
return 1
fi
echo "$input" >&"${WORKER[1]}"
local answer
# Guard: don't block forever if the coprocess hangs
if ! read -r -t 5 answer <&"${WORKER[0]}"; then
echo "[ERROR] Coprocess timed out after 5s" >&2
return 1
fi
printf '%s\n' "$answer"
}
7. Lifecycle: Coprozesse sauber beenden
Ein Coprozess muss aktiv beendet werden, sonst bleibt der Kindprozess auch nach dem Ende des übergeordneten Skripts am Leben, weil er nicht automatisch mit der Elternshell terminiert. Der korrekte Weg ist, zunächst den Schreib-Deskriptor mit exec {ARRAY[1]}>&- zu schließen, was dem Coprozess ein EOF auf seiner Standardeingabe signalisiert. Viele REPL-artige Werkzeuge beenden sich daraufhin selbst sauber, ähnlich wie beim Drücken von Ctrl-D in einer interaktiven Sitzung.
Reagiert das Werkzeug nicht auf EOF, ist ein expliziter kill "$ARRAY_PID" nötig, gefolgt von wait, um Zombie-Prozesse zu vermeiden und den finalen Exit-Code einzusammeln. In produktiven Skripten gehört das Beenden eines Coprozess in eine trap-Cleanup-Funktion, damit auch bei einem Fehler oder Signal mitten im Skriptablauf kein verwaister Prozess zurückbleibt.
#!/usr/bin/env bash
set -euo pipefail
coproc SESSION { ./interactive-tool.sh; }
cleanup() {
# Signal EOF to the coprocess, then force-terminate if needed
exec {SESSION[1]}>&- 2>/dev/null || true
if kill -0 "$SESSION_PID" 2>/dev/null; then
kill "$SESSION_PID" 2>/dev/null || true
fi
wait "$SESSION_PID" 2>/dev/null || true
}
trap cleanup EXIT
8. Grenzen von Coprozessen in Bash
Bash unterstützt pro Shell effektiv nur einen unbenannten Coprozess gleichzeitig, benannte Coprozesse umgehen diese Einschränkung, bringen aber zusätzliche Komplexität mit. Für echte parallele Verarbeitung mit mehreren gleichzeitig arbeitenden Coprozessen wird der Code schnell unübersichtlich, weil jede Kombination aus Lese- und Schreib-Deskriptor manuell verwaltet werden muss. Ab einer gewissen Komplexität ist ein Coprozess in Bash nicht mehr das richtige Werkzeug.
Ein zweiter Grenzfall betrifft Werkzeuge mit gepuffertem Output. Viele Programme puffern ihre Ausgabe, wenn sie nicht an ein Terminal, sondern an eine Pipe angeschlossen sind, was dazu führt, dass ein Coprozess scheinbar hängt, obwohl der Prozess intern längst geantwortet hat. Für solche Fälle helfen Optionen wie --unbuffered oder das Werkzeug stdbuf, um die Pufferung zu erzwingen oder zu deaktivieren. Ohne diese Anpassung erscheint jeder Coprozess mit einem gepufferten Werkzeug fälschlich als eingefroren.
9. Coprozess-Ansätze im direkten Vergleich
Die folgende Tabelle stellt die verschiedenen Möglichkeiten der Prozesskommunikation in Bash gegenüber und zeigt, wann ein Coprozess die richtige Wahl ist.
| Ansatz | Richtung | Persistenter Prozess | Typischer Einsatz |
|---|---|---|---|
| Normale Pipe | Einseitig | Nein | Einmaliges Transformieren von Daten |
| Process Substitution | Einseitig | Nein | Ausgaben vergleichen oder duplizieren |
| Named Pipe (FIFO) | Beidseitig (2 FIFOs) | Ja | Kommunikation über Prozessgrenzen im Dateisystem |
| Coprozess (coproc) | Bidirektional | Ja | Request-Response mit einem REPL-artigen Werkzeug |
Ein Coprozess ist damit die kompakteste reine Bash-Lösung für bidirektionale, persistente Kommunikation, während Named Pipes mehr Kontrolle über die Dateisystem-Sichtbarkeit bieten, aber auch mehr Boilerplate erfordern. Für die meisten Batch-Verarbeitungsfälle in Administrationsskripten reicht ein Coprozess vollständig aus.
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Batch-Skripte, die durch wiederholten Prozessstart ausgebremst werden?
Wir analysieren Bash-Skripte mit hoher Iterationszahl, identifizieren Kandidaten für Coprozesse und bauen bidirektionale, robuste Kommunikation mit Timeout- und Fehlerbehandlung ein, statt tausendfach neue Prozesse zu starten.
Performance-Analyse
Skripte mit wiederholtem Prozessstart auf Coprozess-Potenzial prüfen
Robustes Refactoring
Timeout, Existenzprüfung und Cleanup für Coprozesse nachrüsten
Batch-Verarbeitung
Persistente Datenbank- und CLI-Verbindungen für große Datenmengen
10. Zusammenfassung
Ein Coprozess ist das Bash-Bordmittel für bidirektionale, persistente Kommunikation mit einem Hintergrundprozess. coproc legt automatisch ein Array mit Lese- und Schreib-Deskriptor sowie eine PID-Variable an, über die die Shell in einem Request-Response-Muster mit dem Prozess sprechen kann. Der größte Gewinn zeigt sich bei Schleifen mit vielen Iterationen gegen dasselbe externe Werkzeug, wo der wiederholte Prozessstart sonst spürbar Zeit kostet.
Robuste Coprozesse brauchen zusätzlich Timeout-Absicherung beim Lesen, eine Existenzprüfung vor dem Schreiben und eine saubere Cleanup-Funktion, die den Schreib-Deskriptor schließt und den Prozess notfalls beendet. Wer diese drei Bausteine konsequent kombiniert, nutzt einen Coprozess ohne die typischen Hänger, die bei naiver Verwendung schnell auftreten.
Coprozesse in Bash — Das Wichtigste auf einen Blick
coproc-Syntax
coproc NAME { befehl; } legt ein Array NAME mit Lese- (0) und Schreib-Deskriptor (1) sowie NAME_PID an.
Wann sinnvoll
Bei vielen wiederholten Anfragen an dasselbe externe Werkzeug spart ein Coprozess den Overhead des Prozessstarts.
Absicherung
read -t für Timeouts, kill -0 zur Existenzprüfung, trap cleanup EXIT für sauberes Beenden.
Grenzen
Nur ein unbenannter Coprozess pro Shell, gepufferte Ausgaben können ihn scheinbar einfrieren lassen.