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всегда передаётся по указателю, иначе горутины считают на разных копиях.