Урок курса

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

Задача 1

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

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

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Разработчик написал следующий код для защиты счётчика:

var mu sync.Mutex
counter := 0
go func() {
    defer wg.Done()
    mu.Lock()
    counter++
    time.Sleep(time.Second) // долгая операция
    mu.Unlock()
}()

Что не так с этим критическим участком с точки зрения производительности?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Флаг go test -race не запускается на машине разработчика с ошибкой о недоступности CGO. Какой вывод из этого следует?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Параллельный подсчёт голосов

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

В программном воплощении этой идеи каждая пачка голосов отдаётся отдельной горутине, которая самостоятельно считает сумму своей части. Чтобы ни одна из них не была случайно забыта и программа не вернула результат раньше времени, координация ведётся через явное ожидание всех участников. Когда же несколько горутин одновременно хотят добавить свой частичный итог к общему счётчику, они делают это по очереди, исключая одновременный доступ и гарантируя целостность результата.

Задача состоит в том, чтобы реализовать функцию, которая принимает набор пачек голосов и возвращает точную суммарную цифру — ту самую, которую организаторы огласят после завершения параллельного подсчёта. Корректность результата должна сохраняться как для нескольких непустых пачек, так и в вырожденном случае, когда голоса не поступили вовсе.

Что нужно сделать

Реализуйте CountVotes. Перед каждой горутиной вызывайте wg.Add(1), внутри гарантируйте Done через defer. Сначала вычисляйте локальную сумму пачки, затем защищайте sync.Mutex только обновление общего итога. Возвращайте результат после wg.Wait().

Открыть задание в уроке →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

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