Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +20 XP

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

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

Содержание урока · 7 разделов

До этого урока бот был реактивным: пришёл апдейт — отреагировал, тишина — сидит ждёт. Напоминание ломает модель: сообщение должно прийти тогда, когда пользователь ничего боту не пишет, а значит источника события нужно два. Первый — цикл 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 случайна по порядку — для напоминаний это неважно.

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

Пользователь пишет «напомни в 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 или очередь с отложенными задачамиРасписания, повторения и несколько инстансов бота

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

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

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

Что делает ticker.Stop() — и чего он не делает?

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

Прочитайте код и выберите, что он выведет:

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

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

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

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

time.Ticker

нажмите, чтобы перевернуть
Определение

Таймер с повторением: канал C получает отметку времени каждый интервал. Для медленного получателя тики сбрасываются, а не копятся.

1 / 6

Бот научился действовать сам, без апдейта — это водораздел между скриптом и сервисом. Дальше маршрут такой: следующий урок — обзорная карта платежей и Telegram Stars, если боту пора зарабатывать; урок про деплой — systemd и автозапуск, после которого вопрос выживания карты напоминаний при рестарте станет практическим. Фундамент этого урока лежит в одиннадцатом уроке про хранение данных — карта с мьютексом оттуда, — а вежливые паузы при массовой отправке описаны в уроке про рассылки и уроке про лимиты. Механику горутин и каналов, на которой всё держится, с нуля разбирает курс go-advanced — горутины, каналы и time с таймерами. Полный маршрут — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Напоминания — первая фоновая задача бота: горутина с 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 при старте и проверьте, что рестарт процесса не теряет записи.

Личные заметкиПусто
Хранятся только в вашем браузере (Telegram · Напоминания: goroutine и тикер)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Prometheus25 уроков~12 чпрактика25 уроков уже доступны

Мониторинг Go с Prometheus: метрики, Grafana, алерты

Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.

  • Понимать четыре типа метрик и выбирать правильный
  • Экспортировать метрики из Go через promhttp
  • Инструментировать HTTP-запросы и бизнес-события
  • Писать PromQL-запросы: rate, sum, p99