Шаблон проектирования Канал в Go — Полное руководство по паттерну Or-Done

Схема работы паттерна Or-Done с каналами и горутинами в языке Go

Разработка надежных конкурентных приложений на Go требует внимательного отношения к жизненному циклу горутин. Одна из самых частых проблем — это утечка горутин (goroutine leaks). Она возникает, когда функция навсегда блокируется в ожидании данных из канала, в то время как вызывающий код уже завершил работу или отменил операцию.

В этой статье мы подробно разберем шаблон проектирования Or-Done (часто называемый паттерном канала с отменой), который элегантно решает эту проблему, инкапсулируя логику отмены и делая код чище.

Проблема — Утечка горутин при чтении

Представим стандартную ситуацию. У вас есть канал, из которого горутина читает данные. Обычно это реализуется через простой цикл for...range.

func processData(in <-chan int) {
    for val := range in {
        // Обработка поступающих данных
        fmt.Println(val)
    }
}

Этот подход отлично работает, если вы гарантированно знаете, что канал in будет закрыт отправителем. Но что, если данные перестанут поступать из-за ошибки сети, а канал так и останется открытым? Горутина, выполняющая этот цикл, “зависнет” в режиме ожидания навсегда. Это приведет к неконтролируемой утечке памяти.

Стандартный и правильный способ защиты — использование конструкции select и сигнального канала отмены done.

func processDataSafe(done <-chan struct{}, in <-chan int) {
    for {
        select {
        case <-done:
            return // Безопасный выход по сигналу отмены
        case val, ok := <-in:
            if !ok {
                return // Выход, если канал данных закрыт
            }
            fmt.Println(val)
        }
    }
}

Код стал безопасным. Однако представьте, что у вас сложный конвейер обработки данных (pipeline) с пятью или десятью стадиями. Вам придется писать эту громоздкую конструкцию select на каждом шаге. Код становится зашумленным и трудным для восприятия.

Изящное решение — Паттерн Or-Done

Паттерн Or-Done предлагает вынести эту рутинную проверку канала done в отдельную функцию-обертку. Эта функция принимает исходный канал данных и канал отмены, а возвращает новый канал, который автоматически закроется при получении сигнала отмены.

Благодаря этому мы можем вернуться к удобному циклу for...range, сохраняя при этом полную безопасность.

Реализация паттерна с дженериками

Используя возможности дженериков (generics) в современных версиях Go, мы можем написать универсальную функцию, которая будет работать с каналами абсолютно любого типа.

package concurrency

// OrDone принимает канал отмены и канал данных,
// возвращая безопасный для чтения канал.
func OrDone[T any](done <-chan struct{}, c <-chan T) <-chan T {
    valStream := make(chan T)

    go func() {
        defer close(valStream)
        for {
            select {
            case <-done:
                return // Завершаем работу при отмене контекста
            case v, ok := <-c:
                if !ok {
                    return // Завершаем работу, если исходный канал закрыт
                }

                // Внутренний select для безопасной отправки данных
                select {
                case valStream <- v:
                case <-done:
                }
            }
        }
    }()

    return valStream
}

Обратите внимание на внутренний select. Почему он необходим? Если горутина-потребитель работает медленно, отправка данных valStream <- v может заблокироваться. Если ровно в этот момент придет сигнал отмены в канал done, внутренний блок select позволит мгновенно прервать попытку отправки и корректно завершить работу горутины. Это защищает нас от потенциальных дедлоков на этапе пересылки.

Пример использования в конвейере

Давайте посмотрим, как применение паттерна Or-Done преображает код реального приложения. Реализуем простой конвейер с генератором чисел и потребителем.

package main

import (
    "fmt"
    "time"
)

// generate — простая функция создания потока данных
func generate(done <-chan struct{}, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case <-done:
                return
            case out <- n:
            }
        }
    }()
    return out
}

// Вставляем сюда нашу функцию OrDone
func OrDone[T any](done <-chan struct{}, c <-chan T) <-chan T {
    valStream := make(chan T)
    go func() {
        defer close(valStream)
        for {
            select {
            case <-done:
                return
            case v, ok := <-c:
                if !ok {
                    return
                }
                select {
                case valStream <- v:
                case <-done:
                }
            }
        }
    }()
    return valStream
}

func main() {
    done := make(chan struct{})

    // Создаем поток данных
    intStream := generate(done, 1, 2, 3, 4, 5, 6, 7)

    // Имитируем программную отмену через 1 миллисекунду
    go func() {
        time.Sleep(1 * time.Millisecond)
        close(done)
    }()

    // Используем OrDone для безопасного и лаконичного чтения
    for val := range OrDone(done, intStream) {
        fmt.Printf("Обработано значение — %d\n", val)
        time.Sleep(500 * time.Microsecond) // Имитируем затратную операцию
    }

    fmt.Println("Работа конвейера успешно завершена.")
}

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

go run main.go

Как видите, логика в функции main выглядит максимально лаконично. Мы используем привычный и понятный цикл for val := range, но при этом надежно защищены от зависаний горутин с помощью обертки OrDone.

Внедрение шаблона Or-Done в ваши проекты дает несколько ключевых преимуществ:

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

Используйте этот паттерн при построении сложных многоступенчатых пайплайнов обработки данных и при работе с долгоживущими каналами.