Перейти к основному содержимому

Механика языка и гонки данных

Разработчику

Этот материал связывает базовый синтаксис и реальную эксплуатацию Go-сервисов. В небольших примерах конкурентный код выглядит безопасным, а под нагрузкой проявляются скрытые гонки и проблемы инициализации. Поэтому важно заранее закрепить рабочие правила.

См. также: Асинхронность и горутины · Синтаксические конструкции · Обработка ошибок.


Порядок инициализации

При запуске программы Go выполняет:

  1. Инициализацию пакетов в порядке импорта (зависимости раньше зависимых).
  2. В каждом пакете — присваивания переменным уровня пакета, затем все функции init() (их может быть несколько в одном файле).
  3. Функцию main в пакете main.
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}

Разбор:

  • func init() — специальная функция жизненного цикла Go, которая вызывается автоматически до main.
  • log.SetFlags(...) настраивает формат стандартного логгера для всего процесса.
  • log.LstdFlags добавляет дату и время, а log.Lshortfile — короткое имя файла и строку вызова.
  • Оператор | объединяет флаги побитово, формируя итоговую конфигурацию логирования.
  • Такой init удобен для глобальной базовой настройки, но важно не перегружать его тяжёлой логикой.

init нельзя вызвать вручную. Используют для регистрации драйверов БД (import _ "github.com/lib/pq"), глобальных настроек, проверок окружения. Избегайте тяжёлой логики и сетевых вызовов в init — усложняет тесты и скрывает зависимости.


Пустой идентификатор _

Игнорирует значение:

_ = riskyCall() // явно "результат не нужен"
for _, item := range list { }
f, _ := os.Open("x") // ошибку намеренно не обрабатывают — только если обосновано

Разбор:

  • _ = riskyCall() явно показывает намерение: функция вызвана ради побочного эффекта, а возвращаемое значение не используется.
  • for _, item := range list { } пропускает индекс с помощью _, оставляя только элемент коллекции item.
  • f, _ := os.Open("x") игнорирует ошибку открытия файла — синтаксически допустимо, но практично только в строго обоснованных случаях.
  • Пустой идентификатор _ помогает удовлетворить требование компилятора "каждая переменная должна использоваться", но не заменяет осмысленную обработку ошибок.
  • В review такие места требуют внимания: частое игнорирование ошибок может скрыть дефекты в runtime.

В импорте _ означает импорт только ради побочного эффекта (вызов init пакета):


import _ "net/http/pprof"
import _ "image/png"

Разбор:

  • import _ "net/http/pprof" подключает пакет ради его init(), который регистрирует pprof-обработчики в net/http mux.
  • import _ "image/png" регистрирует PNG-декодер в подсистеме image, чтобы image.Decode умел читать PNG.
  • Символ _ в импорте означает, что имя пакета не попадёт в область видимости текущего файла.
  • Такой приём используется для регистраций через side effects и должен сопровождаться понятным комментарием в production-коде.

Имя пакета при этом не попадает в область видимости — нельзя писать pprof.StartCPUProfile без обычного импорта.


Направленные каналы

Тип канала может ограничивать операции:

func producer(out chan<- int) { out <- 42 }
func consumer(in <-chan int) { v := <-in }

Разбор:

  • chan<- int в producer ограничивает канал только отправкой: функция не сможет читать из out даже случайно.
  • <-chan int в consumer ограничивает канал только чтением: функция получает данные, но не может в него писать.
  • Такая типизация делает конкурентный API самодокументируемым и снижает класс ошибок "не в ту сторону канала".
  • out <- 42 отправляет значение в канал и может блокироваться, пока его не прочитает получатель (для небуферизованного канала).
  • v := <-in получает значение из канала и может блокироваться до появления данных.

chan<- T — только отправка; <-chan T — только приём. chan T — оба направления. Это контракт API: функция не сможет случайно читать из канала, в который только пишет.

Закрытие — только на стороне отправителя; получатель проверяет:

v, ok := <-ch
if !ok {
// канал закрыт
}

Разбор:

  • Выражение v, ok := <-ch использует "comma-ok" форму чтения из канала.
  • ok == true означает, что получено реальное значение, канал ещё открыт.
  • ok == false означает, что канал закрыт и новых значений больше не будет.
  • Эта форма необходима, когда нужно корректно завершать worker-цикл и отличать "нулевое значение типа" от факта закрытия канала.
  • Правильная реакция на !ok предотвращает бесконечные циклы и лишнюю нагрузку после завершения продюсера.

Подробнее о паттернах — асинхронность.


Типичные гонки данных

Гонка — два потока обращаются к одной памяти, хотя бы один пишет, без синхронизации.


Замыкание в цикле

for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // часто три раза "3"
}()
}

Разбор:

  • В цикле запускаются горутины, но замыкание внутри func() захватывает одну и ту же переменную i, а не её значение на конкретной итерации.
  • К моменту выполнения большинства горутин цикл уже завершён, и i становится равной 3.
  • Поэтому вывод часто повторяет одно и то же число вместо 0, 1, 2.
  • Это классический баг конкурентного кода: проблема не в каналах, а в области видимости и времени жизни переменной.
  • Выявляется особенно часто под нагрузкой или при медленном планировании goroutine runtime-ом.

Исправление — передать копию:

for i := 0; i < 3; i++ {
i := i
go func() { fmt.Println(i) }()
}

Разбор:

  • Строка i := i создаёт новую переменную в теле текущей итерации и копирует в неё текущее значение счётчика.
  • Замыкание захватывает уже эту локальную копию, уникальную для каждой итерации цикла.
  • В результате каждая горутина печатает ожидаемое значение (0, 1, 2) в произвольном порядке.
  • Альтернатива с тем же смыслом — передать i аргументом в анонимную функцию.
  • Это базовый шаблон защиты от гонок/логических ошибок при запуске goroutine из цикла.

Общая map без mutex

Карты не потокобезопасны. Варианты: sync.Mutex + map, sync.Map для read-heavy кэшей, или одна горутина-владелец map и канал команд.

Пример защиты map через sync.Mutex:

var (
mu sync.Mutex
counters = map[string]int{}
)

func inc(key string) {
mu.Lock()
counters[key]++
mu.Unlock()
}

Разбор:

  • sync.Mutex обеспечивает взаимоисключение: в критическую секцию одновременно входит только одна горутина.
  • counters — общая map, к которой нельзя безопасно обращаться конкурентно без синхронизации.
  • mu.Lock() захватывает блокировку перед изменением map.
  • counters[key]++ становится атомарным относительно других горутин именно за счёт mutex.
  • mu.Unlock() обязательно освобождает блокировку; в более сложных функциях обычно используют defer mu.Unlock().

Срез из нескольких горутин

append может гонять за внутренний массив. Либо mutex, либо каждая горутина пишет в свой кусок, либо агрегация через канал.


sync-примитивы

ТипНазначение
sync.Mutex / RWMutexЗащита общих структур
sync.WaitGroupДождаться N горутин
sync.OnceОднократная инициализация
atomicСчётчики, флаги без mutex

Предпочитайте передачу данных каналами, когда владение однозначно; mutex — когда общее состояние сложное.


Детектор гонок

Включение при тестах и локальном запуске:

go test -race ./...
go run -race .

Разбор:

  • Флаг -race включает встроенный race detector, который инструментирует доступ к памяти и ищет несинхронизированные read/write.
  • go test -race ./... проверяет все пакеты проекта в тестовом режиме и показывает стеки конфликтующих операций.
  • go run -race . полезен для ручной проверки сценариев, которые не покрыты тестами.
  • Инструментация заметно замедляет выполнение и увеличивает потребление памяти, поэтому режим используют в разработке и CI, но не в production.
  • Отчёт race detector-а обычно сразу указывает проблемные участки конкурентного кода и ускоряет исправление дефектов.

Runtime отслеживает небезопасный доступ и печатает стек. Замедляет выполнение — в production не включают, в CI — да.

Статический go vet дополняет (например, копирование lock в структуре). Вместе с -race это базовый минимум для конкурентного кода.


Практический сценарий из API

Частый инцидент в сервисах: несколько HTTP-обработчиков обновляют общий счётчик или map без блокировок. На локальной машине ошибка может не проявляться, а в production значения становятся нестабильными.

Рабочая последовательность:

  1. Запустить go test -race ./....
  2. Добавить нагрузочный тест на проблемный endpoint.
  3. Найти по стеку общий участок памяти.
  4. Ввести явный контракт владения: mutex или горутина-владелец.

Пример минимального теста, который стоит гонять с -race:

func TestCounterConcurrent(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
inc("hits")
}()
}
wg.Wait()
}

Разбор:

  • sync.WaitGroup синхронизирует завершение всех запущенных горутин в тесте.
  • Цикл создаёт конкурентную нагрузку на функцию inc, имитируя боевой сценарий с параллельными запросами.
  • wg.Add(1) увеличивает счётчик задач перед запуском goroutine, а defer wg.Done() уменьшает его при завершении.
  • wg.Wait() блокирует тест, пока все конкурентные операции не будут завершены.
  • В сочетании с go test -race этот тест позволяет поймать реальные гонки в доступе к разделяемому состоянию.

Чек-лист перед code review

  • Есть ли go внутри for с переменными цикла?
  • Разделяют ли горутины map/slice без синхронизации?
  • Закрывает ли канал только отправитель?
  • Запускали ли go test -race для затронутых пакетов?
go test -race

Разбор:

  • go test -race запускает тесты текущего пакета с детектором гонок, если не указан путь ./....
  • Это быстрый минимальный smoke-check прямо перед коммитом или code review.
  • Команда хорошо подходит как локальный "предохранитель" для изменений в goroutine/каналах/map/slice.
  • Если пакет не содержит тестов, стоит добавить хотя бы базовые concurrency-тесты, иначе проверка будет ограниченной.

См. чек-лист самопроверки.

Для системного закрепления темы перейдите к вебу на стандартной библиотеке и асинхронности.