Coprozesse in Bash praktisch nutzen
AI generated
$_
#!/
Bash · Coprozesse · coproc · Prozesskommunikation
Coprozesse in Bash praktisch nutzen
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.

17 Min. Lesezeit coproc · Filedeskriptoren · Named Pipes Bash 4.x · 5.x · Linux

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.

11. FAQ: Coprozesse in Bash

1Was ist ein Coprozess in Bash?
Ein mit coproc gestarteter Hintergrundprozess mit bidirektionalen Filedeskriptoren, statt nur eingesammelter Ausgabe.
2Wann lohnt sich ein Coprozess?
Bei vielen wiederholten Anfragen an dasselbe Werkzeug, um wiederholten Prozessstart zu vermeiden.
3Zugriff auf Filedeskriptoren?
Über das Array NAME[0] und NAME[1] zusammen mit den Operatoren <& und >&.
4Warum benannte Coprozesse?
Das unbenannte COPROC-Array wird bei erneutem Aufruf überschrieben, benannte erlauben mehrere gleichzeitig.
5Wie korrekt beenden?
Schreib-Deskriptor schließen für EOF, sonst explizit mit kill beenden und mit wait aufräumen.
6Was, wenn ein Coprozess abstürzt?
read kann unendlich blockieren. read -t und eine vorherige kill -0 Prüfung fangen das ab.
7Warum wirkt er eingefroren?
Gepufferte Ausgabe bei Pipe-Anschluss. --unbuffered oder stdbuf erzwingen sofortige Ausgabe.
8Mehrzeilige Antworten möglich?
Nur mit eindeutiger Sentinel-Zeile am Ende jeder Antwort, bis zu der die Leseschleife läuft.
9Wie viele gleichzeitig?
Ein unbenannter, aber beliebig viele benannte Coprozesse mit je eigenem Array und eigener PID.
10Alternative zu Named Pipes?
Ja, meist einfacher, weil kein Anlegen und Aufräumen von FIFO-Dateien im Dateisystem nötig ist.