Dawniej, kiedy w urządzeniach był tylko jeden wątek bądź nawet nie było systemów do zarządzania procesami, kod wykonywał się linijka po linijce.
W dzisiejszych czasach mamy już lepsze komputery, które często posiadają co najmniej dwa wątki w procesorze. Dysponujemy również systemami do zarządzania procesami. Dzięki temu, jesteśmy w stanie wykonywać programy, które są o wiele bardziej złożone – takie, jak właśnie współbieżne programowanie. Oznacza to, że często kod jest wykonywany równocześnie, co może prowadzić do znacznie szybszego przetwarzania danych.
W tym celu Go udostępnia nam narzędzie takie jak gorutyny (ang. gorutines), które przybliżę w niniejszym artykule.
Czym są gorutyny?
Gorutyny to ,,lekki wątek”, który wykonuje funkcje bądź metody jednocześnie. Same gorutyny działają asynchronicznie. Jeżeli ,,runtime” ma więcej przypisanych procesów, to wtedy mogą działać również równolegle. Sposób ich uruchomienia jest bardzo prosty. Trzeba zdefiniować funkcję oraz przed jej wywołaniem napisać po prostu „go”.
package main
import (
"fmt"
)
func HelloWorld() {
fmt.Println("Hello world!")
}
func main() {
go HelloWorld()
}
Synchronizacja gorutyn
Można by pomyśleć: ,,wow, jakie to proste”. Niestety – powyższy kod nie pokaże nam nic. Wynika to z tego, że główna linia programu się skończy, zanim gorutyna zdąży się wykonać.
Aby zapobiec takiej sytuacji, należałoby poczekać w głównej linii programu na moment, aż gorutyny zostaną wykonane.
Są na to dwa sposoby:
- WaitGroups – można je przekazać do funkcji lub opakować w funkcję anonimową:
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d started\n", id)
// Worker job
fmt.Printf("Worker %d finished\n", id)
}
func main() {
var wg sync.WaitGroup
// Statring few worker gorutines
for i := 1; i <= 5; i++ {
wg.Add(1) // Add number of gorutines called
go worker(i, &wg)
}
// Waiting for worker finish job
wg.Wait()
fmt.Println("All workers finished")
}
Istnieje również możliwość, aby tę funkcję uruchomić jako funkcję anonimową. Dzięki temu nie musimy pisać specjalnej funkcji do tak prostych rzeczy.
- Kanały (ang. chanells) – można je wykorzystać do komunikacji pomiędzy dwiema różnymi gorutynami lub tak, jak pokażę to w przykładzie, można z nich skorzystać do komunikacji o tym, że dana gorutyna już skończyła.
package main
import (
"fmt"
)
func HelloWorld(done chan int) {
fmt.Printf("Hello world!")
// done <- 1
}
func main() {
// Definition of the chanel
done := make(chan int)
// starting of goroutine
go HelloWorld(done)
// waiting for finish
<-done
}
W tym przykładzie po wykonaniu funkcji HelloWorld w kanał done zostanie przesłana liczba jeden. Natomiast w głównej linii programu oczekujemy na sygnał <-done.
Jak widać, obie metody są poprawne, jednak metoda z wait grupami jest bardziej czytelna. Do większej liczby gorutyn powinniśmy używać wait group, z racji tego, że przykład z kanałem obsłuży nam tylko i wyłącznie jedną gorutynę.
Podczas korzystania z obu metod trzeba pamiętać o tym, żeby je prawidłowo zamknąć. Gdy tego nie zrobimy, dojdzie do sytuacji, której każdy programista się obawia – nastąpi deadlock.
Jak działają gorutyny?
Język Go sam zarządza tym, jak gorutyny są wykonywane. Mówimy, że jest to wykonywanie jednoczesne, ale w rzeczywistości tak być nie musi. Gorutynami zarządza Scheduler Go. Działa on w tle i automatycznie decyduje, czy daną gorutynę wykonać sekwencyjnie, czy przypisać ją do wątku.
Warto zaznaczyć, że programista Go nie ma kontroli nad tym, czy dana gorutyna zostanie wykonana w osobnym wątku, czy też zostanie wykonana sekwencyjnie.
Gorutyny zajmują bardzo mało pamięci – zazwyczaj 2-4 kB. Oczywiście, ta waga może być większa – zależy to od wielkości stosu, ilości pamięci zaalokowanej na stercie oraz liczby zmiennych lokalnych, a także liczby używanych kanałów i mutexów.
Wykonanie sekwencyjne a przypisanie do wątku
Co to znaczy, że gorutyna jest wykonana sekwencyjnie, a co, że przypisana do wątku? Do wytłumaczenia zarządzania wykonaniem sekwencyjnym założymy, że procesor ma tylko jeden wątek.
Scheduler Go będzie zarządzał dostępem czasu do procesora tak, aby każdej gorutynie przydzielić go równomiernie.
Jeżeli gorutyny będą działały na jednym wątku, to nie ma gwarancji, że zostaną one uruchamiane w konkretnej kolejności. Scheduler zdecyduje, którą z nich uruchomić w danym momencie. Może to wynikać z dostępnych zasobów takich jak np. kanały czy mutexy. Ogólna kolejność wykonywania jest zależna od ich gotowości oraz momentu dodania do kolejki.
Natomiast, jeżeli mamy procesor, który ma co najmniej dwa wątki, gorutyny mogą zostać wykonane równolegle na różnych wątkach.
Podsumowując: gorutyny 3 i 1 zostaną wykonane dokładnie w tym samym czasie. Natomiast gorutyny 2 i 4, zostaną wykonane równolegle, gdy zostanie im przydzielony czas.
Problemy gorutyn
Gorutyny są dosyć proste i intuicyjne w wykorzystaniu. Trzeba jednak pamiętać, że jest to cały czas programowanie współbieżne, które może prowadzić do takich samych problemów jak w programowaniu na wątkach. Należą do nich:
- race condition,
- deadlock,
- starvation.
Możemy również napotkać:
- tzw. live-locki,
- wyciek gorutyny.
Deadlock
Deadlock występuje, gdy gorutyny czekają na siebie nawzajem, wzajemnie się blokując. W takim wypadku program nigdy się nie zakończy.
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 1
v := <-ch2
fmt.Println(v)
}()
go func() {
ch2 <- 2
v := <-ch1
fmt.Println(v)
}()
// deadlock!
time.Sleep(5 * time.Second)
}
W powyższym przykładzie mamy dwie gorutyny, które komunikują się za pomocą dwóch kanałów: ch1 i ch2. Pierwsza gorutyna próbuje przesłać wartość 1 do kanału ch1, a następnie usiłuje odebrać wartość z kanału ch2. Druga gorutyna próbuje przesłać wartość 2 do kanału ch2, a następnie odebrać wartość z kanału ch1.
Jednakże, obie gorutyny czekają wzajemnie na przesłanie danych przez kanały, co prowadzi do deadlocka. Program zawiesza się i nie kończy. Aby uniknąć deadlocków w gorutynach, należy unikać sytuacji, w których dwie gorutyny (lub więcej) czekają na siebie nawzajem. Można to osiągnąć poprzez użycie jednego kanału, który działa w dwóch kierunkach lub poprzez użycie mechanizmu synchronizacji takiego jak WaitGroup lub Mutex.
Poniżej zaprezentuję właściwą implementację tak, aby taka sytuacja się nie pojawiła.
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() {
ch <- 1
val := <-ch
fmt.Println(val)
wg.Done()
}()
go func() {
val := <-ch
fmt.Println(val)
ch <- 2
wg.Done()
}()
wg.Wait()
}
Race-condition
Gdy gorutyny mają dostęp do tych samych danych bez mutexów służących do ich synchronizacji, może pojawić się właśnie ta sytuacja. Dzieje się tak, kiedy dwie lub więcej gorutyn próbuje zapisać wartość do tej samej zmiennej. Wynik takiej sytuacji jest nie do przewidzenia. Natomiast jasne jest to, że dane będą niespójne.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(2)
var counter int
go func() {
for i := 0; i < 10000; i++ {
counter++
}
wg.Done()
}()
go func() {
for i := 0; i < 10000; i++ {
counter++
}
wg.Done()
}()
wg.Wait()
fmt.Println("Counter:", counter)
}
Aby zapobiec takiej sytuacji, należy użyć mutexa:
package main
import (
"fmt"
"sync"
)
var (
counter = 0
mutex = &sync.Mutex{}
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go incrementCounter(&wg)
}
wg.Wait()
fmt.Println("Counter:", counter)
}
func incrementCounter(wg *sync.WaitGroup) {
mutex.Lock()
defer mutex.Unlock()
counter++
wg.Done()
}
Wynikiem tego kodu będzie 100, dzięki temu, że zastosowaliśmy mutexa, aby zsynchronizować czas dostępu do zmiennej counter.
Starvation
Starvation to sytuacja, w której pewne zadania nie są w stanie wykonać swojej pracy, ponieważ są stale zablokowane lub nie otrzymują wystarczającej ilości zasobów, których potrzebują do pracy. W efekcie te zadania są nieskończenie odkładane w czasie, co prowadzi do nieefektywności i braku postępu w wykonywaniu programu.
Przykładem może być sytuacja, w której wiele gorutyn oczekuje na dostęp do jednego zasobu (np. blokującego mutexa). Jeśli jedna z gorutyn jest w stanie zająć ten zasób na dłuższy czas, to inne gorutyny mogą być stale blokowane, co prowadzi do ich „głodzenia” i braku możliwości wykonania swojej pracy.
package main
import (
"fmt"
"sync"
"time"
)
var mutex sync.Mutex
func main() {
wg := sync.WaitGroup{}
wg.Add(2)
// Gorutyna 1
go func() {
defer wg.Done()
mutex.Lock()
defer mutex.Unlock()
fmt.Println("Goroutine 1 has acquired the lock")
time.Sleep(10 * time.Second)
}()
// Gorutyna 2
go func() {
defer wg.Done()
mutex.Lock()
defer mutex.Unlock()
fmt.Println("Goroutine 2 has acquired the lock")
}()
wg.Wait()
}
Aby zapobiec takiej sytuacji jak wyżej, musielibyśmy zaimplementować kolejki i semafory:
package main
import (
"fmt"
"sync"
"time"
)
const maxConcurrent = 1
var (
mutex = sync.Mutex{}
jobDuration = time.Millisecond * 500
)
func main() {
wg := sync.WaitGroup{}
semaphore := make(chan struct{}, maxConcurrent)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
semaphore <- struct{}{}
fmt.Printf("Goroutine %d: Entering critical section.\n", i)
mutex.Lock()
fmt.Printf("Goroutine %d: Executing critical section.\n", i)
time.Sleep(jobDuration)
fmt.Printf("Goroutine %d: Finished executing critical section.\n", i)
mutex.Unlock()
<-semaphore
}(i)
}
wg.Wait()
fmt.Println("All goroutines have finished.")
}
W powyższym przykładzie został stworzony semafor, który mówi, ile gorutyn może się znajdować w danej chwili w sekcji krytycznej.
Działa to w ten sposób, że gorutyna próbuje przesłać pustą strukturę do kanału. Nie jest to jednak możliwe, gdyż jest to kanał buforowany dopuszczający tylko jeden element w danej chwili.
Live-Lock
Live-lock jest sytuacją, w której gorutyny wykonują niekończący się cykl zależności, co powoduje, że nie są w stanie kontynuować pracy. Może to prowadzić do sytuacji, w której program cały czas działa, ale niczego nie osiąga:
package main
import (
"fmt"
"sync"
)
var lockA sync.Mutex
var lockB sync.Mutex
func goroutineA() {
for {
lockA.Lock()
lockB.Lock()
fmt.Println("Goroutine A")
lockA.Unlock()
lockB.Unlock()
}
}
func goroutineB() {
for {
lockB.Lock()
lockA.Lock()
fmt.Println("Goroutine B")
lockB.Unlock()
lockA.Unlock()
}
}
func main() {
go goroutineA()
go goroutineB()
for {
}
}
W powyższym przykładzie mamy dwie gorutyny (goroutineA i goroutineB), które wykonują niekończący się cykl zależności. Obie próbują uzyskać blokadę na dwóch różnych zasobach, ale w takiej samej kolejności. W efekcie każda gorutyna musi czekać na zwolnienie blokady przez drugą, co prowadzi do sytuacji, w której obie gorutyny zawieszą się w nieskończoność i nie będą w stanie kontynuować pracy.
Nie ma jednoznacznej recepty na to, żeby uniknąć tego problemu – trzeba odpowiednio przemyśleć swój algorytm, aby taka sytuacja nie wystąpiła.
Goroutine leakage
Do Goroutine leakage dochodzi wtedy, gdy gorutyna jest niepoprawnie zamykana i nie jest zwalniana z pamięci.
package main
import (
"fmt"
"time"
)
func main() {
go leakyFunction()
fmt.Println("Main function is exiting.")
}
func leakyFunction() {
for {
time.Sleep(time.Second)
}
}
Funkcja leakyFunction jest nieskończona, gdy już główna linia progamu jest zakończona. Prowadzi to do wycieku.
Aby zapobiec takiej sytuacji, można skorzystać z contextu i przekazywać go do gorutyn. Jeżeli zdarzy się sytuacja, że program główny będzie zakończony, to wtedy gorutyny również będą zamknięte.
package main
import (
"context"
"fmt"
"sync"
"time"
)
func goroutineCancel(cancel context.CancelFunc) {
time.Sleep(3 * time. Second)
cancel()
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wg := &sync.WaitGroup{}
go goroutineCancel(cancel)
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("goroutine canceled")
return
default:
fmt.Println("goroutine running")
time.Sleep(time.Second)
}
}
}()
wg.Wait()
fmt.Println("all goroutines completed")
}
W tym przykładzie mamy nieskończoną gorutynę, która wykonuje się w pętli i wypisuje na ekranie „goroutine running” co sekundę. Aby zabezpieczyć się przed wyciekiem gorutyny, dodajemy ją do WaitGroup i oczekujemy na jej zakończenie za pomocą wg.Wait().
Żeby anulować gorutynę, gdy program zostanie zakończony, używamy kontekstu ctx i wywołujemy funkcję cancel() w funkcji gorutineCancel. W przypadku anulowania kontekstu, gorutyna zostanie zakończona poprzez zakończenie pętli i wyjście z funkcji.
Podsumowanie
Jak widać, w porównaniu do innych języków programowania, gorutyny są dużo prostsze w stosowaniu. Zwłaszcza, że Go dostarcza nam ogromną liczbę metod do tego, aby je synchronizować oraz żeby komunikować się między nimi.
Należy również pamiętać, że gorutyny nie są lekarstwem na wszystko. Korzystanie z nich może znacznie usprawnić i przyspieszyć działanie naszego programu, lecz wiąże się to z konsekwencjami w postaci trudniejszego testowania oraz znacznym podniesieniem złożoności kodu, gdy mamy sporo danych do synchronizowania pomiędzy gorutynami.
***
Jeśli interesuje Cię obszar korutyn, zachęcamy również do zapoznania z innymi artykułami naszych ekspertów.
Zostaw komentarz