Wyślij zapytanie Dołącz do Sii

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.  

Zarządzanie czasem w Scheduler Go
Ryc. 1 Zarządzanie czasem w Scheduler Go

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. 

Równoległe wykonanie gorutyn
Ryc. 2 Równoległe wykonanie gorutyn

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.

5/5 ( głosy: 5)
Ocena:
5/5 ( głosy: 5)
Autor
Avatar
Krzysztof Heinke

W programowaniu rozwija się już od 6 lat. Zaczynał od C++, później odbył podróż poprzez języki takie jak: Python, bash i Perl. Jego ostatnim konikiem jest Go.

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?