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)
}
}
Č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.
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 |
Dôležité veci pri kanáloch:
|
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.
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.
-
Producent vyprodukuje výsledok
1
a čaká na konzumenta. -
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 pre2!
. -
Ihneď na to sa vyprodukuje výsledok pre faktoriál 2 a čaká sa konzumenta v druhej iterácii.
-
Konzument však paralelne sekundu spí a keď sa zobudí, skonzumuje výsledok
1
. -
To odblokuje producenta, ktorý môže vyrátať
3!
a zapísať do do kanála. -
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
afor
číta z kanála