Время и планировщики в Go: рабочие приёмы без утечек
time.After в цикле, забытый Stop у таймера, тесты, зависящие от реального времени — четыре ситуации, где очевидный код в Go течёт или ломается, и как писать сразу правильно.

Вы пишете фоновую задачу, которая раз в минуту опрашивает API. Через сутки под нагрузкой процесс держит десятки тысяч живых таймеров, память растёт, а профиль показывает гору аллокаций в runtime.timerproc. Причина — одна строка: time.After внутри select в цикле. Ниже — четыре типичные ошибки работы со временем в Go 1.22+ и то, как написать код так, чтобы к ним не возвращаться.
Материал для тех, кто уже пишет на Go и упёрся в конкретные грабли: утечку таймеров, дрейф периода, нетестируемое время. Разбираем приёмы и цену каждого, полные файлы не приводим — только фрагменты, показывающие разницу между «работает» и «работает правильно».
Ошибка первая: time.After в цикле
time.After(d) создаёт новый Timer при каждом вызове и не даёт его остановить. В select внутри бесконечного цикла это значит: на каждой итерации, где сработала другая ветка, старый таймер остаётся жить в куче планировщика до своего срабатывания.
// ПЛОХО: новый таймер на каждой итерации, отменить нельзя
for {
select {
case msg := <-messages:
handle(msg)
case <-time.After(5 * time.Second):
flush()
}
}
Пока идёт поток сообщений, ветка таймера почти никогда не выигрывает select, но таймеры всё равно создаются и висят по 5 секунд каждый. При тысяче сообщений в секунду это тысячи мёртвых таймеров одновременно.
Правильно — один Timer, который вы переиспользуете через Reset:
// ХОРОШО: один таймер, явный сброс
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-messages:
handle(msg)
// сбрасываем на новый интервал бездействия
if !timer.Stop() {
<-timer.C // осушаем канал, если таймер уже сработал
}
timer.Reset(5 * time.Second)
case <-timer.C:
flush()
timer.Reset(5 * time.Second)
}
}
Начиная с Go 1.23 семантика Reset и Stop изменилась: канал таймера гарантированно не отдаёт устаревшее значение после Stop, и осушать его вручную больше не требуется. Но если код должен работать и на 1.22 — оставляйте осушение, оно безвредно на новых версиях.
Ошибка вторая: осушение канала при Reset
Строка if !timer.Stop() { <-timer.C } выглядит как ритуал, но у неё есть смысл только на Go до 1.23 и только в одной горутине. Если таймер читают из нескольких горутин, эта конструкция может заблокироваться навсегда: Stop вернул false, потому что значение уже забрала другая горутина, а вы встали в чтение пустого канала.
- Одна горутина владеет таймером — паттерн со
Stopи осушением корректен (Go 1.22 и раньше). - Несколько горутин — не делите один таймер. Дайте каждой свой либо синхронизируйте доступ.
- Go 1.23+ — просто вызывайте
Reset, ручное осушение убирайте, оно больше не нужно.
Ошибка третья: Ticker вместо Sleep для периодических задач
Соблазн — сделать периодическую задачу через time.Sleep в цикле. Проблема в дрейфе: Sleep(interval) засыпает на интервал после завершения работы, а не выравнивает моменты запуска. Если задача заняла 800 мс, то при интервале в секунду фактический период станет 1,8 секунды.
// ДРЕЙФ: период = interval + время работы задачи
for {
doWork()
time.Sleep(time.Second)
}
time.Ticker тикает по абсолютным моментам времени и не копит дрейф:
ticker := time.NewTicker(time.Second)
defer ticker.Stop() // без Stop — утечка горутины и таймера
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
doWork()
}
}
Важное свойство: если doWork дольше интервала, тики не копятся в очередь. Буфер канала ticker.C — один элемент, лишние тики отбрасываются. Это защищает от лавины при отставании, но означает, что «пропущенные» запуски не наверстаются — учитывайте, если вам нужен ровно N срабатываний.
Когда что
| Задача | Инструмент | Почему |
|---|---|---|
| Однократная задержка/таймаут | time.NewTimer + Stop | можно отменить, нет утечки |
| Таймаут в одноразовом select | time.After | вызывается один раз, не в цикле |
| Периодическая работа | time.NewTicker + Stop | без дрейфа, отбрасывает лишние тики |
| Отмена по времени и по сигналу | context.WithTimeout | единый канал отмены |
Ошибка четвёртая: таймаут через time.After вместо context
Когда операция должна завершиться либо за N секунд, либо по внешней отмене, не собирайте это вручную из time.After и флагов. Используйте context.WithTimeout — он объединяет дедлайн и отмену в один канал и корректно освобождает ресурсы через cancel.
ctx, cancel := context.WithTimeout(parentCtx, 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)
// при таймауте err обёрнёт context.DeadlineExceeded
Забытый defer cancel() — самая частая утечка в коде с контекстами. go vet ловит её проверкой lostcancel, включённой по умолчанию в go test и go build через vet.
Ошибка пятая: тесты, зависящие от реального времени
Код, который вызывает time.Now() и time.Sleep напрямую, невозможно протестировать быстро и детерминированно. Тест либо ждёт реальные секунды, либо флакует. Решение — вынести источник времени за интерфейс и подменять его в тестах.
// Часы как зависимость
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
// В боевом коде
type RateLimiter struct {
clock Clock
last time.Time
}
func (r *RateLimiter) Allow() bool {
now := r.clock.Now()
if now.Sub(r.last) < time.Second {
return false
}
r.last = now
return true
}
В тесте подставляете фейковые часы с управляемым временем и проверяете логику без единого Sleep. Для полноценной подмены таймеров и тикеров есть готовые библиотеки — проверьте актуальный API в их документации перед использованием, интерфейсы там менялись между мажорными версиями.
Ещё одна ловушка: монотонное время
С Go 1.9 значение time.Time, полученное из time.Now(), несёт монотонную составляющую. Для измерения интервалов через Sub это правильно — результат не прыгнет при переводе системных часов. Но если вы сериализуете время (JSON, БД) и потом сравниваете с новым Now(), монотонная часть теряется, и разница может вести себя неожиданно. Для длительностей меряйте t2.Sub(t1), а не вычитайте Unix-время.
Что проверить в своём коде прямо сейчас
- Найдите
time.Afterвнутриfor— почти всегда это утечка, замените на переиспользуемыйTimer. - У каждого
NewTimerиNewTickerдолжен бытьdefer Stop()либо явныйStopна всех путях выхода. - После каждого
context.WithTimeout/WithCancel—defer cancel(). Прогонитеgo vet ./.... - Периодические задачи — на
Ticker, а не наSleepв цикле. - Если время участвует в бизнес-логике — спрячьте
time.Now()за интерфейс, чтобы тесты не зависели от wall clock.
Следующий шаг — снимите профиль памяти под нагрузкой: go tool pprof по heap-профилю покажет, копятся ли таймеры в runtime. Растущее число объектов time.Timer между двумя снимками при стабильной нагрузке — прямой признак невыключенных таймеров.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Package time — Go standard library documentation, Package context — Go standard library documentation
Частые вопросы
А если у меня Go 1.22, а не 1.23 — паттерн с осушением канала обязателен?
Ticker точно не накапливает пропущенные тики?
Что будет, если забыть defer cancel() у context.WithTimeout?
Почему нельзя мерить длительность через t2.Unix() - t1.Unix()?
Как тестировать код с таймаутами, если не хочется ждать реальные секунды?
Утечка таймеров реально заметна или это микрооптимизация?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.