Fan-in / Fan-out в Go: как раздавать задачи и собирать результаты

Zero Values в Go: что это такое, зачем нужны и где чаще всего ошибаются
Есть задачи, которые сами по себе несложные, но их слишком много. Например, нужно сделать 1000 HTTP-запросов, обработать пачку файлов, сходить в несколько внешних API, проверить список URL или прогнать много однотипных операций.

Если выполнять всё последовательно, программа будет ждать каждую операцию по очереди. Один запрос занял 500 мс, тысяча запросов легко превращается в долгие минуты ожидания.

В Go такие задачи удобно решать через горутины и каналы. Но просто запускать горутину на каждую задачу не всегда хорошая идея. Если задач миллион, миллион горутин может съесть память, забить планировщик и превратить ускорение в проблему.

Для таких случаев в Golang часто используют два паттерна конкурентности: Fan-out и Fan-in.

Если коротко:

  • Fan-out раздаёт одну очередь задач нескольким воркерам
  • Fan-in собирает результаты из нескольких горутин в один канал
  • вместе они дают понятный конвейер: задачи → воркеры → результаты

Эта статья про то, как работают fan in golang, fan out golang, как они связаны с worker pool, где чаще всего ошибаются и как писать такой код без дедлоков и утечек горутин.
  • 600+ записей собесов с идеальными ответами
  • прокачка Go в игровом формате (как Duolingo)
  • структурированная обновляемая база знаний по Go
  • комьюнити с быстрым фидбеком
  • практика и лекции, которые реально готовят к рынку

990 ₽/месяц

Закрытый IT-клуб ВЕКТОР: сообщество + приложение

Что такое Fan-out и Fan-in в Go простыми словами

Представь, что у тебя есть очередь задач. Например, список из 1000 URL. Их нужно обработать: сходить по HTTP, получить ответ, распарсить данные и сохранить результат.

Можно сделать так:

  1. Берём первый URL
  2. Обрабатываем
  3. Берём второй
  4. Обрабатываем
  5. И так тысячу раз

Это последовательно и долго.

А можно создать канал задач и запустить несколько воркеров. Каждый воркер будет брать следующую свободную задачу из канала. Один занят, второй берёт следующую. Второй занят, третий берёт ещё одну. Так работа распределяется автоматически.

Это и есть Fan-out.

После этого каждый воркер получает результат. Но читать результаты из пяти, десяти или двадцати разных мест неудобно. Поэтому воркеры пишут в один общий канал результатов, а основной код читает всё из него.

Это уже Fan-in.

В итоге получается схема:
tasks channel → worker 1 → results channel
              → worker 2 → results channel
              → worker 3 → results channel
              → worker 4 → results channel
              → worker 5 → results channel
Один вход, несколько обработчиков, один выход.

Зачем нужны Fan-in / Fan-out в Golang

Fan-in и Fan-out в Go нужны, когда одну большую пачку независимых задач можно обрабатывать параллельно.

Типичные примеры:

  • HTTP-запросы к внешним сервисам
  • обработка файлов
  • чтение данных из очереди
  • парсинг страниц
  • batch-обработка событий
  • запросы в базу данных
  • фоновые джобы
  • обработка изображений
  • валидация большого списка данных

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

Например:
1000 HTTP-запросов последовательно: условно 10 минут
1000 HTTP-запросов через пул воркеров: условно 30 секунд
Цифры зависят от сети, API, лимитов, железа и кода. Но принцип один: для IO-bound задач параллельная обработка часто даёт огромный прирост.

Fan-out в Go: раздаём работу нескольким воркерам

Fan-out в Go, это когда несколько горутин читают задачи из одного канала.

Канал в таком случае работает как очередь. Кто из воркеров свободен, тот и забирает следующую задачу.

Пример базовой схемы:
package main

import "fmt"

type Task struct {
	ID int
}

func process(task Task) {
	fmt.Println("process task", task.ID)
}

func main() {
	tasks := make(chan Task)

	for i := 0; i < 5; i++ {
		go func(workerID int) {
			for task := range tasks {
				fmt.Println("worker", workerID)
				process(task)
			}
		}(i)
	}

	for i := 1; i <= 10; i++ {
		tasks <- Task{ID: i}
	}

	close(tasks)
}
Что здесь происходит:

  • создаём канал tasks
  • запускаем 5 воркеров
  • каждый воркер читает из tasks через range
  • отправляем задачи в канал
  • закрываем tasks, когда задач больше не будет

Когда канал tasks закрывается и все значения из него прочитаны, цикл for task := range tasks завершается, и воркеры спокойно выходят.

Это базовый fan-out.

Почему канал работает как очередь задач

В Go канал можно использовать как безопасную очередь между горутинами.

Когда несколько воркеров читают из одного канала, Go сам распределяет значения между ними. Не нужно вручную писать mutex, индекс текущей задачи, список занятых воркеров или свой балансировщик.

Вот почему fan-out в Golang так удобен:

  • один канал хранит поток задач
  • несколько горутин читают из него конкурентно
  • каждая задача попадёт только одному воркеру
  • свободный воркер заберёт следующую задачу
  • код остаётся коротким и понятным

Но важно не путать. Канал не гарантирует идеальное равномерное распределение. Если один воркер быстрее, он может обработать больше задач. Обычно это даже хорошо, потому что получается естественная балансировка по скорости.

Сколько воркеров запускать

Один из частых вопросов: сколько воркеров нужно для fan-out?

Ответ зависит от типа задачи.

Если задача IO-bound

IO-bound, это когда код в основном ждёт внешние операции:

  • HTTP
  • базу данных
  • файловую систему
  • сеть
  • очередь сообщений

Для таких задач воркеров может быть больше, чем ядер CPU. Например, 10, 20, 50, 100. Но число всё равно нужно ограничивать.

Почему? Потому что внешние сервисы имеют лимиты, база данных имеет пул соединений, сеть имеет пропускную способность, а сама программа не должна создавать бесконечную нагрузку.

Если задача CPU-bound

CPU-bound, это когда код реально грузит процессор:

  • сжатие изображений
  • хеширование больших файлов
  • криптография
  • тяжёлые вычисления
  • парсинг больших объёмов данных

Для таких задач обычно начинают с количества воркеров около runtime.NumCPU().

Пример:
workers := runtime.NumCPU()
Если запустить сильно больше CPU-bound воркеров, быстрее может не стать. Процессор не станет бесконечным, а переключений между горутинами будет больше.

Почему не стоит запускать горутину на каждую задачу

Горутины лёгкие, но не бесплатные.

Да, в Go горутина намного дешевле потока ОС. Но если задач миллион, запускать миллион горутин просто потому что можно, плохая идея.

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

Плохой вариант:
for _, task := range tasks {
	go process(task)
}
Для 100 задач может быть нормально. Для миллиона уже опасно.

Лучше использовать фиксированный пул воркеров:
workerCount := 20
Так ты контролируешь параллелизм и не даёшь программе разрастись до неконтролируемого состояния.
var c Cache
c.items["key"] = "value"
получишь panic, потому что map внутри структуры равен nil.
package main

type Item struct {
	Value string
}

type Cache struct {
	items map[string]Item
}

func NewCache() *Cache {
	return &Cache{
		items: make(map[string]Item),
	}
}
Здесь NewCache() нужен, потому что иначе поле items будет nil, и запись в карту вызовет panic.

Пример, где конструктор не нужен
package main

type Counter struct {
	count int
}

func (c *Counter) Inc() {
	c.count++
}
Здесь всё хорошо. count по умолчанию равен 0, и с ним уже можно работать.

Nil slice vs nil map в Go: одна из самых частых ловушек

Вот здесь много кто ошибается, особенно в начале.

И slice, и map в Go имеют zero value nil. Но ведут себя они по-разному.

Nil slice в Go

nil slice в Go обычно безопасен:

  • len(s) вернёт 0
  • cap(s) вернёт 0
  • append(s, x) сработает
  • range по nil slice не упадёт

Пример:
package main

import "fmt"

func main() {
	var s []int

	fmt.Println(len(s)) // 0
	fmt.Println(cap(s)) // 0

	s = append(s, 1)
	fmt.Println(s) // [1]
}

Nil map в Go

nil map ведёт себя иначе:

  • читать из неё можно
  • len(m) вернёт 0
  • range сработает с нулём итераций
  • писать в неё нельзя, будет panic

Пример:
package main

import "fmt"

func main() {
	var m map[string]int

	fmt.Println(m["key"]) // 0

	m["key"] = 1 // panic: assignment to entry in nil map
}

Почему это важно

Потому что код с nil map компилируется нормально. Компилятор тебя не остановит. Ошибка вылезет уже в рантайме.

Именно поэтому разница между nil slice и nil map в Go, это не теоретическая мелочь, а реальная ловушка, из-за которой падают сервисы.

Почему append работает с nil slice, а запись в nil map ломается

Потому что append возвращает новый slice header, и Go сам может выделить память под элементы.

С map так не работает. Карта должна быть явно инициализирована через make, потому что запись требует внутренней структуры хеш-таблицы, а её у nil map просто нет.

Правильный вариант:
m := make(map[string]int)
m["key"] = 1

Что ещё важно помнить про zero values в Go

Если хочешь глубже понимать тему, вот ещё несколько моментов, которые часто всплывают в реальном коде.

1. Nil channel блокирует выполнение

Zero value для канала, это nil.
var ch chan int
Такой канал нельзя использовать для отправки и получения. Операции с ним будут блокироваться.
ch <- 1   // блокировка
<-ch      // блокировка
Чтобы канал работал, нужен make:
ch = make(chan int)

2. Nil interface и typed nil, это не одно и то же

Это уже более продвинутый момент, но знать его полезно. Интерфейс в Go считается nil только когда у него нет ни типа, ни значения.

Вот так:
var err error
fmt.Println(err == nil) // true
А вот так уже нет:
var p *MyError = nil
var err error = p

fmt.Println(err == nil) // false
А вот так уже нет:
Почему? Потому что внутри интерфейса уже лежит тип *MyError, даже если само значение nil.
Эта штука часто кусает в обработке ошибок.

3. Nil slice и empty slice, это не всегда одно и то же

Для большинства операций они выглядят похоже:
var s1 []int
s2 := []int{}
Оба вроде пустые. Но технически это разные состояния:

  • s1 == nil -> true
  • s2 == nil -> false

Особенно это важно при JSON-маршалинге:

  • nil slice часто превращается в null
  • пустой slice превращается в []

Если ты пишешь API, это уже может быть критично.

Частые ошибки с zero values в Go

Вот набор ошибок, которые повторяются снова и снова.

Запись в nil map

var m map[string]int
m["x"] = 10 // panic
Исправление:
m := make(map[string]int)
m["x"] = 10

Ожидание, что nil channel будет работать

var ch chan int
ch <- 1 // зависание
Исправление:
ch := make(chan int)
ch <- 1

Уверенность, что zero value структуры всегда безопасен

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

Игнорирование разницы между nil slice и empty slice

На собесах это любят спрашивать. В реальном проде это всплывает в JSON, API и тестах.

Как проектировать свои типы в Go правильно

Один из самых полезных принципов в Golang звучит так:
Если можно сделать так, чтобы zero value был полезен, делай именно так.
Почему это хорошо:
  • код проще использовать
  • меньше шансов забыть инициализацию
  • меньше лишних конструкторов
  • API выглядит чище и понятнее

Хороший вопрос при проектировании

Когда создаёшь новый тип, спроси себя:
Что произойдёт, если другой разработчик напишет var x MyType и сразу начнёт использовать?
Дальше варианты:
  • если всё работает нормально, значит дизайн удачный
  • если код падает, требует обязательного Init() или скрытых условий, значит тип неудобный

Практическое правило

Используй NewXxx, если:
  • внутри есть map, в которую нужно писать
  • внутри есть chan, который должен работать сразу
  • у типа есть обязательные зависимости
  • zero value логически невалиден
Не используй NewXxx, если:
  • zero value уже безопасен
  • тип можно использовать сразу
  • конструктор не добавляет реальной пользы

Почему zero values, это сильная сторона Go

  1. Безопасность. Переменная никогда не содержит мусор.
  2. Предсказуемость. Ты почти всегда понимаешь, в каком состоянии находится объект после объявления.
  3. Меньше бойлерплейта. Не надо везде городить конструкторы и ручную инициализацию.
  4. Удобное проектирование API. Хороший Go-код часто можно использовать сразу, без лишних действий.
  5. Читаемость. Когда zero value рабочий, код становится проще для всех, кто будет его поддерживать.

Что нужно запомнить про zero values в Go

Если совсем сжать тему до главного, получится вот это:

  • у каждого типа в Go есть значение по умолчанию
  • zero value, это часть дизайна языка, а не случайная фича
  • числа получают 0, строки "", bool получает false
  • pointer, slice, map, chan, func, interface получают nil
  • nil slice обычно безопасен
  • nil map читать можно, писать нельзя
  • если zero value структуры опасен, нужен NewXxx
  • если тип можно сделать рабочим сразу после var x T, это обычно хороший дизайн
Zero values в Go кажутся базовой темой только до тех пор, пока не начинаешь писать реальный код. Потом выясняется, что именно на них завязано очень много вещей: от поведения обычных переменных до архитектуры собственных типов и удобства API.

Если ты пишешь на Go, полезно не просто помнить таблицу значений по умолчанию, а реально понимать философию за этим механизмом.

И главный вопрос, который стоит держать в голове при проектировании своих структур и пакетов:

Что будет, если кто-то создаст мой тип через var x T и сразу начнёт его использовать?

Если ответ хороший, значит ты двигаешься в правильную сторону.

F.A.Q.

Что такое zero values в Go?

Zero value в Go, это значение по умолчанию, которое получает переменная при объявлении без явной инициализации.

Какой zero value у string в Go?

Для string zero value, это пустая строка "".

Какой zero value у map в Go?

Для map zero value, это nil.

Можно ли писать в nil map в Go?

Нет. Чтение из nil map работает, а запись вызывает panic.

Чем nil slice отличается от nil map в Go?

nil slice можно безопасно использовать с append, len и range. В nil map нельзя записывать без make.

Когда нужен конструктор в Go?

Конструктор NewXxx нужен, если zero value типа нерабочий, опасный или требует обязательной инициализации внутренних полей.
Шпаргалки по Zero Values
Сохранить шпаргалку по Zero Values в Golag по ссылке https://t.me/niyaz_golang/675
Senior Go developer
Работал в Авито в инфраструктуре
Кодил на Go, Java, Python, JS
200+ собеседований провел лично
Менторю больше 2 лет
У меня большой нетворк: всегда в курсе, как проходит найм в разных компаниях
Нияз
Автор