JavaRush /Курси /Go SELF /Гонка даних у Go: чому «іноді працює» — погана ознака

Гонка даних у Go: чому «іноді працює» — погана ознака

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

1. Чому важливо говорити про data race

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

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

Чому «іноді працює» — це особливо небезпечно

У послідовній програмі «спочатку рядок 1, потім рядок 2» — це доволі чесна угода з реальністю. У конкурентній програмі планувальник Go може розбивати виконання goroutine на маленькі шматочки й перемикатися між ними будь-коли: через навантаження, таймери, системні виклики, кількість ядер або просто тому, що так склалося.

У результаті з’являється неприємний ефект: ви запускаєте програму 10 разів і отримуєте 10 різних варіантів поведінки. І ось тут мозок новачка часто робить неправильний висновок: «Ну, значить, це просто порядок виводу в консоль плаває». Іноді так. Але іноді плаває і сам сенс обчислень.

Погляньмо на просту блок-схему перемішування дій:

flowchart TD
    A[goroutine #1 читає counter] --> B[goroutine #1 збільшує локально]
    C[goroutine #2 читає counter] --> D[goroutine #2 збільшує локально]
    B --> E[goroutine #1 записує counter]
    D --> F[goroutine #2 записує counter]

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

2. Визначення data race простими словами

Data race, або гонка даних, виникає тоді, коли дві або більше goroutine одночасно звертаються до однієї й тієї самої ділянки пам’яті, і при цьому хоча б один доступ є записом, а синхронізації немає. Це коротке визначення на рівні перевірки реальності.

Щоб було простіше тримати це в голові, корисно уявити таку таблицю:

Ситуація Кілька goroutine чіпають одну змінну? Є запис? Є узгодження? Це data race?
Лише читання спільного значення так ні неважливо ні
Читання + запис без правил так так ні так
Запис + запис без правил так так ні так
Читання/запис, але строго за договором (синхронізація) так так так ні

Сьогодні ми не вводимо нові інструменти синхронізації — жодних Mutex, atomic чи каналів. Поки що вчимося розпізнавати проблему й писати код так, щоб не створювати спільну точку запису.

3. Класика гонок: від counter++ до map

Чому counter++ — не «одна операція»

Почнімо з прикладу, який виглядає настільки невинно, що хочеться поставити йому лайк і відправити в продакшн.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	counter := 0

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // гонка даних
		}()
	}

	wg.Wait()
	fmt.Println("counter =", counter) // може бути 1, 2 або 3
}

Чому це гонка? Тому що counter++ логічно складається з трьох кроків: прочитати counter, додати 1 і записати назад. А ці кроки можуть перемішатися між goroutine.

Уявіть, що counter був 0. Дві goroutine майже одночасно читають 0. Обидві збільшують значення до 1. Обидві записують 1. У результаті замість очікуваних 2 ви отримуєте 1. І так, іноді ви отримаєте 2, якщо «пощастило» і виконання не перетнулося.

WaitGroup не робить дані безпечними

Після минулої лекції легко спіймати себе на думці: «Окей, я ж поставив wg.Wait(), отже все під контролем». На жаль, WaitGroup — це шлагбаум на виїзді, а не дорожня розмітка.

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

Щоб відчути різницю, можна подумки переформулювати так: WaitGroup відповідає на питання «Коли можна продовжувати?», але не відповідає на питання «Що буде, якщо вони пишуть в одне місце одночасно?».

З практичного погляду це означає: «Я дочекався завершення» не дорівнює «Я отримав коректний результат».

Гонки у «справжньому» коді: структури, поля та спільний слайс

Поки ми ганяли int, усе виглядало як іграшка. Але в реальному застосунку ви майже завжди ділите не один int, а структуру або слайс — і гонка стає менш очевидною.

Уявімо, що в нас є шматочок нашого навчального застосунку (умовний менеджер задач), і ми хочемо паралельно обробити задачі та відмітити статистику.

package main

import (
	"fmt"
	"sync"
)

type Stats struct {
	Done int
}

func main() {
	var wg sync.WaitGroup
	stats := Stats{}

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			stats.Done++ // гонка даних: спільний об’єкт, спільний запис
		}()
	}

	wg.Wait()
	fmt.Println("done =", stats.Done) // може бути 1, 2 або 3
}

Тут гонка вже «сховалася» за полем структури. Але по суті це той самий counter++, тільки в більш ошатному вигляді.

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

Окрема зірка небезпеки: конкурентний запис у map

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

Погляньмо на приклад:

package main

import (
	"sync"
)

func main() {
	var wg sync.WaitGroup
	m := make(map[int]int)

	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			m[i] = i // небезпечно: конкурентний запис у map
		}(i)
	}

	wg.Wait()
}

Іноді ви побачите паніку на кшталт fatal error: concurrent map writes. Іноді — «пронесе», і ви взагалі нічого не помітите, що, як ми вже домовилися, не привід радіти. Погана новина в тому, що «воно не впало» не означає «воно коректне».

4. Як уникати гонок без нових примітивів

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

Ця ідея дуже співзвучна Go-підходу «не спілкуйтеся через спільну пам’ять» — часто його формулюють як «share memory by communicating». Тобто замість спільного запису краще будувати комунікацію та чіткі межі володіння.

Ми поки не використовуємо канали — це буде пізніше — тому зробімо «комунікацію через заздалегідь виділені комірки».

Мінікейс: порахувати внесок задач паралельно

Припустімо, у нас є задачі, і ми хочемо порахувати, скільки серед них позначено як done. Розпаралелювати це лише заради розпаралелювання безглуздо, але як навчальний приклад — ідеально.

Спочатку поганий варіант — із гонкою:

package main

import (
	"fmt"
	"sync"
)

type Task struct {
	Title string
	Done  bool
}

func main() {
	tasks := []Task{
		{"Прочитати книжку про Go", true},
		{"Написати код", true},
		{"Вийти на прогулянку", false},
	}

	var wg sync.WaitGroup
	doneCount := 0

	for _, t := range tasks {
		wg.Add(1)
		go func(t Task) {
			defer wg.Done()
			if t.Done {
				doneCount++ // гонка даних
			}
		}(t)
	}

	wg.Wait()
	fmt.Println("doneCount =", doneCount) // може бути 1 або 2
}

Тепер зробімо варіант без гонки: кожна goroutine пише у свою комірку marks[i], а підсумовування виконуємо вже після очікування:

package main

import (
	"fmt"
	"sync"
)

type Task struct {
	Title string
	Done  bool
}

func main() {
	tasks := []Task{
		{"Прочитати книжку про Go", true},
		{"Написати код", true},
		{"Вийти на прогулянку", false},
	}

	var wg sync.WaitGroup
	marks := make([]int, len(tasks))

	for i := 0; i < len(tasks); i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			if tasks[i].Done {
				marks[i] = 1
			}
		}(i)
	}

	wg.Wait()

	sum := 0
	for i := 0; i < len(marks); i++ {
		sum += marks[i]
	}
	fmt.Println("doneCount =", sum) // doneCount = 2
}

Тут ключова магія не в WaitGroup, а в тому, що немає спільної змінної, у яку всі пишуть. Кожна goroutine заповнює свою комірку, тобто не конкурує за одне й те саме місце в пам’яті. А читання marks починається лише після wg.Wait(), коли всі записи вже завершені.

5. Як думати про коректність: володіння та момент читання

«Гонка» буває навіть без ++

Новачки часто думають: «Гонка — це коли я роблю counter++». Це поширена асоціація, але гонка значно ширша. Достатньо, щоб один потік писав, а інший читав у той самий час, і ви вже в зоні невизначеності.

Наприклад, один потік оновлює поле структури, а інший друкує всю структуру. Або один потік змінює елемент слайса, а інший будує звітний рядок на основі цього слайса. Навіть якщо програма ніби не падає, ви можете отримати частково оновлені дані.

У побутовій аналогії це виглядає так: одна людина переписує список покупок, а друга в цей момент фотографує аркуш. Фото вийде, але питання в тому, що саме на ньому опиниться? «Хліб» уже дописали, «молоко» ще стирають, а «сир» раптом виглядає як «сир» — бо ручка була в процесі.

Два питання, які рятують від хаосу

Дуже корисна звичка — проговорювати вголос або хоча б у коментарі два питання.

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

Друге питання: коли інші goroutine можуть читати це значення? Якщо відповідь «ну будь-коли, воно ж просто число» — це теж тривожний дзвіночок. У конкурентному коді «будь-коли» потрібно замінювати на «після того, як ми дочекалися завершення» або «після того, як дані опубліковано за домовленістю».

На рівні сьогоднішніх інструментів — goroutine + WaitGroup — найпростіший договір звучить так: пишемо паралельно, читаємо лише після wg.Wait(). Це не універсальний закон Всесвіту, але це хороший стартовий шаблон, який уже рятує від багатьох проблем.

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

Помилка №1: «Я поставив WaitGroup, значить гонки немає».
WaitGroup — це очікування завершення, а не захист даних. Він гарантує, що робота завершилася, але не гарантує, що робота виконувалася без одночасного запису в одну й ту саму пам’ять. Якщо всередині goroutine ви робите x++ над спільною змінною — гонка лишається, просто тепер ви стабільно дочікуєтеся її наслідків.

Помилка №2: «Якщо програма не падає — значить усе добре».
Із гонками це особливо підступно: вони часто проявляються лише під навантаженням, на іншому залізі, в іншій версії рантайму або тоді, коли зірки зійшлися. Відсутність падіння не доводить коректність. У цьому сенсі «іноді працює» — саме погана ознака: ви не контролюєте поведінку.

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

Помилка №4: «З map усе як зі словником: вставив і забув».
Звичайний map у Go не призначений для конкурентного запису без узгодження. У найкращому разі ви отримаєте неправильні дані, у найгіршому — аварійне завершення процесу. І ще раз: wg.Wait() тут не рятує, бо гонка відбувається ще до очікування.

Помилка №5: «Та що там, я лише читаю спільний об’єкт».
Читання спільного об’єкта безпечне лише тоді, коли ви впевнені, що ніхто не пише в нього одночасно. Якщо хоча б одна goroutine пише, читання теж стає учасником гонки. Типовий сценарій: одна goroutine оновлює статистику, інша друкує звіт у реальному часі. Без протоколу це перетворюється на генератор примарних багів.

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