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 systemowy | Gorutyna | |
|---|---|---|
| Tworzy go | jądro | runtime Go |
| Stos początkowy | stały, często 1 MB lub więcej | kilka KB, rośnie w razie potrzeby |
| Przełączanie | przełączenie kontekstu w jądrze | planista Go, w przestrzeni użytkownika |
| Tożsamość | ma identyfikator wątku | celowo brak identyfikatora do odczytania |
| Typowa liczba | setki | od 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/atomicdla pojedynczego licznika lub flagi:var n atomic.Int64; n.Add(1). - Użyj
sync.Mutexwokół czegokolwiek większego, np. mapy albo struktury z kilkoma polami. Strona o mutexie opisuje teżRWMutexisync.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.
mainzwraca, a praca po cichu nigdy się nie wykonuje. Każda instrukcjagopotrzebuje odpowiadającego jej sposobu, by wiedzieć, że się zakończyła. - Wywołanie
wg.Addwewnątrz gorutyny.Waitmoże wykonać się przedAdd, zobaczyć licznik równy zero i wrócić za wcześnie. WywołujAddprzed 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, czegorecovernie 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.