1. Co dokladnie przechwytuje domkniecie w Go: zmienna czy wartosc
Jesli dopiero zaczynasz pisac kod wspolbiezny, bardzo latwo trafic w sytuacje „zrobilem wszystko dobrze... ale to czasem wypisuje nie to”. I wtedy zaczyna sie magia, ktora w rzeczywistosci nie jest magia, lecz kombinacja trzech rzeczy: domkniecia przechwytuja zmienne, petla ponownie wykorzystuje zmienne, a goroutine wykonywane sa w nieprzewidywalnej kolejnosci. W Go 1.22+ czesc bolu usunieto, ale nie caly.
Wyobraz sobie, ze petla to tasma produkcyjna, a goroutine to kurierzy, ktorzy uciekaja z notatka. Problem historycznie polegal na tym, ze nie kopiowales tresci notatki, tylko dawales kurierowi odwolanie do jednej i tej samej kartki papieru. Petla zdarzala sie przepisac kartke i wszyscy kurierzy przynosili „ostatnia wersje”. W Go 1.22+ dla typowych petli tasma zaczela wydawac nowa kartke przy kazdej iteracji (hurra), ale jesli sam na sile uzywasz jednej kartki, przywracasz stary problem.
Domkniecia czesto wyjasnia sie jako „funkcja z pamiecia”. I to prawie prawda, ale wazny jest detal: domkniecie „pamieta” nie wartosc, lecz zmienna (czyli miejsce, w ktorym wartosc lezy). Dlatego jesli ktos pozniej zmieni ta zmienna, domkniecie zobaczy nowa wartosc. Dopoki wszystko jest jednowatkowe — po prostu sie zdziwisz. Jesli obok jest goroutine — zdziwisz sie glosniej.
Zacznijmy od prostego przykladu bez goroutine — zeby zobaczyc mechanike w spokojnych warunkach:
package main
import "fmt"
func main() {
x := 10
f := func() {
fmt.Println("x =", x)
}
x = 20
f() // x = 20
}
Tutaj domkniecie f uzywa zmiennej x z zewnetrznego zakresu widocznosci. Zmienilismy x, a f() uczciwie zobaczylo nowa wartosc.
Teraz utrwalmy to mala tabela — to wazniejsze, niz sie wydaje:
| Formula | Po ludzku | W praktyce w kodzie |
|---|---|---|
| „Domkniecie przechwytuje wartosc” | jakby kopia liczby/napisu | nie, nie kopia |
| „Domkniecie przechwytuje zmienna” | odwolanie do miejsca, gdzie lezy wartosc | tak, widzi zmiany |
I dlatego petla + domkniecie to wybuchowa mieszanka: petla zwykle pracuje na jednej zmiennej, ktora zmienia sie z iteracji na iteracje.
3. Klasyczna pulapka range i goroutine przed Go 1.22
Nawet jesli piszesz w Go 1.25, warto rozumiec, jaki dokladnie problem byl naprawiany. W przeciwnym razie latwo wyciagnac zly wniosek: „w Go teraz zawsze jest bezpiecznie uruchamiac goroutine w petli”. To nie tak.
Klasyczny przyklad (kiedys mogl wypisywac kilka razy ta sama wartosc):
package main
import (
"fmt"
"sync"
)
func main() {
values := []string{"a", "b", "c"}
var wg sync.WaitGroup
wg.Add(len(values))
for _, v := range values {
go func() {
defer wg.Done()
fmt.Println(v)
}()
}
wg.Wait()
}
Przed Go 1.22 zmienna v byla jedna dla calej petli range, tylko jej wartosc zmieniala sie przy kazdej iteracji. Domkniecie wewnatrz goroutine „trzymalo sie” zmiennej v, i dlatego moglo zobaczyc nie ta wartosc, ktorej intuicyjnie oczekiwales.
Począwszy od Go 1.22 „dlugo zyjaca pulapka zmiennej petli” zostala rozwiazana: zmienne iteracji nie sa juz „przypadkowo wspoldzielone” miedzy iteracjami w tym sensie, ktory lamal domkniecia. W informacji o wydaniu Go 1.22 wprost napisano, ze ten gotcha zostal naprawiony, i podano przyklad, ktory wypisuje "a", "b", "c" w pewnej kolejnosci.
Wazne: nawet kiedy wszystko jest naprawione, kolejnosc pozostaje niedeterministyczna. To znaczy „w pewnej kolejnosci” to normalny wynik.
4. Co zmienilo sie w Go 1.22+ i dlaczego „typowy przypadek” teraz dziala
W Go 1.22+ (a wiec i w Go 1.25) zmienne zadeklarowane w naglowku petli zachowuja sie „przyjazniej” wobec domkniec: kazda iteracja dostaje swoja „logiczna” zmienna iteracji. Dzieki temu typowe wzorce range + go func(){...}() przestaly byc bomba z opóźnionym zaplonem.
Ale tutaj bardzo latwo popelnic nieprawidlowy skok logiczny: „to znaczy, ze w ogole nie trzeba myslec”. Nie. Poprawka dotyczy typowego przypadku, gdy zmienna iteracji jest deklarowana wewnatrz naglowka petli (for _, v := range ..., for i := 0; ...; ...). Gdy tylko zaczynasz „krecic” zmienna recznie (zadeklarowac ja na zewnatrz i przypisywac wewnatrz), wracasz do starego modelu.
Mozna to narysowac jako schemat:
flowchart TD
A[Petla] --> B{Czy zmienna iteracji jest zadeklarowana w naglowku?}
B -- tak --> C[Go 1.22+ czyni iteracje bezpieczniejszymi dla domkniec w typowym przypadku]
B -- nie --> D[Zostala jedna zmienna dla wszystkich iteracji]
D --> E[Domkniecia i goroutine widza to samo miejsce w pamieci]
Sens schematu: poprawka nie jest „magiczna”, dotyczy konkretnej skladni i konkretnego miejsca deklaracji zmiennej.
5. Dlaczego parametr w goroutine pozostaje najlepszym stylem
Gdy jezyk naprawia stara pulapke, pojawia sie pokusa pisania „jak najkrocej”. Ale w realnym kodzie „krotsze” czesto znaczy „mniej jawne”. A w wspolbieznosci niejawność jest jak chodzenie nocą po pokoju, na ktorym porozrzucano LEGO: mozna, ale po co.
Dlatego nawet w Go 1.25 dobry styl to uczynic wejscie goroutine jawnym: przekazywac wartosc jako parametr. To nie tylko obejscie pulapek, ale tez poprawa czytelnosci: przy goroutine widac „oto skad wziela sie ta zmienna”.
package main
import (
"fmt"
"sync"
)
func main() {
values := []string{"a", "b", "c"}
var wg sync.WaitGroup
wg.Add(len(values))
for _, v := range values {
go func(v string) {
defer wg.Done()
fmt.Println(v)
}(v)
}
wg.Wait()
}
To ten sam sens, ale teraz zaleznosc goroutine od v wyglada jak normalna „wejsciowa zmienna funkcji”.
Podobne podejscie spotyka sie tez w klasycznych przykladach wspolbieznosci: gdy z petli uruchamia sie goroutine, wartosc najlepiej przekazac jako parametr, aby domkniecie nie zalezalo od zmiennej zewnetrznej, ktora sie zmienia.
6. Pulapki z ponownym uzyciem zmiennych w petli
Zmienna zadeklarowana poza petla i ponownie uzywana przez przypisanie
Teraz do sedna: „typowy przypadek zostal naprawiony, ale pozostaja przypadki z ponownym uzyciem zmiennej”. Najczestszy to taki, gdy zmienna zostala zadeklarowana na zewnatrz, a w petli tylko sie ja przypisuje.
I tutaj w pewnym sensie „cofasz” ulepszenie Go 1.22+, bo znowu tworzysz jedna zmienna dla calej petli.
package main
import (
"fmt"
"sync"
)
func main() {
values := []string{"a", "b", "c"}
var wg sync.WaitGroup
wg.Add(len(values))
var v string // jedna zmienna dla calej petli
for _, s := range values {
v = s
go func() {
defer wg.Done()
fmt.Println(v) // zle: czytamy jedna zmienna, ktora petla zmienia
}()
}
wg.Wait()
}
Co tutaj jest nie tak, i to na dwoch poziomach.
Na poziomie logiki: wszystkie goroutine czytaja ta sama zmienna v, ktora zmienia sie w petli, wiec wynik bedzie „jak sie uda” (czesto — ta sama wartosc).
Na poziomie poprawnosci kodu wspolbieznego: to rowniez potencjalny data race, bo petla zapisuje do v, a goroutine czytaja v wspolbieznie, bez uzgodnienia. I to juz nie jest po prostu „zly wynik”, to jest „program jest nieprzewidywalny”.
Leczy sie to tym samym „robimy wejscie goroutine jawnym”, nawet jesli zmienna jest zewnetrzna:
for _, s := range values {
v = s
go func(v string) {
defer wg.Done()
fmt.Println(v)
}(v)
}
Wyglada to troche zabawnie, bo najpierw zapisujemy do zewnetrznej v, a potem przekazujemy v jako parametr. W realnym kodzie lepiej w ogole nie miec zewnetrznej v, jesli nie jest potrzebna. Ale przyklad pokazuje najwazniejsze: goroutine powinna dostac swoja wartosc, a nie odwolanie do miejsca, ktore ktos dalej zmienia.
Indeks petli zadeklarowany poza for
Go 1.22+ naprawil typowy przypadek, gdy zmienna jest deklarowana w naglowku for. Ale jesli indeks zadeklarowales wczesniej, to znowu jest to jedna zmienna.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(3)
i := 0
for ; i < 3; i++ {
go func() {
defer wg.Done()
fmt.Println("i =", i)
}()
}
wg.Wait()
}
Jesli oczekujesz "i = 0", "i = 1", "i = 2" (w dowolnej kolejnosci), to to oczekiwanie nie musi sie spelnic, bo domkniecie czyta zmienna i, a petla dalej ja zmienia. I znowu: to i blad logiczny, i potencjalny data race.
Poprawka jest ta sama: parametr.
for ; i < 3; i++ {
go func(i int) {
defer wg.Done()
fmt.Println("i =", i)
}(i)
}
Tutaj doslownie mowisz: „oto wartosc i w chwili uruchomienia, uzyj jej”.
7. Niuans range: kopia elementu i adres zmiennej
Istnieje osobna klasa bledow, ktora czesto blednie nazywa sie „problemem domkniec”, chociaz w rzeczywistosci chodzi o semantyke range.
Gdy robisz for _, t := range tasks, zmienna t jest kopią elementu slice'a (jesli element nie jest wskaznikiem). Nawet jesli w Go 1.25 t jest teraz „nowa przy kazdej iteracji”, to i tak jest kopia. Dlatego:
- wypisywanie t — jest w porzadku;
- branie &t — dostaniesz adres kopii, a nie elementu w slice;
- zmienianie t.Done = true — zmienisz kopie, a nie oryginal.
Pokazmy to na przykladzie, bez wspolbieznosci — zeby bylo widac, gdzie dokladnie jest haczyk:
package main
import "fmt"
type Task struct {
Title string
Done bool
}
func main() {
tasks := []Task{{Title: "A"}, {Title: "B"}}
for _, t := range tasks {
t.Done = true // zmieniamy kopie
}
fmt.Println(tasks[0].Done, tasks[1].Done) // false false
}
Jesli chcesz zmieniac prawdziwe elementy slice'a, zwykle potrzebny jest indeks:
for i := range tasks {
tasks[i].Done = true
}
A jesli bardzo chcesz pracowac przez wskaznik, to tak:
for i := range tasks {
t := &tasks[i]
t.Done = true
}
Ten niuans warto miec z tylu glowy, bo w kodzie wspolbieznym moze wygladac jak „goroutine pisze nie tam, gdzie trzeba” — chociaz po prostu pisales do kopii.
8. Mini-przyklad: wspolbiezna walidacja zadan
Zeby polaczyc temat z praktyka, wyobrazmy sobie kawalek naszej aplikacji edukacyjnej z lista zadan. Chcemy szybko sprawdzic kazde zadanie pod katem prostego problemu: pustego tytulu. Sprawdzenie jest sztucznie proste, ale wazny jest dla nas wzorzec uruchamiania goroutine w petli i starannego utrwalenia wartosci.
Stworzmy funkcje, ktora zwraca lancuch bledu dla konkretnego zadania:
package main
import "fmt"
type Task struct {
ID int
Title string
}
func validateTask(t Task) string {
if t.Title == "" {
return fmt.Sprintf("task %d: empty title", t.ID)
}
return ""
}
Teraz chcemy uruchomic walidacje wspolbieznnie i zebrac wyniki bez race condition: kazda goroutine zapisuje do swojego indeksu, a czytamy dopiero po wg.Wait() (dokladnie ten „bezpieczny minimalny” wzorzec, o ktorym rozmawialismy na wykladzie o data race).
package main
import "sync"
func validateAll(tasks []Task) []string {
results := make([]string, len(tasks))
var wg sync.WaitGroup
wg.Add(len(tasks))
for i, t := range tasks {
go func(i int, t Task) {
defer wg.Done()
results[i] = validateTask(t)
}(i, t)
}
wg.Wait()
return results
}
Zwróć uwage na styl: przekazalismy i i, i t jako parametry. W Go 1.25 to czesto zadziala tez bez parametrow, ale z parametrami kod staje sie „samodokumentujacy”: goroutine uzywa dokladnie tych wartosci, ktore byly w danej iteracji.
I tak, to tez wygodny nawyk na przyszlosc: gdy pojawia sie kanaly, select i bardziej zlozone schematy, jawne wejscie goroutine niemal zawsze upraszcza kod.
9. Typowe bledy
Blad nr 1: sadzic, ze Go 1.25 „naprawil domkniecia na zawsze”.
Go 1.22+ rzeczywiscie naprawil znana pulapke zmiennej iteracji w typowym przypadku, i jest to wprost opisane w informacji o wydaniu. Ale jesli deklarujesz zmienna poza petla i ponownie jej uzywasz przez przypisanie, znow tworzysz jedna wspolna zmienna i przywracasz stary problem.
Blad nr 2: „przeciez uzywam WaitGroup, wiec wszystko jest bezpieczne”.
WaitGroup gwarantuje tylko tyle, ze goroutine sie zakoncza. Nie chroni zmiennych przed wspolbieznym odczytem/zapisem. Dlatego kod, w ktorym petla zapisuje do zewnetrznej zmiennej, a goroutine ja czytaja, pozostaje potencjalnym data race — nawet jesli wszystko odczekasz przez wg.Wait().
Blad nr 3: naprawianie zachowania przez time.Sleep.
Czasem chce sie „troche poczekac, zeby goroutine zdazyly”. Ale time.Sleep nie utrwala wartosci zmiennych i nie robi z kolejnosci wykonania kontraktu. On tylko maskuje problem (i czyni go bardziej losowym). Do oczekiwania na zakonczenie — WaitGroup, do utrwalenia wejscia — parametry albo zmienne lokalne.
Blad nr 4: mylenie „problemu domkniecia” z tym, ze range zwraca kopie elementu.
Gdy zmieniasz zmienna t z for _, t := range tasks, zmieniasz kopie. Nawet jesli wszystko jest jednowatkowe i nawet jesli Go 1.25 zrobil zmienna iteracji „swoja dla kazdej iteracji”, oryginalne elementy slice'a sie nie zmienia. Do modyfikacji elementow uzyj indeksu (tasks[i]) albo weź adres elementu (&tasks[i]), a nie adres zmiennej t.
Blad nr 5: pozostawianie zaleznosci goroutine od zewnetrznych zmiennych niejawna.
Nawet jesli „i tak dziala”, kod bez parametrow w goroutine jest trudniejszy do czytania: nie wiadomo, co dokladnie jest utrwalane, a co moze sie zmienic. W kodzie wspolbieznym lepiej byc nudnym i jawnym: parametr do anonimowej funkcji to mala cena za duzy spokoj.
GO TO FULL VERSION