Время и таймеры в Go: рабочие приёмы без утечек
time.After удобен, но течёт в горутинах, time.Ticker забывают останавливать, а time.Parse спотыкается о часовые пояса. Разбираем типичные ошибки при работе со временем в Go и показываем, как их избежать в реальном коде.

Почему привычное решение не работает
Стандартная библиотека time в Go (1.22+) компактна: пара десятков функций, три ключевых типа — Time, Duration, Timer/Ticker. На маленьком проекте всё работает гладко. Проблемы начинаются, когда горутин становится сотни, а таймеры создаются в циклах: утечки памяти, незакрытые каналы и неожиданное поведение time.After в select. Ниже — конкретные приёмы и ошибки, с которыми сталкиваются на живых сервисах.
time.After внутри select: классическая утечка
Самый частый антипаттерн — вызов time.After в ветке select внутри цикла. Каждая итерация создаёт новый Timer, который не будет собран сборщиком мусора, пока не сработает. При быстром потоке сообщений таймеры накапливаются и занимают память.
Плохо: новый таймер на каждую итерацию
// Go 1.22+
for {
select {
case msg := <-messages:
process(msg)
case <-time.After(5 * time.Second): // утечка: таймер живёт 5 с, даже если msg пришёл раньше
log.Println("тайм-аут ожидания")
return
}
}
Если канал messages получает сообщение каждые 10 мс, за секунду создаётся 100 таймеров, каждый из которых висит в рантайме ещё 5 секунд. На нагруженном сервисе это сотни тысяч лишних объектов.
Хорошо: переиспользуемый Timer
// Go 1.22+
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-messages:
// Сбрасываем таймер после каждого сообщения
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(5 * time.Second)
process(msg)
case <-timer.C:
log.Println("тайм-аут ожидания")
return
}
}
Один таймер, одна аллокация. Stop + дренирование канала перед Reset — обязательная последовательность. Без дренирования есть шанс получить ложное срабатывание: старое значение останется в канале timer.C.
Начиная с Go 1.23 поведениеTimerиTickerизменилось: неостановленные таймеры теперь могут быть собраны сборщиком мусора, а канал таймера послеStopилиResetбольше не содержит устаревшее значение, если никто его ещё не прочитал. Однако если вы поддерживаете код на Go 1.22 и ниже, дренирование канала послеStopпо-прежнему необходимо. Проверяйте версию вgo.mod.
Ticker: не забудьте Stop
time.NewTicker создаёт горутину в рантайме, которая шлёт тики в канал. Если не вызвать Stop, горутина живёт до конца программы — это прямая утечка.
// Go 1.22+
func pollStatus(ctx context.Context, endpoint string) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop() // обязательно
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
resp, err := http.Get(endpoint)
if err != nil {
log.Printf("ошибка опроса: %v", err)
continue
}
resp.Body.Close()
log.Printf("статус: %d", resp.StatusCode)
}
}
}
Правило простое: defer ticker.Stop() — следующая строка после NewTicker. Исключений нет.
Сравнение подходов к периодическим задачам
| Подход | Когда подходит | Чем платите |
|---|---|---|
time.Sleep в цикле | Простейший воркер, допустим дрейф интервала | Нельзя прервать досрочно без context; интервал плывёт на время обработки |
time.NewTicker | Стабильный интервал, нужна отмена через ctx | Если обработка дольше тика — тики накапливаются в канале (буфер 1, лишние отбрасываются) |
time.AfterFunc | Разовое отложенное действие | Коллбэк выполняется в отдельной горутине — нужна синхронизация |
| Внешний планировщик (cron-библиотека) | Расписание по часам, дням, с учётом часовых поясов | Зависимость; сложнее тестировать |
Парсинг времени: магическая дата и часовые пояса
Go использует эталонную дату Mon Jan 2 15:04:05 MST 2006 вместо символов %Y-%m-%d. Новичков это путает, но главная ловушка не в формате, а в часовых поясах.
// Go 1.22+
raw := "2024-12-01 18:30:00"
// Ошибка: Parse считает строку без зоны как UTC
t1, _ := time.Parse("2006-01-02 15:04:05", raw)
fmt.Println(t1.Location()) // UTC — возможно, не то, что вы ожидали
// Правильно: явно задаём зону
loc, _ := time.LoadLocation("Europe/Moscow")
t2, _ := time.ParseInLocation("2006-01-02 15:04:05", raw, loc)
fmt.Println(t2.Location()) // Europe/Moscow
Если ваш сервис работает в Docker-контейнере на Alpine, time.LoadLocation может вернуть ошибку: пакет tzdata не установлен. Два способа решить:
- Добавить
apk add tzdataв Dockerfile. - Импортировать
_ "time/tzdata"— Go встроит базу зон прямо в бинарник (увеличивает размер примерно на 450 КБ).
* Эталонная дата — конкретная дата 2 января 2006 года 15:04:05, которую Go использует как шаблон для формата. Каждый компонент (месяц, день, час и т. д.) — уникальное число, по которому парсер определяет, какой элемент даты имеется в виду.
Монотонные часы и сравнение времён
С Go 1.9 значение time.Now() содержит и настенное время, и показание монотонных часов. Это значит, что time.Since(start) не сломается, если системные часы подкрутит NTP. Но есть нюанс: после сериализации и десериализации (JSON, база данных) монотонная компонента теряется.
// Go 1.22+
start := time.Now() // есть монотонное чтение
data, _ := start.MarshalJSON() // монотонное чтение отбрасывается
var restored time.Time
_ = restored.UnmarshalJSON(data)
// restored.Sub(time.Now()) теперь опирается только на настенное время
Если вы замеряете длительность операции и передаёте start между сервисами — считайте, что монотонных часов нет. Замеряйте длительность в том же процессе, где взяли start.
Equal vs ==
Оператор == сравнивает и зону, и монотонную компоненту. time.Time.Equal сравнивает только момент времени в UTC. Для бизнес-логики почти всегда нужен Equal.
Тестирование кода со временем
Код, завязанный на time.Now(), трудно тестировать. Распространённый приём — передавать функцию-часы через зависимость:
// Go 1.22+
type Clock func() time.Time
func RealClock() time.Time { return time.Now() }
type Service struct {
now Clock
}
func (s *Service) IsExpired(deadline time.Time) bool {
return s.now().After(deadline)
}
// В тесте:
fixed := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
svc := &Service{now: func() time.Time { return fixed }}
// Теперь результат IsExpired детерминирован
Альтернатива — интерфейс с методами Now(), After(d Duration), NewTicker(d Duration). Но для большинства случаев хватает простой функции.
Когда что выбирать: краткий чек-лист
- Тайм-аут одной операции —
context.WithTimeout. Не изобретайте свой таймер, если контекст уже есть. - Тайм-аут ожидания в select —
time.NewTimer+Reset. Неtime.Afterв цикле. - Периодическая задача с фиксированным интервалом —
time.NewTicker+defer Stop(). - Разовое отложенное действие —
time.AfterFunc. Помните, что коллбэк запустится в своей горутине. - Замер длительности —
time.Since(start)в том же процессе. Не передавайтеstartпо сети. - Парсинг пользовательского ввода —
time.ParseInLocation, а неtime.Parse, если зона не указана в строке.
Что делать дальше
Проверьте текущий код на два паттерна: time.After внутри for-select и NewTicker без Stop. Это два самых частых источника утечек, и оба находятся простым grep по репозиторию. Если используете Go 1.23+, изучите изменения в поведении Timer и Ticker в release notes — часть защитного кода с дренированием канала можно будет убрать. Для более сложных сценариев с расписанием посмотрите в сторону библиотек планировщиков, но убедитесь, что стандартного Ticker действительно недостаточно, прежде чем тянуть зависимость.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Пакет time — официальная документация Go, Go 1.23 Release Notes — изменения Timer и Ticker
Частые вопросы
Почему time.After в цикле select приводит к утечке памяти?
Обязательно ли дренировать канал Timer.C перед Reset?
Что будет, если не вызвать Ticker.Stop?
Как тестировать код, который зависит от time.Now?
time.LoadLocation не находит зону в Docker-контейнере. Что делать?
В чём разница между == и Equal при сравнении time.Time?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.