Время и планирование в Go: ошибки, которые всплывают в проде
time.Now() без монотонных часов, тикеры, которые текут по памяти, и таймауты, которые никого не отменяют. Разбираем практические ошибки работы со временем в Go и цену каждой.

Сервис отдавал верную длительность запросов ровно до момента синхронизации системных часов по NTP: в логах появились отрицательные значения задержки, метрики p99 обвалились в ноль, а алерт «время ответа < 0 мс» никто заранее не придумал. Причина — time.Sub посчитали на двух значениях time.Now(), между которыми часы прыгнули назад. Ниже — практический разбор ошибок работы со временем в Go, каждая из которых выглядит безобидно в коде и выстреливает под нагрузкой или во времени. Ориентир — Go 1.22+, но почти всё справедливо начиная с 1.9, когда в time.Time добавили монотонные часы.
Измерение длительности через настенные часы
Классика: замеряете начало и конец операции через time.Now() и вычитаете. Кажется, что ошибиться негде.
start := time.Now()
doWork()
elapsed := time.Since(start) // == time.Now().Sub(start)
Здесь как раз всё правильно. Значение, которое возвращает time.Now(), содержит две части: настенное время (wall clock) и монотонные часы (monotonic clock). Пока вы вычитаете два значения, оба полученные из time.Now(), Go использует монотонную составляющую — она не прыгает при NTP-синхронизации, при переводе часов и при уходе времени вперёд-назад.
Ломается всё, когда монотонную часть теряют. Она отбрасывается в нескольких операциях:
t.Round(0)или любойRound/Truncate— обрезает монотонную часть;- сериализация и десериализация:
MarshalJSON,Formatс последующимParse, запись в БД и чтение обратно; t.UTC(),t.Local(),t.In(loc)— тоже сбрасывают монотонные часы;- значение, полученное не из
time.Now(), а собранное черезtime.Date.
Если вы сохранили start в структуру, прогнали её через JSON и потом вычли — вы уже на настенных часах и получите отрицательную длительность при откате времени назад.
Правило простое: для интервалов держите оба значения «сырыми» из time.Now() и не пропускайте их через сериализацию, Round или смену зоны. Настенное время — для «когда это случилось», монотонное — для «сколько это длилось».
Тикеры и таймеры, которые текут
time.After удобен в select, но у него есть цена. Каждый вызов создаёт новый Timer, и до Go 1.23 такой таймер не собирался сборщиком мусора, пока не сработает, — даже если ветка select уже выбрала другой случай. В цикле это утечка.
// Плохо: на каждой итерации новый таймер живёт до срабатывания
for {
select {
case msg := <-ch:
handle(msg)
case <-time.After(5 * time.Second):
return
}
}
В Go 1.23 сборку неиспользуемых таймеров починили: непрочитанные Timer и Ticker теперь могут собираться, даже если их канал никто не дочитал. Но на 1.22 и раньше в горячем цикле time.After будет копить объекты в куче. Переносимый вариант — один переиспользуемый таймер:
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
timer.Reset(5 * time.Second)
select {
case msg := <-ch:
handle(msg)
case <-timer.C:
return
}
}
Ticker обязательно останавливать
time.Tick вообще не даёт способа остановиться — тикер живёт до конца программы. В долгоживущем сервисе это утечка гарантированно. Используйте time.NewTicker и defer ticker.Stop(). Забытый Stop — одна из самых частых причин медленно растущей памяти в фоновых воркерах.
Reset и Stop — с осторожностью
В документации к Timer.Reset отдельно сказано: вызывать Reset у таймера, канал которого не был опустошён, безопасно только при определённых условиях. До Go 1.23 стандартный совет был — останавливать таймер и, если Stop вернул false, сливать значение из канала. Начиная с 1.23 семантику Reset/Stop сделали более предсказуемой (канал таймера теперь буферизован размером 1 и очищается при Stop/Reset), но точную гарантию для своей версии сверяйте в документации пакета time — здесь поведение реально менялось между релизами.
context.WithTimeout без отмены
context.WithTimeout и context.WithCancel возвращают функцию cancel, и её нужно вызвать всегда — даже если операция завершилась раньше таймаута. Пропущенный cancel оставляет внутренний таймер и связанные ресурсы живыми до истечения дедлайна. В обработчике HTTP с таймаутом в 30 секунд и тысячами запросов это заметная утечка.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel() // вызывается в любом случае
resp, err := doRequest(ctx)
if err != nil {
return err
}
_ = resp
go vet и линтер lostcancel ловят большинство таких случаев — держите их в CI. Отдельная ошибка — передать в WithTimeout уже отменённый или почти истёкший родительский контекст: дочерний дедлайн не может быть позже родительского, Go выберет более ранний.
Часовые пояса, которых нет на машине
time.LoadLocation("Europe/Moscow") читает базу IANA. На минимальном Docker-образе (scratch, alpine без пакета tzdata) этой базы нет, и вызов вернёт ошибку — а не тихо переключится на UTC. Код, который эту ошибку игнорирует, начнёт считать локальное время как UTC, и отчёты «за сутки» поедут на три часа.
- В образ кладите пакет
tzdata(в Alpine —apk add tzdata); - либо импортируйте пакет
time/tzdataв код — он вшивает базу в бинарник (плюс несколько сотен килобайт к размеру); - всегда проверяйте ошибку
LoadLocation, а не отбрасывайте её.
Форматирование по опорной дате
В Go нет привычных %Y-%m-%d. Формат задаётся образцом опорной даты Mon Jan 2 15:04:05 MST 2006 — это 01/02 03:04:05PM '06 -0700 по возрастанию. Отсюда две частые ошибки: перепутать месяц и день (оба однозначны в примере) и написать 2006-01-02 15:04, ожидая 24-часовой формат, но взять 03 вместо 15 и получить 12-часовой.
t := time.Date(2025, 3, 5, 14, 30, 0, 0, time.UTC)
t.Format("2006-01-02 15:04") // 2025-03-05 14:30
t.Format("02.01.2006") // 05.03.2025
// Готовые константы предпочтительнее строк
t.Format(time.RFC3339) // 2025-03-05T14:30:00Z
Для машинного обмена берите time.RFC3339, а не собственный образец: меньше шансов ошибиться в порядке полей.
Сравнение таблицей: что чем измерять
| Задача | Что использовать | Чего избегать |
|---|---|---|
| Длительность операции | time.Since(start), где start из time.Now() | вычитать значения из БД или JSON |
| Момент события для лога/БД | time.Now().UTC(), хранить в UTC | хранить в локальной зоне сервера |
| Периодическая задача | time.NewTicker + Stop | time.Tick (не остановить) |
| Таймаут в select (цикл) | переиспользуемый Timer + Reset | time.After в горячем цикле до 1.23 |
| Ограничение времени запроса | context.WithTimeout + defer cancel | WithTimeout без вызова cancel |
Тесты, зависящие от реального времени
Тест, который вызывает time.Sleep и проверяет, что «прошло примерно столько-то», флакует на нагруженном CI и тормозит прогон. Не завязывайте логику на time.Now() напрямую — прокидывайте функцию-источник времени или clock-интерфейс через зависимость. Тогда в тесте вы подставляете фиктивные часы и двигаете время сами, без Sleep. Стандартной библиотеки для этого нет; распространённый подход — интерфейс с методом Now() time.Time, реальный источник в проде и управляемый в тесте.
Что сделать дальше
- Включите
go vetв CI — он ловит потерянныйcancelи неверные строки форматаPrintf-семейства. - Проверьте базовый Docker-образ: есть ли
tzdata, если код грузит зоны. - Найдите в кодовой базе
time.Afterвнутри циклов иtime.Tick— это кандидаты на утечку до 1.23. - Убедитесь, что интервалы длительности не проходят через сериализацию перед вычитанием.
- Сверьте семантику
Timer.Resetс документацией вашей версии Go — поведение менялось.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Пакет time — официальная документация Go, Пакет context — официальная документация Go
Частые вопросы
У меня Go 1.22, а не 1.23. Проблема с утечкой time.After актуальна?
Почему time.Since иногда даёт корректный интервал, а иногда отрицательный?
Что будет, если забыть вызвать cancel у context.WithTimeout?
Как хранить время в базе — в UTC или в локальной зоне?
Я собираю образ на scratch, и LoadLocation падает. Что делать?
Как тестировать код, завязанный на время, без flaky-тестов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.