Разработка надежных конкурентных приложений на 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 в ваши проекты дает несколько ключевых преимуществ:
- Читаемость кода — вы избавляетесь от дублирующихся и громоздких блоков
selectна каждом этапе конвейера данных. - Гарантия безопасности — снижается риск забыть обработать сигнал отмены в одной из функций, что надежно защищает сервер от утечек ресурсов.
- Фокус на бизнес-логике — код читается линейно, позволяя разработчику сосредоточиться на обработке самих данных, а не на механизмах их синхронизации.
Используйте этот паттерн при построении сложных многоступенчатых пайплайнов обработки данных и при работе с долгоживущими каналами.