Антипаттерны Go: чего нельзя делать и почему

8949a95b86d392dac10bd23474253722.jpg

Привет, Хабр! Go часто называют «языком простоты»: мол, нет лишних фич, легко стартовать, запустил горутину — и вперед! Но в реальности эта «простота» — палка о двух концах. Я собрал самые распространенные (на мой взгляд) антипаттерны в Go, которые приводят к дедлокам, паникам и километрам непонятного кода.

Злоупотребление горутинами

«Запустим горутину, а там посмотрим…»

В Go очень легко распараллелить задачу: пишешь go func() { ... }() — и вуаля, новая горутина. Однако за видимой простотой скрываются трудности, о которых не подозреваешь, пока не столкнешься с ними «в полях».

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

Во-вторых, когда у нас много горутин, нужно уметь их синхронизировать и завершать. Мы нередко забываем, что горутина живет своей жизнью, пока сама не закончится или пока кто-то извне ее не прервет. Если не подумать заранее о механизме остановки, можно получить «вечные» горутины, которые уже никому не нужны, но продолжают крутиться.

Как контролировать горутины?

  • Использовать context.Context для передачи сигнала об отмене или таймаута.

  • Передавать в горутину каналы, по которым она получает «команду» завершиться.

  • Следить за тем, где мы создаем горутину и кто за нее отвечает (кто «владеет» ее жизненным циклом).

Код:

func worker(ctx context.Context, jobs <-chan int) {
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return // канал закрыт -- горутина завершает работу
            }
            process(job)
        case <-ctx.Done():
            return // сигнал об отмене -- срочно выходим
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    jobs := make(chan int)
    go worker(ctx, jobs)
    // отправляем задания...

    // Когда закончились задания, закрываем канал:
    close(jobs)

    // или отменяем контекст, если хотим прервать обработку
}

Таким образом, мы контролируем, когда горутина будет завершена, и избегаем «утечек» горутин.

Игнорирование ошибок

Go не заставляет автоматически обрабатывать ошибки, но рекомендует это делать. Тем не менее очень часто в коде встречается:

res, err := doSomething()
// "пофиг" на err
_ = err

Или, что еще хуже:

res, _ := doSomething() // потеряли ошибку навсегда

В итоге, если doSomething() вернул критическую ошибку, программа может попасть в непредсказуемое состояние, и искать корень проблемы придется долго и мучительно. Нередко спустя время видим в логах необъяснимые сбои, а оказывается, когда-то давно что-то тихо упало, а мы об этом даже не узнали.

Почему так делают?

  • Торопятся, считая, что «сейчас надо быстренько код написать, а потом все доделаю».

  • Недооценивают последствия ошибок, думая, что «ну не может оно тут упасть».

  • Лень писать if'ы и обрабатывать разные сценарии.

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

Решение:

Сразу после вызова обрабатывайте err. Если вы не хотите обрабатывать ошибку, хотя бы залогируйте ее.

Используйте подход «либо обрабатываем, либо пробрасываем выше»:

func doTask() error {
    res, err := doSomething()
    if err != nil {
        return fmt.Errorf("doSomething failed: %w", err)
    }
    // ...
    return nil
}

Пользуйтесь github.com/pkg/errors (или встроенными в Go 1.13+ возможностями errors.Wrap, fmt.Errorf с %w), чтобы добавлять контекст к ошибкам: так в логах сразу видно, на каком шаге сбой.

Отсутствие баланса при работе с каналами

«Сделаю канал, через него все пошлю, а закрою как-нибудь потом»

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

Проблема №1:

Закрыть канал можно только один раз, и должен это делать тот, кто «владеет» каналом. Если в другом месте кода тоже взяли и закрыли канал, получим панику close of closed channel. Или — еще хуже — мы вообще забываем закрыть канал, и горутины бесконечно ожидают входящие данные.

Проблема №2:

Дедлок при отправке или приеме. Если канал небуферизированный (make(chan int)), то каждая операция send блокируется, пока не появится «читающая» горутина, и наоборот. В результате можно легко написать код, где горутина зависает, ожидая приема, а «читающая» сторона сама ждет чего-то еще.

Проблема №3:

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

Как не вляпаться:

  • Определите «владельца» канала. Часто это функция, которая создает канал, и она же ответственна за его закрытие.

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

  • Используйте буферизированные каналы (например, make(chan int, 10)), если не хотите, чтобы каждая отправка блокировала до тех пор, пока кто-то не прочтет. Но помните, что при полном буфере send все равно заблокируется — не думайте, что буфер «резиновый».

Пример:

func producer(ch chan<- int, data []int) {
    defer close(ch) // Закрываем канал по завершению работы
    for _, v := range data {
        ch <- v
    }
}

func consumer(ch <-chan int) {
    for v := range ch {
        fmt.Println("Received:", v)
    }
}

func main() {
    data := []int{1, 2, 3, 4, 5}
    ch := make(chan int)

    go producer(ch, data)
    consumer(ch)
}

Здесь явно видно, что producer «владеет» каналом, а consumer только читает. По окончании записи producer закрывает канал, сообщая consumer о завершении.

Неправильная работа с sync.WaitGroup и мьютексами

Go предоставляет немало инструментов для синхронизации: sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Cond и другие. Но неправильное их использование зачастую приводит к таким проблемам, как взаимные блокировки, гонки данных и бесконечное ожидание WaitGroup.

WaitGroup

  • Забыть вызвать wg.Done(): горутина будет «учтена» в wg.Add(1), но никогда не уйдет из расчета, и вызов wg.Wait() зависнет навсегда.

  • Вызывать wg.Done() больше раз, чем Add(): получите панику negative WaitGroup counter.

  • Динамическое число горутин: если Add() вызывается после запуска горутины, та может завершиться раньше, чем мы увеличим счетчик — опять возможен рассинхрон.

Мьютексы (sync.Mutex и sync.RWMutex)

  • Обратный порядок блокировок: приводит к deadlock.

    Пример:

    • Горутина A захватывает mutex1, потом ждет mutex2.

    • Горутина B захватывает mutex2, потом ждет mutex1.

    • Обе висят, и никто не может продолжить.

  • Забыть defer mu.Unlock(): «забыть» освободить мьютекс. В итоге доступ к ресурсу блокируется навсегда.

Что делать:

Злоупотребление panic и recover

Механизм panic/recover в Go — это экстренный способ сообщить о фатальной ошибке, из которой обычно невозможно продолжать корректную работу. Но на практике часто встречаешь код, где panic используется вместо нормального возврата ошибки:

func mustDoSomething() string {
    res, err := doSomething()
    if err != nil {
        panic(err)
    }
    return res
}

Если это критически важный участок, где действительно нет смысла продолжать при ошибке, возможно, это оправдано. Но в целом «паниковать» из-за любого сбоя — плохая практика. Код может работать как сервер, и один panic повалит все приложение.

Когда panic уместна:

  • Фатальные ошибки инициализации, из-за которых программа не может вообще запуститься.

  • Нарушение инвариантов, когда алгоритм встретил ситуацию, которая «никогда не должна была случиться» (но если случилась, лучше остановиться, чем продолжать с неверными данными).

recover позволяет «отловить» panic и продолжить выполнение, но нужно понимать, что состояние программы может быть неконсистентным. Если вы не вычистите последствия паники (закрыть файлы, разблокировать мьютексы и т.д.), то продолжение работы может порождать более глубокие баги.

Поэтому лучше:

  • Возвращать error и давать вызывающей стороне самой решать, падать ли в panic или обрабатывать сбой.

  • Использовать panic в крайних случаях.

Отсутствие context.Context

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

Почему нужен context:

  • Позволяет отменять операции, если пользователь уже ушел или запрос больше не актуален.

  • Дает возможность задавать таймауты и дедлайны, не ломая общий флоу программы.

  • Дает «место» для хранения трассировочных и иных данных, нужных на всех уровнях вызовов.

Пример:

func fetchData(ctx context.Context, url string) (string, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return "", err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    data, err := io.ReadAll(resp.Body)
    return string(data), err
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    result, err := fetchData(ctx, "https://example.com/")
    if err != nil {
        log.Fatalf("fetchData failed: %v", err)
    }
    fmt.Println("Got data:", result)
}

Без context запрос мог бы зависать сколь угодно, а тут явно говорим: «Всего 2 секунды на операцию, дальше — отменяем».

Неразборчивое использование интерфейсов (особенно interface{})

Интерфейсы в Go помогают создавать абстракции, не зависящие от конкретных типов. Но когда везде используется «пустой интерфейс» (interface{}), мы фактически лишаемся типа и переходим в мир динамических проверок. В результате получаем кучу type assertion и не самый очевидный код.

func process(value interface{}) {
    switch v := value.(type) {
    case string:
        fmt.Println("String:", v)
    case int:
        fmt.Println("Integer:", v)
    default:
        fmt.Println("Unknown type")
    }
}

Почему это плохо:

  • Так легко потерять контроль над тем, что же мы передаем в функцию.

  • При расширении или рефакторинге — постоянные ветвления по типам, ошибка в одном месте может аукнуться в другом.

Правильный подход:

  • Создавайте явные интерфейсы с нужными методами. Пусть функция ожидает только то, что ей нужно.

  • Пользуйтесь дженериками (с Go 1.18+) для обобщенного кода, который остается при этом типобезопасным.

Глобальные переменные и неявное состояние

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

var GlobalMap = make(map[string]int)

func setValue(key string, val int) {
    GlobalMap[key] = val
}

func main() {
    go setValue("foo", 42)
    fmt.Println(GlobalMap["foo"])
}

Без синхронизации это код на удачу: в один раз все сработает, в другой — вылетит ошибка concurrent map read and map write, в третий — ничего не произойдет, а данные будут неконсистентными.

Что делать:

  • Убирать глобальные переменные, передавая нужные структуры и данные явно в функции.

  • Если уж крайне необходимо глобальное состояние, используйте sync.Mutex или sync.Map, чтобы избежать гонок.

  • Подумайте об архитектуре: может, стоит хранить эти данные в структуре, а доступ к ним организовывать через методы.

Неправильное понимание слайсов и их копирования

Слайс в Go хранит указатель на массив, длину и емкость. Если вы передаете слайс в функцию, он по-прежнему ссылается на тот же массив. Изменив слайс в одном месте, вы можете неожиданно повлиять на данные в другом месте программы.

func modify(s []int) {
    s[0] = 999
}

func main() {
    arr := []int{1, 2, 3}
    modify(arr)
    fmt.Println(arr) // [999 2 3]
}

Копируется лишь структура слайса, а не сами элементы массива.

Что с этим делать:

  • Если хотите копировать именно данные, используйте copy:

    newSlice := make([]int, len(arr))
    copy(newSlice, arr)

    Теперь newSlice и arr не конфликтуют.

  • Помните, что при расширении слайса (например, через append) может произойти «релоцирование» в новый массив, и часть слайсов будет ссылаться на старый массив, а часть — на новый. Это дополнительный источник путаницы.

  • Всегда проверяйте, не влияет ли функция, принимающая []T, на исходный массив в вызывающем коде.

Заключение

Подытожим основные мысли:

  • Контролируйте горутины: используйте контексты, отлаживайте время жизни, не забывайте завершать ненужные потоки.

  • Обрабатывайте ошибки: не прячьте их под _, а лучше добавляйте контекст и прокидывайте выше.

  • Внимательно работайте с каналами: определите протокол обмена, используйте буферизацию и будьте осторожны с закрытием.

  • Синхронизируйте доступ к разделяемым ресурсам: sync.WaitGroup, Mutex, RWMutex — инструменты полезны, но требовательны к порядку действий.

  • Не «паниковать» без надобности: panic — это аварийный выход, а не обычная замена return err.

  • Всегда думайте о context.Context при длительных операциях.

  • Вместо пустых интерфейсов стройте четкие интерфейсы или используйте дженерики.

  • Уходите от глобальных переменных: они затрудняют отладку и приводят к гонкам.

  • Помните про слайсы: они — не полноценные копии, а окна на общий массив.

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

Удачи в работе с Go!

© Habrahabr.ru