Паттерны конкурентности и синхронизация (Select, Worker Pool)

Мы уже умеем запускать горутины и передавать данные через каналы. В реальных бэкенд-системах этого мало: процессами нужно управлять. Вы должны уметь вовремя останавливать задачи, ограничивать потребление ресурсов и собирать результаты из разных источников.

В Node.js для этого используют Promise.all или Promise.race. В Go инструменты гибче и производительнее.

Ожидание завершения: sync.WaitGroup

Функция main не ждет завершения горутин — она завершается сразу, как только дойдет до конца кода. Чтобы «притормозить» выполнение, пока фоновые задачи не отработают, используют счетчик sync.WaitGroup.

У него три метода:

  1. Add(int) — увеличивает счетчик на количество запускаемых задач.
  2. Done() — уменьшает счетчик на 1 (сигнал завершения задачи).
  3. Wait() — блокирует текущий поток, пока счетчик не станет равен нулю.
func fetchData(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Гарантируем вызов при выходе из функции
    
    fmt.Printf("Запрос %d начат\n", id)
    time.Sleep(time.Second) 
    fmt.Printf("Запрос %d завершен\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1) // Увеличиваем счетчик ДО запуска горутины
        go fetchData(i, &wg)
    }

    wg.Wait() // Ждем обнуления счетчика
    fmt.Println("Все данные получены")
}

Важно: Всегда вызывайте Add в родительской горутине. Если вызвать его внутри новой горутины, есть риск, что Wait сработает раньше, чем горутина успеет запуститься.

Мультиплексирование через select

Если WaitGroup — это ожидание всех, то select — это выбор. Оператор позволяет горутине ждать событий сразу из нескольких каналов. Это похоже на switch, но вместо проверки значений он выбирает тот case, чей канал готов к обмену данными.

Как показано на Схеме 1, select работает как диспетчер, реагируя на первое доступное событие.

Типичный кейс для фронтенд-разработчика — запрос с таймаутом. В Go это реализуется нативно:

select {
case res := <-dataChan:
    fmt.Println("Получены данные:", res)
case <-time.After(500 * time.Millisecond):
    fmt.Println("Таймаут: сервер не ответил вовремя")
}

Если готовы сразу несколько каналов, Go выберет один случайным образом. Это защищает систему от «голодания» каналов и распределяет нагрузку равномерно ⚖️

Паттерны Fan-out и Fan-in

Эти паттерны управляют потоками данных:

  • Fan-out (разветвление): одна горутина распределяет задачи в канал, из которого читают сразу много воркеров. Помогает распараллелить тяжелые вычисления.
  • Fan-in (сведение): несколько горутин пишут в разные каналы, а одна собирает их результаты в общий поток. Удобно для агрегации данных.

Проектирование Worker Pool

В бэкенде нельзя запускать бесконечное количество горутин — вы быстро исчерпаете память или «уроните» базу данных лишними соединениями.

Паттерн Worker Pool ограничивает количество одновременно выполняемых задач. Мы создаем фиксированный набор горутин (воркеров), которые по очереди разбирают задачи из одного канала.

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        fmt.Printf("Воркер %d обрабатывает задачу %d\n", id, j)
        time.Sleep(time.Second) 
        results <- j * 2
    }
}

func main() {
    const numJobs = 5
    const numWorkers = 3

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup

    // Запускаем пул
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Отправляем задачи и закрываем канал
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs) 

    wg.Wait()
    close(results)

    for res := range results {
        fmt.Println("Результат:", res)
    }
}

Шпаргалка по инструментам

ИнструментКогда использовать
sync.WaitGroupНужно просто дождаться завершения группы операций.
selectНужно реагировать на разные каналы или внедрить таймаут.
Worker PoolНужно жестко ограничить потребление ресурсов (CPU, RAM, DB connections).

Мы научились строить надежные схемы взаимодействия. Но в многопоточной среде есть риск: что будет, если два воркера одновременно изменят одну переменную?

В следующем уроке разберем, как находить такие ошибки через Race Detector и почему горутины могут «утекать», если за ними не присматривать.