JavaRush /Курси /Go SELF /sync.WaitGroup: Add, Done, Wait і поширені помилки

sync.WaitGroup: Add, Done, Wait і поширені помилки

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

1. Чому goroutine не «утримують» програму

Коли ви вперше запускаєте goroutine, здається, ніби ви найняли маленького помічника: «Зроби роботу, а я тим часом піду далі». Але в Go своє почуття гумору: помічник працює, а ви можете раптово вимкнути світло в усьому офісі, бо main уже завершився. Тож нам потрібен спосіб дочекатися завершення фонової роботи — не «на око», не через «давай поспимо 100 мс», а за чітким контрактом.

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

package main

import "fmt"

func main() {
	go fmt.Println("привіт із goroutine")
	fmt.Println("main завершився")
}

На практиці ви часто побачите лише:

main завершився

Тому що main завершився, процес зупинився, а goroutine навіть не встигла отримати час CPU.

Іноді новачок додає Sleep і радіє, що все «полагодилося»:

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("привіт із goroutine")

	time.Sleep(10 * time.Millisecond) // заглушка для демонстрації
	fmt.Println("main завершився")
}

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

2. Що таке sync.WaitGroup

Коли в програмі зʼявляється кілька паралельних (конкурентних) задач, легко заплутатися: скільки з них ще працює, хто вже завершився і коли можна друкувати результат? sync.WaitGroup — це найпростіша відповідь Go на цю побутову проблему: він зберігає всередині лічильник «скільки завдань ще не завершилося» і вміє чекати, доки цей лічильник не стане нулем.

Ментальна модель проста: WaitGroup — це табло «Залишилося завдань: N». Ви збільшуєте N, коли плануєте роботу, і зменшуєте N, коли конкретне завдання завершилося. Потім просто кажете собі: «почекатиму, доки N не стане 0».

У Go WaitGroup міститься в пакеті sync, тому імпорт завжди такий:

import "sync"

У WaitGroup є три ключові операції. Зручно тримати їх у голові ось так:

Метод Що робить Як читати «українською»
Add(n)
збільшує/зменшує лічильник на n «додай/відніми завдання»
Done()
зменшує лічильник на 1 «це завдання завершилося»
Wait()
блокується, доки лічильник не стане 0 «дочекатися всіх»

Важливий нюанс: нульове значення sync.WaitGroup уже готове до роботи. Тобто можна писати var wg sync.WaitGroup — і це нормально. Не потрібно make, не потрібно new, не потрібно «ініціалізувати».

3. Контракт Add, Done, Wait

WaitGroup виглядає простим, і це правда. Але все працює доти, доки ви не почнете ставити Add, Done і Wait у «цікаві» місця. Тут важливо не просто знати методи, а тримати в голові їхній контракт. Інакше ви отримаєте зависання — тобто вічне очікування, — або, навпаки, програма завершиться зарано, або навіть спіймаєте паніку від sync із повідомленням про misuse.

Основне правило звучить трохи занудно, зате економить години життя: ми збільшуємо лічильник через Add до запуску goroutine, а зменшуємо через Done усередині goroutine і гарантуємо це зменшення через defer.

Ось схема «ідеального» життєвого циклу одного завдання:

flowchart TD
    A[main] --> B["wg.Add(1)"]
    B --> C["go worker()"]
    C --> D["worker: defer wg.Done()"]
    D --> E[робітник: виконує роботу]
    E --> F[робітник завершився]
    F --> G[лічильник wg зменшився]
    G --> H["main: wg.Wait()"]
    H --> I[main продовжує роботу]

Чому Add має бути до go? Тому що go запускає роботу конкурентно: нова goroutine може встигнути завершитися раніше, ніж ви «додали її до списку тих, на кого чекаємо», і тоді логіка очікування ламається. У сучасних версіях Go це часто завершується явною панікою «WaitGroup misuse», щоб ви не покладалися на випадковість.

Чому Done зручно ставити через defer на початку goroutine? Тому що у вас можуть бути ранні return, помилки, розгалуження — і ви не хочете, щоб одна гілка забула «відмітитися на табло», а Wait() потім чекав вічність.

4. Патерни використання WaitGroup

Мінімальний приклад: одна goroutine

Тепер зберемо «канонічний» мінімальний приклад: одна goroutine виконує роботу, а main чекає.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("роботу виконано") // роботу виконано
	}()

	wg.Wait()
	fmt.Println("main завершується") // main завершується
}

Тут добре видно ритуал у хорошому сенсі: спочатку ми кажемо «буде одна задача» (Add(1)), потім запускаємо goroutine, усередині одразу ставимо defer wg.Done(), а потім у main викликаємо wg.Wait().

Якщо переплутати порядок і зробити wg.Add(1) після go ..., ви як мінімум можете отримати дуже нестабільну поведінку, а як максимум — панічну зупинку програми. І це правильно: краще впасти одразу, ніж інколи друкувати «роботу виконано», інколи ні, а інколи зависати в несподіваний момент.

Кілька goroutine у циклі

У реальних програмах частіше потрібно запустити пакет однотипних завдань: обробити кілька елементів, підготувати кілька рядків звіту, порахувати кілька частин результату. Тут WaitGroup розкривається по-справжньому: він уміє чекати цілу групу.

Уявімо, що у нас є навчальний застосунок «таск-трекер» — менеджер задач. Дані ми поки візьмемо просто з коду, без файлів і без бази: сьогодні нам важлива саме механіка очікування.

Опишемо мінімальну модель задачі:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Тепер хочемо сформувати рядки звіту такого вигляду:

  • [ ] 1: Купити молоко
  • [x] 2: Виправити баг

Нехай форматування нібито дороге. Для демонстрації додамо невелику затримку на основі ID. Важливо: ця затримка потрібна лише для того, щоб ви побачили конкурентність на власні очі, а не для «синхронізації» програми.

package main

import (
	"fmt"
	"time"
)

func formatTaskLine(t Task) string {
	time.Sleep(time.Duration(t.ID) * 10 * time.Millisecond)
	mark := " "
	if t.Done {
		mark = "x"
	}
	return fmt.Sprintf("[%s] %d: %s", mark, t.ID, t.Title)
}

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

package main

import (
	"fmt"
	"sync"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: "Купити молоко", Done: false},
		{ID: 2, Title: "Виправити баг", Done: true},
		{ID: 3, Title: "Прочитати документацію Go", Done: false},
	}

	lines := 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()
			lines[i] = formatTaskLine(t)
		}(i, t)
	}

	wg.Wait()

	for _, line := range lines {
		fmt.Println(line)
	}
}

Тут ви бачите одразу кілька сильних ідей.

  • Ми викликаємо wg.Add(len(tasks)) один раз. Так менше шансів помилитися й забути Add(1) у циклі.
  • Ми передаємо в анонімну функцію і індекс, і саму задачу параметрами. Так, навіть якщо ви чули, що у нових версіях Go проблему зі змінними циклу вже виправили, цей стиль усе одно дуже читабельний: явно видно, що goroutine працює з конкретними значеннями.
  • Ми не друкуємо з goroutine. Ми друкуємо після wg.Wait(), тому вивід буде стабільним за порядком задач, навіть якщо самі goroutine завершувалися перемішано.

Передавання WaitGroup у функції

Коли програма зростає, ви швидко захочете винести логіку goroutine в окрему функцію: так читабельніше, так простіше тестувати, так менше «каші» в main. І тут зʼявляється дуже поширена помилка: передати WaitGroup за значенням, тобто скопіювати його, а потім дивуватися, що Wait() зависає. WaitGroup — це структура з внутрішнім станом, і її не можна бездумно копіювати після початку роботи.

Правильний стиль: передавати вказівник.

package main

import (
	"fmt"
	"sync"
)

func renderTask(wg *sync.WaitGroup, t Task) {
	defer wg.Done()
	fmt.Println("обробка:", t.ID) // наприклад: обробка: 2
}

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go renderTask(&wg, Task{ID: 1, Title: "Купити молоко"})

	wg.Wait()
}

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

Чого WaitGroup не робить

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

Він не гарантує порядок виконання goroutine. Якщо ви зробите:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		fmt.Println("A")
	}()

	go func() {
		defer wg.Done()
		fmt.Println("B")
	}()

	wg.Wait()
}

Друк може бути A потім B, а може B потім A. І це не «помилка», а нормальна недетермінованість під час конкурентного виконання.

Він також не робить доступ до спільної памʼяті безпечним. Якщо кілька goroutine змінюють одну змінну без дисципліни доступу, WaitGroup не врятує. Він чесно дочекається, поки всі завершаться… а ви отримаєте непередбачуваний результат, який інколи «ніби працює». Сьогодні наше завдання — навчитися правильно чекати; безпека спільного стану — окрема тема й окремі інструменти.

5. Типові помилки під час роботи з sync.WaitGroup

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

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

var wg sync.WaitGroup

go func() {
	wg.Add(1)        // погано: Add усередині goroutine
	defer wg.Done()
}()
wg.Wait()

Помилка № 2: Add стоїть після go.
Це майже та сама проблема, лише навпаки за формою: ви запускаєте goroutine і лише потім «вписуєте її до списку тих, на кого чекаємо». Goroutine може встигнути виконатися до Add, і очікування перестає бути гарантованим.

Помилка № 3: забули Done() або він не гарантований (немає defer).
Це класика вічного очікування: лічильник не повертається до нуля, і Wait() блокується назавжди. Часто таке трапляється через ранній return усередині goroutine або через кілька гілок.

Корисна звичка: першим рядком усередині goroutine писати defer wg.Done(), а далі вже будь-яку логіку.

Помилка № 4: невідповідність кількості Add і Done (лічильник іде в мінус).
Якщо викликати Done() більше разів, ніж було «додано», WaitGroup отримає відʼємний лічильник і впаде з панікою. Зазвичай це стається, коли ви робите wg.Add(1) в одному місці, а wg.Done() помилково викликаєте двічі: один раз напряму, один раз через defer, або запускаєте одну й ту саму функцію як worker, але забули, що Done уже всередині.

Помилка № 5: передали WaitGroup за значенням і випадково скопіювали.
Це та сама пастка «все компілюється, але Wait() висить». Якщо ви передали wg як параметр типу sync.WaitGroup, ви зробили копію.

func worker(wg sync.WaitGroup) { // погано: копія
	defer wg.Done()
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go worker(wg)
	wg.Wait() // може зависнути
}

Правильний варіант — приймати *sync.WaitGroup і передавати &wg.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ