Горутины, каналы, select
Первая часть конспекта по конкурентности — сама горутина, каналы и select. База, без которой не имеет смысла переходить к паттернам и sync-пакету дальше.
Горутина — не поток ОС
Горутина запускается словом go перед вызовом функции и выполняется конкурентно под управлением рантайма Go, а не ядра ОС напрямую. Стартовый стек — около 2КБ и растёт по необходимости (у потока ОС — обычно мегабайты сразу), переключение между горутинами происходит в user-space, минуя системные вызовы ядра — на порядки дешевле переключения потоков. Именно поэтому в Go нормально запускать тысячи и миллионы горутин, а не десятки потоков.
Рантайм мультиплексирует много горутин (G) на небольшое число реальных ОС-потоков (M) — это M:N планирование, подробности в модели GMP ниже.
Критично: main() НЕ ждёт запущенные горутины — как только main завершается, вся программа завершается немедленно, даже если горутины ещё работают. time.Sleep — не решение, а угадывание таймаута; правильный инструмент — sync.WaitGroup.
Ловушка про переменную цикла (сегодня почти не актуальна, но спрашивают): до Go 1.22 переменная цикла for i := 0; ... была ОДНОЙ общей переменной на все итерации — горутины, запущенные внутри цикла, почти всегда видели её финальное значение. С Go 1.22 у каждой итерации своя копия i, эта конкретная проблема исчезла для стандартного for, но за захватом переменных в замыканиях всё ещё стоит следить в общем случае (не только в циклах).
Каналы — буферизированные, направленные, close
«Не общайтесь через разделяемую память, разделяйте память через общение» — принцип, объясняющий, зачем каналы нужны при наличии mutex: канал делает синхронизацию частью сигнатуры функции (chan<- T сразу видно, что функция отправляет).
Небуферизированный канал — синхронная передача (rendezvous): отправка блокируется, пока получатель не готов принять, оба «встречаются» в момент передачи. Буферизированный (make(chan T, n)) — отправка блокируется только при заполненном буфере, приём — только при пустом.
Направленные каналы (chan<- T, <-chan T) фиксируют роль канала в сигнатуре, ловя неверное использование на этапе компиляции. for v := range in вычитывает канал, пока он не закрыт И полностью вычитан, и завершается сам.
close(): после закрытия ещё можно дочитать то, что осталось в буфере; когда буфер опустеет — чтение сразу возвращает zero value с ok == false. Запись в закрытый канал ПАНИКУЕТ (send on closed channel), повторный close — тоже паника. Единственная безопасная операция с закрытым каналом — чтение.
Золотое правило: закрывать канал должен ТОЛЬКО отправитель (или один явно назначенный координатор при нескольких отправителях), никогда получатель — получатель не может знать, не собирается ли отправитель прислать что-то ещё.
select — ждать несколько каналов сразу
select блокируется, пока хотя бы один case не станет готов. Если готовы сразу несколько — Go выбирает случайный из готовых, намеренно, чтобы ни один канал не «голодал» из-за порядка в коде. На собеседовании часто дают select с несколькими одновременно готовыми case и просят объяснить, почему нельзя полагаться на порядок в коде — ответ именно этот.
default превращает select в НЕБЛОКИРУЮЩУЮ проверку «готово ли что-то прямо сейчас» — срабатывает мгновенно, если ни один case не готов. Это НЕ таймаут, частая путаница джунов.
select {
case v := <-ch:
fmt.Println("получено:", v)
default:
fmt.Println("канал пуст, ждать не буду")
}Таймаут делают через case <-time.After(d) или (предпочтительнее в реальном коде) case <-ctx.Done(). time.After внутри ЦИКЛА — утечка памяти: каждый вызов создаёт новый таймер, который живёт в памяти, пока не сработает, даже если select уже выбрал другую ветку. context.WithTimeout лучше ещё и потому, что распространяет отмену дальше по цепочке вызовов.
select{} без единого case блокируется навсегда — иногда намеренно (держать main живым), но в обычном коде почти всегда признак ошибки.