Горутины и каналы в Go

Конкурентность — одна из главных причин, по которой Go стал популярен в backend-разработке. Модель построена на двух примитивах: горутинах как единице исполнения и каналах как способе коммуникации между ними. Понять их в отрыве друг от друга можно, но бессмысленно — они проектировались вместе и работают вместе.

"Do not communicate by sharing memory; instead, share memory by communicating" — главный принцип конкурентности в Go.


Горутины

Горутина — это легковесный поток исполнения, управляемый рантаймом Go, а не операционной системой. Запускается ключевым словом go:

go
func main() { go sayHello() // запуск горутины — не блокирует go func() { // анонимная функция fmt.Println("anonymous goroutine")\n }() time.Sleep(time.Second) // даём горутинам время выполниться } func sayHello() { fmt.Println("hello from goroutine") }

time.Sleep здесь — плохой способ ждать горутины. Правильный — каналы или sync.WaitGroup. К этому вернёмся чуть ниже.

Почему горутины дёшевы

Разница между горутиной и OS-потоком принципиальная:

OS ThreadГорутина
Начальный стек1–8 МБ (фиксирован)2–8 КБ (растёт динамически)
Создание~1–10 мкс (syscall)~0.3 мкс (только рантайм)
Переключение~1–2 мкс (kernel)~0.1 мкс (userspace)
Типичное кол-вотысячисотни тысяч

Стек горутины начинается с 2–8 КБ и растёт по необходимости — рантайм автоматически выделяет больший стек и переносит содержимое. Это позволяет иметь сотни тысяч горутин одновременно без исчерпания памяти.

go
// Это нормально в Go for i := 0; i < 100_000; i++ { go func(n int) { // какая-то работа }(i) }

Жизненный цикл горутины

Горутина живёт пока не вернётся из функции, которую она запускает. Если main завершается — все горутины убиваются немедленно, независимо от их состояния:

go
func main() { go func() { time.Sleep(10 * time.Second) fmt.Println("я никогда не выполнюсь")\n }() // main заканчивается — горутина выше умирает вместе с процессом }

Утечки горутин

Утечка горутины — это когда горутина запущена, но никогда не завершается. Она держит память, стек и все захваченные переменные:

go
// Классическая утечка: горутина заблокирована на чтении из канала // который никто никогда не закроет и в который никто не пишет func leak() { ch := make(chan int) go func() { val := <-ch // блокируется навсегда fmt.Println(val) }() // ch выходит из scope, но горутина жива }
go
// Утечка через бесконечный цикл без условия выхода func leak2() { go func() { for { doWork() // нет способа остановить эту горутину снаружи } }() }

Правильный способ — всегда давать горутине способ завершиться. Обычно это context.Context или канал-сигнал:

go
func noLeak(ctx context.Context) { go func() { for { select { case <-ctx.Done(): // получили сигнал завершения return default: doWork() } } }() }

Обнаружить утечки помогает goleak от Uber — библиотека для тестов, которая проверяет что после теста не осталось лишних горутин:

go
func TestMyFunc(t *testing.T) { defer goleak.VerifyNone(t) // тест }

Каналы

Канал — это типизированный, потокобезопасный способ передавать данные между горутинами. Создаётся через make:

go
ch := make(chan int) // небуферизованный ch := make(chan int, 10) // буферизованный на 10 элементов

Небуферизованный канал — рандеву

Небуферизованный канал синхронизирует отправителя и получателя: отправка блокируется до тех пор, пока получатель не готов принять, и наоборот:

go
ch := make(chan int) go func() { fmt.Println("отправляю...")\n ch <- 42 // блокируется пока main не прочитает fmt.Println("отправил") }() time.Sleep(time.Second) fmt.Println("получаю...")\nval := <-ch // разблокирует горутину выше fmt.Println("получил:", val)\n // Вывод: // отправляю... // получаю... // отправил // получил: 42

Это называется рандеву (rendezvous) — встреча двух горутин в точке передачи данных. Именно поэтому небуферизованные каналы — мощный инструмент синхронизации.

Буферизованный канал — асинхронная очередь

Буферизованный канал позволяет отправить до N элементов не блокируясь, пока буфер не заполнен:

go
ch := make(chan int, 3) ch <- 1 // не блокируется ch <- 2 // не блокируется ch <- 3 // не блокируется // ch <- 4 // заблокировалось бы — буфер полон fmt.Println(<-ch) // 1 fmt.Println(<-ch) // 2 fmt.Println(<-ch) // 3

Буферизованный канал ведёт себя как очередь FIFO. Полезен когда производитель и потребитель работают с разной скоростью и нужно сгладить пики.

Закрытие канала

Канал закрывается через close. Закрытый канал:

  • Возвращает zero value немедленно при чтении
  • Паникует при записи
  • Паникует при повторном закрытии
go
ch := make(chan int, 3) ch <- 1 ch <- 2 close(ch) // Чтение из закрытого канала v, ok := <-ch fmt.Println(v, ok) // 1 true — данные ещё есть v, ok = <-ch fmt.Println(v, ok) // 2 true v, ok = <-ch fmt.Println(v, ok) // 0 false — канал закрыт и пуст // range по каналу — завершится когда канал закрыт и пуст for v := range ch { fmt.Println(v) }

Ключевое правило: закрывает канал тот, кто пишет — никогда читатель. Иначе запись в закрытый канал вызовет панику.

Направленные каналы

Тип канала можно ограничить до send-only или receive-only. Это делает намерения явными и ловит ошибки на этапе компиляции:

go
func producer(ch chan<- int) { // только запись ch <- 42 // <-ch // Ошибка компиляции: receive from send-only channel } func consumer(ch <-chan int) { // только чтение val := <-ch // ch <- 1 // Ошибка компиляции: send to receive-only channel fmt.Println(val) } func main() { ch := make(chan int) go producer(ch) // двунаправленный неявно конвертируется consumer(ch) }

select — мультиплексирование каналов

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

go
ch1 := make(chan string) ch2 := make(chan string) go func() { ch1 <- "один" }() go func() { ch2 <- "два" }() select { case msg := <-ch1: fmt.Println("из ch1:", msg)\ncase msg := <-ch2: fmt.Println("из ch2:", msg) }

select с default — неблокирующие операции

default выполняется немедленно если ни один канал не готов:

go
ch := make(chan int) select { case v := <-ch: fmt.Println("получили:", v)\ndefault: fmt.Println("канал не готов, идём дальше") }

Таймаут через select

Классический паттерн ограничения времени ожидания:

go
ch := make(chan Result) go func() { ch <- doHeavyWork() }() select { case result := <-ch: fmt.Println("результат:", result)\ncase <-time.After(5 * time.Second): fmt.Println("таймаут — работа заняла слишком долго") }

time.After возвращает канал, в который придёт значение через указанное время. В паре с select это идиоматичный таймаут в Go.

Бесконечный цикл с select

Типичный паттерн для горутины-воркера:

go
func worker(jobs <-chan Job, results chan<- Result, quit <-chan struct{}) { for { select { case job := <-jobs: results <- process(job) case <-quit: fmt.Println("воркер завершается")\n return } } }

Синхронизация через каналы

Вернёмся к проблеме из начала статьи — правильное ожидание горутин:

Ожидание завершения через канал-сигнал

go
done := make(chan struct{}) go func() { doWork() close(done) // сигнализируем о завершении }() <-done // блокируемся до закрытия канала

struct{} как тип канала — идиоматично для сигналов: нулевой размер, ничего не передаём, только факт события.

WaitGroup для нескольких горутин

Когда горутин несколько — sync.WaitGroup чище каналов:

go
var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) // инкремент до запуска горутины — важно! go func(n int) { defer wg.Done() // декремент при завершении fmt.Println("горутина", n)\n }(i) } wg.Wait() // ждём пока счётчик не станет 0

wg.Add(1) всегда должен вызываться до go, а не внутри горутины. Иначе возможна гонка: wg.Wait() может отработать до того, как горутина успеет вызвать Add.


Deadlock

Deadlock — ситуация, когда все горутины заблокированы и прогресс невозможен. Go детектирует это в рантайме:

go
func main() { ch := make(chan int) ch <- 1 // main блокируется — некому читать // fatal error: all goroutines are asleep - deadlock! }
go
// Взаимный deadlock двух горутин ch1 := make(chan int) ch2 := make(chan int) go func() { ch1 <- 1 // ждёт ch2 <- из второй горутины <-ch2 }() go func() { ch2 <- 2 // ждёт ch1 <- из первой горутины <-ch1 }() // Обе горутины ждут друг друга — deadlock

Go детектирует только полный deadlock (все горутины заблокированы). Частичный — когда часть горутин заблокирована вечно — это утечка, и детектора для неё нет.


Распространённые ошибки

Захват переменной цикла

go
// Ошибка: все горутины захватывают одну переменную i for i := 0; i < 5; i++ { go func() { fmt.Println(i) // скорее всего напечатает 5 5 5 5 5 }() } // Правильно: передаём значение через аргумент for i := 0; i < 5; i++ { go func(n int) { fmt.Println(n) // 0 1 2 3 4 (в неопределённом порядке) }(i) } // Тоже правильно (Go 1.22+): переменная цикла теперь per-iteration // В Go 1.22 семантика изменилась — i создаётся заново на каждой итерации

Запись в закрытый канал

go
ch := make(chan int) close(ch) ch <- 1 // panic: send on closed channel

Паника при закрытии уже закрытого канала

go
ch := make(chan int) close(ch) close(ch) // panic: close of closed channel // Решение: sync.Once для гарантированно однократного закрытия var once sync.Once closeOnce := func() { once.Do(func() { close(ch) }) }

Вопросы на собеседовании

Q: Чем горутина отличается от OS-потока?
A: Горутина управляется рантаймом Go, а не ОС. Начальный стек — 2–8 КБ против 1–8 МБ у потока, создание — микросекунды против десятков микросекунд. Стек горутины растёт динамически. Тысячи горутин — норма, тысячи OS-потоков — проблема.

Q: Что такое утечка горутины? Как обнаружить?
A: Горутина запущена, но никогда не завершится — заблокирована на канале, мьютексе или в бесконечном цикле без условия выхода. Держит память и стек. Обнаружить: goleak в тестах, runtime.NumGoroutine() в метриках, pprof в продакшне.

Q: Чем буферизованный канал отличается от небуферизованного?
A: Небуферизованный — рандеву: отправитель блокируется пока получатель не готов, и наоборот. Буферизованный — асинхронная очередь FIFO: отправка не блокируется пока буфер не заполнен. Небуферизованный — строгая синхронизация, буферизованный — сглаживание пиков.

Q: Что произойдёт при чтении из закрытого канала?
A: Немедленно вернётся zero value и false в двухаргументной форме. Если в канале ещё есть данные — сначала вернут их. Запись в закрытый канал — паника. Повторное закрытие — паника.

Q: Кто должен закрывать канал?
A: Тот, кто пишет — отправитель. Читатель не знает, будет ли ещё запись, и закрытие с его стороны вызовет панику при следующей записи. Если отправителей несколько — используют sync.WaitGroup + отдельную горутину, которая ждёт всех и закрывает.

Q: Что такое select? Что если несколько кейсов готовы одновременно?
A: Конструкция для мультиплексирования каналов — ждёт пока хотя бы один кейс станет готов. Если готовы несколько — выбирает случайный. Это намеренная рандомизация, чтобы не было систематического голодания одного канала.

Q: Как сделать неблокирующую отправку или чтение из канала?
A: Через select с default: если канал не готов — немедленно выполняется default. Без default select ждёт.

Q: Как правильно ждать завершения нескольких горутин?
A: sync.WaitGroup: wg.Add(1) до запуска горутины, defer wg.Done() внутри, wg.Wait() в основной горутине. Альтернатива для простых случаев — канал-сигнал chan struct{}.

Q: Почему wg.Add(1) нужно вызывать до go, а не внутри горутины?
A: Гонка данных: wg.Wait() может выполниться до того, как горутина успеет вызвать Add. Если Add внутри горутины — счётчик может оставаться нулевым в момент Wait, и программа завершится не дождавшись горутин.

Q: Что такое deadlock? Как Go его обнаруживает?
A: Ситуация когда все горутины заблокированы и прогресс невозможен. Go детектирует полный deadlock в рантайме: fatal error: all goroutines are asleep - deadlock!. Частичный deadlock (часть горутин заблокирована вечно) — это утечка, детектора нет.

Q: В чём проблема захвата переменной цикла в горутине?
A: Горутины захватывают переменную по ссылке, а не по значению. К моменту исполнения горутины цикл уже завершён и переменная содержит последнее значение. Решение: передавать как аргумент go func(n int){...}(i). В Go 1.22 семантика переменной цикла изменилась — теперь она создаётся заново на каждой итерации.

Задачи: Горутины и каналы


Задача 1: Конкурентный сбор результатов

Уровень: Лёгкая

Что проверяет: базовый запуск горутин, сбор результатов через канал

Условие: Напиши функцию squareConcurrent(nums []int) []int которая возводит каждое число в квадрат конкурентно (каждое число в отдельной горутине) и возвращает результаты. Порядок результатов должен совпадать с порядком входных данных.

Примеры:

text
squareConcurrent([]int{1, 2, 3, 4, 5}) → [1 4 9 16 25]

Решение:

go
package main import ( "fmt" "sync" ) func squareConcurrent(nums []int) []int { result := make([]int, len(nums)) var wg sync.WaitGroup for i, n := range nums { wg.Add(1) go func(idx, val int) { defer wg.Done() result[idx] = val * val // каждая горутина пишет в свой индекс }(i, n) } wg.Wait() return result } func main() { fmt.Println(squareConcurrent([]int{1, 2, 3, 4, 5})) // [1 4 9 16 25] } // Безопасно без мьютекса: каждая горутина пишет // в уникальный индекс — нет гонки данных.

Задача 2: Таймаут операции

Уровень: Средняя

Что проверяет: select с таймаутом, паттерн ограничения времени

Условие: Напиши функцию withTimeout(timeout time.Duration, fn func() int) (int, error) которая запускает fn в горутине и возвращает результат. Если fn не завершилась за timeout — возвращает ошибку "operation timed out".

Примеры:

text
withTimeout(1*time.Second, func() int { time.Sleep(500*time.Millisecond) return 42 }) → (42, nil) withTimeout(100*time.Millisecond, func() int { time.Sleep(1*time.Second) return 42 }) → (0, "operation timed out")

Решение:

go
package main import ( "errors" "fmt" "time" ) func withTimeout(timeout time.Duration, fn func() int) (int, error) { ch := make(chan int, 1) // буферизованный — горутина не зависнет go func() { ch <- fn() }() select { case result := <-ch: return result, nil case <-time.After(timeout): return 0, errors.New("operation timed out") } } func main() { result, err := withTimeout(time.Second, func() int { time.Sleep(500 * time.Millisecond) return 42 }) fmt.Println(result, err) // 42 <nil> result, err = withTimeout(100*time.Millisecond, func() int { time.Sleep(time.Second) return 42 }) fmt.Println(result, err) // 0 operation timed out } // Буферизованный канал важен: если таймаут сработал раньше // и никто не читает из ch — горутина всё равно запишет // и завершится. Без буфера — утечка горутины.

Задача 3: Pipeline с отменой

Уровень: Сложная

Что проверяет: построение pipeline, корректная отмена через контекст

Условие: Реализуй pipeline из трёх стадий: generate (числа от 1 до n), filter (только чётные), square (возведение в квадрат). Pipeline должен корректно завершаться при отмене контекста. Напиши функцию run(ctx context.Context, n int) []int.

Примеры:

text
run(ctx, 10) → [4 16 36 64 100] // чётные 2,4,6,8,10 → квадраты run(ctx, 6) → [4 16 36]

Подсказка: Каждая стадия — отдельная функция возвращающая канал. В каждом select слушай ctx.Done().

Решение:

go
package main import ( "context" "fmt" ) func generate(ctx context.Context, n int) <-chan int { out := make(chan int) go func() { defer close(out) for i := 1; i <= n; i++ { select { case out <- i: case <-ctx.Done(): return } } }() return out } func filter(ctx context.Context, in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for n := range in { if n%2 == 0 { select { case out <- n: case <-ctx.Done(): return } } } }() return out } func square(ctx context.Context, in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for n := range in { select { case out <- n * n: case <-ctx.Done(): return } } }() return out } func run(ctx context.Context, n int) []int { nums := generate(ctx, n) evens := filter(ctx, nums) squares := square(ctx, evens) var result []int for v := range squares { result = append(result, v) } return result } func main() { ctx := context.Background() fmt.Println(run(ctx, 10)) // [4 16 36 64 100] fmt.Println(run(ctx, 6)) // [4 16 36] }

Практика

Quiz+10 XP

Чем небуферизованный канал отличается от буферизованного?

  • Небуферизованный работает быстрее — нет накладных расходов на буфер
  • Небуферизованный блокирует отправителя до тех пор, пока получатель не готов принять (рандеву)
  • Буферизованный гарантирует порядок доставки, небуферизованный — нет
  • Разницы нет — оба работают одинаково
Predict+15 XP

Что выведет этот код?

go
package main import ( "fmt" "time" ) func main() { ch := make(chan int) go func() { fmt.Println("отправляю...")\n ch <- 42 fmt.Println("отправил")\n }() time.Sleep(50 * time.Millisecond) fmt.Println("получаю...")\n val := <-ch fmt.Println("получил:", val) }
Исправь код+25 XP

Исправь код: программа должна дождаться завершения горутины перед выводом "всё готово". Сейчас "всё готово" может напечататься до горутины или горутина может не выполниться вообще.

Задача+20 XP

Реализуй функцию mapConcurrent, которая применяет функцию f к каждому элементу слайса конкурентно (каждый элемент в отдельной горутине) и возвращает результаты в том же порядке. Используй sync.WaitGroup.