Диагностика Race Condition и предотвращение утечек горутин

Мы научились запускать сотни задач одновременно через воркер-пулы и каналы. В мире бэкенда на Go высокая скорость требует контроля над памятью. Если в React состояние защищено иммутабельностью, то в Go вы работаете с данными напрямую.

Сегодня разберем, как избежать «тихой» порчи данных и не превратить горутины в «зомби», пожирающих ресурсы сервера.

Race Condition: когда горутины спорят

Race Condition (состояние гонки) возникает, когда несколько горутин одновременно обращаются к одной переменной и хотя бы одна из них записывает данные. В Go это приводит к неопределенному поведению или аварийному завершению программы (panic).

В TypeScript код выполняется последовательно в рамках Event Loop. В Go планировщик может переключить контекст между горутинами в любой момент — даже в середине операции инкремента.

// ОПАСНО: Пример с Race Condition
func main() {
    counter := 0
    for i := 0; i < 1000; i++ {
        go func() {
            counter++ // Несколько горутин одновременно пишут в одну ячейку памяти
        }()
    }
}

На выходе вы почти никогда не получите 1000. Операция counter++ состоит из трех шагов: чтение, увеличение, запись. Если две горутины прочитают «5» одновременно, обе запишут «6». Одно обновление потеряется.

Race Detector: ваш личный аудитор

Go содержит встроенный инструмент для поиска таких ошибок — Race Detector. Он анализирует доступ к памяти в реальном времени.

Запустите код с флагом -race:

go run -race main.go
# или при тестировании
go test -race ./...

Если детектор найдет конфликт, он укажет конкретные строки кода, где произошла коллизия. В современной разработке проверка этим флагом в CI/CD — обязательный стандарт.

Mutex: замок для данных

Чтобы предотвратить гонку, нужно создать «критическую секцию», куда может войти только одна горутина. Для этого используют Mutex (mutual exclusion) из пакета sync.

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

Исправим пример с counter:

import "sync"

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()         // Запираем замок
    defer c.mu.Unlock() // Разблокируем строго при выходе из функции
    c.value++
}

Правило: Всегда пишите defer mu.Unlock() сразу после захвата. Это спасет от Deadlock — ситуации, когда функция завершилась с ошибкой, а замок остался закрытым навсегда, парализуя программу 🧱

Goroutine leak: утечки в бэкенде

В React вы используете cleanup-функции в useEffect, чтобы отписаться от событий. В Go нет автоматического «уборщика» для горутин.

Goroutine leak (утечка горутины) — это состояние, когда горутина заблокирована навсегда и не может завершиться. Она продолжает занимать память, нагружая Garbage Collector.

Типичная ошибка:

func leakyFunction() {
    ch := make(chan int)
    go func() {
        val := <-ch // Горутина ждет данные вечно
        fmt.Println(val)
    }()
    return // Канал потерян, горутина «утекла»
}

Контроль через Context

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

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done(): // Сигнал отмены (timeout или отмена запроса)
            return         // Завершаем работу корректно
        default:
            // Полезная нагрузка
        }
    }
}

Это основа Graceful Shutdown — безопасного выключения сервиса без потери данных.

Мы укрепили фундамент и научились писать безопасный конкурентный код. В следующей теме перейдем к практике: научимся превращать Go-структуры в JSON, чтобы ваш фронтенд на React мог общаться с новым бэкендом.