---
title: "Первая задача: отправить письмо"
description: "Первая asynq задача: тип email:welcome, payload с json.Marshal, клиент Enqueue и хендлер ProcessTask — пошаговый разбор полного примера с письмом."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/03-pervaya-zadacha/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 20
---

# Первая задача: отправить письмо

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 3 из 25. ~20 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/03-pervaya-zadacha/*

Первая задача в asynq собирается из трёх частей: конструктор NewTask с типом и payload, клиентский Enqueue и хендлер ProcessTask с сигнатурой func(context.Context, *asynq.Task) error. В этом уроке соберём связку целиком на примере welcome-письма: от json.Marshal payload до строки «письмо отправлено» в логах воркера.

Окружение из [предыдущего урока](/courses/asynq/lessons/02-ustanovka-podklyuchenie/) живёт: Redis в Docker отвечает PONG, модуль с двумя точками входа собран. Теперь первый настоящий сценарий: новый пользователь зарегистрировался, и API должен отправить ему welcome-письмо. Вы уже знаете, почему это не вызов SMTP прямо в хендлере, — значит, пишем задачу. План такой: объявляем тип задачи, описываем данные в payload-структуре, пишем конструктор, ставим задачу через Enqueue и встречаем её хендлером на воркере. Всё это — около тридцати строк кода, и каждая строка здесь сохранит смысл до конца курса: крупные сценарии из следующих уроков будут отличаться от этой связки только опциями. Код запускается локально — Redis, клиент и воркер должны быть живы; песочница сайта, как всегда в этом курсе, показывает только тематическую программу на чистом Go.

### Asynq первая задача: тип и конструктор

Asynq первая задача начинается не с кода, а с имени: у каждой задачи есть тип — строка, по которой воркер поймёт, какой хендлер звать. Возьмите конвенцию «домен:действие» — email:welcome, report:daily, webhook:ping; она читается в дашборде и логах лучше любых комментариев. Тип объявляется константой в общем пакете tasks, который импортируют обе стороны связки: клиент кладёт задачу с этим типом, воркер регистрирует на него хендлер, и опечатка здесь равна потере письма. Сама задача — это значение asynq.Task: строка типа плюс байты данных, и конструктор asynq.NewTask принимает ровно два аргумента. Я всегда оборачиваю NewTask в функцию NewWelcomeEmailTask(userID int, email string): она прячет сериализацию, даёт типизированный вызов вместо сырых байтов и делает код постановки задачи читаемым. Ошибку Marshal возвращаем наверх — и хотя на обычных структурах она почти невозможна, привычка проверять всё, что возвращает ошибку, в Go не обсуждается. Константа вместо сырой строки окупается при переименованиях: компилятор найдёт все точки использования, тогда как опечатка в литерале живёт до первой жалобы пользователя. А префикс домена в имени типа превращает дашборд в фильтруемый список: email:*, report:*, webhook:* группируются глазами, без таблицы соответствий.

### Пакет tasks: тип, payload, конструктор

```go
package tasks

import (
    "encoding/json"

    "github.com/hibiken/asynq"
)

const TypeWelcomeEmail = "email:welcome"

type WelcomeEmailPayload struct {
    UserID int    `json:"user_id"`
    Email  string `json:"email"`
}

func NewWelcomeEmailTask(userID int, email string) (*asynq.Task, error) {
    payload, err := json.Marshal(WelcomeEmailPayload{UserID: userID, Email: email})
    if err != nil {
        return nil, err
    }
    return asynq.NewTask(TypeWelcomeEmail, payload), nil
}
```

Три артефакта первой задачи: константа типа, payload-структура и конструктор. NewTask принимает строку-тип и байты данных — это весь payload-протокол asynq. Marshal почти не ошибается на простых структурах, но возврат ошибки сохраняет сигнатуру честной. Тип-константа импортируется обеими сторонами: клиент и воркер физически не могут разойтись в строке.

### Payload-структура как контракт двух процессов

Обратите внимание на payload-структуру: она живёт в общем пакете не из педантизма, а потому что это контракт между двумя процессами. Клиент маршалит поля, воркер анмаршалит те же поля, и одна структура описывает обе стороны. Теги json задают имена ключей в сериализованных байтах — user_id вместо UserID читается в дашборде и в redis-cli без привычки к горбикам. Отсюда два технических следствия, о которых узнают по нулевым значениям, если пропустить. Первое: поля структуры обязаны быть экспортированными — json.Marshal молча пропускает неэкспортированные, ошибки компиляции не будет, и вы просто найдёте UserID равным нулю в воркере. Второе: json не переварит каналы и функции — Marshal вернёт UnsupportedTypeError ещё на клиенте, и это ещё один довод проверять ошибку конструктора, а не считать формальностью. В четвёртом уроке мы [разберём payload во всей красе](/courses/asynq/lessons/04-payload-i-id/): вложенные структуры, срезы, обратную совместимость при изменении полей. Пока важно другое: данные задачи — это байты, а не живые указатели на ваши объекты. Между постановкой и выполнением письмо-задача пролежит в Redis, возможно, минуты, а возможно, и дни: в неё нельзя упаковать открытое соединение с базой или объект io.Writer, только данные, которые переживут сериализацию.

### Enqueue: клиент отправляет задачу

Постановка задачи — одна строка с двумя проверками. client.Enqueue(task) синхронно пишет задачу в Redis и возвращает два значения: *asynq.TaskInfo и ошибку. В info лежит всё, что стоит залогировать: ID задачи — строка, которую asynq сгенерировала сама, имя очереди, куда попала задача, и время запуска. Ошибка здесь — не формальность: Redis мог отвалиться, соединение могло разорваться, и молча потерять постановку — худший сценарий для кода, который только что обещал пользователю письмо. Без опций Enqueue кладёт задачу в очередь default с настройками по умолчанию — до 25 попыток при ошибках и запуск немедленно; обе договорённости мы будем менять опциями в следующих уроках. Клиент при этом ничего не знает о воркерах: не проверяет их доступность и не ждёт выполнения. Он сделал запись в Redis — и вернулся к своим делам. Запись в локальный Redis стоит доли миллисекунды, так что латентность ответа постановка не проедает — но и её лучше держать подальше от горячего пути: тяжёлые пакетные сценарии собирают задачи и ставят пачкой из фонового процесса. Если Enqueue вернул ошибку, у приложения есть выбор: повторить постановку пару раз с короткой паузой, переживая сетевой пик, или сразу отдать 5xx и оставить повтор пользователю. Я выбираю первое для писем и второе для платежей, где двойная постановка хуже пропавшей; как сделать повтор постановки безопасным, покажет четвёртый урок с его TaskID.

### Хендлер ProcessTask: сигнатура и разбор payload

Хендлер — обычная функция с обязательной сигнатурой: func(context.Context, *asynq.Task) error. Первый аргумент — контекст, и это не пустышка, а рабочая ручка: опция Timeout при постановке ставит его дедлайн, и долгие операции внутри хендлера обязаны на отмену реагировать. Через context.WithValue сюда же добираются значения, которые вы решили не тащить в payload, — trace-id, код локали. Второй — сама задача: метод t.Payload() возвращает те самые байты, которые клиент упаковал Marshal-ом, и первым делом их надо разобрать в payload-структуру через json.Unmarshal. Возвращаемое значение — главная ручка управления: nil означает «задача выполнена», и asynq снимает её с учёта; ошибка означает «не вышло», и задача уйдёт в повтор с растущей задержкой — до тех пор, пока не кончатся попытки. Сигнатура носит разговорное имя ProcessTask — в примерах документации хендлеры часто так и называются, хотя asynq принимает любую функцию этого вида. Какие ошибки стоит повторять, а какие — приговор задаче без повтора, [разберём в уроке про ошибки](/courses/asynq/lessons/14-oshibki-handlera/); пока правило простое: вернули ошибку — готовьтесь к ретраю. Хендлер при этом не обязан тащить бизнес-логику внутри себя: здоровая привычка — распаковать payload и передать работу сервисному слою, протестированному отдельно от asynq. Тогда хендлер остаётся тонкой обёрткой — разбор, вызов, ошибка наверх, — и его легко покрыть тестом без Redis.

### Хендлер: Unmarshal и бизнес-логика

```go
func handleWelcomeEmail(ctx context.Context, t *asynq.Task) error {
    var p tasks.WelcomeEmailPayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        return fmt.Errorf("не разобрать payload: %v", err)
    }

    log.Printf("отправляю welcome-письмо %d на %s", p.UserID, p.Email)
    // здесь был бы вызов SMTP-клиента
    return nil
}
```

Скелет любого хендлера asynq: контекст, задача, разбор payload, работа, возврат. Unmarshal в shared-структуру из пакета tasks гарантирует, что поля совпали с тем, что маршалил клиент. Ошибка декода возвращается наверх — asynq повторит задачу, а значит, сломанный payload нельзя молча игнорировать. На месте комментария в реальном сервисе стоит вызов SMTP с обработкой его ошибок.

### Регистрация хендлера в mux и запуск

Хендлер написан, но воркер о нём не знает: функции нужно зарегистрировать в ServeMux по строке-типу. mux.HandleFunc(tasks.TypeWelcomeEmail, handleWelcomeEmail) — и связь установлена: задача с типом email:welcome попадёт именно в эту функцию. После регистрации остаётся запустить srv.Run(mux), и цикл обработки закрутится. Порядок запуска для проверки: сначала Redis, потом воркер, потом клиент — хотя asynq прощает любой порядок, задачи просто дождутся воркера в очереди. Функция — не единственная форма хендлера: mux принимает и значение, реализующее интерфейс asynq.Handler с методом ProcessTask(context.Context, *asynq.Task) error. Структурный хендлер удобен, когда логике нужны зависимости — SMTP-клиент, база, конфиг: соберите структуру с полями при старте и зарегистрируйте её метод, и глобальные переменные не понадобятся. На один mux вешают сколько угодно типов: HandleFunc вызывается для каждого, и воркер одного процесса обслуживает и письма, и отчёты. В логах вы увидите два события: клиент напечатает ID задачи и имя очереди, воркер — строку про письмо. Между ними пройдёт меньше секунды: воркер вынимает задачи из Redis практически мгновенно. Напомню, что в этом уроке клиент и воркер живут в одном main.go — так проще всего увидеть связку целиком; в продакшене их разведут по уроку про топологию.

### main.go: постановка и выполнение в одном файле

```go
func main() {
    client := asynq.NewClient(asynq.RedisClientOpt{Addr: "localhost:6379"})
    defer client.Close()

    task, err := tasks.NewWelcomeEmailTask(42, "anna@example.com")
    if err != nil {
        log.Fatalf("не собрать задачу: %v", err)
    }

    info, err := client.Enqueue(task)
    if err != nil {
        log.Fatalf("не поставить задачу: %v", err)
    }
    log.Printf("задача %s в очереди %s", info.ID, info.Queue)

    srv := asynq.NewServer(asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{Concurrency: 10})
    mux := asynq.NewServeMux()
    mux.HandleFunc(tasks.TypeWelcomeEmail, handleWelcomeEmail)
    log.Fatal(srv.Run(mux))
}
```

Монолит для разработки: клиент ставит задачу, воркер тут же её исполняет. Две проверки ошибок — на сборке и на постановке; обе обязательны. info.ID и info.Queue — первые поля TaskInfo, которые стоит логировать: по ID задачу потом находят в дашборде и Inspector-е. Run блокирует main до сигнала остановки.

### Пять участников связки

| Участник | Код урока | Ответственность |
| --- | --- | --- |
| Тип задачи | const TypeWelcomeEmail | строка-маршрут от постановки до хендлера |
| Payload | WelcomeEmailPayload | данные, переживающие сериализацию в байты |
| Клиент | client.Enqueue(task) | синхронная запись задачи в Redis |
| Redis | opt в RedisClientOpt | хранение задач между процессами и рестартами |
| Хендлер | handleWelcomeEmail + mux | разбор payload и сама работа |

### Шесть шагов первой задачи

1. Объявить константу типа: email:welcome по конвенции домен:действие.
2. Описать payload-структуру с json-тегами в общем пакете tasks.
3. Написать конструктор: Marshal внутри, NewTask снаружи, ошибка наверх.
4. Поставить задачу: client.Enqueue с проверкой ошибки и логом TaskInfo.
5. Написать хендлер: Unmarshal, работа, nil при успехе и ошибка при сбое.
6. Зарегистрировать хендлер в mux и запустить srv.Run.

> **Задача без хендлера.** Забыли mux.HandleFunc — и связка выглядит живой: клиент ставит, воркер берёт. Но воркер не находит хендлер для типа, asynq считает это ошибкой и повторяет задачу, пока попытки не кончатся, — письмо не уйдёт никогда. Что сломалось бы: половина рассылок, если тип в конструкторе написали строкой с опечаткой. Лечение из этого урока — константа типа в общем пакете; диагностика — лог воркера про отсутствие хендлера.

> **Логируйте TaskInfo, а не надежду.** info.ID — единственный ключ, которым задача потом находится в Inspector и asynqmon. Пишите его в лог постановки и передавайте в метрики: когда через неделю придёт вопрос «а ушло ли письмо пользователю 42», ID сократит поиск от минут к секунде.

### Проверка: сигнатура хендлера

**Вопрос:** Какая сигнатура у хендлера задачи asynq?

- func(*asynq.Task) error
- ✅ func(context.Context, *asynq.Task) error
- func(context.Context, *asynq.Task)
- func(*asynq.Task) (string, error)

Оба аргумента обязательны: контекст для отмены при остановке воркера и задача с payload. Возврат error обязателен — это ручка ретраев; функция без ошибки у хендлера asynq не скомпилируется.

### Проверка: что значит nil

**Вопрос:** Хендлер вернул nil. Что сделает asynq с задачей?

- Повторит её через пять минут на всякий случай
- ✅ Отметит выполненной и снимет с учёта
- Оставит в очереди до рестарта воркера
- Перенесёт задачу в архив

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

### Что будет с задачей после ошибки Unmarshal?

```go
if err := json.Unmarshal(t.Payload(), &p); err != nil {
    return fmt.Errorf("не разобрать payload: %v", err)
}
```

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

- asynq проигнорирует ошибку и продолжит
- Задача будет отмечена выполненной
- ✅ Задача уйдёт в повтор с растущей задержкой
- Воркер остановится

Возвращённая ошибка — сигнал «не вышло»: asynq отложит задачу и попробует снова, пока попытки не исчерпаются. Для сломанного payload повтор бессмыслен — как сделать задачу неповторяемой, отдельная история в уроке про SkipRetry.

### Соберите хендлер по скелету

Восстановите сигнатуру, разбор payload и обращение к полю.

```go
func handleWelcomeEmail(ctx ___, t *asynq.Task) error {
    var p tasks.WelcomeEmailPayload
    if err := json.___(t.___(), &p); err != nil {
        return fmt.Errorf("не разобрать payload: %v", err)
    }
    log.Printf("письмо для %d", p.___)
    return nil
}
```

Подсказка: Первый аргумент хендлера — контекст; байты задачи отдаёт t.Payload(); их разбирает json.Unmarshal; идентификатор пользователя лежит в поле UserID.

Правильные ответы в пропусках: `context.Context`, `Unmarshal`, `Payload()`, `UserID`

### Соберите жизнь одной задачи

Расставьте строки в порядке событий: от константы типа до блокирующего запуска воркера.

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

1. `const TypeWelcomeEmail = "email:welcome"`
2. `payload, err := json.Marshal(WelcomeEmailPayload{UserID: 42})`
3. `task := asynq.NewTask(TypeWelcomeEmail, payload)`
4. `info, err := client.Enqueue(task)`
5. `mux := asynq.NewServeMux()`
6. `mux.HandleFunc(TypeWelcomeEmail, handleWelcomeEmail)`
7. `srv := asynq.NewServer(opt, asynq.Config{Concurrency: 10})`
8. `srv.Run(mux)`

### Карточки: первая задача

- **Тип задачи** — Строка-маршрут вроде email:welcome, по которой ServeMux выбирает хендлер. Живёт константой в общем пакете клиента и воркера.
- **Payload** — Байты данных внутри задачи: структура с json-тегами, упакованная json.Marshal. Между постановкой и выполнением хранится в Redis.
- **asynq.NewTask** — Конструктор задачи: принимает строку-тип и []byte. Обычно обёрнут в функцию NewXxxTask, прячущую сериализацию.
- **client.Enqueue** — Синхронно пишет задачу в Redis и возвращает *TaskInfo с ID и именем очереди. Ошибка — не формальность: проверять всегда.
- **Хендлер (ProcessTask)** — func(context.Context, *asynq.Task) error: nil — задача выполнена, ошибка — повтор с задержкой.
- **TaskInfo.ID** — Строка-идентификатор задачи, сгенерированная asynq. Единственный ключ для поиска задачи в Inspector и asynqmon.

Связка собрана и работает: константа, payload, конструктор, Enqueue, хендлер, mux — шесть шагов, которые останутся каркасом на весь курс. Дальше двигаемся по глубине: в четвёртом уроке разберём payload и собственные ID задач — там же покажем, почему поля структуры нельзя менять без плана миграции. В пятом — отложенные задачи: то же Enqueue, но с ProcessIn и ProcessAt, когда письмо надо отправить не сейчас, а через час. Маршрутизацию в mux, включая middleware для задач, отложим до восьмого урока, а тонкости ошибок — до четырнадцатого. Пока запустите монолит из урока несколько раз подряд и посмотрите в логи: каждая постановка создаёт новую задачу с новым ID — и это подведение к вопросу о дубликатах, которым мы займёмся в уроке про уникальность.

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

- Тип задачи — строка «домен:действие» вроде email:welcome; объявляется константой в общем пакете.
- Payload — структура с json-тегами: клиент маршалит, воркер анмаршалит, данные обязаны переживать сериализацию.
- Enqueue синхронно пишет задачу в Redis и возвращает TaskInfo: ID, очередь, время запуска.
- Хендлер — func(context.Context, *asynq.Task) error: nil завершает задачу, ошибка запускает ретрай.
- Хендлер регистрируется в ServeMux по строке-типу: mux.HandleFunc(TypeWelcomeEmail, handleWelcomeEmail).
- Задача без зарегистрированного хендлера повторяется до исчерпания попыток — письмо не уйдёт.
- Хендлер — тонкая обёртка: разбор payload, вызов сервисного слоя, ошибка наверх; зависимости живут в полях структуры.
- info.ID — ключ для поиска задачи в дашборде: логируйте его при каждой постановке.

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

Добавьте вторую задачу: тип email:reset-password с payload из двух полей — UserID и Email. Напишите конструктор и хендлер по образцу урока, зарегистрируйте его в том же mux и отправьте обе задачи подряд в одном main. Убедитесь, что воркер исполнил обе, и найдите их ID в логе постановки. Затем спровоцируйте сбой: закомментируйте регистрацию второго хендлера и посмотрите в логах воркера, как задача без хендлера уходит в повтор. Запуск — только локально с живым Redis.

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

### Можно ли ставить задачу без payload?

Да: asynq.NewTask принимает nil как []byte, и для задач без параметров этого достаточно. Практика всё же советует минимум один идентификатор в payload — без него задачу нельзя трассировать, а хендлер становится одноразовым.

### Клиент и воркер в одном main — нормальная практика?

Для разработки и учебных примеров — да, так проще всего увидеть связку. В продакшене их разделяют: постановка принадлежит API, выполнение — воркерам с собственной репликой; схему разделения разбирает урок про топологию. Разделяйте, когда у постановки и исполнения разный профиль нагрузки или разный график релизов.

### Что если хендлер с типом зарегистрирован дважды?

Второй вызов HandleFunc на тот же тип перезапишет первый: в mux по типу живёт ровно один хендлер. Такое случается при случайном дублировании регистрации в разных файлах — еще одна причина держать все регистрации в одном месте рядом с Run. Если два хендлера на один тип всё же нужны, различайте их разными типами задач, а не перезаписью регистраций.
