Мастерство конкурентности в Go:

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

В этой статье мы рассмотрим генератор и попытаемся визуализировать его. Итак, давайте подготовимся, поскольку весь процесс пройдем с примерами.

Генератор

Генератор как фонтан, который непрерывно производит значения, которые мы можем использовать при необходимости.

В Go это функция, которая создает поток значений и отправляет их по каналу, позволяя другим частям нашей программы получать эти значения по запросу.

К примеру:

// generateNumbers создает генератор, который выдает числа от 1 до max.
func generateNumbers(max int) chan int {
    // Создаем канал для отправки сообщений 
    out := make(chan int)

    // Запускаем горутину для генерации чисел
    go func() {
        // ВАЖНО: всегда закрываем канал после завершения
        defer close(out)

        for i := 1; i <= max; i++ {
            out <- i  // Кладем значение в канал
        }
    }()

    // Возвращаем канал
    return out
}

// Используем генератор
func main() {
    // Создаем генератор который генерит числа 1-5
    numbers := generateNumbers(5)

    // Получаем числа
    for num := range numbers {
        fmt.Println("Received:", num)
    }
}

тык

В этом примере наша функция генератора выполняет три ключевые задачи:

  1. Создает канал для отправки значений

  2. Запускает горутину для генерации значений

  3. Немедленно возвращает канал для использования потребителями

Зачем использовать генераторы?

  1. Отделите создание значений от потребления

  2. Генерируйте значения по требованию (lazy evaluation)

  3. Позволяют представлять бесконечные последовательности без использования бесконечной памяти

  4. Обеспечивают конкурентное производство и потребление значений

Пример

Чтение большого файла построчно.

func generateLines(filename string) chan string {
    out := make(chan string)
    go func() {
        defer close(out)
        file, err := os.Open(filename)
        if err != nil {
            return
        }
        defer file.Close()

        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
            out <- scanner.Text()
        }
    }()
    return out
}

Теперь вы, возможно, думаете: что в этом особенного? Мы можем делать то же самое, например, генерировать последовательность данных или считывать строки по одной без горутин. Не является ли это избыточным? Давайте попробуем визуализировать оба случая:

Без горутин


func getNumbers(max int) []int {
    numbers := make([]int, max)
    for i := 1; i <= max; i++ {
        numbers[i-1] = i
        // Тяжелые вычисления тут 
        time.Sleep(100 * time.Millisecond)
    }
    return numbers
}

тык

В этом случае вам придется ждать пока не закончатся все вычисления прежде чем получить какой-то результат. Все или ничего.

Вызываем горутину


func generateNumbers(max int) chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 1; i <= max; i++ {
            out <- i
            // Тяжелые вычисления тут
            time.Sleep(100 * time.Millisecond)
        }
    }()
    return out
}

тык

Разница в том что, во втором случае мы можем обрабатывать данные сразу, а в первом случае мы должны ждать пока функция не отработает полностью(поставьте таймер на 1000 * time.Millisecond)

Ключевые преимущества паттерна «Генератор» (Generator Pattern):

  1. Неблокирующее выполнение — Генерация и обработка происходят одновременно, что позволяет эффективно использовать ресурсы и не ждать полной загрузки данных.

  2. Эффективное использование памяти — Данные генерируются и обрабатываются по одному значению, что снижает потребление памяти, так как нет необходимости хранить весь массив.

  3. Поддержка бесконечных последовательностей — Можно генерировать бесконечные последовательности (например, числа Фибоначчи) без проблем с памятью.

  4. Автоматическая обработка «Backpressure» — Если потребитель обрабатывает данные медленнее, генератор естественно замедляется из‑за блокировки канала, что предотвращает перегрузку памяти.

// скорость генератора будет снижаться (backpressure handling)
for line := range generateLines(bigFile) {
    // за счет блокировки канала следующее значение не будет отправлено
    // пока первое не прочитано из канала
    processSlowly(line)
}

тык

Частые ошибки

  1. Забыл закрыть канал

// ПЛОХО ❌
func badGenerator() chan int {
    out := make(chan int)
    go func() {
        for i := 1; i <= 5; i++ {
            out <- i
        }
        // Channel never closed!
    }()
    return out
}

// Норм ✅
func goodGenerator() chan int {
    out := make(chan int)
    go func() {
        defer close(out)  // Always close when done
        for i := 1; i <= 5; i++ {
            out <- i
        }
    }()
    return out
}

Попробуй предсказать что случится, если не закрыть канал. Как это можно исправить?

2. Без обработки ошибок

// Обработка ошибок
func generateWithErrors() (chan int, chan error) {
    out := make(chan int)
    errc := make(chan error, 1)  // Буферизированный канал

    go func() {
        defer close(out)
        defer close(errc)

        for i := 1; i <= 5; i++ {
            if i == 3 {
                errc <- fmt.Errorf("error at number 3")
                return
            }
            out <- i
        }
    }()

    return out, errc
}

тык

3. Утечки Ресурсов — при использовании генераторов с ресурсами (например, файлами) необходимо обеспечивать их корректное освобождение.

func generateFromFile(filename string) chan string {
    out := make(chan string)
    go func() {
        defer close(out)

        file, err := os.Open(filename)
        if err != nil {
            return
        }
        defer file.Close()  // ВАЖНО все открытое - закрыть

        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
            out <- scanner.Text()
        }
    }()
    return out
}

Это завершает наше погружение в паттерн Генератор!

Далее мы разберём конкурентный паттерн «Конвейер» (Pipeline), где научимся связывать генераторы друг с другом, создавая мощные потоки обработки данных.

Комментарии (3)


  1. KUZURU
    12.02.2025 05:09

    Зачем это нужно, если в Go уже существуют нативные генераторы, в том числе и обобщенные (Generic)?

    Не хочу задеть этим комментарием автора, но, по своей сути, изобретаем велосипед заново?

    Или как их ещё называют range functions, которые в своей имплементации ничем не отличаются от представленного в статье примера

    Взять то же самое семейство Seq0 из пакета iter:

    type Seq[V any] func(yield func(V) bool)

    Который можно использовать аналогично:

    import "iter"
    
    func GenerateNumbers() iter.Seq[int] {
      return func(yield func(int) bool) {
        someNumbers := []int{1, 2, 3}
        for _, v := range someNumbers {
          if !yield(v) {
              return
          }
        }
      }
    }

    И использовать их аналогично в for-range


    1. northartbar Автор
      12.02.2025 05:09

      Я не берусь отвечать за автора, но выскажу своё мнение. В статье речь идёт о конкурентном паттерне “Генератор”, который знакомит с концепцией, лежащей в основе многих реализаций.

      Автор предлагает конкретную реализацию, странно сравнивать паттерн и конкретный механизм (iter.Seq). Это похоже на спор о курице и яйце: iter.Seq — лишь одна из возможных реализаций генератора коих тьма.

      Не совсем ясен поинт про “изобретение велосипеда”. Паттерн “Генератор” появился задолго до iter.Seq, так что сравнивать их напрямую некорректно.

      На мой взгляд, в статье элегантно показано, как обычный генератор становится ленивым за счёт блокировок канала. Это и есть ключевая идея: канал сам регулирует поток данных, что естественным образом реализует ленивость и конкурентность одновременно.

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

      Цикл статей “Мастерство конкурентности в Go” посвящён именно конкурентному программированию, а iter.Seq из коробки не использует горутины и каналы.

      Также непонятно, как с import "iter" можно построить конкурентный пайплайн. Генерация данных — это одно, но обработка последовательностей в параллельных потоках требует дополнительных решений, которые и раскрываются в статье.


      1. KUZURU
        12.02.2025 05:09

        Понял вас. Возможно, я поспешил с выводами. Постараюсь чуть более глубже поисследовать эту тему. Спасибо за подробный ответ и понимание!