sync.WaitGroup и Mutex в Go: когда что использовать
WaitGroup и Mutex решают разные задачи, но их путают и комбинируют неправильно. Разбираем на примере параллельного сбора данных с нескольких API — где нужна синхронизация завершения, а где защита общего состояния.

Проблема: параллельные запросы, общий результат
Типичная задача — обойти список URL, собрать ответы в одну структуру и вернуть результат. Очевидное решение: запустить горутины, дождаться всех, отдать данные. Здесь появляются два инструмента из пакета sync: WaitGroup и Mutex. Оба «про конкурентность», но назначение у них разное, и подмена одного другим ведёт к гонкам данных или дедлокам.
Что делает WaitGroup
sync.WaitGroup — это счётчик горутин. Он отвечает на единственный вопрос: «все ли закончили?». Три метода — Add, Done, Wait — и больше ничего. WaitGroup не знает, какие данные горутины произвели и куда их положили. Он не защищает память.
Частая ошибка — вызывать Add внутри горутины. Если планировщик не успеет запустить горутину до того, как основной поток дойдёт до Wait, счётчик останется нулевым и Wait вернёт управление раньше времени. Add всегда вызывается до запуска горутины.
Что делает Mutex
sync.Mutex — это замок на общие данные. Пока одна горутина держит Lock, остальные блокируются на этом же вызове. Unlock освобождает замок. Mutex не считает горутины и не умеет ждать их завершения.
Есть вариант sync.RWMutex: он разрешает параллельное чтение (RLock/RUnlock), но блокирует запись. Это важно, когда читателей много, а писатель один — например, кеш в памяти с редким обновлением.
Сноска. Data race (гонка данных) — ситуация, когда две горутины одновременно обращаются к одной переменной и хотя бы одна из них пишет. В Go гонку можно поймать флагом -race при сборке или тестировании. Программа с гонкой данных имеет неопределённое поведение — даже если «вроде работает».
Пример: параллельный сбор HTTP-ответов
Задача: запросить три эндпоинта, собрать тела ответов в map[string]string, вернуть карту. Версия Go 1.21+.
Наивный вариант — гонка данных
// НЕПРАВИЛЬНО: гонка данных на записи в results
package main
import (
"fmt"
"io"
"net/http"
"sync"
)
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
}
results := make(map[string]string)
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Запись в map без защиты — гонка
results[url] = string(body)
}(u)
}
wg.Wait()
fmt.Println(results)
}
Запустите с go run -race main.go — детектор гонок выдаст предупреждение. Карта в Go не потокобезопасна: одновременная запись из нескольких горутин — неопределённое поведение.
Исправленный вариант — WaitGroup + Mutex
package main
import (
"fmt"
"io"
"net/http"
"sync"
)
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
}
results := make(map[string]string)
var wg sync.WaitGroup
var mu sync.Mutex // защищает results
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
mu.Lock()
results[url] = string(body)
mu.Unlock()
}(u)
}
wg.Wait()
fmt.Println(len(results), "ответов собрано")
}
Теперь WaitGroup отвечает за ожидание завершения всех горутин, а Mutex — за безопасную запись в общую карту. Каждый делает своё.
Альтернатива — канал вместо Mutex
package main
import (
"fmt"
"io"
"net/http"
)
type result struct {
url string
body string
}
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
}
ch := make(chan result, len(urls))
for _, u := range urls {
go func(url string) {
resp, err := http.Get(url)
if err != nil {
ch <- result{url: url}
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
ch <- result{url: url, body: string(body)}
}(u)
}
results := make(map[string]string, len(urls))
for range urls {
r := <-ch
results[r.url] = r.body
}
fmt.Println(len(results), "ответов собрано")
}
Здесь не нужны ни WaitGroup, ни Mutex. Канал и считает горутины (знаем, сколько результатов ждём), и передаёт данные без общей переменной. Запись в results происходит в единственной горутине — main.
Когда что выбирать
| Критерий | WaitGroup + Mutex | Канал |
|---|---|---|
| Количество горутин известно заранее | Да — Add до запуска | Да — размер буфера или счётчик в цикле |
| Количество горутин динамическое | Удобнее: Add(1) перед каждым запуском | Сложнее: нужен отдельный сигнал завершения или WaitGroup поверх канала |
| Результат нужен в общую структуру | Mutex защищает структуру | Канал передаёт данные, сборка в одной горутине |
| Результат не нужен, только побочный эффект | Только WaitGroup, Mutex не нужен | Избыточно — создавать канал только ради подсчёта |
| Нужна потоковая обработка по мере поступления | Не подходит: Wait блокирует до полного завершения | Подходит: читаем из канала по мере записи |
| Читаемость для новичков | Прозрачнее — явные Lock/Unlock | Требует понимания паттерна fan-in |
Мьютекс защищает состояние. WaitGroup координирует время жизни. Канал делает и то, и другое, но ценой дополнительного уровня абстракции. Если задача сводится к «запустил, подождал, прочитал» — WaitGroup + Mutex проще и явнее. Если данные обрабатываются потоком — каналы.
Типичные ошибки
Addвнутри горутины. Гонка между запуском горутины и вызовомWait. Детектор-raceможет не поймать — баг проявляется при нагрузке.- Забытый
Unlock. Дедлок. Используйтеdefer mu.Unlock()сразу послеLock, если критическая секция заканчивается вместе с функцией. Но если в функции несколько секций —deferосвободит замок только при выходе, что увеличит время блокировки. - Копирование
WaitGroupилиMutex. Оба содержат внутреннее состояние. Передача по значению создаёт независимую копию — счётчик или замок перестают работать. Передавайте указатель. - Mutex вместо RWMutex при частых чтениях. Если 95% обращений — чтение, обычный Mutex сериализует их без необходимости.
RWMutex.RLockпозволяет параллельное чтение. - Канал размера 0 при записи без читателя. Горутина заблокируется навсегда. Буферизированный канал (
make(chan T, n)) решает проблему, еслиn>= числу писателей.
Проверка: как убедиться, что гонок нет
Go поставляется с детектором гонок. Он работает в рантайме и замедляет программу в 2–10 раз, поэтому используется при тестировании, а не в проде.
- Запустите тесты:
go test -race ./... - Или программу напрямую:
go run -race main.go - Детектор выведет стек вызовов обеих горутин, между которыми обнаружена гонка. Ищите строку
Write atиPrevious write at(илиRead). - Если детектор молчит — это не доказательство отсутствия гонок, а лишь то, что в данном запуске они не проявились. Запускайте тесты с
-count=100для повышения вероятности обнаружения.
Что дальше
После освоения WaitGroup и Mutex стоит посмотреть на errgroup из пакета golang.org/x/sync/errgroup. Он сочетает ожидание горутин с обработкой ошибок и контекстом отмены — именно то, чего не хватает голому WaitGroup в продакшен-коде, где любая горутина может упасть. Тема обработки ошибок в конкурентном коде и паттерн graceful degradation — отдельный разговор, но errgroup — хорошая отправная точка.
Инфобез и 152-ФЗ: Аудит политик конфиденциальности, правила сбора cookie.
Спросить за 20 ₽Источники: Пакет sync — официальная документация Go, Data Race Detector — Go Documentation
Частые вопросы
Можно ли использовать WaitGroup без Mutex?
Чем sync.Mutex отличается от sync.RWMutex?
Почему go run -race не всегда находит гонку?
Когда лучше использовать каналы вместо Mutex?
Что будет, если скопировать Mutex или WaitGroup?
А если у меня другая версия Go — код заработает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.