Menu

Goroutines w Go: jak działają gorutyny, z przykładami

Jak uruchamiać funkcje współbieżnie słowem kluczowym go, czekać na ich zakończenie, odbierać wyniki i unikać wyścigów danych, wycieków i awarii, o które przy gorutynach bardzo łatwo.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Uruchamianie gorutyny

Postaw go przed wywołaniem funkcji, a to wywołanie wykona się współbieżnie. Instrukcja wraca natychmiast; kod wywołujący nie czeka.

Cztery gorutyny wykonują shout jednocześnie. Kolejność, w jakiej się kończą, nie jest określona, ale wynik zawsze jest w pierwotnej kolejności, bo każda gorutyna zapisuje pod własny indeks, a main czyta slice dopiero po powrocie z wg.Wait().

Ten przykład zawiera już trzy rzeczy, których potrzebuje prawie każdy program z gorutynami: sposób na uruchomienie pracy (go), sposób na czekanie na nią (sync.WaitGroup) i sposób na odebranie wyników bez tego, by dwie gorutyny dotykały tej samej pamięci (każda ma swój element slice'a).

Main nie czeka

Gdy main zwraca, program się kończy. Gorutyny, które jeszcze działają, zostają zatrzymane tam, gdzie akurat są. Nic na nie nie czeka.

Zwykle wypisuje to tylko from main. Czasem gorutyna zdąży zostać zaplanowana i widać obie linie. Właśnie to „zwykle” jest problemem: kod działa na twoim komputerze i zawodzi na obciążonym serwerze.

time.Sleep na końcu main sprawia, że demo wypisuje obie linie, ale to zła poprawka. Zgaduje, ile potrwa praca. Czekaj na samą pracę, za pomocą WaitGroup (szczegółowo opisuje go strona o WaitGroup) albo kanału.

Odbieranie wyników

Instrukcja go wyrzuca wartości zwracane przez funkcję. x := go f() się nie kompiluje. Są dwa standardowe sposoby na zwrócenie danych.

Jedna pozycja na gorutynę, jak w pierwszym przykładzie. Utwórz slice o z góry znanym rozmiarze, daj każdej gorutynie jej indeks, odczytaj po Wait. Kolejność zostaje zachowana i nie trzeba żadnych blokad, bo żadne dwie gorutyny nie zapisują tego samego elementu.

Kanał. Każda gorutyna wysyła swój wynik, a odbiorca je zbiera. Wyniki przychodzą w kolejności zakończenia, a nie uruchomienia.

Odebranie dokładnie len(nums) wartości służy jednocześnie za czekanie: main nie wyjdzie z pętli, dopóki każda gorutyna nie wyśle wyniku. Kolejność nadejścia zmienia się między uruchomieniami, więc program sortuje, zanim wypisze cokolwiek, co zależy od kolejności. Strona o kanałach opisuje kanały buforowane, zamykanie i range po kanale.

Zmienne pętli i domknięcia (zmiana w Go 1.22)

Od Go 1.22 każda iteracja pętli for dostaje świeżą kopię zmiennych pętli. Domknięcie uruchomione w gorutynie przechwytuje wartość z danej iteracji, więc ten kod jest poprawny:

for i, w := range words {
	go func() {
		results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
	}()
}

Przed Go 1.22 wszystkie iteracje współdzieliły jedno i i jedno w, a każda gorutyna zwykle widziała ostatnią wartość. Starszy kod obchodzi to, przekazując wartości jako argumenty, go func(i int, w string) { ... }(i, w), albo przez przesłonięcie, i := i. Oba sposoby są nieszkodliwe w Go 1.22 i nowszych i nadal spotkasz je w istniejącym kodzie. Nowe zachowanie obowiązuje, gdy go.mod modułu zawiera go 1.22 lub wyżej.

Gorutyny są tanie

Gorutyna startuje z małym stosem (kilka kilobajtów), który runtime powiększa i zmniejsza w miarę potrzeby. Planista Go uruchamia gorutyny na puli wątków systemowych, z których co najwyżej GOMAXPROCS wykonuje kod Go jednocześnie, a domyślnie GOMAXPROCS równa się liczbie procesorów. Blokada na kanale, mutexie, uśpieniu albo sieciowym I/O odstawia gorutynę na bok i zwalnia wątek dla innej.

Dlatego uruchamianie gorutyny na każde zadanie jest w porządku nawet przy dużych liczbach:

Sto tysięcy gorutyn kończy się w ułamku sekundy. Suma zawsze wynosi 4999950000, bo atomic.Int64 sprawia, że każde dodawanie jest niepodzielne. Tanie nie znaczy jednak darmowe: każda gorutyna, która wciąż jest zablokowana, utrzymuje przy życiu swój stos i wszystko, do czego się odwołuje.

Wątek systemowyGorutyna
Tworzy gojądroruntime Go
Stos początkowystały, często 1 MB lub więcejkilka KB, rośnie w razie potrzeby
Przełączanieprzełączenie kontekstu w jądrzeplanista Go, w przestrzeni użytkownika
Tożsamośćma identyfikator wątkucelowo brak identyfikatora do odczytania
Typowa liczbasetkiod tysięcy do milionów

Wyścigi danych

Gdy dwie gorutyny jednocześnie korzystają z tej samej zmiennej i co najmniej jedna do niej zapisuje, powstaje wyścig danych (data race). Wynik jest nieprzewidywalny, a nie tylko „trochę nie taki”: aktualizacje giną, a wyścig na wartości typu string, slice, mapa albo interfejs może wywrócić program lub uszkodzić pamięć.

Na maszynie wielordzeniowej przy większości uruchomień program wypisuje inną liczbę mniejszą niż 10000, bo dwie gorutyny czytają tę samą starą wartość i obie zapisują ją powiększoną o jeden. Na jednym rdzeniu może wypisać 10000, co jest jeszcze gorsze: błąd przechodzi test i wychodzi dopiero na produkcji.

Go ma wbudowany detektor wyścigów. Uruchom program lub testy z -race:

go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
  main.main.func1()
      /tmp/race/main.go:16 +0x94

Previous write at 0x00c000090038 by goroutine 6:
  main.main.func1()
      /tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66

Wskazuje dokładną linię (counter++) i obie gorutyny. Zgłasza tylko te wyścigi, które faktycznie wystąpią podczas uruchomienia, więc używaj go z testami, które przechodzą przez współbieżne ścieżki. Kilkukrotnie spowalnia program, więc nadaje się do testów i środowiska staging, a nie na produkcję.

Poprawki, od najprostszej do najbardziej ogólnej:

  • Nie współdziel. Daj każdej gorutynie własne dane i połącz je na końcu (wzorzec jednej pozycji na gorutynę).
  • Użyj sync/atomic dla pojedynczego licznika lub flagi: var n atomic.Int64; n.Add(1).
  • Użyj sync.Mutex wokół czegokolwiek większego, np. mapy albo struktury z kilkoma polami. Strona o mutexie opisuje też RWMutex i sync.Once.
  • Przesyłaj dane kanałem, tak by w danej chwili należały tylko do jednej gorutyny.

Panika w gorutynie zabija program

Jeśli gorutyna wpadnie w panikę i nic jej nie przechwyci w tej samej gorutynie, cały program się wywraca, łącznie z main i wszystkimi innymi gorutynami. recover w main nie pomoże, bo recover łapie tylko paniki we własnej gorutynie.

Takie przechwytywanie ma sens na brzegu długo działającego serwera, gdzie jedno złe żądanie nie może położyć całej reszty. W zwykłym kodzie panika oznacza zwykle błąd i głośna awaria jest właściwym wynikiem.

Wycieki gorutyn

Gorutyna, która blokuje się na zawsze, nigdy się nie kończy i nigdy nie zwalnia pamięci. Klasyczna przyczyna to wysyłka, której nikt nigdy nie odbierze:

func firstResult(urls []string) string {
	ch := make(chan string) // unbuffered
	for _, u := range urls {
		go func() { ch <- fetch(u) }()
	}
	return <-ch // takes the first result; the other senders block forever
}

Każde wywołanie gubi len(urls) - 1 gorutyn. Na serwerze, który obsługuje to żądanie tysiące razy, pamięć rośnie, aż proces padnie. Są dwie poprawki: zrób kanał na tyle duży, żeby każdy nadawca mógł skończyć (make(chan string, len(urls))), albo daj gorutynom sposób na rezygnację, zwykle context.Context plus select na ctx.Done(). Wycieki możesz śledzić w testach za pomocą runtime.NumGoroutine().

Ograniczanie liczby jednocześnie działających gorutyn

„Jedna gorutyna na element” sprawdza się przy 10 000 tanich obliczeń. Nie sprawdza się przy 10 000 żądań HTTP do tego samego serwera ani 10 000 otwartych plików. Ogranicz współbieżność kanałem buforowanym użytym jako semafor:

Kanał buforowany mieści co najwyżej 3 żetony, więc w każdej chwili co najwyżej 3 gorutyny są za linią sem <-. Szczyt nigdy nie przekroczy 3, a przy dwunastu zadaniach, z których każde śpi, w praktyce go osiąga. Drugi popularny kształt to stała pula gorutyn roboczych czytających z kanału zadań; buduje ją strona o WaitGroup.

Poza biblioteką standardową golang.org/x/sync/errgroup łączy w jednym typie WaitGroup, pierwszy błąd, anulowanie przez kontekst i limit współbieżności (g.SetLimit(n)). To typowy wybór w kodzie produkcyjnym, który potrzebuje wszystkich czterech.

Częste błędy

  • Brak czekania. main zwraca, a praca po cichu nigdy się nie wykonuje. Każda instrukcja go potrzebuje odpowiadającego jej sposobu, by wiedzieć, że się zakończyła.
  • Wywołanie wg.Add wewnątrz gorutyny. Wait może wykonać się przed Add, zobaczyć licznik równy zero i wrócić za wcześnie. Wywołuj Add przed instrukcją go.
  • Współdzielenie zmiennej bez synchronizacji. Typowy przypadek to mapy: runtime zwykle wykrywa współbieżne zapisy do mapy i wywraca program z fatal error: concurrent map writes, czego recover nie złapie.
  • Zakładanie kolejności. Gorutyny działają w takiej kolejności, jaką wybierze planista. Jeśli wynik musi być uporządkowany, zbierz go i posortuj albo zapisuj do indeksowanych pozycji.
  • Synchronizacja przez time.Sleep. Testy stają się wolne i nadal niestabilne. Czekaj na zdarzenie, a nie na zgadywany czas.
  • Uruchamianie gorutyny bez sposobu na jej zatrzymanie. Wszystko, co działa w pętli albo czeka na I/O, powinno przyjmować context.Context, żeby kod wywołujący mógł to anulować.

Najczęściej zadawane pytania

Czym jest goroutine w Go?

Gorutyna (goroutine) to wywołanie funkcji, które działa współbieżnie z resztą programu. Uruchamiasz ją, stawiając go przed wywołaniem: go work(). Gorutynami zarządza runtime Go, a nie system operacyjny, i rozkłada wiele z nich na niewielką liczbę wątków systemowych, więc uruchomienie tysięcy gorutyn to nic niezwykłego.

Jak w Go poczekać, aż gorutyny się zakończą?

Użyj sync.WaitGroup: wywołaj wg.Add(1) przed każdą instrukcją go, defer wg.Done() na początku gorutyny i wg.Wait() tam, gdzie wszystkie muszą być już zakończone. Jeśli gorutyny zwracają wartości, odebranie z kanału jednej wartości na gorutynę też działa jak czekanie.

Czym różni się goroutine od wątku?

Wątek systemowy ma stały stos (często 1 MB lub więcej) i planuje go jądro. Gorutyna zaczyna ze stosem wielkości kilku kilobajtów, który rośnie w miarę potrzeby, a planista Go przełącza gorutyny w przestrzeni użytkownika. Runtime wykonuje gorutyny na co najwyżej GOMAXPROCS wątkach jednocześnie (domyślnie tylu, ile jest procesorów).

Jak odebrać wartość zwracaną z gorutyny?

Instrukcja go odrzuca wartości zwracane przez funkcję. Wyślij wynik kanałem (results <- compute(x)) albo zapisz go we własnej pozycji slice'a o z góry ustalonym rozmiarze (out[i] = compute(x)) i odczytaj po wg.Wait().

Dlaczego mój program w Go kończy się, zanim gorutyna cokolwiek wypisze?

Gdy main zwraca, program się kończy, a wszystkie pozostałe gorutyny zostają zatrzymane bez wykonania reszty swojego kodu. Nic nie czeka na gorutyny automatycznie. Zablokuj main, aż praca się skończy, za pomocą WaitGroup albo odbioru z kanału. Dodanie time.Sleep tylko ukrywa problem.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ