Частые ошибки в Go common errors и как их избегать

05 января 2026
Автор

Олег Марков

Введение

Частые ошибки в 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

Решения:

  1. Используйте 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
}
  1. Или sync.RWMutex для разделения чтения и записи.
  2. Или 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
// В тестах подменяете на "фиктивные" часы и управляете временем вручную

Так тесты становятся детерминированными и быстрыми, а логика не зависит от реального времени.

Стрелочка влевоДеплой FSD проекта - deploymentCode splitting в FSD - практическое руководство для фронтенд разработчиковСтрелочка вправо

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий