Channels in Go: Bequem parallel

Seite 3: Zahl der verfügbaren CPUs zur Laufzeit

Inhaltsverzeichnis

Go kann die Zahl der verfügbaren CPUs zur Laufzeit ermitteln, hier per numcpu. Bei I/O-intensiven Go-Routinen und ähnlichen Situationen kann die Anwendung den Wert auch mit einem Faktor versehen. Sie startet dann numcpu-Instanzen, denen es über den Channel fnchan den Dateinamen übergibt. Welche Go-Routine das nächste Datum aus nchan erhält, entscheidet die Go-Runtime. Sind alle Go-Routinen beschäftigt, wartet der Aufrufer. Die Go-Routinen senden Werte – oder Fehlermeldungen – an eine Empfänger-Routine. main() kann sich derweil anderen Dingen widmen.

Mit dem Schließen von fnchan enden auch die Go-Routinen. Mit einem Trick lässt sich die WaitGroup umgehen: scaler() sendet vor dem Return einen leeren String, und der Empfänger zählt, bis von numcpu leere Strings empfangen wurden. Der leere String ist nur ein Beispiel – eine Struktur mit einem Boolean tut es ebenso wie bei Pointern der Wert nil.

Auf eine Falle ist hier zu achten: Die Schleife im Empfänger wird zwar durch close(msgchan) verlassen, aber vielleicht geschieht das nicht sofort. Um danach auf die Ergebnisse zuzugreifen, wartet main() auf die Fertigmeldung über den Bool-Channel done. Erst wenn diese Meldung eintrifft, sind sowohl die Go-Routinen scale als auch die Empfänger-Go-Routine beendet.

Diese Umsetzung reicht in den meisten Fällen aus. Sollte es im Channel msgchan häufig Stau geben, kann das Programm diesen Kanal auch mit einem Puffer für mehrere Objekte generieren. Dabei können mehrere Empfängerroutinen parallel laufen, sofern dabei keine Konflikte auftreten können. Zumindest der Empfang von Fehlermeldungen dürfte in der Regel über eine gesonderte Go-Routine laufen, wenn das Paket log nicht ausreicht (Logging ist Thread-sicher). Wer das mit Mutexen realisieren möchte, muss mit deutlich mehr Schwierigkeiten rechnen.

Der Code aus dem vorherigen Listing taugt für die meisten Fälle, aber nicht für alle. Beispielsweise dauerte das rekursive Bearbeiten eines großen Dateibaums zu lange. Eine bei jedem gefundenen Verzeichnis neu gestartete Go-Routine würde ähnlich wie im vorigen Abschnitt funktionieren, doch existiert ein einfacherer Weg, der in diesem Listing zu finden ist:

import (
	"io/fs"
	"os"
	"runtime"
)

var gocnt chan bool

func main() {
	gocnt = make(chan bool, 4*runtime.NumCPU())
	parseDir(".")
}

func parseDir(dir string) {
	defer func() {
		<-gocnt
	}()

	gocnt <- true

	flist, _ := os.ReadDir(dir)
	for _, entry := range flist {
		if entry.Type() & fs.ModeType == 0 {
			// entry ist eine Datei
			do_something_heavy_with(entry)
			return
		}
		if entry.IsDir() {
			parseDir(entry.Name())
		}
	}
}

Dieser Code zeigt die einfache Beschränkung der Zahl der Go-Routinen.

Dort erzeugt das Programm einen Channel mit Puffer – in diesem Fall kommt die vierfache Anzahl der CPU-Kerne zum Einsatz, weil es um I/O-intensive Operationen geht und weniger gerechnet wird. Ruft das Programm parseDir() auf, wird ein true hineingeschrieben. Wenn der Channel aber gefüllt ist und zu viele Go-Routinen aktiv sind, wartet die Routine an dieser Stelle, noch bevor weitere Aktionen folgen oder Ressourcen beansprucht werden. Beim Verlassen der Routine entfernt sie den Wert per defer() aus dem Channel. So können höchstens 4*numcpu Goroutinen gleichzeitig aktiv sein. Dieses Vorgehen brachte einen deutlichen Performancezuwachs beim Parsen und sogar beim parallelen Schreiben auf eine SSD-Platte dank intelligentem Controller hervor. Ein elegantes Beispiel zeigt folgendes Listing, das von Eli Bendersky stammt.

func limitNumClients(handle http.HandlerFunc, maxClients int) http.HandlerFunc {
	sema := make(chan bool, maxClients)

	return func(w http.ResponseWriter, req *http.Request) {
		sema <- true
		handle(w, req)
		<-sema
	}
}

// Limit to max 10 connections for this handler.
http.HandleFunc("/inc", limitNumClients(inchandler, 10))

Das Programm zeigt eine Möglichkeit zur eleganten Beschränkung der HTTP-Rufe im Webserver.

Dieses Programm gibt eine unbenannte Funktion zurück, die auf den Channel sema (der hier wie ein Semaphore agiert) zurückgreifen kann, da er in ihrem Kontext definiert ist. So kann man die gleichzeitige Nutzerzahl einer Webseite einfach beschränken. Channels können ihrerseits Channels oder sogar Funktionen mit einheitlicher Signatur übermitteln. Da Go typsicher ist, stellt dieses Vorgehen aber dank Go-Interfaces kein Problem dar. Programmierer und Programmiererinnen können bei Bedarf variable Aufgaben an eine Menge von "Arbeitsroutinen" (Workern) verteilen. In jedem Fall müssen sie nur einen Ablauf entwerfen und sich nicht mit unübersichtlicher Mutex-Logik plagen.