Время и таймеры в Go: чек-лист перед продакшеном
Утечки time.After в бесконечном select, забытый Stop() у таймера, монотонные часы и Reset с непустым каналом. Разбираем то, на чём чаще всего спотыкаются перед выкатом.

Сервис на Go отлично отработал нагрузочный тест на пять минут, а через сутки под ровной нагрузкой съел лишний гигабайт памяти. Профиль показал десятки тысяч живых таймеров. Причина — time.After внутри бесконечного select. Ниже — чек-лист того, что стоит проверить в коде, работающем со временем, прежде чем он попадёт в прод. Проверено на Go 1.21–1.23; часть поведения таймеров изменилась именно в 1.23, об этом отдельно.
1. time.After в цикле — источник утечки
Типовой паттерн: горутина в цикле ждёт либо данные из канала, либо таймаут.
// Проблемный вариант
for {
select {
case msg := <-input:
handle(msg)
case <-time.After(5 * time.Second):
flush()
}
}
Каждая итерация с приходом msg создаёт новый Timer через time.After, но старый не отменяется. До Go 1.23 такой таймер жил в куче и в очереди рантайма до полного срабатывания — до пяти секунд. Если сообщения идут потоком, таймеры копятся быстрее, чем истекают.
Правильно — создать один Timer вне цикла и переиспользовать его через Reset:
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-input:
handle(msg)
// сбрасываем таймаут на следующую итерацию
if !timer.Stop() {
<-timer.C // осушаем канал, если таймер уже сработал
}
timer.Reset(5 * time.Second)
case <-timer.C:
flush()
timer.Reset(5 * time.Second)
}
}
Если ваш сервис живёт часами, а не минутами, любойtime.Afterвнутриforили горутины-воркера — это кандидат на утечку. Ищите его первым.
Что изменилось в Go 1.23
В Go 1.23 сборщик мусора научился собирать таймеры и тикеры, на которые нет ссылок, даже если они ещё не сработали и Stop() не вызывали. То есть утечка памяти от time.After в цикле в 1.23 менее фатальна. Но сам таймер до истечения продолжает занимать место в очереди рантайма, а не осушённый канал по-прежнему может привести к логическим ошибкам. Полагаться на новый GC вместо явного Stop — плохая привычка: код должен корректно работать и на 1.21.
2. Осушение канала при Stop и Reset
Классическая ловушка Reset. Документация time.Timer до Go 1.23 прямо предупреждала: Reset нельзя вызывать, пока канал таймера не осушён, иначе в нём останется старое значение и следующее <-timer.C вернётся мгновенно.
Безопасная последовательность до 1.23:
- Вызвать
Stop(). - Если он вернул
false— таймер уже сработал, значит в канале лежит значение, его надо прочитать:<-timer.C. - Только теперь звать
Reset.
Важное изменение 1.23: начиная с Go 1.23 Reset и Stop автоматически очищают канал таймера, и осушать его вручную больше не нужно. Но если вы поддерживаете сборку на старых версиях — оставляйте паттерн с Stop и осушением: он безопасен везде.
Отдельная тонкость: осушение вида <-timer.C внутри select в горутине, отличной от той, что читает канал, — это гонка. Осушайте канал только в той же горутине, где происходит основной приём.
3. Ticker всегда нужно останавливать
time.Tick удобен, но у возвращаемого им тикера нет способа остановиться — рантайм не сможет его собрать, пока живёт программа. В долгоживущем сервисе это утечка по определению. Используйте time.NewTicker и defer ticker.Stop().
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
collectMetrics()
}
}
Проверка: как понять, что вы забыли Stop. В runtime/pprof посмотрите профиль горутин и heap — растущее число объектов runtime.timer при ровной нагрузке говорит о ненужных живых таймерах.
4. Монотонные и настенные часы
С Go 1.9 значение time.Now() содержит две части: настенное время (wall clock) и монотонные часы (monotonic). Разница критична для измерения интервалов.
- Настенное время скачет: перевод часов, синхронизация по NTP, ручное изменение времени на сервере. Между двумя
Now()разница по wall clock может даже оказаться отрицательной. - Монотонные часы только растут и не зависят от NTP. Для измерения длительности они и нужны.
Пока вы считаете интервал вычитанием двух time.Now(), Go сам использует монотонную часть — это правильно:
start := time.Now()
doWork()
elapsed := time.Since(start) // использует монотонные часы
Ловушка: монотонная часть теряется при операциях, которые «нормализуют» время — t.Round, t.Truncate, сериализация в JSON и обратно, сохранение в БД. После этого вычитание считается по настенным часам, и NTP-корректировка испортит замер. Правило: для измерения длительности не пропускайте time.Time через сериализацию между стартом и финишем.
5. Все блокирующие операции — через context
Ожидание без таймаута — самая частая причина зависших горутин в проде. Любой select, любое сетевое чтение и запрос к БД должны иметь путь отмены.
func fetch(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // освобождаем ресурсы таймера контекста
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // при истечении срока — context.DeadlineExceeded
}
defer resp.Body.Close()
return nil
}
context.WithTimeout внутри держит таймер. Забытый cancel() — это утечка таймера до истечения таймаута, поэтому defer cancel() обязателен, даже если запрос завершился раньше.
6. Тайм-зоны: не полагайтесь на локаль сервера
Функция time.LoadLocation("Europe/Moscow") требует базы часовых поясов. В контейнере FROM scratch или минимальном alpine её может не быть, и вызов вернёт ошибку. Варианты:
- установить пакет
tzdataв образ; - импортировать
time/tzdataв бинарь — тогда база встраивается в исполняемый файл (добавляет около полумегабайта).
Проверьте, что time.Now() на проде возвращает то, что вы ожидаете. Хранить в БД лучше в UTC, а в локальное время переводить только на выводе.
Сводный чек-лист
| Что проверить | Риск, если пропустить |
|---|---|
Нет time.After в бесконечном цикле | Утечка таймеров, рост памяти |
defer timer.Stop() / ticker.Stop() | Живые таймеры до срабатывания |
Осушение канала перед Reset (до 1.23) | Мгновенное ложное срабатывание |
Замеры длительности через time.Since | Скачки при NTP-корректировке |
Все блокировки под context с таймаутом | Зависшие горутины |
| Наличие tzdata в образе | Ошибка загрузки часового пояса |
Что настроить дальше
Включите -race в CI — гонки при осушении канала таймера он ловит. Добавьте профилирование через net/http/pprof на служебном порту и снимайте профиль горутин под нагрузкой: график живых горутин и объектов таймеров на ровной нагрузке должен быть плоским. Если у вас крупная кодовая база, прогоните go vet и линтеры — существуют правила, отлавливающие потерянный cancel() у контекста. И зафиксируйте минимальную версию Go в go.mod: поведение Reset/Stop зависит от неё напрямую.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Package time — Go documentation, Go 1.23 Release Notes — Timer changes
Частые вопросы
А если у меня Go 1.23 или новее — нужно ли всё ещё осушать канал таймера?
Утечка таймеров от time.After — она правда исчезла в 1.23?
Как отличить в проде утечку таймеров от обычного роста нагрузки?
Почему мой замер длительности иногда получается отрицательным?
Обязательно ли вызывать cancel(), если запрос и так завершился раньше таймаута?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.