Горутины и каналы в Go
Конкурентность — одна из главных причин, по которой Go стал популярен в backend-разработке. Модель построена на двух примитивах: горутинах как единице исполнения и каналах как способе коммуникации между ними. Понять их в отрыве друг от друга можно, но бессмысленно — они проектировались вместе и работают вместе.
"Do not communicate by sharing memory; instead, share memory by communicating" — главный принцип конкурентности в 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
for i := 0; i < 100_000; i++ {
go func(n int) {
// какая-то работа
}(i)
}
Жизненный цикл горутины
Горутина живёт пока не вернётся из функции, которую она запускает. Если main завершается — все горутины убиваются немедленно, независимо от их состояния:
func main() {
go func() {
time.Sleep(10 * time.Second)
fmt.Println("я никогда не выполнюсь")\n }()
// main заканчивается — горутина выше умирает вместе с процессом
}
Утечки горутин
Утечка горутины — это когда горутина запущена, но никогда не завершается. Она держит память, стек и все захваченные переменные:
// Классическая утечка: горутина заблокирована на чтении из канала
// который никто никогда не закроет и в который никто не пишет
func leak() {
ch := make(chan int)
go func() {
val := <-ch // блокируется навсегда
fmt.Println(val)
}()
// ch выходит из scope, но горутина жива
}
// Утечка через бесконечный цикл без условия выхода
func leak2() {
go func() {
for {
doWork() // нет способа остановить эту горутину снаружи
}
}()
}
Правильный способ — всегда давать горутине способ завершиться. Обычно это context.Context или канал-сигнал:
func noLeak(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done(): // получили сигнал завершения
return
default:
doWork()
}
}
}()
}
Обнаружить утечки помогает goleak от Uber — библиотека для тестов, которая проверяет что после теста не осталось лишних горутин:
func TestMyFunc(t *testing.T) {
defer goleak.VerifyNone(t)
// тест
}
Каналы
Канал — это типизированный, потокобезопасный способ передавать данные между горутинами. Создаётся через make:
ch := make(chan int) // небуферизованный
ch := make(chan int, 10) // буферизованный на 10 элементов
Небуферизованный канал — рандеву
Небуферизованный канал синхронизирует отправителя и получателя: отправка блокируется до тех пор, пока получатель не готов принять, и наоборот:
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 элементов не блокируясь, пока буфер не заполнен:
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 немедленно при чтении
- Паникует при записи
- Паникует при повторном закрытии
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. Это делает намерения явными и ловит ошибки на этапе компиляции:
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 для каналов. Он ждёт пока хотя бы один из кейсов станет готов, и выполняет его. Если несколько готовы одновременно — выбирает случайный:
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 выполняется немедленно если ни один канал не готов:
ch := make(chan int)
select {
case v := <-ch:
fmt.Println("получили:", v)\ndefault:
fmt.Println("канал не готов, идём дальше")
}
Таймаут через select
Классический паттерн ограничения времени ожидания:
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
Типичный паттерн для горутины-воркера:
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
}
}
}
Синхронизация через каналы
Вернёмся к проблеме из начала статьи — правильное ожидание горутин:
Ожидание завершения через канал-сигнал
done := make(chan struct{})
go func() {
doWork()
close(done) // сигнализируем о завершении
}()
<-done // блокируемся до закрытия канала
struct{} как тип канала — идиоматично для сигналов: нулевой размер, ничего не передаём, только факт события.
WaitGroup для нескольких горутин
Когда горутин несколько — sync.WaitGroup чище каналов:
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 детектирует это в рантайме:
func main() {
ch := make(chan int)
ch <- 1 // main блокируется — некому читать
// fatal error: all goroutines are asleep - deadlock!
}
// Взаимный deadlock двух горутин
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 1 // ждёт ch2 <- из второй горутины
<-ch2
}()
go func() {
ch2 <- 2 // ждёт ch1 <- из первой горутины
<-ch1
}()
// Обе горутины ждут друг друга — deadlock
Go детектирует только полный deadlock (все горутины заблокированы). Частичный — когда часть горутин заблокирована вечно — это утечка, и детектора для неё нет.
Распространённые ошибки
Захват переменной цикла
// Ошибка: все горутины захватывают одну переменную 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 создаётся заново на каждой итерации
Запись в закрытый канал
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Паника при закрытии уже закрытого канала
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 которая возводит каждое число в квадрат конкурентно (каждое число в отдельной горутине) и возвращает результаты. Порядок результатов должен совпадать с порядком входных данных.
Примеры:
squareConcurrent([]int{1, 2, 3, 4, 5}) → [1 4 9 16 25]
Решение:
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".
Примеры:
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")
Решение:
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.
Примеры:
run(ctx, 10) → [4 16 36 64 100] // чётные 2,4,6,8,10 → квадраты
run(ctx, 6) → [4 16 36]
Подсказка: Каждая стадия — отдельная функция возвращающая канал. В каждом select слушай ctx.Done().
Решение:
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]
}
Практика
Чем небуферизованный канал отличается от буферизованного?
Что выведет этот код?
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)
}
Исправь код: программа должна дождаться завершения горутины перед выводом "всё готово". Сейчас "всё готово" может напечататься до горутины или горутина может не выполниться вообще.
Реализуй функцию mapConcurrent, которая применяет функцию f к каждому элементу слайса конкурентно (каждый элемент в отдельной горутине) и возвращает результаты в том же порядке. Используй sync.WaitGroup.