Привет, Хабр! 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()
: «забыть» освободить мьютекс. В итоге доступ к ресурсу блокируется навсегда.
Что делать:
-
Для
WaitGroup
:-
Обязательно вызывайте
Add()
до запуска горутины. -
В самой горутине делайте
defer wg.Done()
. Тогда нет риска забыть.
-
-
Для мьютексов:
-
Придерживайтесь одного и того же порядка захвата блокировок в разных местах кода, чтобы избежать deadlock.
-
Логируйте или хотя бы комментируйте, когда и зачем захватываете мьютекс — особенно если проект большой, и несколько команд трогают одни и те же структуры.
-
Злоупотребление 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!
ссылка на оригинал статьи https://habr.com/ru/articles/870138/
Добавить комментарий