---
title: "Напоминания: goroutine и тикер"
description: "Напоминания в телеграм-боте на Go: time.Ticker в фоновой горутине, map с мьютексом, сверка времени, тайм-зоны пользователей и остановка тикера. Код урока."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/19-napominaniya/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 18
---

# Напоминания: goroutine и тикер

*Курс «Telegram-боты на Go: от эха до полноценного сервиса», урок 19 из 25. ~18 минут. Обновлено 25.09.2026. Источник: https://golangmaster.ru/courses/telegram-bot/lessons/19-napominaniya/*

Напоминания в боте на Go — первая фоновая задача: горутина с time.Ticker просыпается каждые несколько секунд, сверяет время и рассылает сообщения, не трогая основной цикл апдейтов. Собираем бот-будильник целиком: хранилище напоминаний с мьютексом, аккуратную остановку тикера, тайм-зоны пользователей, из-за которых «девять утра» у каждого свои, и место, где боты чаще всего встают намертво.

До этого урока бот был реактивным: пришёл апдейт — отреагировал, тишина — сидит ждёт. Напоминание ломает модель: сообщение должно прийти тогда, когда пользователь ничего боту не пишет, а значит источника события нужно два. Первый — цикл GetUpdates или webhook, из уроков про polling и webhook, он работает как работал. Второй — собственная горутина с таймером, запущенная рядом в main: она не слушает Telegram, она слушает часы. Каждые десять-пятнадцать секунд она сверяет хранилище напоминаний с текущим временем и отправляет всё, что созрело. Архитектура примитивная и именно поэтому живучая: две горутины, одна общая структура данных и один мьютекс. В уроке пройдём от структуры Reminder до тайм-зон, а по пути разберём две ошибки, которые на этом пути делают почти все — рассылку под мьютексом и тикер без Stop.

### Хранилище напоминаний: map[int64][]Reminder с мьютексом

Напоминание — это получатель, текст и момент срабатывания: структура Reminder с полями ChatID int64, Text string и At time.Time. Хранить удобнее картой чатов: map[int64][]Reminder — на каждый chat_id свой срез, добавление копеечное, чистка сработавших не трогает чужие чаты. Карта общая для двух горутин: цикл апдейтов пишет в неё команду /remind, тикерная горутина читает и удаляет. Голая карта в Go так не умеет: одновременные запись и чтение дают фатальную ошибку concurrent map read and map write, которая убивает весь процесс и не ловится recover — бот падает целиком, а не отдельная функция. Поэтому структура Reminders оборачивает карту в sync.Mutex: методы Add, Due и любые будущие операции захватывают лок на всю работу с картой. RWMutex здесь не спасёт: тикерная горутина и так каждый тик модифицирует карту, вырезая сработавшее, — читателей без записи почти нет.

### Структура хранилища и добавление

```go
type Reminder struct {
        ChatID int64
        Text   string
        At     time.Time
}

type Reminders struct {
        mu     sync.Mutex
        byChat map[int64][]Reminder
}

func NewReminders() *Reminders {
        return &Reminders{byChat: make(map[int64][]Reminder)}
}

func (r *Reminders) Add(chatID int64, text string, at time.Time) {
        r.mu.Lock()
        defer r.mu.Unlock()
        r.byChat[chatID] = append(r.byChat[chatID],
                Reminder{ChatID: chatID, Text: text, At: at})
}
```

Мьютекс лежит рядом с данными, которые защищает, — так невозможно забыть, к чему он относится. Add захватывает лок и defer-ом отпускает: паника при вычислении не оставит замок захваченным. Внешний код не видит карту вообще — весь доступ идёт через методы, и гонки исключены по построению, а не по дисциплине вызывающего.

### time.Ticker в фоновой горутине

Тикер — это таймер с повторением: time.NewTicker(interval) возвращает указатель на Ticker с каналом C, в который раз в interval прилетает текущее время. Читаем канал в select вместе с ctx.Done — так горутина одновременно ждёт и тика, и команды завершиться, а по закрытию контекста просто выходит. Классическая ошибка вместо тикера — цикл со time.Sleep: он не отменяем посреди паузы, и сигнал завершения придётся ждать до конца сна, а его интервал дрейфует — к длительности паузы прибавляется время обработки, и тики постепенно съезжают. Тикер держит ритм независимо от того, сколько занимает рассылка. Частота выбирается по желаемой точности: напоминания с точностью до минуты не требуют тика чаще, чем раз в десять-пятнадцать секунд, — и экономят запросы. defer ticker.Stop() ставится сразу после создания: пропущенный Stop в долгоживущем процессе — это таймер, который продолжает тикать в канал, который никто не читает.

### Цикл напоминаний в горутине

```go
func reminderLoop(ctx context.Context, bot *tgbotapi.BotAPI, store *Reminders) {
        ticker := time.NewTicker(15 * time.Second)
        defer ticker.Stop()

        for {
                select {
                case <-ctx.Done():
                        return
                case <-ticker.C:
                        for _, rem := range store.Due(time.Now()) {
                                msg := tgbotapi.NewMessage(rem.ChatID,
                                        "Напоминание: "+rem.Text)
                                _, _ = bot.Send(msg)
                        }
                }
        }
}

// в main:
go reminderLoop(ctx, bot, reminders)
```

Горутина запускается одним словом go рядом с созданием бота и живёт своей жизнью, пока контекст не отменят. Select на двух каналах делает выход мгновенным: сигнал в ctx.Done выбирается даже посреди ожидания тика. Send идёт после выхода из store.Due — по уже отпущенному замку; ошибка отправки логируется или молча глотается, но не паникует: паника в горутине убивает весь процесс.

Ключевой метод — Due(now time.Time) []Reminder: он возвращает всё, чей момент наступил, и вырезает это из карты. Под локом один проход по карте: у каждого чата срез делится на сработавшее и оставшееся, пустые чаты удаляются delete-ом, чтобы карта не разбухала навсегда. Лок отпускается, и только потом начинается отправка. Разделение «собрать под замком — отправить без замка» — самое важное правило урока, и следующая врезка объясняет, что происходит, когда его нарушают. Здесь же живёт политика повторов: если bot.Send упал из-за сети, простое решение — вернуть напоминание в карту с небольшим сдвигом вперёд, а не терять его молча. Проверка !rem.At.After(now) срабатывает на секунду или позже — при тикере в пятнадцать секунд напоминание опоздает максимум на пятнадцать, и для «позвонить маме» это приемлемая цена.

### Выборка сработавших под замком

```go
func (r *Reminders) Due(now time.Time) []Reminder {
        r.mu.Lock()
        defer r.mu.Unlock()

        var due []Reminder
        for chatID, rems := range r.byChat {
                var kept []Reminder
                for _, rem := range rems {
                        if !rem.At.After(now) {
                                due = append(due, rem)
                                continue
                        }
                        kept = append(kept, rem)
                }
                if len(kept) == 0 {
                        delete(r.byChat, chatID)
                        continue
                }
                r.byChat[chatID] = kept
        }
        return due
}
```

Проход по карте собирает два результата сразу: срез к отправке и вычищенная версия хранилища. Все изменения происходят под замком, наружу возвращается обычный срез — с ним горутина отправки работает без всякой синхронизации, потому что данные уже никому не принадлежат внутри хранилища. Итерация по map в Go случайна по порядку — для напоминаний это неважно.

> **Живое место: рассылка под мьютексом.** Вариант с минимальными правками, который делает почти каждый: вызвать bot.Send прямо внутри Due, под локом. Пока идёт HTTP-запрос к Telegram API — сотни миллисекунд в норме, десятки секунд при их лагах, — замок держится, и каждый входящий апдейт в основном цикле висит на mu.Lock. Бот не падает, он замерзает: сообщения отвечают с задержкой, команда /remind не принимается, а при недоступности Telegram замирает целиком. Вторая вариация — паника в тикерной горутине: она не ловится в основном цикле и убивает процесс целиком. Лечение одной строкой: собирать сработавшее под замком, отправлять после — и держать в цикле recover, если текст напоминания приходит от пользователей и ему нельзя доверять.

### Тайм-зона пользователя: чьих девять утра

Пользователь пишет «напомни в 9:00» — но его девять и ваши девять не обязаны совпадать, и сервер вообще может стоять в UTC. Хранить момент нужно одним эталоном: At в UTC, а рядом с пользователем — его зона: поле в профиле, явная команда /tz или значение из настроек клиента, если вы делаете Mini App. Парсинг ввода идёт в зоне пользователя: time.ParseInLocation с шаблоном 15:04 и локацией строит момент уже с учётом смещения, после чего хранится в UTC. Загрузка зоны функцией time.LoadLocation с именем вроде Europe/Moscow читает системную базу IANA, которой нет в пустых контейнерах и scratch-образах; решение — импорт пакета time/tzdata с пустым именем в main, он вшивает копию базы в бинарник. Осторожно с переводом часов: напоминание «ежедневно в 9» честнее пересчитывать в момент каждого срабатывания из зоны пользователя, чем вычислять раз и навсегда, — иначе после смены смещения оно уползёт на час.

Карта в памяти честно умирает вместе с процессом: перезапустили бота — все напоминания испарились, и пользователи узнают об этом из собственных обид. Поэтому хранилище делает выгружаемым: при добавлении записать в Redis или базу, при старте прочитать всё обратно и наполнить карту — схема перехода от карты к Redis разобрана в уроке про хранение данных бота. Тикерная рассылка подчиняется тем же лимитам Telegram, что и ручная: сотни чатов, сработавших в один тик, нельзя лить без пауз — API ответит 429 с RetryAfter, и вежливая очередь с задержками из уроков про рассылки и про лимиты переносится сюда как есть. Команда /remind при этом остаётся тривиальной: CommandArgs достаёт строку вида 18:30, ParseInLocation строит момент, Add кладёт в хранилище, бот подтверждает ответом — весь диалог умещается в двадцать строк.

### Чем будит время: четыре подхода

| Подход | Как работает | Когда брать |
| --- | --- | --- |
| time.Sleep в цикле | Пауза после каждой итерации | Скрипты; для бота плохо: не отменяем посреди сна, интервал дрейфует |
| time.Ticker | Канал с регулярными тиками, select с ctx.Done | Наш выбор: точный ритм, мгновенная остановка, одна горутина |
| time.AfterFunc | Одноразовый таймер на каждое напоминание | Десятки разовых напоминаний; на тысячах — сотни живых таймеров в памяти |
| Внешний планировщик | cron или очередь с отложенными задачами | Расписания, повторения и несколько инстансов бота |

> **Проверка за минуту.** Понадобится локальный Go и токен бота из BotFather в переменной BOT_TOKEN. Поставьте интервал тикера в три секунды и добавьте напоминание через полминуты: /remind через команду с моментом time.Now().Add(30 * time.Second). Сообщение придёт без единого действия с вашей стороны — лучший способ почувствовать разницу между реактивным ботом и сервисом, который сам помнит. Потом верните пятнадцать секунд и добавьте загрузку из Redis.

### Проверка: общая карта

**Вопрос:** Цикл апдейтов пишет в map[int64][]Reminder, тикерная горутина пишет в неё же. Мьютекса нет. Что случится?

- Ничего: горутины Go безопасно делят карты
- Гонка иногда даёт неверные данные, но бот работает
- ✅ Фатальная ошибка concurrent map write — падает весь процесс, recover не спасает
- Падает только тикерная горутина, цикл апдейтов продолжается

Гонка на карте в Go — не исключение, а фатальная ошибка рантайма: процесс убивается целиком, и recover в чужой горутине его не вернёт. Мьютекс вокруг всех операций с картой — минимальная цена за то, чтобы две горутины делили данные без гарантированной аварии в неудачный момент.

### Проверка: что делает Stop

**Вопрос:** Что делает ticker.Stop() — и чего он не делает?

- Закрывает канал C и останавливает таймер
- ✅ Останавливает тики, но канал C не закрывает
- Убивает горутину, читающую канал
- Сбрасывает интервал на ноль

По документации Stop выключает тикер, но канал намеренно не закрывает — чтобы читатель в другой горутине не получил ложный «последний тик» посреди гонки. Отсюда правило: закрытие канала, если оно нужно для завершения range, делается отдельно, а выход из цикла чаще строят на ctx.Done.

### Медленная рассылка против тикера

```go
ticker := time.NewTicker(1 * time.Second)
go func() {
        for range ticker.C {
                fmt.Println("тик")
                time.Sleep(5 * time.Second) // долгая рассылка
        }
}()
time.Sleep(12 * time.Second)
```

**Что выведет программа?**

- Тик напечатается 12 раз — тикер копит тики в очереди
- ✅ Тик напечатается 2-3 раза — тики, не влезшие в канал, теряются
- Программа падает: тикер не выдерживает отставания
- Тик напечатается 12 раз подряд, когда рассылка догонит

У канала тикера буфер на один элемент, и для медленного получателя тики сбрасываются: тикер «подстраивает интервал или сбрасывает тики», как сказано в документации. Копить долги и потом отдавать залпом он не умеет — и это правильное поведение для напоминаний: опоздавшие тики означают опоздавшие напоминания, а не лавину догоняющих рассылок.

### Соберите цикл напоминаний

Впишите конструктор тикера, остановку и ветки select.

```go
ticker := time.___(15 * time.Second)
defer ticker.___()
for {
        select {
        case <-ctx.___():
                return
        case <-ticker.___:
                store.SendDue(bot)
        }
}
```

Подсказка: Тикер строит NewTicker, вежливость — defer Stop, завершение приходит через ctx.Done, а тики живут в канале C.

Правильные ответы в пропусках: `NewTicker`, `Stop`, `Done`, `C`

### Соберите тик тикера

Расставьте строки: от создания тикера до отправки сообщения.

Правильный порядок строк:

1. `ticker := time.NewTicker(15 * time.Second)`
2. `defer ticker.Stop()`
3. `due := store.Due(time.Now())`
4. `for _, rem := range due {`
5. `msg := tgbotapi.NewMessage(rem.ChatID, rem.Text)`
6. `_, _ = bot.Send(msg)`

### Карточки: напоминания

- **time.Ticker** — Таймер с повторением: канал C получает отметку времени каждый интервал. Для медленного получателя тики сбрасываются, а не копятся.
- **ticker.Stop** — Выключает тики, канал не закрывает. Ставится defer-ом сразу после создания — иначе таймер тикает в нечитаемый канал до конца процесса.
- **map + mutex** — Общая карта между горутинами без мьютекса — фатальная ошибка concurrent map write и падение всего процесса. Оборачивайте доступ в методы с Lock.
- **ParseInLocation** — Парсит время в конкретной зоне пользователя: «9:00» превращается в корректный момент в его часовом поясе, который потом хранится в UTC.
- **time/tzdata** — Импорт с пустым именем вшивает базу тайм-зон в бинарник — обязательная вещь для контейнеров без системной базы IANA.
- **Собрать под замком — слать без** — Правило тикерной горутины: Due под мьютексом возвращает срез, отправка идёт после. Send под замком замораживает весь бот на время похода в Telegram.

Бот научился действовать сам, без апдейта — это водораздел между скриптом и сервисом. Дальше маршрут такой: [следующий урок](/courses/telegram-bot/lessons/20-platezhi-obzor/) — обзорная карта платежей и Telegram Stars, если боту пора зарабатывать; [урок про деплой](/courses/telegram-bot/lessons/24-deploy-bota/) — systemd и автозапуск, после которого вопрос выживания карты напоминаний при рестарте станет практическим. Фундамент этого урока лежит в [одиннадцатом уроке про хранение данных](/courses/telegram-bot/lessons/11-hranenie-dannyh/) — карта с мьютексом оттуда, — а вежливые паузы при массовой отправке описаны в [уроке про рассылки](/courses/telegram-bot/lessons/14-rassylki/) и [уроке про лимиты](/courses/telegram-bot/lessons/17-oshibki-i-limity/). Механику горутин и каналов, на которой всё держится, с нуля разбирает [курс go-advanced](/courses/go-advanced/) — [горутины](/courses/go-advanced/lessons/04-gorutiny/), [каналы](/courses/go-advanced/lessons/05-kanaly/) и [time с таймерами](/courses/go-advanced/lessons/23-time/). Полный маршрут — [на странице курса](/courses/telegram-bot/).

## Коротко: что запомнить

- Напоминания — первая фоновая задача бота: горутина с time.Ticker живёт рядом с циклом апдейтов и слушает часы.
- Хранилище — map[int64][]Reminder под sync.Mutex: гонка на общей карте — фатальная ошибка всего процесса.
- Select на ticker.C и ctx.Done даёт точный ритм и мгновенную остановку; defer ticker.Stop() — сразу после создания.
- Due собирает сработавшее под замком и вырезает из карты; отправка — строго после выхода из лока.
- Время хранится в UTC, ввод парсится в зоне пользователя; в контейнерах база зон вшивается импортом time/tzdata.
- Карта умирает при рестарте: под неё закладывается загрузка из Redis, а рассылка подчиняется лимитам Telegram.

## Домашнее задание

Соберите бот-будильник: хранилище Reminders с Add и Due, горутину reminderLoop с тикером в пятнадцать секунд и контекстом, команду /remind 18:30 текст с ParseInLocation в вашей зоне. Убедитесь, что бот шлёт напоминание, когда вы ничего не пишете. Затем сломайте его осознанно: перенесите bot.Send внутрь Due под локом и добавьте искусственную задержку — посмотрите, как замрут ответы на команды. Верните отправку после лока, добавьте загрузку напоминаний из Redis при старте и проверьте, что рестарт процесса не теряет записи.

## Вопросы и ответы по уроку

### Почему не time.AfterFunc на каждое напоминание?

AfterFunc отлично работает для десятков разовых таймеров: не нужен ни тикер, ни сверка времени. Но таймеры живут в памяти и не видны при отладке, на тысячах напоминаний их количество перестаёт быть бесплатным, а общий тикер даёт одно место для логов, повторов и лимитов. Переход между схемами дешёвый, пока Due и Add вынесены в хранилище.

### Как быть, если напоминание должно сработать через месяц?

Так же: момент хранится в UTC, тикер сверяет его каждые пятнадцать секунд — долго живущие записи просто дольше ждут в карте. Реальный риск не в ожидании, а в рестарте процесса: месяц — это гарантированный перезапуск, поэтому под хранилище сразу закладывается Redis или база.

### Что делать с напоминанием, если Telegram был недоступен и Send упал?

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