Антипаттерны Go: чего нельзя делать и почему
Привет, Хабр! 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!