select и WaitGroup

Разбираем вопрос о том, как одновременно ждать несколько каналов и как корректно дождаться завершения целой группы горутин.

select — оператор, который ждёт сразу несколько каналов и выполняет ветку для того из них, что первым оказался готов. sync.WaitGroup — счётчик, который позволяет одной горутине дождаться завершения произвольного числа других.

Вопрос: как дождаться данных сразу из нескольких каналов?

Чёткий ответ. Если просто написать <-ch1, а затем <-ch2, программа будет ждать ch1 первым, даже если ch2 готов раньше. Оператор select устроен иначе: он смотрит на все перечисленные каналы одновременно и срабатывает по тому, что готов первым. Синтаксически он похож на switch, только вместо значений сравниваются операции с каналами.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(50 * time.Millisecond)
		ch1 <- "сообщение из ch1"
	}()
	go func() {
		time.Sleep(20 * time.Millisecond)
		ch2 <- "сообщение из ch2"
	}()

	select {
	case msg1 := <-ch1:
		fmt.Println(msg1)
	case msg2 := <-ch2:
		fmt.Println(msg2)
	}
}

Вывод:

сообщение из ch2

ch2 "готов" раньше (пришёл через 20 мс против 50 мс у ch1), поэтому сработает именно его ветка. Если бы готовы одновременно оказались оба канала, select выбрал бы одну из веток случайно — это специально сделано так, чтобы не было "любимого" канала и не возникало систематической несправедливости.

select с default: не ждать, а проверить и пойти дальше

Если добавить ветку default, select перестаёт блокироваться: если ни один канал не готов прямо сейчас, выполнится именно default, и программа не будет ждать.

package main

import "fmt"

func main() {
	ch := make(chan int)

	select {
	case v := <-ch:
		fmt.Println("получили:", v)
	default:
		fmt.Println("данных пока нет, идём дальше")
	}
}

Вывод:

данных пока нет, идём дальше

Такой приём часто спрашивают в контексте "неблокирующей проверки канала" — например, чтобы горутина могла периодически проверять сигнал остановки, не замирая на нём намертво.

Вопрос: как дождаться завершения нескольких горутин?

Чёткий ответ. Для этого в пакете sync есть WaitGroup — по сути атомарный счётчик "сколько горутин ещё работает". Используются три метода: Add(n) увеличивает счётчик на n перед запуском горутин, Done() уменьшает его на 1 (обычно вызывается через defer в самом начале горутины), а Wait() блокирует вызывающую горутину, пока счётчик не станет равным нулю.

package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // сообщаем "я закончил", что бы ни случилось
	fmt.Printf("воркер %d начал работу\n", id)
	fmt.Printf("воркер %d закончил работу\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1) // +1 к счётчику на каждую горутину
		go worker(i, &wg)
	}

	wg.Wait() // ждём, пока счётчик не станет 0
	fmt.Println("все воркеры закончили")
}

Вывод (порядок строк воркеров может отличаться, но последняя строка всегда одна и та же):

воркер 1 начал работу
воркер 1 закончил работу
воркер 3 начал работу
воркер 3 закончил работу
воркер 2 начал работу
воркер 2 закончил работу
все воркеры закончили

Обратите внимание на defer wg.Done() в самом начале функции — это устоявшийся идиоматичный приём в Go. Даже если внутри горутины где-то случится паника, defer всё равно выполнится и корректно уменьшит счётчик, а не оставит main висеть в Wait() навсегда.

select и WaitGroup вместе: типичный паттерн "работа с таймаутом"

На практике select часто комбинируют с каналом-таймером, чтобы не ждать медленную операцию бесконечно:

select {
case result := <-resultCh:
	fmt.Println("успели:", result)
case <-time.After(200 * time.Millisecond):
	fmt.Println("не успели за 200 мс, идём дальше")
}

time.After возвращает канал, в который значение придёт ровно через указанное время — удобный способ поставить "дедлайн" ожиданию без ручных таймеров.

Частые ошибки на собеседовании

  • Забывают вызвать wg.Add(1) ДО запуска горутины (а не внутри неё) — иначе возможна гонка: Wait() может сработать раньше, чем горутина успеет прибавить себя к счётчику.
  • Передают WaitGroup по значению, а не по указателю — тогда каждая горутина работает со своей копией счётчика, и Wait() в main не видит завершения.
  • Путают: select без default — блокирующий, с default — нет.
  • Не понимают, что при готовности нескольких веток select одновременно выбор между ними случаен, а не по порядку записи в коде.

Итог

  • select ждёт сразу несколько каналов и выполняет ветку первого готового; при нескольких готовых сразу — выбор случайный.
  • Ветка default делает select неблокирующим.
  • sync.WaitGroup — счётчик активных горутин: Add перед запуском, Done (обычно через defer) внутри горутины, Wait — там, где нужно дождаться всех.
  • WaitGroup всегда передаётся по указателю, иначе горутины считают на разных копиях.
Проверьте себя
1. Что делает оператор select, если сразу несколько его веток с каналами готовы одновременно?
AВыполняет первую по порядку написания ветку
BВыполняет все готовые ветки подряд
CВыбирает одну из готовых веток случайным образом
DЗавершает программу с ошибкой
2. Что изменится, если добавить в select ветку default?
AНичего не изменится
Bselect станет ждать все каналы сразу, а не один
Cselect перестанет блокироваться: если ни один канал не готов, сразу выполнится default
Ddefault всегда выполняется первым, даже если канал готов
3. Почему sync.WaitGroup нужно передавать в горутины по указателю (*sync.WaitGroup), а не по значению?
AПо значению так просто нельзя написать, будет ошибка компиляции
BПередача по значению даёт каждой горутине свою копию счётчика, и Wait() в вызывающей горутине не увидит завершения
CПо указателю работает быстрее из-за меньшего размера структуры
DЭто просто вопрос стиля, разницы в поведении нет