Урок курса

Горутины и sync.WaitGroup: конкурентный запуск и ожидание

Golang с нуля

В прошлом уроке мы читали и писали JSON, работая с одним файлом в одном потоке выполнения. Теперь посмотрим, что происходит, когда несколько функций начинают работать одновременно — и что нужно сделать, чтобы программа не сломалась.

Горутина: конкурентный запуск и почему main не ждёт

Запустить функцию как горутину — это одна строка:

go greet("Alice")

Планировщик Go начнёт выполнять greet конкурентно с тем кодом, который идёт после этой строки. Вызывающий код не блокируется и продолжает работать немедленно.

Чтобы почувствовать разницу, посмотрим на простой пример:

package main

import "fmt"

func greet(name string) {
    fmt.Println("Hello,", name)
}

func main() {
    go greet("Alice")
    go greet("Bob")
    fmt.Println("main done")
}

Запустите это — скорее всего, вы увидите только main done, а оба Hello не появятся вовсе. Иногда одно из них мелькнет, иногда нет. Причина: как только main дошла до конца, рантайм Go завершает процесс, и все незавершённые горутины прерываются — независимо от того, что они делали.

Это ключевое свойство: горутины не привязаны к вызывающей функции по времени жизни. go greet("Alice") — это не «вызов с ожиданием результата», это «запрос к планировщику: начни это при первой возможности».

Теперь о терминах, которые часто путают. Конкурентность означает, что горутины могут перекрываться по времени — пока одна ждёт, другая продвигается вперёд.

Параллельность — это буквальное одновременное выполнение на разных ядрах CPU. Go может быть параллельным, если машина многоядерная и рантайм решит задействовать несколько потоков ОС, но гарантии параллельности нет. Ваш код должен быть корректен в обоих случаях.

sync.WaitGroup: Add до запуска, defer Done внутри, Wait в вызывающем коде

Чтобы main дождалась всех горутин, нужен механизм ожидания. sync.WaitGroup — самый прямолинейный: это счётчик, который вызывающий код инкрементирует перед запуском горутины и который горутина декрементирует при выходе.

package main

import (
    "fmt"
    "sync"
)

func greet(name string, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Println("Hello,", name)
}

func main() {
    var wg sync.WaitGroup

    wg.Add(1)
    go greet("Alice", &wg)

    wg.Add(1)
    go greet("Bob", &wg)

    wg.Wait()
    fmt.Println("all done")
}

Теперь all done появится только после того, как обе горутины отработали. Разберём три правила, которые здесь принципиальны.

wg.Add(1) — до go, не после. Если поставить Add после запуска горутины, планировщик вправе выполнить её целиком ещё до того, как Add зафиксирует единицу в счётчике. Тогда Done уменьшит счётчик ниже нуля — это паника sync: negative WaitGroup counter. Или, что хуже, Wait вернётся до завершения горутины, потому что в момент проверки счётчик уже равен нулю.

defer wg.Done() — первой строкой внутри горутины. defer гарантирует вызов Done при любом выходе из функции — в том числе при панике. Именно здесь пригождается механизм defer, с которым мы работали раньше: он не про «красивый код», а про надёжность освобождения ресурса.

wg.Wait() — после запуска всех горутин. Он блокирует текущую горутину до тех пор, пока счётчик не упадёт до нуля.

Один момент с передачей: WaitGroup нельзя копировать после первого использования — поэтому передаём по указателю (&wg) или используем замыкание. Значение, не указатель, приведёт к тому, что каждая горутина работает со своей копией счётчика и Wait не увидит их завершения.

Гонка на общем счётчике и её устранение через 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 не превращает прошедший функциональный тест в доказательство отсутствия гонок — синхронизация должна быть корректной в коде, а не подтверждённой одним удачным запуском.

Попробуйте решить

Код запускает горутину и сразу завершает main. Горутина должна напечатать «done», но программа завершается раньше.

func main() {
    go func() { fmt.Println("done") }()
}

Какой механизм нужно добавить, чтобы гарантированно дождаться горутины?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку