JavaRush /Курси /Go SELF /Замикання та змінні циклу в Go: пастки range після Go 1.2...

Замикання та змінні циклу в Go: пастки range після Go 1.22+

Go SELF
Рівень 65 , Лекція 4
Відкрита

1. Що саме захоплює замикання в Go: змінну чи значення

Якщо ви лише починаєте писати конкурентний код, дуже легко потрапити в ситуацію: «Я все зробив(ла) правильно… але програма інколи друкує не те». І тут починається магія, яка насправді не магія, а поєднання трьох речей: замикання захоплюють змінні, цикл перевикористовує змінні, а goroutine виконуються в непередбачуваному порядку. У Go 1.22+ частину цієї проблеми прибрали, але не всю.

Уявіть, що цикл — це конвеєр, а goroutine — курʼєри, які забирають записку. Історично проблема була в тому, що ви не копіювали текст записки, а віддавали курʼєру посилання на один і той самий аркуш паперу. Цикл встигав переписати аркуш, тож усі курʼєри приносили «останню версію». У Go 1.22+ для типових циклів конвеєр почав видавати новий аркуш на кожній ітерації (ура), але якщо ви навмисно використовуєте один аркуш, то повертаєте стару проблему назад.

Замикання часто пояснюють як «функцію з памʼяттю». Це майже правда, але є важлива деталь: замикання «памʼятає» не значення, а змінну — тобто місце, де це значення лежить. Тому якщо хтось пізніше змінить цю змінну, замикання побачить нове значення. Поки все однопотоково — ви просто здивуєтеся. Якщо поруч goroutine — здивуєтеся голосніше.

Почнімо з простого прикладу без goroutine — щоб побачити механіку в спокійній обстановці:

package main

import "fmt"

func main() {
	x := 10

	f := func() {
		fmt.Println("x =", x)
	}

	x = 20
	f() // x = 20
}

Тут замикання f використовує змінну x із зовнішньої області видимості. Ми змінили x, і f() чесно побачила нове значення.

Тепер зафіксуймо це маленькою таблицею — це важливіше, ніж здається:

Формулювання Людською мовою На практиці в коді
«Замикання захоплює значення» ніби копія числа чи рядка ні, не копія
«Замикання захоплює змінну» посилання на місце, де лежить значення так, бачить зміни

Ось чому цикл і замикання — вибухонебезпечне поєднання: цикл зазвичай працює з однією змінною, яку змінює від ітерації до ітерації.

3. Класична пастка range і goroutine до Go 1.22

Навіть якщо ви пишете на Go 1.25, корисно розуміти, яку саме проблему виправили. Інакше легко дійти хибного висновку: «У Go тепер завжди безпечно запускати goroutine у циклі». Це не так.

Класичний приклад (раніше міг друкувати одне й те саме значення кілька разів):

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()
}

До Go 1.22 змінна v була одна на весь цикл range, а значення змінювалося на кожній ітерації. Замикання всередині goroutine захоплювало змінну v, тож могло побачити не те значення, яке ви інтуїтивно очікували.

Починаючи з Go 1.22, давню пастку змінної циклу усунули: змінні ітерації більше не «шаряться» між ітераціями в тому сенсі, який ламав замикання. У примітках до релізу Go 1.22 прямо сказано, що цю пастку виправлено, і наведено приклад, який друкує "a", "b", "c" у будь-якому порядку.

Важливо: навіть коли все виправлено, порядок залишається недетермінованим. Тобто «у будь-якому порядку» — це нормальний результат.

4. Що змінилося в Go 1.22+ і чому «типовий випадок» тепер працює

У Go 1.22+ — а отже і в Go 1.25 — змінні, оголошені в заголовку циклу, поводяться «дружніше» до замикань: кожна ітерація отримує свою «логічну» змінну ітерації. Завдяки цьому типові шаблони range + go func(){...}() перестали бути міною уповільненої дії.

Але тут дуже легко зробити неправильний логічний стрибок: «значить, можна взагалі перестати думати». Не можна. Виправлення стосується типового випадку, коли змінна ітерації оголошена всередині заголовка циклу (for _, v := range ..., for i := 0; ...; ...). Щойно ви починаєте вручну крутити змінну — оголошуєте її зовні й присвоюєте всередині — ви повертаєте стару модель.

Можна намалювати це як схему:

flowchart TD
    A[Цикл] --> B{Змінну ітерації оголошено в заголовку?}
    B -- так --> C[Go 1.22+ робить ітерації безпечнішими для замикань у типовому випадку]
    B -- ні --> D[Залишається одна змінна на всі ітерації]
    D --> E[Замикання та goroutine бачать те саме місце в памʼяті]

Сенс схеми такий: виправлення не «магічне», воно стосується конкретного синтаксису й конкретного місця оголошення змінної.

5. Чому параметр у goroutine залишається найкращим стилем

Коли мова виправляє стару пастку, виникає спокуса писати якнайкоротше. Але в реальному коді «коротше» часто означає «менш явно». А в конкурентності неявність — це як ходити вночі по кімнаті, де на підлозі розкидані LEGO: можна, але навіщо.

Тому навіть у Go 1.25 хороший стиль — передавати вхід goroutine явно: як параметр. Це не лише обхід пасток, а й підвищення читабельності: у goroutine видно, звідки взялася ця змінна.

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()
}

Це той самий сенс, але тепер залежність goroutine від v виглядає як нормальна «вхідна змінна функції».

Схожий підхід трапляється і в класичних прикладах конкурентності: коли з циклу запускають goroutine, значення краще передати параметром, щоб замикання не залежало від змінної зовнішньої області, яка змінюється.

6. Пастки повторного використання змінних у циклі

Змінну оголошено поза циклом і перевикористано присвоюванням

Тепер до головного: типовий випадок виправили, але кейси з повторним використанням змінної лишилися. Найчастіший — коли змінну оголосили зовні, а в циклі лише присвоюють нове значення.

І от тут ви ніби «скасовуєте» поліпшення Go 1.22+, тому що знову створюєте одну змінну на весь цикл.

package main

import (
	"fmt"
	"sync"
)

func main() {
	values := []string{"a", "b", "c"}

	var wg sync.WaitGroup
	wg.Add(len(values))

	var v string // одна змінна на весь цикл

	for _, s := range values {
		v = s
		go func() {
			defer wg.Done()
			fmt.Println(v) // погано: читаємо одну змінну, яку цикл змінює
		}()
	}

	wg.Wait()
}

Тут одразу дві проблеми.

На рівні логіки: усі goroutine читають одну й ту саму змінну v, яку цикл змінює, тож результат буде «як пощастить» — часто одне й те саме значення.

На рівні коректності конкурентного коду: це ще й потенційна гонка даних, тому що цикл пише в v, а goroutine читають v одночасно, без узгодження. І це вже не просто «не той вивід», а непередбачувана програма.

Лікується це все тим самим — робимо вхід goroutine явним, навіть якщо змінна зовнішня:

for _, s := range values {
	v = s
	go func(v string) {
		defer wg.Done()
		fmt.Println(v)
	}(v)
}

Виглядає трохи кумедно, бо спочатку ми пишемо в зовнішню v, а потім передаємо v параметром. У реальному коді краще взагалі не мати зовнішньої v, якщо вона не потрібна. Але приклад показує головне: goroutine має отримувати своє значення, а не посилання на місце, яке хтось продовжує змінювати.

Індекс циклу оголошено поза for

Go 1.22+ виправив типовий випадок, коли змінну оголошують у заголовку for. Але якщо індекс ви оголосили заздалегідь, це знову одна змінна.

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()
}

Якщо ви очікуєте "i = 0", "i = 1", "i = 2" — у будь-якому порядку, — то це очікування тут не зобов’язане справдитися, бо замикання читає змінну i, а цикл продовжує її змінювати. І знову: це і логічна помилка, і потенційна гонка даних.

Виправлення — те саме: параметр.

for ; i < 3; i++ {
	go func(i int) {
		defer wg.Done()
		fmt.Println("i =", i)
	}(i)
}

Тут ви буквально кажете: «Ось значення i на момент запуску, використай його».

7. Нюанс range: копія елемента й адреса змінної

Є окремий клас багів, який часто помилково називають «проблемою замикань», хоча насправді це про семантику range.

Коли ви робите for _, t := range tasks, змінна t — це копія елемента слайса, якщо елемент не є вказівником. Навіть якщо в Go 1.25 t тепер нова на кожну ітерацію, це все одно копія. Тому:

  • друкувати t — нормально;
  • брати &t — ви отримаєте адресу копії, а не елемента в слайсі;
  • змінювати t.Done = true — ви зміните копію, а не оригінал.

Покажімо це на прикладі без конкурентності — так буде видно, де саме підступ:

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 // змінюємо копію
	}

	fmt.Println(tasks[0].Done, tasks[1].Done) // false false
}

Якщо ви хочете змінювати реальні елементи слайса, зазвичай потрібен індекс:

for i := range tasks {
	tasks[i].Done = true
}

А якщо дуже хочеться працювати через вказівник, то так:

for i := range tasks {
	t := &tasks[i]
	t.Done = true
}

Цей нюанс корисно тримати в голові, бо в конкурентному коді він може виглядати як «goroutine пише не туди», хоча насправді ви просто писали в копію.

8. Мініприклад: конкурентна перевірка задач

Щоб повʼязати тему з практикою, уявімо шматочок нашого навчального застосунку зі списком завдань. Ми хочемо швидко перевірити кожне завдання на просту проблему: порожній заголовок. Перевірка штучно проста; нам важливий сам шаблон запуску goroutine у циклі й акуратна фіксація значення.

Зробімо функцію, яка повертає рядок-помилку для конкретного завдання:

package main

import "fmt"

type Task struct {
	ID    int
	Title string
}

func validateTask(t Task) string {
	if t.Title == "" {
		return fmt.Sprintf("завдання %d: порожній заголовок", t.ID)
	}
	return ""
}

Тепер хочемо запустити перевірку конкурентно й зібрати результати без гонок: кожна goroutine пише у свій індекс, а читаємо ми після wg.Wait() (рівно той «безпечний мінімум», який ми обговорювали в лекції про гонку даних).

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
}

Зверніть увагу на стиль: ми передали і i, і t параметрами. У Go 1.25 це часто працюватиме і без параметрів, але з параметрами код стає «самодокументованим»: goroutine використовує саме ті значення, які були на цій ітерації.

І так, це ще й корисна звичка на майбутнє: коли зʼявляться канали, select і складніші схеми, явний вхід goroutine майже завжди робить код простішим.

9. Типові помилки

Помилка №1: думати, що Go 1.25 «полагодив замикання назавжди».
Go 1.22+ справді виправив відому пастку змінної ітерації в типовому випадку, і це прямо зазначено в примітках до релізу. Але якщо ви оголошуєте змінну поза циклом і перевикористовуєте її через присвоювання, ви знову створюєте одну спільну змінну та повертаєте стару проблему.

Помилка №2: «я ж використовую WaitGroup, отже все безпечно».
WaitGroup гарантує лише те, що goroutine завершаться. Він не захищає змінні від одночасного читання та запису. Тому код, де цикл пише в зовнішню змінну, а goroutine її читають, залишається потенційною гонкою даних — навіть якщо ви все дочекалися через wg.Wait().

Помилка №3: виправляти поведінку через time.Sleep.
Інколи хочеться просто трохи зачекати, щоб goroutine встигли. Але time.Sleep не фіксує значення змінних і не робить порядок виконання контрактом. Він лише маскує проблему — і робить її ще випадковішою. Для очікування завершення — WaitGroup, для фіксації входу — параметри або локальні змінні.

Помилка №4: плутати проблему замикання з тим, що range повертає копію елемента.
Коли ви змінюєте змінну t у for _, t := range tasks, ви змінюєте копію. Навіть якщо все однопотоково і навіть якщо Go 1.25 зробив змінну ітерації «своєю» для кожної ітерації, оригінальні елементи слайса не зміняться. Для зміни елементів використовуйте індекс (tasks[i]) або беріть адресу елемента (&tasks[i]), а не адресу змінної t.

Помилка №5: залишати залежність goroutine від зовнішніх змінних неявною.
Навіть якщо «і так працює», код без параметрів у goroutine складніше читати: незрозуміло, що саме фіксується, а що може змінитися. У конкурентності краще бути нудним і явним: параметр в анонімну функцію — невелика ціна за великий спокій.

1
Опитування
Конкурентність 1: goroutines, рівень 65, лекція 4
Недоступний
Конкурентність 1: goroutines
Конкурентність 1: goroutines
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ