CodeGym /Kursy /Go SELF /Domkniecia i zmienne petli w Go: pulapki range po Go 1.22...

Domkniecia i zmienne petli w Go: pulapki range po Go 1.22+

Go SELF
Poziom 65, Lekcja 4
Dostępny

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.

1
Zadanie
Go SELF, poziom 65, lekcja 4
Niedostępne
Kwadraty na swoich miejscach
Kwadraty na swoich miejscach
1
Zadanie
Go SELF, poziom 65, lekcja 4
Niedostępne
Dziennik zadań
Dziennik zadań
1
Zadanie
Go SELF, poziom 65, lekcja 4
Niedostępne
Wskaźniki zadań
Wskaźniki zadań
1
Zadanie
Go SELF, poziom 65, lekcja 4
Niedostępne
Walidacja równoległa
Walidacja równoległa
1
Ankieta/quiz
Konkurencyjność 1: goroutines, poziom 65, lekcja 4
Niedostępny
Konkurencyjność 1: goroutines
Konkurencyjność 1: goroutines
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION