Recepty pre gorutiny a kanály

2023/01/07

Čo je gorutina?

Gorutina je ľahučké vlákenko na vykonávanie paralelných úloh v Go.

Paralelné gorutiny

Vypisujme paralelne čísla od 1 po 10.

package main

import (
	"log"
	"time"
)

func main() {
	go print("goroutine") (1)
	print("main") (2)
}

func print(c string) {
	for i := 0; i < 10; i++ {
		log.Printf("%10s %d\n", c, i)
		time.Sleep(1 * time.Second) (3)
	}
}
1 Spustíme funkciu ako gorutinu paralelne s behom funkcie main.
2 Funkcia main beží v hlavnej gorutine.
3 V každom kole výpisu zaspíme na sekundu.

Výsledkom bude paralelný výpis čísiel, napríklad takýto:

2023/01/08 11:30:39       main 0
2023/01/08 11:30:39  goroutine 0
2023/01/08 11:30:40       main 1
2023/01/08 11:30:40  goroutine 1
2023/01/08 11:30:41  goroutine 2
2023/01/08 11:30:41       main 2
2023/01/08 11:30:42       main 3
2023/01/08 11:30:42  goroutine 3
2023/01/08 11:30:43  goroutine 4
2023/01/08 11:30:43       main 4
  • Výpisy idú bok po boku.

  • Niekedy je poradie vymenené, pretože závisí od poradia vykonávania, ktoré je prakticky nepredvídateľné.

Nečakáme!

Skúsme spustiť len samotnú gorutinu.

package main

import (
	"log"
	"time"
)

func print(c string) {
	for i := 0; i < 10; i++ {
		log.Printf("%10s %d\n", c, i)
		time.Sleep(1 * time.Second)
	}
}

func main() {
	go print("goroutine") (1)
}
1 Hlavná gorutina len spustí funkciu print ako gorutinu.

Ak to však spustíme, neuvidíme nič.

Hlavná gorutina vo funkcii main() je veľmi rýchla a nebude čakať na dobehnutie gorutiny s funkciou print.

Čakanie pri kanáli

Ak chceme počkať na dokončenie jednej gorutiny, použime kanál.

Kanál (channel) je rúra, ktorou tečú typované dáta.

Do jedného konca lejeme dáta — zapisujeme — z druhého konca sa dáta lejú von — čítame ich.

Kanály umožňujú bezpečnú komunikáciu medzi gorutinami bez nutnosti riešiť konkurentné problémy s prístupom k spoločným dátam.

Ten, kto zapisuje do kanála, sa často nazýva producent, a ten, kto číta, je konzument. Do jedného kanála môže zapisovať viacero producentov a čítať môže rovnako viacero konzumentov.

Kanál vytvárame zabudovanou funkciou make:

done := make(chan bool) (1)
1 Vytvoríme kanál, ktorým tečú booleany. Tento kanál je nebufferovaný.

Nebufferovaný kanál (unbuffered channel) slúži na synchrónnu komunikáciu medzi producentom a konzumentom.

Producent čaká (blokuje) pri zápise hodnoty dovtedy, kým si ju konzument neprečíta.

To platí aj naopak — konzument čaká s čítaním dovtedy, kým producent nezapíše hodnotu.

Ako hovorí dokumentácia: „komunikácia uspeje len vtedy, ak odosielateľ a prijímateľ sú pripravení“.

Ukážme si použitie kanála s čakaním na výsledok.

import (
	"log"
	"time"
)

func main() {
	done := make(chan bool) (1)
	go print("goroutine", done) (2)
	isDone := <-done (5)
	log.Printf("main completed: %v", isDone) (6)
}

func print(c string, done chan bool) { (3)
	for i := 0; i < 10; i++ {
		log.Printf("%10s %d\n", c, i)
		time.Sleep(1 * time.Second)
	}
	done <- true (4)
}
1 Vytvoríme nebufferovaný kanál pre booleovské hodnoty.
2 Kanál pošleme ako argument do funkcie print.
3 Funkcia má samostatný parameter typu chan bool.
4 Po skončení výpisu indikujeme koniec vykonávania funkcie zápisom do kanála. Čítame „do kanála done zapíšeme hodnotu true“. Šípka ukazuje tok údajov!
5 V hlavnej gorutine čítame z kanála. Čítame „do premennej isDone načítame hodnotu z kanála done“. Šípka opäť ukazuje tok údajov!

Na tomto mieste zároveň blokujeme — čakáme, kým do kanála niekto nezapíše hodnotu a to sa stane až na konci funkcie print.

Dôležité veci pri kanáloch:

  • make vytvára kanál

  • kanál má typ chan <dátovýTyp>

  • ← done číta z kanála jednu hodnotu

  • done ← true zapisuje do kanála jednu hodnotu

  • kanály posielame do funkcie priamo — nepoužívame pointre!

Upratovanie

Preleštime si ešte kód:

V tomto prípade len čakáme na dobehnutie korutiny a skutočná hodnota v kanáli nás nezaujíma.

Čítanie tak môžeme zjednodušiť:

func main() {
	done := make(chan bool)
	go print("goroutine", done)
	<-done (1)
	log.Printf("main completed")
}
1 Čítame nejakú hodnotu, ktorej výsledok nás nezaujíma. Dôležité je, že blokujeme hlavnú gorutinu — čakáme na producenta.

Ak používame kanál v parametri funkcie, oplatí sa určiť, či z kanála čítame alebo zapisujeme.

Nasledovný parameter hovorí, že funkcia do kanála len zapisuje:

done chan<- bool

Šípka opäť ukazuje smer toku údajov!

V kóde:

func print(c string, done chan<- bool) {
    //... zvyšok
}

Šípky sú hlavne dokumentačné, aby používateľ funkcie vedel, ako sa s kanálom pracuje.

  • done chan← bool: funkcia len zapisuje do kanála booleovské hodnoty.

  • jobs ←chan string: funkcia len číta z kanála reťazce

  • signals chan int: funkcia mieni čítať aj zapisovať čísla.

Dobré vývojové prostredie vie upozorniť na prípad, keď parameter a jeho tok údajov nezodpovedá realite v kóde.

Čakanie na výsledok

Funkcia spúšťané v gorutine nemôže vracať výsledok cez return. To by popieralo jej zmysel, pretože volanie funkcie s návratovou hodnotou v bežnom kóde doslova čaká na výsledok, a pri gorutinách je dôležité paralelný beh bez čakania.

Výsledky gorutín patria do výstupného kanála!

Ukážme si slimačí výpočet faktoriálu:

func factorial(n int, result chan<- int) { (1)
	fac := 1
	for i := 1; i <= n; i++ {
		fac = fac * i
		time.Sleep(1 * time.Second)
		log.Printf("%d! = %d", i, fac)
	}
	result <- fac (2)
}
1 Výsledok je číslo, patrí do kanála čísiel, ktorý príde ako argument.
2 Hotový výsledok zapíšeme do kanála.

Číslo prečítame z kanála podobne ako pri bežnom čakaní.

func main() {
	result := make(chan int) (1)
	go factorial(5, result)
	log.Printf("Result: %d", <-result) (2)
1 Vytvoríme kanál čísiel int pre výsledky.
2 Čakáme — blokujeme hlavnú gorutinu — kým nepríde výsledok.

Priebežné výsledky

Do kanála môžeme zapisovať viacero hodnôt.

Ak máme nebufferovaný kanál, zápis každej hodnoty vždy čaká na čítanie od konzumenta.

func factorial(n int, result chan<- int) {
	fac := 1
	for i := 1; i <= n; i++ {
		fac = fac * i
		time.Sleep(1 * time.Second)
		log.Printf("Producing %d! = %d", i, fac)
		result <- fac (1)
	}
}
1 Priebežne zapisujeme výsledky do kanála.
func main() {
	result := make(chan int)
	n := 5
	go factorial(n, result)
	for i := 1; i <= n; i++ { (1)
		log.Printf("Consuming %d! = %d", i, <-result) (2)
	}
}
1 Budeme čítať toľko hodnôt, koľko medzivýsledkov očakávame.
2 Vždy načítame čiastočný výsledok. Posledný výsledok je finálny.

Keďže máme nebufferovaný kanál, každý zápis čaká na čítanie, čiže každé produkovanie čaká na konzum — a teda vidíme na striedačku zápis-čítanie, zápis-čítanie, zápis-čítanie.

2023/01/11 10:15:21 Producing 1! = 1
2023/01/11 10:15:21 Consuming 1! = 1
2023/01/11 10:15:22 Producing 2! = 2
2023/01/11 10:15:22 Consuming 2! = 2
2023/01/11 10:15:23 Producing 3! = 6
2023/01/11 10:15:23 Consuming 3! = 6
2023/01/11 10:15:24 Producing 4! = 24
2023/01/11 10:15:24 Consuming 4! = 24
2023/01/11 10:15:25 Producing 5! = 120
2023/01/11 10:15:25 Consuming 5! = 120

Priebežné výsledky s uzavretím kanála

Ak čítame z nebufferovaného kanála, kde sa objavuje viacero hodnôt, musíme vedieť, kedy skončiť. V opačnom prípade sa zahrávame s deadlockom.

Ak by sme z kanála omylom načítali postupne viac hodnôt než zapísal konzument (napríklad 6 výsledkov pre faktoriál 5), uvidíme deadlock — vzájomné vyblokovanie producenta a konzumenta. V tomto prípade by konzument márne čakal na producenta, ktorý už nikdy nič nezapíše.

Koniec čítania vieme realizovať:

  • buď počítadlom výsledkov

  • alebo explicitným uzavretím kanála.

Počítanie výsledkov sme videli v kanáli. Ak gorutina vyráta faktoriál 3, očakávame tri výsledky a tri iterácie pri čítaní.

Druhá možnosť je explicitné uzavretie kanála. Na to slúži zabudovaná funkcia close.

Kanál má uzatvárať producent, nikdy nie konzument! Filozoficky to zodpovedá „koncu súboru“ (end-of-file).
func factorial(n int, result chan<- int) {
	fac := 1
	for i := 1; i <= n; i++ {
		fac = fac * i
		time.Sleep(1 * time.Second)
		log.Printf("Producing %d! = %d", i, fac)
		result <- fac
	}
	close(result) (1)
}
1 Po vyprodukovaní všetkých výsledkov producent uzavrie kanál.

Ak vieme, že kanál bude uzatvorený, môžeme položky z kanála konzumovať kombináciou cyklu for a operátora range.

range postupne číta prvky z kanála dovtedy, kým sa kanál neuzavrie.
func main() {
	result := make(chan int)
	n := 5
	go factorial(n, result)
	for fac := range result { (1)
		log.Printf("Consuming result %d", fac) (2)
	}
}
1 Čítame z kanála a každý prvok priradíme do premennej fac. V každej iterácii akoby sme vykonali fac := ←result, čo opakujeme dovtedy, kým sa kanál nezavrie.

Keďže máme nebufferovaný kanál, čítanie vždy čaká na zápis.

2023/01/11 10:29:41 Producing 1! = 1
2023/01/11 10:29:41 Consuming result 1
2023/01/11 10:29:42 Producing 2! = 2
2023/01/11 10:29:42 Consuming result 2
2023/01/11 10:29:43 Producing 3! = 6
2023/01/11 10:29:43 Consuming result 6
2023/01/11 10:29:44 Producing 4! = 24
2023/01/11 10:29:44 Consuming result 24
2023/01/11 10:29:45 Producing 5! = 120
2023/01/11 10:29:45 Consuming result 120

Rýchly producent, pomalý konzument

Doteraz sme mali pomalého producenta a rýchleho konzumenta na nebufferovanom kanáli?

Čo ak to bude naopak?

func factorial(n int, result chan<- int) {
	fac := 1
	for i := 1; i <= n; i++ {
		fac = fac * i
		log.Printf("Producing %d! = %d", i, fac)
		result <- fac (1)
	}
	close(result)
}

func main() {
	log.SetFlags(log.LstdFlags | log.Lmicroseconds)

	n := 5
	result := make(chan int)
	go factorial(n, result)
	for fac := range result {
		time.Sleep(1 * time.Second) (2)
		log.Printf("Consuming result %d", fac)
	}
}
1 Producent chrlí medzivýsledky tak rýchlo, ako to ide.
2 Konzument je pomalý, trvá mu sekundu zožuť výsledok.

Ako bude vyzerať beh?

2023/01/11 12:01:16.530831 Producing 1! = 1
2023/01/11 12:01:16.530956 Producing 2! = 2
2023/01/11 12:01:17.532070 Consumed result 1
2023/01/11 12:01:17.532102 Producing 3! = 6
2023/01/11 12:01:18.534279 Consumed result 2
2023/01/11 12:01:18.534329 Producing 4! = 24
2023/01/11 12:01:19.534741 Consumed result 6
2023/01/11 12:01:19.535096 Producing 5! = 120
2023/01/11 12:01:20.536281 Consumed result 24
2023/01/11 12:01:21.538732 Consumed result 120

Riadky sú nateraz poprehadzované, ale to má dôvod.

  1. Producent vyprodukuje výsledok 1 a čaká na konzumenta.

  2. Prakticky okamžite sa spustí čítanie kanála cez operátor range, ale spracovanie načítaného prvku okamžite zaspí na sekundu. Tak či onak, prvok opustil kanál a producent môže produkovať ďalej výsledok pre 2!.

  3. Ihneď na to sa vyprodukuje výsledok pre faktoriál 2 a čaká sa konzumenta v druhej iterácii.

  4. Konzument však paralelne sekundu spí a keď sa zobudí, skonzumuje výsledok 1.

  5. To odblokuje producenta, ktorý môže vyrátať 3! a zapísať do do kanála.

  6. Paralelne ubehne sekunda, keď konzument žul výsledok pre 2! = 1.

Rýchly producent a rýchly konzument

Čo ak je producent aj konzument taký rýchly, ako to ide?

Odstráňme všetky spánky cez sleep.

2023/01/11 12:11:26.616269 Producing 1! = 1
2023/01/11 12:11:26.616370 Producing 2! = 2
2023/01/11 12:11:26.616372 Consumed result 1
2023/01/11 12:11:26.616373 Consumed result 2
2023/01/11 12:11:26.616374 Producing 3! = 6
2023/01/11 12:11:26.616375 Producing 4! = 24
2023/01/11 12:11:26.616376 Consumed result 6
2023/01/11 12:11:26.616377 Consumed result 24
2023/01/11 12:11:26.616378 Producing 5! = 120
2023/01/11 12:11:26.616382 Consumed result 120

Filozofia je úplne rovnaká, producent paralelne produkuje hodnoty ale vždy čaká na konzumenta a naopak.

Sumár

Zhrňme si veci:

  • nebufferovaný kanál vytvárame cez make

  • zapisujeme šípkou za kanálom

  • čítame šípkou pred kanálom

  • pri nebufferovanom kanáli zápis čaká na čítanie a čítanie čaká na zápis hodnoty

  • kanál odovzdávame do funkcií priamo — nie cez pointer

  • kanál uzatvára producent cez close

  • range a for číta z kanála

>> Home