Горутины и sync.WaitGroup: конкурентный запуск и ожидание
Содержание курса
Гонка на общем счётчике и её устранение через sync.Mutex с коротким критическим участком
Теперь допустим, что горутины не просто печатают, а обновляют общую переменную:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // ← проблема здесь
}()
}
wg.Wait()
fmt.Println(counter)
}
Запустите несколько раз — вы получите разные числа: 978, 991, 1000, снова 987. Ожидается 1000, но результат непредсказуем.
Почему? counter++ — это неатомарная операция read-modify-write на уровне семантики программы: чтение текущего значения, вычисление нового и запись результата не образуют единого неделимого действия. Рассмотрим допустимое чередование двух горутин G1 и G2:
G1 читает counter → видит 500
G2 читает counter → видит 500 (G1 ещё не записала новое значение)
G1 записывает 501
G2 записывает 501 (обновление G1 потеряно)
Одно из обновлений теряется, и итоговое значение оказывается меньше ожидаемого. Это гонка данных (data race): программа с гонкой формально некорректна, даже если при конкретном запуске выдаёт правильный ответ.
Исправление — обернуть обновление в sync.Mutex:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter) // всегда 1000
}
mu.Lock() захватывает мьютекс; если другая горутина уже держит его, текущая блокируется и ждёт. mu.Unlock() освобождает. Код между ними — критический участок — в каждый момент времени исполняет ровно одна горутина, и описанное чередование становится невозможным.
Обратите внимание: Unlock вызывается сразу после counter++, а не через defer. Здесь это сделано намеренно. Если бы функция была длиннее и делала что-то после обновления счётчика — например, логирование или I/O — defer mu.Unlock() растянул бы критический участок на весь оставшийся код функции. Правило простое: держи мьютекс ровно столько, сколько нужно для защиты разделяемых данных, не дольше. Долгие операции — сетевые вызовы, файловый I/O — под мьютексом не выполняют.
Как и WaitGroup, Mutex нельзя копировать после первого использования. Либо храните его в структуре рядом с защищаемыми данными, либо передавайте по указателю.
О корректности и go test -race. Фундамент корректной конкурентной программы — правильно выстроенная синхронизация всех конфликтующих доступов. Обычные функциональные тесты (go test) проверяют контракт программы, но не доказывают отсутствие гонок: несинхронизированный код может многократно вернуть правильный ответ просто потому, что конкретные запуски не столкнулись в нужный момент.
go test -race дополняет тесты: он инструментирует все обращения к памяти и сообщает о реально произошедших гонках во время прогона. Важно понимать его ограничение — детектор видит только те пути выполнения, которые были реально пройдены в данном запуске. Кроме того, флаг требует CGO и C toolchain (gcc или clang), поэтому доступен не везде. Недоступность -race не превращает прошедший функциональный тест в доказательство отсутствия гонок — синхронизация должна быть корректной в коде, а не подтверждённой одним удачным запуском.
