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 складніше читати: незрозуміло, що саме фіксується, а що може змінитися. У конкурентності краще бути нудним і явним: параметр в анонімну функцію — невелика ціна за великий спокій.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ