Олег Марков
Частые ошибки в Go common errors и как их избегать
Введение
Частые ошибки в Go обычно возникают не из-за сложного синтаксиса, а из-за неверных ожиданий от поведения языка и стандартной библиотеки. Основные "подводные камни" связаны со срезами, картами, указателями, конкуренцией и обработкой ошибок.
Давайте системно разберем распространенные ошибки, посмотрим на реальные куски кода и увидим, что на самом деле происходит "под капотом". Я буду показывать примеры, давать комментарии прямо в коде и объяснять, почему возникает ошибка и как ее исправить.
Ошибки при работе со срезами
Непонимание связи среза и массива
Срез в Go — это "окно" на подлежащий массив. Частая ошибка — думать, что при копировании срезов создаются независимые данные.
Посмотрите на этот пример:
package main
import "fmt"
func main() {
nums := []int{1, 2, 3, 4, 5}
// Здесь мы создаем два среза, указывающих на один и тот же подлежащий массив
a := nums[:3] // [1 2 3]
b := nums[1:] // [2 3 4 5]
a[1] = 99 // Меняем второй элемент среза a
fmt.Println(nums) // [1 99 3 4 5] - изменился исходный массив
fmt.Println(b) // [99 3 4 5] - и срез b тоже "увидел" изменение
}
Здесь ошибка в ожиданиях: многие предполагают, что a и b независимы. На практике они разделяют один массив.
Как избежать проблемы:
Если нужен независимый срез, нужно явно копировать данные:
src := []int{1, 2, 3}
dst := make([]int, len(src)) // Создаем новый срез с тем же размером
copy(dst, src) // Копируем значения
src[0] = 42 // Меняем исходные данные
fmt.Println(src) // [42 2 3]
fmt.Println(dst) // [1 2 3] - данные не изменились
Использование append без учета перераспределения
Смотрите, что происходит, когда вы используете append:
func main() {
base := []int{1, 2, 3}
a := base[:2] // [1 2]
b := base[:2] // [1 2]
// Здесь возможны два сценария, зависящие от capacity среза
a = append(a, 100) // Добавляем новый элемент в a
// Если capacity исходного среза достаточно,
// изменение затронет и base, и b
fmt.Println("base:", base)
fmt.Println("a:", a)
fmt.Println("b:", b)
}
Если cap(base) достаточно большой, append модифицирует тот же массив, и данные в b тоже изменятся. Если нет — append создаст новый массив и a станет ссылаться на него, а base и b останутся старыми.
Ошибка возникает, когда разработчик не учитывает эту "двойственность" поведения append.
Правило:
- Если вы передаете срез куда-то и ожидаете, что он не изменит ваши данные, либо:
- документируйте, что функция не должна модифицировать срез;
- либо заранее создайте копию среза.
Ошибка: "срез растет, а память не освобождается"
Еще одна распространенная проблема — "утечки" из-за того, что срез удерживает ссылку на большой массив.
type User struct {
Name string
}
func filterUsers(all []User) []User {
// Здесь мы создаем срез, который "смотрит" на тот же массив
var active []User
for _, u := range all {
if u.Name != "" {
active = append(active, u) // Добавляем элементы из all
}
}
return active
}
Если all — огромный срез, а active содержит только несколько пользователей, подлежащий массив может быть все равно очень большим и не будет освобожден, пока живет active.
Решение:
Создать новый срез и скопировать в него только нужные элементы:
func filterUsersCopy(all []User) []User {
active := make([]User, 0, len(all)) // Выделяем новый массив
for _, u := range all {
if u.Name != "" {
active = append(active, u) // Теперь данные лежат в новом массиве
}
}
return active
}
Ошибки при работе с картами (map)
Использование неинициализированной карты
Частая ошибка — попытка записать данные в nil-map.
func main() {
var m map[string]int // nil map
// Попытка записи вызовет панику:
// panic: assignment to entry in nil map
m["a"] = 1
}
Как правильно:
m := make(map[string]int) // Создаем карту
m["a"] = 1 // Теперь все работает
Ожидание детерминированного порядка обхода
Многие пытаются полагаться на порядок элементов при переборе map:
for k, v := range m {
fmt.Println(k, v) // Порядок ключей не гарантируется
}
Go специально "перемешивает" порядок, чтобы вы не опирались на него.
Если нужен порядок:
// Сначала собираем ключи
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// Сортируем ключи
sort.Strings(keys)
// Теперь обходим в определенном порядке
for _, k := range keys {
fmt.Println(k, m[k])
}
Чтение и запись в map из нескольких горутин
Карта не потокобезопасна. Ошибка, которая часто встречается:
var cache = make(map[string]string)
func writer() {
// Здесь горутина записывает данные в карту
cache["key"] = "value"
}
func reader() {
// А здесь другая горутина читает
_ = cache["key"]
}
Если вы вызываете writer и reader параллельно, вы рискуете получить runtime panic:
fatal error: concurrent map read and map write
Решения:
- Используйте
sync.Mutex:
type SafeMap struct {
m map[string]string
mu sync.Mutex
}
func (s *SafeMap) Get(k string) string {
s.mu.Lock()
defer s.mu.Unlock()
return s.m[k]
}
func (s *SafeMap) Set(k, v string) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
- Или
sync.RWMutexдля разделения чтения и записи. - Или
sync.Mapдля особых сценариев (много параллельного чтения, редкая запись).
Ошибки с указателями и значениями
Путаница между значением и указателем к структуре
Давайте посмотрим на типичную ситуацию:
type Config struct {
Port int
}
func setPort(c Config) {
// Здесь мы меняем копию структуры
c.Port = 8080
}
func main() {
cfg := Config{Port: 80}
setPort(cfg) // Изменяем копию
fmt.Println(cfg.Port) // 80 - порт не изменился
}
Разработчик ожидает, что порт поменяется, но функция работает с копией.
Как исправить:
Передавать указатель:
func setPortPtr(c *Config) {
// Здесь мы меняем оригинал через указатель
c.Port = 8080
}
func main() {
cfg := Config{Port: 80}
setPortPtr(&cfg) // Передаем адрес
fmt.Println(cfg.Port) // 8080 - порт изменился
}
Возврат указателя на локальную переменную
В Go это допустимо, но иногда новичков это пугает:
func newConfig() *Config {
cfg := Config{Port: 80}
// Мы возвращаем указатель на локальную переменную
return &cfg
}
Это не ошибка: сборщик мусора понимает, что переменная должна "жить" дольше, и размещает ее в куче. Но ошибка может быть в другом: если вы ожидаете "легкий" объект, а на самом деле каждый вызов приводит к выделению памяти в куче.
Рекомендация:
- Профилируйте код, если сомневаетесь.
- Не бойтесь возвращать указатели, но не делайте этого без необходимости.
Ошибки в обработке ошибок
Игнорирование возвращаемой ошибки
В Go ошибки — это обычные значения, и их легко проигнорировать:
data, _ := ioutil.ReadFile("config.yaml") // Здесь мы игнорируем ошибку
Часто это приводит к неочевидным багам: код продолжает работать с пустыми или некорректными данными.
Лучше всегда обрабатывать ошибку:
data, err := ioutil.ReadFile("config.yaml")
if err != nil {
// Здесь мы явно обрабатываем ошибку
log.Fatalf("failed to read config file - %v", err)
}
fmt.Println(string(data))
Потеря контекста ошибки
Такая конструкция встречается очень часто:
if err != nil {
return err // Контекст теряется
}
В итоге в лог попадает только "file not found", без информации, что это за файл и в каком месте кода он нужен.
Добавляйте контекст:
if err != nil {
return fmt.Errorf("load config - %w", err) // Заворачиваем ошибку с контекстом
}
Смотрите, как это помогает при логировании:
if err := run(); err != nil {
// В стеке можно будет увидеть всю "цепочку" ошибок
log.Println("application error -", err)
}
Паника вместо ошибки
Другой полюс — использовать panic везде, где что-то пошло не так:
if err != nil {
panic(err) // Нежелательно в обычной бизнес-логике
}
panic оправдан:
- при реально критических ошибках, от которых невозможно восстановиться;
- при нарушении внутренних инвариантов, когда программа в некорректном состоянии.
В остальном лучше возвращать error и решать, что с ним делать, на уровне вызывающего кода.
Ошибки в конкурентном коде (goroutine, channels)
Утечки горутин
Очень частая проблема — горутина запускается, но никогда не завершается, потому что ждет данные, которые уже не придут.
func worker(ch <-chan int) {
for v := range ch {
fmt.Println("got", v)
}
// Здесь горутина завершится только при закрытии канала
}
func main() {
ch := make(chan int)
go worker(ch)
ch <- 1
// Дальше main завершается, а горутина может "зависнуть"
}
Если вы забыли закрыть канал, горутина будет висеть в ожидании.
Решение:
- Закрывать каналы, когда данные больше не будут отправляться.
- Использовать контекст (
context.Context), чтобы сигнализировать о завершении.
func worker(ctx context.Context, ch <-chan int) {
for {
select {
case v, ok := <-ch:
if !ok {
// Канал закрыт - выходим
return
}
fmt.Println("got", v)
case <-ctx.Done():
// Контекст отменен - выходим
return
}
}
}
Закрытие канала отправителем и получателем одновременно
Еще одна распространенная ошибка — несколько горутин пытаются закрыть один и тот же канал.
func producer(ch chan<- int, done <-chan struct{}) {
defer close(ch) // Первый закрывает канал
for {
select {
case <-done:
return
case ch <- 1:
}
}
}
func anotherCloser(ch chan int) {
// Второй тоже пытается закрыть
close(ch) // panic - close of closed channel
}
Канал должен закрывать только одна сторона, обычно та, которая "пишет" в канал. Нужно явно договориться в архитектуре, кто закрывает канал.
Использование буферизированных каналов как очередей без контроля
Буферизированный канал иногда используют как очередь:
jobs := make(chan Job, 100) // Очередь на 100 задач
func submit(job Job) {
jobs <- job // Может заблокироваться, если очередь заполнена
}
Ошибка здесь — отсутствие стратегии, что делать, когда очередь заполнена. В итоге submit может "подвесить" систему.
Варианты решения:
- Добавить тайм-аут:
func submit(job Job) error {
select {
case jobs <- job:
return nil
case <-time.After(time.Second):
return errors.New("queue is full")
}
}
- Или использовать неблокирующую отправку:
func submitNonBlocking(job Job) bool {
select {
case jobs <- job:
return true
default:
return false // Очередь заполнена
}
}
Ошибки со строками и рунами
Индексация строки как массива символов
Строка в Go — это последовательность байт, а не символов Unicode. Это важное отличие, которое часто игнорируют.
s := "Привет"
// Здесь мы берем первый байт, а не первый символ
b := s[0]
fmt.Println(b) // Выведет значение байта, а не "П"
Для кириллических символов один символ обычно занимает несколько байт. Из-за этого:
len(s)— возвращает количество байт, а не символов;s[i]— возвращает байт.
Как правильно работать с символами:
for i, r := range s {
// Здесь r - это руна (Unicode кодовая точка)
// i - позиция в байтах
fmt.Printf("index=%d rune=%c\n", i, r)
}
Если нужно получить "символы" как срез рун:
runes := []rune(s) // Преобразуем строку в срез рун
fmt.Println(len(s)) // Количество байт
fmt.Println(len(runes)) // Количество символов
fmt.Println(string(runes[0])) // Первый Unicode символ
Ошибки при работе с временем и таймзонами
Использование time.Now без учета часового пояса
Многие пишут так:
now := time.Now()
fmt.Println(now) // Локальное время
Проблема возникает, когда приложение переносится в другой часовой пояс или запускается в Docker, где часовой пояс может быть другим.
Рекомендация:
- Внутри системы использовать
UTC. - На границе с пользователем (UI, API) преобразовывать в нужный часовой пояс.
nowUTC := time.Now().UTC() // Универсальное координированное время
fmt.Println(nowUTC)
Сравнение времени без нормализации
Другая ошибка — сравнивать время, не учитывая зону:
t1 := time.Now() // Локальное время
t2 := time.Now().UTC() // Время в UTC
fmt.Println(t1.Equal(t2)) // Может быть false, хотя момент тот же
Лучше приводить к одной зоне:
t1UTC := t1.UTC()
fmt.Println(t1UTC.Equal(t2)) // Теперь сравнение корректно
Ошибки при работе с JSON
Игнорирование тегов для экспортируемых полей
Стандартный encoding/json работает только с экспортируемыми (с заглавной буквы) полями структуры. Новички часто этого не учитывают:
type User struct {
name string // поле с маленькой буквы - не экспортируется
Age int
}
u := User{name: "Alex", Age: 30}
data, _ := json.Marshal(u)
fmt.Println(string(data)) // {"Age":30} - name проигнорирован
Как сделать правильно:
type User struct {
Name string `json:"name"` // Поле с большой буквы + тег
Age int `json:"age"`
}
Ошибка при декодировании в неправильный тип
Еще одна частая ошибка — ожидать конкретный тип, когда JSON может содержать другой:
type Config struct {
Port int `json:"port"`
}
data := []byte(`{"port":"8080"}`) // Здесь порт - строка в кавычках
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
// Получим ошибку типа
fmt.Println("error:", err)
}
Здесь "8080" — строка, а не число, и парсер JSON об этом честно сообщает.
Возможные решения:
- Исправить формат входного JSON.
- Использовать более общий тип, например
json.RawMessageилиinterface{}, а затем разбирать вручную.
Ошибки при использовании defer
Ожидание "ленивого" вычисления аргументов defer
Смотрите на этот пример:
func main() {
f, err := os.Open("file.txt")
if err != nil {
log.Fatal(err)
}
// Здесь аргумент f.Close вычисляется сразу
defer fmt.Println(f.Close()) // Закрытие происходит при вызове defer,
// но Println получает результат уже сейчас
}
На самом деле, аргументы функций в defer вычисляются сразу, в момент объявления defer, а сам вызов выполняется позже.
Правильно:
defer func() {
// Здесь мы явно вызываем Close и проверяем ошибку
if err := f.Close(); err != nil {
log.Println("failed to close file -", err)
}
}()
defer в цикле без учета стоимости
Еще одна проблема — использовать defer в большом цикле:
for i := 0; i < 100000; i++ {
f, _ := os.Open("file.txt")
defer f.Close() // Все отложенные вызовы накопятся до конца функции
}
Здесь вы получаете 100000 отложенных вызовов, которые будут выполнены только в самом конце. В результате файлы могут долго оставаться открытыми, а дескрипторы — не освобождаться.
Решение:
Закрывать ресурс в конце каждой итерации:
for i := 0; i < 100000; i++ {
f, _ := os.Open("file.txt")
// Здесь мы используем defer, но внутри отдельной функции
func() {
defer f.Close() // Выполнится в конце каждой итерации
// Работаем с файлом
}()
}
Или закрывать явно:
for i := 0; i < 100000; i++ {
f, _ := os.Open("file.txt")
// Работаем с файлом
f.Close() // Явное закрытие без defer
}
Заключение
Основные частые ошибки в Go связаны не с синтаксисом, а с особенностями реализации ключевых концепций:
- срезы разделяют общий массив и могут неожиданно влиять друг на друга;
- карты не потокобезопасны и имеют недетерминированный порядок обхода;
- указатели и значения требуют осознанного выбора способа передачи данных;
- ошибки нужно не только проверять, но и дополнять контекстом;
- конкурентный код легко "подвесить" или сломать, если не управлять жизненным циклом горутин и каналов;
- строки — это байты, а не "символы" в привычном понимании, и это важно для Unicode;
- работа со временем, JSON и defer требует понимания деталей.
Если вы обращаете внимание на эти аспекты, количество "странных" багов в Go-проектах заметно сокращается. Когда видите неожиданное поведение, полезно задать себе вопрос: "Не связано ли это со срезами, картами, указателями, каналами или областями видимости ошибок" — именно там чаще всего и прячется причина.
Частозадаваемые технические вопросы
Как безопасно передавать срезы между горутинами чтобы избежать гонок данных
Лучший вариант — не разделять изменяемые срезы между горутинами, а передавать данные по каналам:
// producer формирует данные и отправляет копии по каналу
func producer(out chan<- []int) {
for {
data := []int{1, 2, 3}
// Здесь мы можем скопировать срез, если не хотим разделять память
copyData := append([]int(nil), data...)
out <- copyData
}
}
Если все же нужен общий срез, защищайте его sync.Mutex и меняйте данные только под блокировкой.
Почему нельзя менять map во время range по ней в другой горутине
map не потокобезопасна. При одновременном чтении и записи структура данных внутри карты может быть в неконсистентном состоянии, что приводит к runtime panic. Если нужно итерироваться и одновременно писать, используйте sync.RWMutex:
mu.RLock()
for k, v := range m {
// только чтение
}
mu.RUnlock()
Запись делайте под mu.Lock / mu.Unlock.
Как правильно ограничить количество одновременно работающих горутин
Один из простых способов — использовать семафор на базе буферизированного канала:
sem := make(chan struct{}, 10) // не больше 10 горутин
for _, task := range tasks {
sem <- struct{}{} // захватываем "слот"
go func(t Task) {
defer func() { <-sem }() // освобождаем "слот"
t.Do()
}(task)
}
Так вы контролируете максимальное количество параллельных задач.
Как избежать data race при инкременте счетчика из нескольких горутин
Используйте sync.Mutex или атомарные операции:
var (
counter int64
mu sync.Mutex
)
func inc() {
mu.Lock()
counter++
mu.Unlock()
}
Или sync/atomic:
var counter int64
func inc() {
atomic.AddInt64(&counter, 1)
}
Как безопасно работать с временем в тестах чтобы не зависеть от реальных таймеров
Вместо реального time.Now и time.Sleep вводите абстракцию "часов" через интерфейс:
type Clock interface {
Now() time.Time
Sleep(d time.Duration)
}
// В проде используете реализацию на основе time
// В тестах подменяете на "фиктивные" часы и управляете временем вручную
Так тесты становятся детерминированными и быстрыми, а логика не зависит от реального времени.